diff --git a/.dockerignore b/.dockerignore index 147544119..33b4697ce 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,30 +7,41 @@ # compose `environment:` block at boot — `.env` should never ship. .env .env.* -!.env.example -!.env.production.example +.env.example +.env.production.example # Version control + CI metadata. .git .github +.gitignore .gitleaks.toml # Build / install output that gets regenerated inside the builder stage. node_modules .next out +dist coverage test-results playwright-report +playwright/.cache +playwright.config.ts +vitest.config.mts +vitest.integration.config.mts +tests +tests/ +e2e +e2e/ e2e-results .eslintcache +eslint-plugins +eslint-plugins/ +knip.json -# Persisted session state from e2e runs. -e2e/setup/storageState.json - -# Planning scratch + per-release audit notes (operator-local). -.planning -docs/audit +# Generated local cache files +*.tsbuildinfo +next-env.d.ts +public/sw-version.js # Captures from local debugging sessions. *.har @@ -43,3 +54,34 @@ docs/audit .vscode .idea .DS_Store +*.swp +*.swo + +# Logs / Debug +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# Docker compose / local ops files +docker-compose*.yml +compose*.yml +Dockerfile +.dockerignore + +# Documentation not needed for runtime image +README.md +CHANGELOG.md +CLAUDE.md +CONTRIBUTING.md +CONTRIBUTING-AI.md +CODE_OF_CONDUCT.md +SECURITY.md +# LICENSE / LICENSE.md are deliberately NOT ignored: AGPL-3.0 conveys with +# the software, so the licence text ships inside the image. +AGENTS.md +.planning +docs +docs/*.md +docs/**/*.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c31386416..87f9ee19b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [1.10.2] — 2026-06-03 — Honest AI connection test, consistent insights, retention re-enabled + +### Fixed + +- **The AI connection test now tells you what actually went wrong.** A failed test used to surface a cryptic parse error ("unexpected token") because the server answered with a gateway-level error page the settings screen could not read; worse, the test probed a different provider than the one your nightly insights are generated with, so it could report a problem with a fallback you do not even use. The test now always returns a readable, translated reason — rejected credentials, rate-limited, a provider server error, or unreachable — and it checks the same provider your insights run on, so the result is the truth about your setup. +- **Every insight metric page carries the same time-range controls.** The core-metric pages (weight, blood pressure, pulse, BMI, sleep) were missing the period selector that the other metric pages already had; they now match. Drill-down back-links are consistent and a "show all values" link returns you to the metric you came from. +- **The derived-metrics dashboard no longer collapses or jumps while loading.** It shows a placeholder row at the right height, keeps its heading hidden until there is something to show, and offers an inline retry if the data fails to load instead of silently vanishing. +- **More status colours meet contrast on the light theme** across the medication and admin surfaces. + +### Changed + +- **The intra-day retention drain is back on.** The storage optimisation behind the stress reading was switched off in 1.10.0.2 after a daily-summary collision kept the app cycling post-deploy. The fold now adopts the existing daily row deterministically and recovers cleanly if a concurrent write reaches the same slot first — verified against real data — and the start-up defer that protects the connection pool stays in place. +- **The batch-ingest and health-record export endpoints cap request-body size**, rejecting an oversized payload before parsing it. + +### Added + +- **A capability endpoint for API clients.** `GET /api/meta/capabilities` returns the server's live metric, tile, ingest and FHIR vocabularies alongside a contract version, so a client renders what it recognises and ignores what it does not — no more transcribing each release's new identifiers by hand. + ## [1.10.1] — 2026-06-03 — Consistent medication cards ### Fixed diff --git a/README.md b/README.md index c8479c9f3..04239df32 100644 --- a/README.md +++ b/README.md @@ -530,7 +530,7 @@ A SwiftUI iOS companion that lives on the same `/api` surfaces as the web client ### What the phone unlocks - **HealthKit two-way sync.** Today's step count, weight, blood pressure, HRV, sleep, glucose, etc. read live from Apple Health and round-trip to your server through `HKObserverQuery` + `HKAnchoredObjectQuery` + a `BGProcessingTask` for guaranteed delivery. `HKMetadataKeyExternalUUID` on every iOS-side write keeps the server and Apple Health from echoing each other. -- **Medication reminders that actually fire.** Local notifications with action buttons (Genommen / Snooze 15 min / Übersprungen) that hit the server's mark-intake endpoint directly — without opening the app. +- **Medication reminders that actually fire.** Local notifications with action buttons (Taken / Snooze 15 min / Skipped) that hit the server's mark-intake endpoint directly — without opening the app. - **On-device AI Coach.** A conversational surface powered by Apple's Foundation Models framework on iOS 26+ Apple-Intelligence-eligible iPhones. The prompt and the response never leave the device — your numbers, your health questions, your phone. - **Passkey-first auth.** Sign in with Face ID or Touch ID via WebAuthn; email + magic-link is the fallback. SPKI public-key pinning on every authenticated request, Keychain with `afterFirstUnlockThisDeviceOnly`, and a log sanitizer that filters tokens and hostnames out of every line. - **Doctor-report export.** Generate a FHIR-flavoured PDF bundle from the iPhone based on the LOINC mappings reviewed on the server's clinician surface. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index dcfed2711..d4873dfef 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.10.1 + version: 1.10.2 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -38,7 +38,43 @@ tags: - name: Export - name: Sync - name: Admin + - name: Meta paths: + /api/meta/capabilities: + get: + tags: + - Meta + summary: Live server capability / id-vocabulary discovery + description: Returns the server's REAL id vocabularies (derived-metric ids, vitals-baseline types, layout tile-ids, + metric-status ids, the HealthKit ingest mapping, the FHIR coding constants) plus the running API contract + version. Every list is derived server-side from the canonical registry it documents, so a client can gate its UI + / decoder against what the server actually ships rather than a hand-maintained copy. Auth via cookie or Bearer + (not admin). + responses: + "200": + description: Capability snapshot. + content: + application/json: + schema: + $ref: "#/components/schemas/CapabilitiesEnvelope" + "401": &a1 + description: Authentication required or invalid credentials. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": &a2 + description: Request validation failed. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": &a3 + description: Rate limit exceeded. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" /api/export/health-record: post: tags: @@ -170,24 +206,9 @@ paths: schema: type: string format: binary - "401": &a1 - description: Authentication required or invalid credentials. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" - "422": &a2 - description: Request validation failed. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" - "429": &a3 - description: Rate limit exceeded. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" + "401": *a1 + "422": *a2 + "429": *a3 /api/auth/login: post: tags: @@ -3469,6 +3490,125 @@ components: primitives are mutually exclusive — enforced by the Zod refine on writes, the route layer, and a DB CHECK constraint (`medication_schedules_rrule_xor_rolling`). v1.7.0 adds `scheduleType` (SCHEDULED / PRN / CYCLIC) and the cyclic on/off-week fields. + CapabilitiesEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/CapabilitiesResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CapabilitiesResponse: + type: object + properties: + apiContractVersion: + type: string + description: Running build version — mirrors GET /api/version `version`. + derivedMetricIds: + type: array + items: + type: string + description: Closed derived-metric id set (GET /api/insights/derived). + vitalsBaselineTypes: + type: array + items: + type: string + description: MeasurementTypes the typical-range baseline engine supports. + layoutTileIds: + type: array + items: + type: string + description: Canonical insights layout tile-id set (English ids). + metricStatusIds: + type: array + items: + type: string + description: Closed metric-status / assessment id set. + ingest: + type: object + properties: + quantityTypes: + type: array + items: + type: object + properties: + type: + type: string + description: HealthLog MeasurementType. + hk: + type: string + description: HealthKit identifier. + unit: + type: string + description: Canonical DB unit. + required: + - type + - hk + - unit + additionalProperties: false + description: Accepted HealthKit quantity-sample mappings. + eventTypes: + type: array + items: + type: string + description: MeasurementTypes for device-flagged EVENT-class HealthKit samples. + computedScores: + type: array + items: + type: string + description: Server-owned nightly composite score types. + writeAllowlist: + type: array + items: + type: string + description: MeasurementSources a client may attribute on a write (others are server-owned). + required: + - quantityTypes + - eventTypes + - computedScores + - writeAllowlist + additionalProperties: false + description: Ingest vocabularies the batch / single-write paths accept. + fhir: + type: object + properties: + atcSystem: + type: string + description: WHO ATC CodeSystem URI. + snomedRoute: + type: string + description: SNOMED CT CodeSystem URI. + germanAtcDefaultLocales: + type: array + items: + type: string + description: App locales that default the additive BfArM ATC coding on. + required: + - atcSystem + - snomedRoute + - germanAtcDefaultLocales + additionalProperties: false + description: FHIR coding constants the health-record export emits. + required: + - apiContractVersion + - derivedMetricIds + - vitalsBaselineTypes + - layoutTileIds + - metricStatusIds + - ingest + - fhir + additionalProperties: false + description: Live id vocabularies + contract version. Every list is derived server-side from the canonical registry it + documents, so it cannot drift from the values the routes actually accept/emit. ErrorEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index 78bf46e5b..10928eb92 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2047,7 +2047,9 @@ "weekday": "Die meisten deiner {metric}-Werte fallen auf einen Wochentag. Messungen an anderen Tagen ergeben ein vollständigeres Bild.", "timeOfDay": "Die meisten deiner {metric}-Werte fallen auf eine ähnliche Tageszeit. Unterschiedliche Zeiten ergeben ein vollständigeres Bild." }, - "valuesBack": "Zurück zu Insights" + "valuesBack": "Zurück zu Insights", + "valuesBackToMetric": "Zurück zu {metric}", + "scoresBack": "Zurück zu Insights" }, "sleep": { "title": "Schlaf", @@ -2526,6 +2528,8 @@ }, "vitals": { "sectionTitle": "Deine Vitalwerte", + "loadingLabel": "Vitalwerte werden geladen", + "loadError": "Deine Vitalwerte konnten gerade nicht geladen werden.", "building": "Typischer Bereich entsteht — {count} von {target} Tagen", "typicalRange": "Typisch für dich: {low}–{high}", "fitnessYounger": "Ausdauer wie ~{years} J. jünger", @@ -3255,6 +3259,11 @@ "testProvider": "Aktiven Provider testen", "testSuccess": "OK — {provider} ({model})", "testFailedShort": "Test fehlgeschlagen: {message}", + "testUnexpectedResponse": "Verbindung zum KI-Anbieter fehlgeschlagen — unerwartete Antwort vom Server.", + "testReasonCredentials": "Der Anbieter hat die Anmeldedaten abgelehnt — melde dich in den KI-Einstellungen erneut an.", + "testReasonRateLimited": "Der Anbieter hat die Anfrage gedrosselt — versuche es in Kürze erneut.", + "testReasonServerError": "Der KI-Anbieter hat einen Serverfehler zurückgegeben.", + "testReasonUnreachable": "Der KI-Anbieter war nicht erreichbar.", "disableCoach": { "title": "Coach ausblenden", "description": "Versteckt den Coach-Button und das Coach-Panel auf allen Seiten.", diff --git a/messages/en.json b/messages/en.json index 054d61a83..84e8cfc5c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2047,7 +2047,9 @@ "weekday": "Most of your {metric} readings land on one weekday. Logging on other days gives a fuller picture.", "timeOfDay": "Most of your {metric} readings land at a similar time of day. Varying the time gives a fuller picture." }, - "valuesBack": "Back to insights" + "valuesBack": "Back to insights", + "valuesBackToMetric": "Back to {metric}", + "scoresBack": "Back to insights" }, "sleep": { "title": "Sleep", @@ -2526,6 +2528,8 @@ }, "vitals": { "sectionTitle": "Your vitals", + "loadingLabel": "Loading your vitals", + "loadError": "Could not load your vitals right now.", "building": "Building your typical range — {count} of {target} days", "typicalRange": "Typical for you: {low}–{high}", "fitnessYounger": "Cardio fitness of someone ~{years} yr younger", @@ -3255,6 +3259,11 @@ "testProvider": "Test active provider", "testSuccess": "OK — {provider} ({model})", "testFailedShort": "Test failed: {message}", + "testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.", + "testReasonCredentials": "Provider rejected the credentials — re-authenticate in AI settings.", + "testReasonRateLimited": "Provider rate-limited the request — try again shortly.", + "testReasonServerError": "The AI provider returned a server error.", + "testReasonUnreachable": "Could not reach the AI provider.", "disableCoach": { "title": "Hide Coach", "description": "Hides the Coach button and drawer everywhere.", diff --git a/messages/es.json b/messages/es.json index b16140457..e6741d045 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2047,7 +2047,9 @@ "weekday": "La mayoría de tus mediciones de {metric} caen en un mismo día de la semana. Registrar en otros días ofrece una imagen más completa.", "timeOfDay": "La mayoría de tus mediciones de {metric} caen a una hora similar del día. Variar la hora ofrece una imagen más completa." }, - "valuesBack": "Volver a Insights" + "valuesBack": "Volver a Insights", + "valuesBackToMetric": "Volver a {metric}", + "scoresBack": "Volver a Insights" }, "sleep": { "title": "Sueño", @@ -2526,6 +2528,8 @@ }, "vitals": { "sectionTitle": "Tus constantes", + "loadingLabel": "Cargando tus constantes", + "loadError": "No se pudieron cargar tus constantes en este momento.", "building": "Creando tu rango habitual — {count} de {target} días", "typicalRange": "Habitual para ti: {low}–{high}", "fitnessYounger": "Forma cardiovascular de alguien ~{years} años más joven", @@ -3255,6 +3259,11 @@ "testProvider": "Test active provider", "testSuccess": "OK — {provider} ({model})", "testFailedShort": "Test failed: {message}", + "testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.", + "testReasonCredentials": "Provider rejected the credentials — re-authenticate in AI settings.", + "testReasonRateLimited": "Provider rate-limited the request — try again shortly.", + "testReasonServerError": "The AI provider returned a server error.", + "testReasonUnreachable": "Could not reach the AI provider.", "disableCoach": { "title": "Ocultar el Coach", "description": "Oculta el botón del Coach y el panel en todas las páginas.", diff --git a/messages/fr.json b/messages/fr.json index 6479aa6df..79b4a3195 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2047,7 +2047,9 @@ "weekday": "La plupart de vos mesures de {metric} tombent le même jour de la semaine. Enregistrer d'autres jours donne une image plus complète.", "timeOfDay": "La plupart de vos mesures de {metric} tombent à une heure similaire de la journée. Varier l'heure donne une image plus complète." }, - "valuesBack": "Retour aux Insights" + "valuesBack": "Retour aux Insights", + "valuesBackToMetric": "Retour à {metric}", + "scoresBack": "Retour aux Insights" }, "sleep": { "title": "Sommeil", @@ -2526,6 +2528,8 @@ }, "vitals": { "sectionTitle": "Vos constantes", + "loadingLabel": "Chargement de vos constantes", + "loadError": "Impossible de charger vos constantes pour le moment.", "building": "Construction de votre plage habituelle — {count} sur {target} jours", "typicalRange": "Habituel pour vous : {low}–{high}", "fitnessYounger": "Forme cardio d'une personne ~{years} ans plus jeune", @@ -3255,6 +3259,11 @@ "testProvider": "Test active provider", "testSuccess": "OK — {provider} ({model})", "testFailedShort": "Test failed: {message}", + "testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.", + "testReasonCredentials": "Provider rejected the credentials — re-authenticate in AI settings.", + "testReasonRateLimited": "Provider rate-limited the request — try again shortly.", + "testReasonServerError": "The AI provider returned a server error.", + "testReasonUnreachable": "Could not reach the AI provider.", "disableCoach": { "title": "Masquer le Coach", "description": "Masque le bouton et le panneau du Coach sur toutes les pages.", diff --git a/messages/it.json b/messages/it.json index e65233fa0..be4f32660 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2047,7 +2047,9 @@ "weekday": "La maggior parte delle tue misurazioni di {metric} cade in un solo giorno della settimana. Registrare in altri giorni offre un quadro più completo.", "timeOfDay": "La maggior parte delle tue misurazioni di {metric} cade in un orario simile della giornata. Variare l'orario offre un quadro più completo." }, - "valuesBack": "Torna a Insights" + "valuesBack": "Torna a Insights", + "valuesBackToMetric": "Torna a {metric}", + "scoresBack": "Torna a Insights" }, "sleep": { "title": "Sonno", @@ -2526,6 +2528,8 @@ }, "vitals": { "sectionTitle": "I tuoi parametri vitali", + "loadingLabel": "Caricamento dei tuoi parametri vitali", + "loadError": "Al momento non è stato possibile caricare i tuoi parametri vitali.", "building": "Creazione del tuo intervallo abituale — {count} di {target} giorni", "typicalRange": "Abituale per te: {low}–{high}", "fitnessYounger": "Forma cardio di chi ha ~{years} anni in meno", @@ -3255,6 +3259,11 @@ "testProvider": "Test active provider", "testSuccess": "OK — {provider} ({model})", "testFailedShort": "Test failed: {message}", + "testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.", + "testReasonCredentials": "Provider rejected the credentials — re-authenticate in AI settings.", + "testReasonRateLimited": "Provider rate-limited the request — try again shortly.", + "testReasonServerError": "The AI provider returned a server error.", + "testReasonUnreachable": "Could not reach the AI provider.", "disableCoach": { "title": "Nascondi il Coach", "description": "Nasconde il pulsante e il pannello del Coach su tutte le pagine.", diff --git a/messages/pl.json b/messages/pl.json index c70c1c764..acbf89332 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2047,7 +2047,9 @@ "weekday": "Większość Twoich pomiarów {metric} przypada na jeden dzień tygodnia. Pomiary w inne dni dają pełniejszy obraz.", "timeOfDay": "Większość Twoich pomiarów {metric} przypada o podobnej porze dnia. Różne pory dają pełniejszy obraz." }, - "valuesBack": "Powrót do Insights" + "valuesBack": "Powrót do Insights", + "valuesBackToMetric": "Powrót do {metric}", + "scoresBack": "Powrót do Insights" }, "sleep": { "title": "Sen", @@ -2526,6 +2528,8 @@ }, "vitals": { "sectionTitle": "Twoje parametry życiowe", + "loadingLabel": "Wczytywanie parametrów życiowych", + "loadError": "Nie udało się teraz wczytać Twoich parametrów życiowych.", "building": "Budowanie Twojego typowego zakresu — {count} z {target} dni", "typicalRange": "Typowe dla Ciebie: {low}–{high}", "fitnessYounger": "Kondycja jak u osoby ~{years} lat młodszej", @@ -3255,6 +3259,11 @@ "testProvider": "Test active provider", "testSuccess": "OK — {provider} ({model})", "testFailedShort": "Test failed: {message}", + "testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.", + "testReasonCredentials": "Provider rejected the credentials — re-authenticate in AI settings.", + "testReasonRateLimited": "Provider rate-limited the request — try again shortly.", + "testReasonServerError": "The AI provider returned a server error.", + "testReasonUnreachable": "Could not reach the AI provider.", "disableCoach": { "title": "Ukryj Coacha", "description": "Ukrywa przycisk Coacha i panel na wszystkich stronach.", diff --git a/package.json b/package.json index b071e945e..7a7689f12 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.10.1", + "version": "1.10.2", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev", diff --git a/src/app/api/ai/test/__tests__/route.test.ts b/src/app/api/ai/test/__tests__/route.test.ts index ea8072237..054150966 100644 --- a/src/app/api/ai/test/__tests__/route.test.ts +++ b/src/app/api/ai/test/__tests__/route.test.ts @@ -65,11 +65,27 @@ function jsonRequest(body: unknown): Request { }); } +interface TestFailureEnvelope { + data: { + ok: false; + providerType: string; + reasonCode: "credentials" | "rate_limited" | "server_error" | "unreachable"; + reason: string; + }; + error: null; +} + // V3 audit: /api/ai/test was returning provider err.message + bodyExcerpt // directly to the client, leaking provider URLs / partial keys / internal // headers. Server now logs full details via annotate() and responds with -// a categorised, generic message. -describe("POST /api/ai/test — provider error leak guard", () => { +// a categorised, secret-free reason. +// +// This route MUST NEVER return a 5xx: a 5xx origin response is rewritten +// by Cloudflare to its own HTML error page, so the browser's res.json() +// crashes with `Unexpected token '<', " { function makeProviderThatThrows( err: Error & { httpStatus?: number; bodyExcerpt?: string }, ) { @@ -81,7 +97,7 @@ describe("POST /api/ai/test — provider error leak guard", () => { } as never); } - it("does not echo provider err.message back to the client (HIGH coverage gap)", async () => { + it("never returns a 5xx and does not echo provider err.message back to the client", async () => { const err = Object.assign( new Error("OpenAI 401 from https://api.openai.com/v1 sk-leaked-key"), { @@ -92,49 +108,67 @@ describe("POST /api/ai/test — provider error leak guard", () => { makeProviderThatThrows(err); const response = await POST(emptyRequest() as never); - const body = (await response.json()) as ApiErrorEnvelope; - - // v1.4.5: credential failures map to 422 (not 502) so Cloudflare - // doesn't replace our JSON body with its HTML error page — that - // rewrite was the root cause of the "Unexpected token '<'…" client - // crash the maintainer hit when typing a bad OpenAI key. - expect(response.status).toBe(422); - expect(body.error ?? "").not.toMatch(/sk-/); - expect(body.error ?? "").not.toMatch(/api\.openai\.com/); - expect(body.error ?? "").not.toMatch(/invalid api key/i); - expect(body.error).toBe("Provider rejected the credentials"); + // Always JSON-parseable, never a 5xx (the Cloudflare HTML-rewrite + // root cause of "Unexpected token '<'…"). + expect(response.status).toBe(200); + const body = (await response.json()) as TestFailureEnvelope; + expect(body.data.ok).toBe(false); + expect(body.data.reasonCode).toBe("credentials"); + // The stable machine code must itself be secret-free. + expect(body.data.reasonCode).not.toMatch(/sk-/); + expect(body.data.reason).not.toMatch(/sk-/); + expect(body.data.reason).not.toMatch(/api\.openai\.com/); + expect(body.data.reason).not.toMatch(/invalid api key/i); + expect(body.data.reason).toMatch(/re-authenticate/i); }); - it("returns the 429-categorised message when the provider rate-limits", async () => { + it("reclassifies a 5xx whose body signals an invalidated session as a credential failure", async () => { + // The shape an operator hit: re-auth, gateway answers 500 with + // "authentication token has been invalidated" instead of a 401. + makeProviderThatThrows( + Object.assign(new Error("upstream 500"), { + httpStatus: 500 as const, + bodyExcerpt: + '{"error":{"message":"Your authentication token has been invalidated. Please try signing in again."}}', + }), + ); + const response = await POST(emptyRequest() as never); + expect(response.status).toBe(200); + const body = (await response.json()) as TestFailureEnvelope; + expect(body.data.ok).toBe(false); + expect(body.data.reasonCode).toBe("credentials"); + expect(body.data.reason).toMatch(/re-authenticate/i); + }); + + it("categorises a 429 rate-limit", async () => { makeProviderThatThrows( Object.assign(new Error("429 from openai"), { httpStatus: 429 as const }), ); const response = await POST(emptyRequest() as never); - // v1.4.5: rate-limit passes through as 429 (was 502 in v1.4.4) so - // the React Query mutation can read the JSON body instead of - // tripping over Cloudflare's HTML 502. - expect(response.status).toBe(429); - expect(((await response.json()) as ApiErrorEnvelope).error).toBe( - "Provider rate-limited the request", - ); + expect(response.status).toBe(200); + const body = (await response.json()) as TestFailureEnvelope; + expect(body.data.reasonCode).toBe("rate_limited"); + expect(body.data.reason).toMatch(/rate-limited/i); }); - it("returns the 5xx-categorised message when the provider has a server error", async () => { + it("categorises a plain 5xx provider server error (no auth signal)", async () => { makeProviderThatThrows( Object.assign(new Error("503 upstream"), { httpStatus: 503 as const }), ); const response = await POST(emptyRequest() as never); - expect(((await response.json()) as ApiErrorEnvelope).error).toBe( - "Provider returned a server error", - ); + expect(response.status).toBe(200); + const body = (await response.json()) as TestFailureEnvelope; + expect(body.data.reasonCode).toBe("server_error"); + expect(body.data.reason).toMatch(/server error/i); }); - it("returns the unknown-error fallback otherwise", async () => { + it("categorises a network/timeout failure", async () => { makeProviderThatThrows(new Error("ECONNRESET")); const response = await POST(emptyRequest() as never); - expect(((await response.json()) as ApiErrorEnvelope).error).toBe( - "Provider connection failed", - ); + expect(response.status).toBe(200); + const body = (await response.json()) as TestFailureEnvelope; + expect(body.data.reasonCode).toBe("unreachable"); + expect(body.data.reason).toMatch(/could not reach/i); }); }); diff --git a/src/app/api/ai/test/route.ts b/src/app/api/ai/test/route.ts index c866f3726..1dbf3decb 100644 --- a/src/app/api/ai/test/route.ts +++ b/src/app/api/ai/test/route.ts @@ -81,7 +81,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // V3 audit: do not return provider error message + bodyExcerpt to the // client (leaks provider URL / partial keys / internal headers). // Log full details server-side for the operator and respond with a - // categorised, generic message. + // categorised, generic reason. const err = e as Error & { httpStatus?: number; bodyExcerpt?: string }; annotate({ meta: { @@ -91,28 +91,72 @@ export const POST = apiHandler(async (request: NextRequest) => { ai_test_provider: provider.type, }, }); - // Map upstream status to a *client-side* status code that won't be - // rewritten by Cloudflare. 5xx from origin causes Cloudflare to swap - // the body for its own HTML error page — `await res.json()` in the - // browser then crashes with `Unexpected token '<', "= 500) { - outboundStatus = 502; - safeMessage = "Provider returned a server error"; - } else { - outboundStatus = 422; - safeMessage = "Provider connection failed"; - } - return apiError(safeMessage, outboundStatus); + // This route MUST NEVER return a 5xx. A 5xx origin response is + // rewritten by Cloudflare / the reverse proxy to its own HTML error + // page, so the browser's `res.json()` crashes with + // `Unexpected token '<', "= 500 && looksLikeAuth)) { + return { + reasonCode: "credentials", + reason: "Provider rejected the credentials — re-authenticate in AI settings.", + }; + } + if (status === 429) { + return { + reasonCode: "rate_limited", + reason: "Provider rate-limited the request — try again shortly.", + }; + } + if (status >= 500) { + return { + reasonCode: "server_error", + reason: "The AI provider returned a server error.", + }; + } + return { + reasonCode: "unreachable", + reason: "Could not reach the AI provider.", + }; +} diff --git a/src/app/api/export/health-record/route.ts b/src/app/api/export/health-record/route.ts index b8f14a602..073af830a 100644 --- a/src/app/api/export/health-record/route.ts +++ b/src/app/api/export/health-record/route.ts @@ -39,7 +39,10 @@ import { sanitisePracticeName, } from "@/lib/doctor-report-data"; import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core"; -import { buildFhirDocumentBundle } from "@/lib/fhir/build-bundle"; +import { + buildFhirDocumentBundle, + GERMAN_ATC_DEFAULT_LOCALES, +} from "@/lib/fhir/build-bundle"; import { exportSelectionSchema, toDoctorReportPrefs, @@ -58,7 +61,14 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Maximum 10 exports per hour", 429); } - const { data: body, error: jsonError } = await safeJson(request); + // The selection payload is small and bounded — format, range, + // section toggles, a few flags, an optional practice-name string. + // 64 KB is far above any legitimate selection while still rejecting + // a multi-megabyte body before it reaches `JSON.parse`. A DoS + // ceiling, not a tight bound. + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = exportSelectionSchema.safeParse(body); @@ -92,7 +102,9 @@ export const POST = apiHandler(async (request: NextRequest) => { // BfArM ATC: an explicit selection flag wins; otherwise derive it from a // German-region locale. The WHO ATC coding is unaffected either way. - const germanAtc = selection.germanAtc ?? locale === "de"; + const germanAtc = + selection.germanAtc ?? + (GERMAN_ATC_DEFAULT_LOCALES as readonly string[]).includes(locale); const [data, userTz, userRow] = await Promise.all([ collectDoctorReportData(user.id, range, { practiceName, sections }), diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 152435efa..a0c2a7af1 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -62,6 +62,15 @@ const PR_DETECTION_SILENT_THRESHOLD = 50; const MAX_BATCH_ENTRIES = 500; +// Body-size ceiling backstopping the per-entry cap. A worst-case entry +// (max-length hkIdentifier + unit + externalId + two ISO dates + the +// numeric fields) serialises to well under 1 KB, so a legitimate +// 500-entry batch tops out a few hundred KB. 4 MB leaves an order of +// magnitude of headroom above any real batch while still rejecting a +// multi-megabyte payload before it reaches `JSON.parse` and the heap. +// The cap is a DoS ceiling, not a tight bound. +const MAX_BODY_BYTES = 4 * 1024 * 1024; + // v1.4.25 W10 reconcile (security H-2): cap batch ingest at 60 // batches per user per minute. Healthy iOS sync drains its // HealthKit observer queue in well under one batch per minute (the @@ -177,7 +186,9 @@ async function postBatch(request: NextRequest): Promise { return apiError("Too many batch submissions, try again later", 429); } - const { data: rawBody, error: jsonError } = await safeJson(request); + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: MAX_BODY_BYTES, + }); if (jsonError) return jsonError; // Distinguish "too many entries" from "validation failed" so the diff --git a/src/app/api/meta/capabilities/__tests__/route.test.ts b/src/app/api/meta/capabilities/__tests__/route.test.ts new file mode 100644 index 000000000..65aae485e --- /dev/null +++ b/src/app/api/meta/capabilities/__tests__/route.test.ts @@ -0,0 +1,172 @@ +/** + * GET /api/meta/capabilities route tests. + * + * This endpoint exists to retire the "doc says N, server ships M" enum-drift + * class for the native client — so the drift-guard assertions ARE the point: + * + * 1. 401 when unauthenticated. + * 2. `derivedMetricIds` is exactly the derived registry's id set (same + * length AND members) — the endpoint can never silently diverge from + * the registry it claims to mirror. + * 3. `ingest.writeAllowlist` deep-equals the `WRITABLE_MEASUREMENT_SOURCES` + * constant — a client reads the live write allowlist, not a copy. + * 4. Every quantity-mapping row + the FHIR constants + the contract version + * are sourced from the canonical server constants, not hand-typed here. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// The route transitively imports `@/lib/insights/derived/wellness-scores`, +// which imports the Prisma client; mock it so the module graph loads without +// a real DB. The route itself only reads `WELLNESS_SCORE_TYPES` (a plain +// object), never the client. +vi.mock("@/lib/db", () => ({ prisma: {} })); + +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("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { DERIVED_METRIC_IDS } from "@/lib/insights/derived/registry"; +import { WRITABLE_MEASUREMENT_SOURCES } from "@/lib/validations/measurement"; +import { APPLE_HEALTH_TYPE_MAP } from "@/lib/measurements/apple-health-mapping"; +import { + ATC_SYSTEM, + SNOMED_SYSTEM, + GERMAN_ATC_DEFAULT_LOCALES, +} from "@/lib/fhir/build-bundle"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; + +type CapabilitiesBody = { + data: { + apiContractVersion: string; + derivedMetricIds: string[]; + vitalsBaselineTypes: string[]; + layoutTileIds: string[]; + metricStatusIds: string[]; + ingest: { + quantityTypes: { type: string; hk: string; unit: string }[]; + eventTypes: string[]; + computedScores: string[]; + writeAllowlist: string[]; + }; + fhir: { + atcSystem: string; + snomedRoute: string; + germanAtcDefaultLocales: string[]; + }; + }; +}; + +async function call(): Promise { + return (GET as unknown as () => Promise)(); +} + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("GET /api/meta/capabilities — auth", () => { + it("returns 401 when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await call(); + expect(res.status).toBe(401); + }); +}); + +describe("GET /api/meta/capabilities — drift guards", () => { + beforeEach(() => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + }); + + it("derivedMetricIds is exactly the derived registry's id set", async () => { + const res = await call(); + expect(res.status).toBe(200); + const body = (await res.json()) as CapabilitiesBody; + + // Length AND membership — the endpoint cannot silently drift from the + // registry (the whole reason it exists). + expect(body.data.derivedMetricIds.length).toBe(DERIVED_METRIC_IDS.length); + expect([...body.data.derivedMetricIds].sort()).toEqual( + [...DERIVED_METRIC_IDS].sort(), + ); + }); + + it("ingest.writeAllowlist deep-equals WRITABLE_MEASUREMENT_SOURCES", async () => { + const res = await call(); + const body = (await res.json()) as CapabilitiesBody; + expect(body.data.ingest.writeAllowlist).toEqual([ + ...WRITABLE_MEASUREMENT_SOURCES, + ]); + }); + + it("computedScores are the three persisted wellness scores", async () => { + const res = await call(); + const body = (await res.json()) as CapabilitiesBody; + expect([...body.data.ingest.computedScores].sort()).toEqual( + ["RECOVERY_SCORE", "STRAIN_SCORE", "STRESS_SCORE"], + ); + }); + + it("fhir constants mirror the FHIR builder source", async () => { + const res = await call(); + const body = (await res.json()) as CapabilitiesBody; + expect(body.data.fhir.atcSystem).toBe(ATC_SYSTEM); + expect(body.data.fhir.snomedRoute).toBe(SNOMED_SYSTEM); + expect(body.data.fhir.germanAtcDefaultLocales).toEqual([ + ...GERMAN_ATC_DEFAULT_LOCALES, + ]); + }); + + it("quantityTypes are sourced from the HealthKit ingest mapping", async () => { + const res = await call(); + const body = (await res.json()) as CapabilitiesBody; + + // Every emitted quantity row must trace back to a real mapping entry — + // never an entry the ingest path doesn't actually accept. + const byHk = new Map( + Object.values(APPLE_HEALTH_TYPE_MAP).map((m) => [m.hkIdentifier, m]), + ); + expect(body.data.ingest.quantityTypes.length).toBeGreaterThan(0); + for (const q of body.data.ingest.quantityTypes) { + const mapping = byHk.get(q.hk); + expect(mapping).toBeDefined(); + expect(q.type).toBe(mapping!.measurementType); + expect(q.unit).toBe(mapping!.dbUnit); + // Quantity rows are the non-event-class mappings. + expect( + Boolean( + mapping!.eventClassificationMap || mapping!.fallbackClassification, + ), + ).toBe(false); + } + }); + + it("apiContractVersion is a non-empty string", async () => { + const res = await call(); + const body = (await res.json()) as CapabilitiesBody; + expect(typeof body.data.apiContractVersion).toBe("string"); + expect(body.data.apiContractVersion.length).toBeGreaterThan(0); + }); +}); diff --git a/src/app/api/meta/capabilities/route.ts b/src/app/api/meta/capabilities/route.ts new file mode 100644 index 000000000..eaeeea996 --- /dev/null +++ b/src/app/api/meta/capabilities/route.ts @@ -0,0 +1,101 @@ +/** + * GET /api/meta/capabilities + * + * Live capability / discovery surface for the native client. Returns the + * server's REAL id vocabularies — derived-metric ids, vitals-baseline + * types, insights layout tile-ids, metric-status ids, the HealthKit ingest + * mapping, and the FHIR coding constants — plus the running API contract + * version. The iOS app reads this once on launch (or on a version bump) and + * gates its UI / decoder against what the server actually ships, retiring + * the recurring "doc says N, server ships M" enum-drift class. + * + * Auth: cookie session OR Bearer token (`requireAuth`). NOT admin — every + * authenticated client may discover the contract. + * + * DESIGN RULE: every list is SOURCED from the existing canonical server + * constant / Zod enum / registry — never hand-duplicated here. Adding a + * derived metric, a tile, a writable source, or a HealthKit mapping + * auto-updates this endpoint, so it cannot drift from the real registries. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; + +import packageJson from "../../../../../package.json"; + +import { + DERIVED_METRIC_IDS, + VITALS_BASELINE_TYPES, +} from "@/lib/insights/derived/registry"; +import { INSIGHTS_TILE_IDS } from "@/lib/insights-layout"; +import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry"; +import { WELLNESS_SCORE_TYPES } from "@/lib/insights/derived/wellness-scores"; +import { WRITABLE_MEASUREMENT_SOURCES } from "@/lib/validations/measurement"; +import { + APPLE_HEALTH_TYPE_MAP, + type AppleHealthMapping, +} from "@/lib/measurements/apple-health-mapping"; +import { + ATC_SYSTEM, + SNOMED_SYSTEM, + GERMAN_ATC_DEFAULT_LOCALES, +} from "@/lib/fhir/build-bundle"; + +export const dynamic = "force-dynamic"; + +/** + * An EVENT-class HealthKit identifier carries a device-produced verdict, not + * a continuous reading — it is marked by an `eventClassificationMap` or a + * `fallbackClassification` in the ingest mapping. Everything else is a + * quantity sample. Splitting the single mapping by this predicate keeps the + * two ingest lists derived from one registry. + */ +function isEventMapping(m: AppleHealthMapping): boolean { + return Boolean(m.eventClassificationMap || m.fallbackClassification); +} + +export const GET = apiHandler(async () => { + await requireAuth(); + annotate({ action: { name: "meta.capabilities.read" } }); + + // Mirror the /api/version source so the contract version cannot drift from + // the running build's reported version (build-arg env wins over the + // package.json fallback for local dev). + const apiContractVersion = + process.env.NEXT_PUBLIC_APP_VERSION?.trim() || packageJson.version; + + const mappings = Object.values(APPLE_HEALTH_TYPE_MAP); + + const quantityTypes = mappings + .filter((m) => !isEventMapping(m)) + .map((m) => ({ + type: m.measurementType, + hk: m.hkIdentifier, + unit: m.dbUnit, + })); + + // Distinct event-class MeasurementTypes (several HK event identifiers can + // share one MeasurementType, e.g. the audio-exposure pair). + const eventTypes = Array.from( + new Set(mappings.filter(isEventMapping).map((m) => m.measurementType)), + ); + + return apiSuccess({ + apiContractVersion, + derivedMetricIds: DERIVED_METRIC_IDS, + vitalsBaselineTypes: VITALS_BASELINE_TYPES, + layoutTileIds: INSIGHTS_TILE_IDS, + metricStatusIds: METRIC_STATUS_IDS, + ingest: { + quantityTypes, + eventTypes, + computedScores: Object.values(WELLNESS_SCORE_TYPES), + writeAllowlist: WRITABLE_MEASUREMENT_SOURCES, + }, + fhir: { + atcSystem: ATC_SYSTEM, + snomedRoute: SNOMED_SYSTEM, + germanAtcDefaultLocales: GERMAN_ATC_DEFAULT_LOCALES, + }, + }); +}); diff --git a/src/app/api/workouts/batch/route.ts b/src/app/api/workouts/batch/route.ts index 5ee00fb99..a490b2e3d 100644 --- a/src/app/api/workouts/batch/route.ts +++ b/src/app/api/workouts/batch/route.ts @@ -87,13 +87,13 @@ const PR_DETECTION_SILENT_THRESHOLD = 50; const BATCH_RATE_LIMIT_MAX = 60; const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000; -// 5 MB request-body ceiling. The route geometry is the largest tail in -// a workout payload; with the 20 000-point cap on a single LineString -// AND the 100-workout cap per batch, the worst-case body is bounded -// well below this ceiling for typical lon/lat encodings. A request -// above this ceiling almost certainly indicates a misbehaving client -// or an attempted DoS — return 413 so the iOS client falls back to -// one-workout-per-call. +// 5 MB request-body ceiling (pre-existing). The route geometry is the +// largest tail in a workout payload; a typical batch sits comfortably +// under this, but the 20 000-point LineString cap combined with the +// 100-workout batch cap allows a theoretical tens-of-MB body for an +// extreme GPS-heavy backlog. Such a body returns 413 so the iOS client +// falls back to smaller / one-workout-per-call batches — which is also +// the correct response to a misbehaving client or an attempted DoS. const MAX_BODY_BYTES = 5 * 1024 * 1024; /** @@ -175,7 +175,16 @@ async function postBatch(request: NextRequest): Promise { return apiError("Too many batch submissions, try again later", 429); } - const { data: rawBody, error: jsonError } = await safeJson(request); + // The Content-Length pre-flight above bounds the common case, but a + // chunked upload carries no Content-Length. The `safeJson` body cap + // backstops that gap: the raw text is measured before `JSON.parse`, + // so an over-limit chunked body returns a clean 413 before the parse + // cost. Reuses the same 5 MB ceiling — a typical iOS batch sits under + // it; an extreme GPS-heavy backlog that exceeds it falls back to + // smaller batches client-side. + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: MAX_BODY_BYTES, + }); if (jsonError) return jsonError; // Distinguish "too many entries" from "validation failed" so the diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx index cd3e71194..c4c0bf9a8 100644 --- a/src/app/insights/blood-pressure/page.tsx +++ b/src/app/insights/blood-pressure/page.tsx @@ -11,7 +11,9 @@ import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; import { Button } from "@/components/ui/button"; import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; +import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge"; import { MetricEmptyState } from "@/components/insights/metric-empty-state"; +import { MetricRangeControls } from "@/components/insights/metric-range-controls"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { getBpTargets } from "@/lib/analytics/bp-targets"; @@ -101,7 +103,22 @@ export default function InsightsBlutdruckPage() { description={t("insights.subPage.blutdruckDescription")} explainerMetric="bloodPressure" coachLaunch + diversityNudge={ + + } + showAllValuesType="BLOOD_PRESSURE_SYS" + /* No `statStrip`: `` is single-series, but blood + pressure is two series (systolic + diastolic). A single min / max / + median / mean strip would silently drop the diastolic half, so the + page omits it rather than mislead. */ > + {/* The period-over-period delta keys on the systolic series — the + canonical BP figure the targets + status surfaces lead with. */} + + {/* No `statStrip` / `diversityNudge` / `showAllValuesType`: BMI is a + derived metric (computed from the WEIGHT series + the user's height), + so it has no first-class summary or raw-reading rows of its own. A + stat strip would have nothing to read, and a diversity nudge / "show + all values" entry would point at WEIGHT and duplicate the weight + page's controls. + BMI is derived from WEIGHT, so the period-over-period delta keys on + the underlying weight series the chart reuses. */} + + {/* No `` here: medication compliance is + event-driven, not a MeasurementType series, so the period-over-period + range read has nothing to aggregate. */}
= 2 ? "grid gap-4 sm:grid-cols-2" : "grid gap-4" diff --git a/src/app/insights/mood/page.tsx b/src/app/insights/mood/page.tsx index a21ea684c..d53a91c2b 100644 --- a/src/app/insights/mood/page.tsx +++ b/src/app/insights/mood/page.tsx @@ -100,6 +100,10 @@ export default function InsightsStimmungPage() { explainerMetric="mood" coachLaunch > + {/* No `` here: mood is event-driven, not a + MeasurementType series, so the period-over-period range read + (`/api/analytics/range`, keyed on a MeasurementType enum) has + nothing to aggregate. */} + + +