diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e2a6a5ed..fa6f71e1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## [1.37.27] — 2026-08-23 + +Five things this release fixes have the same shape: a check that was green because it was not checking. + +### Fixed + +- Importing an Apple Health archive rejected every ECG recording exported outside a US locale, and counted each one as a failure without recording why. Four separate causes, none about the recording: the file opens with a header row that has no value after it, waveform samples are written with a decimal comma, the sample rate reads `511,422 hertz` rather than `512 Hz`, and a German watch writes the column names in German. The parser had been written against a test fixture and the fixture was US-style throughout. All four forms are accepted now, and both dialects are pinned side by side so a later edit cannot narrow it back to one. +- Three of Apple's own English verdicts were dropped as unknown. `Heart Rate Over 120` and `Heart Rate Under 50` are what the export actually writes, which matches neither the framework's documented case names nor the wording in Apple's instructions. A verdict the parser cannot represent is now told apart from one it has never heard of. +- The Coach could not report a single environmental correlation. Four surfaces assembled the correlation matrix independently and three carried different channel sets, so asking whether air pressure related to your sleep searched a matrix that never held the pair. All four assemble from one place now. The Coach's file header had claimed for months that it ran the same scan as the insights page, which is why nobody looked. +- A metric card's assessment received every surviving correlation in the record rather than the ones about that metric. The filter that narrows to one metric had been replaced by the dismissal filter instead of chained with it, so the name, the docstring and the early-return guard all said "one metric" while the return said otherwise. Its test passed because the fixture held one pair. +- Reading a personal record issued the same single-row lookup once per metric. On a three-year record the dashboard did it 25 times and the analytics page 17. The comment above the helper had warned about exactly this since it was written. It resolves once per request now. +- Citation coverage reported full coverage for four of the six languages, because it measured against an English and German word bank while the text it measured was written in the reader's language. +- The Coach refused legitimate questions in some languages and let off-topic ones through. Its topic vocabulary was English and German, so the Italian word for "series" sat on the off-topic list while nothing recognised the Italian for "measurements". +- A number the Coach wrote in prose could be matched against an unrelated reading. Unit words were recognised in English only, so `7,4 Stunden` carried no kind, and a value with no kind clears the gate against every entry in the ledger: a sleep figure could ground itself on a glucose result. French dates were not parsed either, so correct prose was stripped as unverifiable. +- Asking Telegram for help in French, Spanish or Polish got no reply at all. +- The mood icon picker searched only English names, so typing back the translated group header it had just displayed returned an empty grid. + +### Changed + +- Deleting every threshold override at once asks first. The endpoint erased the whole set when the metric parameter was omitted, with no confirmation and, unlike its sibling, no rate limit of its own. It takes the same explicit confirmation the account deletion does. Deleting a single metric's override is unchanged. +- Removing a passkey now needs a fresh proof of possession, the same gate that has always guarded removal of a second-factor security key. The softer gate had been sitting on the primary sign-in credential. A passkey satisfies its own gate, so an account with nothing else enrolled keeps control of its own credential list, and the app keeps the capability through the step-up it already uses. +- A portable backup that claims a section it does not carry is refused whole rather than restored in part, and the refusal names the missing section. A section the export deliberately left out and declared as omitted still restores, which is what keeps the disaster-recovery export working. + +### Internal + +- The read surfaces have measured budgets for the first time. Against a three-year record (29,565 measurements, 1,095 mood entries, 5,475 medication intakes, 20,688 rollup rows) the heaviest surface issues 103 statements and 77 KB, and page load does not depend on record size at all. The budgets pin query counts, payload sizes and which rollup arm was taken rather than wall-clock, because counts were identical across runs while wall-clock moved up to sevenfold on the same machine. One ratio budget asserts that counts do not grow between a 30-day record and a three-year one. +- Every authenticated page was downloading the settings validation layer. The app shell wraps all of them and read one small function from a barrel that also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode along on every page load for a check that reads a URL slug. The shell reads that function from its own module now. No page grew, a hundred of them lost 5 to 8 KB, and the Insights overview has room under its budget again after three releases pressed against it. +- The dead-code gate had been blind to every re-export since knip 6.16. The tool changed at 6.28 and the old silence turned out to be an artefact of one setting rather than a clean tree: with that setting off, the old and new versions agree exactly. Two dead constants and 204 unimported re-exports are gone; every definition still in use stayed. + ## [1.37.26] — 2026-08-23 The app is offered in six languages, and in seven places it was reading what you wrote as though there were two. diff --git a/bundle-budget.json b/bundle-budget.json index c2415210d..c0df0e9d6 100644 --- a/bundle-budget.json +++ b/bundle-budget.json @@ -1,11 +1,11 @@ { - "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling.", + "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged.", "routesKbGz": { "/page": 460, "/insights/page": 445, "/measurements/page": 445, "/insights/mood/page": 460 }, - "totalClientKbGz": 3452, + "totalClientKbGz": 3500, "maxRechartsChunks": 1 } diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 70df81108..a732bb79e 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.37.26 + version: 1.37.27 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -2581,9 +2581,21 @@ paths: the account has no password — an account must keep at least one way in. - No step-up: this endpoint takes a plain cookie session or a wildcard Bearer token, where removing a - second-factor security key (DELETE /api/auth/me/mfa/webauthn/{id}) requires a fresh factor proof. The asymmetry - is in the code as it stands, not a description of intent. + Step-up gated, on the same mechanism and at the same strength as removing a second-factor security key (DELETE + /api/auth/me/mfa/webauthn/{id}). The caller must present a fresh possession proof: on the cookie path a session + that completed one within the last five minutes, on Bearer an `X-Step-Up` elevation minted at POST + /api/auth/step-up against a re-proved factor. A password-proved elevation is NOT enough. + + + A PASSKEY IS SUCH A PROOF here, unlike on the second-factor routes. An account whose only credential is a + passkey can satisfy this gate: a passkey login stamps the session, and POST /api/auth/step-up accepts `method: + "passkey"` against the account's primary passkeys. So its refusal is `meta.errorCode: auth.stepup.required` — + re-prove and retry — and not the dead-end `auth.stepup.mfa_not_enrolled`, which is reserved for an account + holding no credential this gate could ever accept. + + + The elevation is spent only when the deletion is about to happen. A 404 for an unknown id, or the + last-credential refusal, leaves it unconsumed and reusable. parameters: - in: path name: id @@ -2604,7 +2616,16 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "401": *a1 + "401": + description: Not authenticated, or the request carried no fresh possession proof (`meta.errorCode` = + `auth.stepup.required`). Re-prove and retry — on a native client by minting an elevation at POST + /api/auth/step-up, on the web by signing in again, which is what stamps the session. + `auth.stepup.mfa_not_enrolled` is the other arm and means the account holds no credential this gate can + accept, so retrying will not help. Nothing was removed either way. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "404": description: No such passkey for this account. content: @@ -7701,18 +7722,27 @@ paths: summary: Reset one threshold override, or all of them description: >- With `?metric=` it drops that metric's override; WITHOUT the parameter it drops EVERY override on the account. - There is no confirmation step and no dry run, so a client that means to reset one metric must send the - parameter. + The wide form is not reachable by omitting the parameter alone — it requires `{ "confirm": "RESET_THRESHOLDS" }` + in the body, the same typed-confirmation shape account deletion and the data reset use. The `?metric=` form + takes no body and is unchanged. - Audit-logged with the before and after maps. Unlike the PUT this path runs no rate limit of its own. + Audit-logged with the before and after maps. Rate-limited 30 / 5 min per user, matching the PUT. parameters: - in: query name: metric schema: - description: The single metric to reset. Omit to reset every override on the account. + description: The single metric to reset. Omit to reset every override on the account — which then requires the + confirmation body. $ref: "#/components/schemas/ThresholdMetric" - description: The single metric to reset. Omit to reset every override on the account. + description: The single metric to reset. Omit to reset every override on the account — which then requires the + confirmation body. + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/ThresholdsResetAllRequest" responses: "200": description: The remaining override map. @@ -7727,7 +7757,12 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "422": *a4 + "422": + description: '`metric` was omitted and the body did not carry `{ "confirm": "RESET_THRESHOLDS" }`. Nothing was reset.' + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/auth/me/devices: get: @@ -23030,6 +23065,17 @@ components: - TOTAL_BODY_WATER - BONE_MASS - OXYGEN_SATURATION + ThresholdsResetAllRequest: + type: object + properties: + confirm: + type: string + const: RESET_THRESHOLDS + description: Required when `metric` is omitted. Compared exactly; any other value is refused. + required: + - confirm + description: Confirmation body for the reset-everything form. Send no body at all when resetting a single metric with + `?metric=`. DevicePatchRequest: type: object properties: diff --git a/knip.jsonc b/knip.jsonc index 42ce9d06b..7361033fa 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -32,7 +32,7 @@ // This is a review tool, not a second gate, for two reasons written down so // the next reader does not have to rediscover them: // -// 1. It reports 29 files today. Some are test-only by construction (the +// 1. It reports 21 files today. Some are test-only by construction (the // guard helpers under `src/__tests__/helpers/`, the settings section // harness) and some are deliberately unreachable from production // (`src/lib/ai/mock-client.ts` is excluded structurally by the @@ -130,6 +130,19 @@ // Scoped to a single file: an export used only inside its own module is not // a finding. It is also why the default pass cannot answer the question the // production pass answers — this setting is about exports, not reachability. + // + // It had a second effect nobody asked for, worth knowing because it means + // the gate under-reported for a year. Until knip 6.28.0 this setting also + // exempted every RE-EXPORT: a barrel line was unconditionally marked + // "referenced in its own file", so `export { x } from "./x"` that nothing + // imported was never reported. knip 6.28.0 (PR webpro-nl/knip#1895, fixing + // issue #1894 — `--fix` deleted the source export and left the barrel line + // behind, emitting code that did not compile) dropped the exemption. The + // upgrade from 6.16.1 took this repo from 0 findings to 206 on unchanged + // code; all but two were barrel lines with no importer, and they are gone + // now. The suppression was entirely gated on this setting — with it off, + // 6.16.1 and 6.32.2 agree exactly. So a green Dead-code gate on any knip + // before 6.28.0 said nothing about re-exports. "ignoreExportsUsedInFile": true, "ignoreIssues": { "src/components/ui/**": ["exports", "types"], diff --git a/messages/de.json b/messages/de.json index a2d2e6d9e..c3cc01318 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6404,7 +6404,8 @@ "error": "Die Einstellung konnte nicht gespeichert werden." }, "dangerZoneDetail": "Dein Konto und deine Anmeldedaten bleiben erhalten.", - "deleteAccountCardDetail": "Dieser Vorgang kann nicht rückgängig gemacht werden." + "deleteAccountCardDetail": "Dieser Vorgang kann nicht rückgängig gemacht werden.", + "passkeyStepUpRequired": "Aus Sicherheitsgründen bitte erneut anmelden, bevor du einen Passkey entfernst." }, "admin": { "title": "Administration", @@ -6922,7 +6923,14 @@ "restoreSkippedPersonalRecordReference": "Herkunft einer Bestleistung", "restoreSkippedEcgReference": "Herkunft eines EKG-Streifens", "restoreSkippedMedicationTarget": "Zielwert-Analyt einer Medikation", - "restoreSkippedScheduleRevisionLink": "Abfolge der Schema-Zeiträume" + "restoreSkippedScheduleRevisionLink": "Abfolge der Schema-Zeiträume", + "restoreMissingTitle": "Wiederherstellung abgelehnt — die Datei ist unvollständig", + "restoreMissingDescription": "Das Manifest der Datei sagt, sie enthalte den folgenden Abschnitt, und sie enthält ihn nicht. Es wurde nichts geändert: Eine Wiederherstellung ersetzt eine ganze Datenklasse, ein fehlender Abschnitt hätte sie also geleert.", + "restoreMissingRemedy": "Nimm eine Notfall-Sicherung oder exportiere das Konto erneut und lade die neue Datei hoch. Ein Abschnitt, den das Manifest ausdrücklich als weggelassen ausweist, ist etwas anderes und wird normal wiederhergestellt.", + "restoreMissingDocuments": "Dokumente", + "restoreMissingWorkouts": "Workouts", + "restoreMissingMentalHealth": "Psychische Fragebögen", + "restoreMissingConsent": "Einwilligungsnachweise" }, "danger-zone": { "title": "Gefahrenzone", diff --git a/messages/en.json b/messages/en.json index ba0a5ac87..4e9238e5f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6404,7 +6404,8 @@ "error": "Could not save the setting." }, "dangerZoneDetail": "Your account and the credentials you sign in with are kept.", - "deleteAccountCardDetail": "This cannot be undone." + "deleteAccountCardDetail": "This cannot be undone.", + "passkeyStepUpRequired": "For your security, sign in again before removing a passkey." }, "admin": { "title": "Administration", @@ -6922,7 +6923,14 @@ "restoreSkippedPersonalRecordReference": "Personal best provenance", "restoreSkippedEcgReference": "ECG strip provenance", "restoreSkippedMedicationTarget": "Medication target analyte", - "restoreSkippedScheduleRevisionLink": "Schedule era succession" + "restoreSkippedScheduleRevisionLink": "Schedule era succession", + "restoreMissingTitle": "Restore refused — the file is incomplete", + "restoreMissingDescription": "The file's own manifest says it carries the section below, and it does not. Nothing was changed: a restore replaces a whole class of records, so putting back a section that is not in the file would have emptied it.", + "restoreMissingRemedy": "Use a disaster-recovery snapshot, or export the account again and upload the new file. A section the manifest declares as omitted is a different thing and restores normally.", + "restoreMissingDocuments": "Documents", + "restoreMissingWorkouts": "Workouts", + "restoreMissingMentalHealth": "Mental-health screeners", + "restoreMissingConsent": "Consent receipts" }, "danger-zone": { "title": "Danger Zone", diff --git a/messages/es.json b/messages/es.json index 632a9f9ed..bf5db0715 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6404,7 +6404,8 @@ "error": "No se pudo guardar el ajuste." }, "dangerZoneDetail": "Tu cuenta y las credenciales con las que inicias sesión se conservan.", - "deleteAccountCardDetail": "Esto no se puede deshacer." + "deleteAccountCardDetail": "Esto no se puede deshacer.", + "passkeyStepUpRequired": "Por seguridad, vuelve a iniciar sesión antes de quitar una clave de acceso." }, "admin": { "title": "Administration", @@ -6922,7 +6923,14 @@ "restoreSkippedPersonalRecordReference": "Procedencia de un récord personal", "restoreSkippedEcgReference": "Procedencia de un trazado de ECG", "restoreSkippedMedicationTarget": "Analito objetivo de un medicamento", - "restoreSkippedScheduleRevisionLink": "Sucesión de periodos de pauta" + "restoreSkippedScheduleRevisionLink": "Sucesión de periodos de pauta", + "restoreMissingTitle": "Restauración rechazada: el archivo está incompleto", + "restoreMissingDescription": "El propio manifiesto del archivo dice que incluye la sección siguiente, y no la incluye. No se cambió nada: una restauración sustituye toda una clase de registros, así que una sección ausente la habría vaciado.", + "restoreMissingRemedy": "Usa una copia de recuperación ante desastres, o vuelve a exportar la cuenta y sube el archivo nuevo. Una sección que el manifiesto declara omitida es otra cosa y se restaura con normalidad.", + "restoreMissingDocuments": "Documentos", + "restoreMissingWorkouts": "Entrenamientos", + "restoreMissingMentalHealth": "Cuestionarios de salud mental", + "restoreMissingConsent": "Comprobantes de consentimiento" }, "danger-zone": { "title": "Zona de peligro", diff --git a/messages/fr.json b/messages/fr.json index 5e03401f1..73fe2a65f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6404,7 +6404,8 @@ "error": "Le réglage n'a pas pu être enregistré." }, "dangerZoneDetail": "Votre compte et vos identifiants de connexion sont conservés.", - "deleteAccountCardDetail": "Cette action est irréversible." + "deleteAccountCardDetail": "Cette action est irréversible.", + "passkeyStepUpRequired": "Par sécurité, reconnecte-toi avant de supprimer une clé d'accès." }, "admin": { "title": "Administration", @@ -6922,7 +6923,14 @@ "restoreSkippedPersonalRecordReference": "Provenance d'un record personnel", "restoreSkippedEcgReference": "Provenance d'un tracé ECG", "restoreSkippedMedicationTarget": "Analyte cible d'un médicament", - "restoreSkippedScheduleRevisionLink": "Succession des périodes de schéma" + "restoreSkippedScheduleRevisionLink": "Succession des périodes de schéma", + "restoreMissingTitle": "Restauration refusée — le fichier est incomplet", + "restoreMissingDescription": "Le manifeste du fichier indique qu'il contient la section ci-dessous, et il ne la contient pas. Rien n'a été modifié : une restauration remplace toute une classe d'enregistrements, une section absente l'aurait donc vidée.", + "restoreMissingRemedy": "Utilise une sauvegarde de reprise après sinistre, ou réexporte le compte et téléverse le nouveau fichier. Une section que le manifeste déclare omise est autre chose et se restaure normalement.", + "restoreMissingDocuments": "Documents", + "restoreMissingWorkouts": "Séances", + "restoreMissingMentalHealth": "Questionnaires de santé mentale", + "restoreMissingConsent": "Preuves de consentement" }, "danger-zone": { "title": "Zone de danger", diff --git a/messages/it.json b/messages/it.json index eb02d4511..d2c73dcf0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6404,7 +6404,8 @@ "error": "Non è stato possibile salvare l'impostazione." }, "dangerZoneDetail": "Il tuo account e le credenziali con cui accedi restano.", - "deleteAccountCardDetail": "Questa azione non può essere annullata." + "deleteAccountCardDetail": "Questa azione non può essere annullata.", + "passkeyStepUpRequired": "Per sicurezza, accedi di nuovo prima di rimuovere una passkey." }, "admin": { "title": "Administration", @@ -6922,7 +6923,14 @@ "restoreSkippedPersonalRecordReference": "Provenienza di un primato personale", "restoreSkippedEcgReference": "Provenienza di un tracciato ECG", "restoreSkippedMedicationTarget": "Analita obiettivo di un farmaco", - "restoreSkippedScheduleRevisionLink": "Successione dei periodi di schema" + "restoreSkippedScheduleRevisionLink": "Successione dei periodi di schema", + "restoreMissingTitle": "Ripristino rifiutato — il file è incompleto", + "restoreMissingDescription": "Il manifesto del file dichiara di contenere la sezione qui sotto, e non la contiene. Non è stato modificato nulla: un ripristino sostituisce un'intera classe di record, quindi una sezione assente l'avrebbe svuotata.", + "restoreMissingRemedy": "Usa uno snapshot di disaster recovery, oppure esporta di nuovo l'account e carica il file nuovo. Una sezione che il manifesto dichiara omessa è un'altra cosa e si ripristina normalmente.", + "restoreMissingDocuments": "Documenti", + "restoreMissingWorkouts": "Allenamenti", + "restoreMissingMentalHealth": "Questionari sulla salute mentale", + "restoreMissingConsent": "Ricevute di consenso" }, "danger-zone": { "title": "Zona pericolosa", diff --git a/messages/pl.json b/messages/pl.json index 18514d304..d44095179 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6404,7 +6404,8 @@ "error": "Nie udało się zapisać ustawienia." }, "dangerZoneDetail": "Konto i dane logowania zostają zachowane.", - "deleteAccountCardDetail": "Tej operacji nie można cofnąć." + "deleteAccountCardDetail": "Tej operacji nie można cofnąć.", + "passkeyStepUpRequired": "Ze względów bezpieczeństwa zaloguj się ponownie przed usunięciem klucza dostępu." }, "admin": { "title": "Administracja", @@ -6922,7 +6923,14 @@ "restoreSkippedPersonalRecordReference": "Pochodzenie rekordu osobistego", "restoreSkippedEcgReference": "Pochodzenie zapisu EKG", "restoreSkippedMedicationTarget": "Analit docelowy leku", - "restoreSkippedScheduleRevisionLink": "Następstwo okresów schematu" + "restoreSkippedScheduleRevisionLink": "Następstwo okresów schematu", + "restoreMissingTitle": "Przywracanie odrzucone — plik jest niekompletny", + "restoreMissingDescription": "Manifest pliku mówi, że zawiera on poniższą sekcję, a jej nie zawiera. Nic nie zostało zmienione: przywracanie zastępuje całą klasę rekordów, więc brakująca sekcja by ją opróżniła.", + "restoreMissingRemedy": "Użyj kopii do odtwarzania po awarii albo wyeksportuj konto ponownie i wgraj nowy plik. Sekcja, którą manifest jawnie deklaruje jako pominiętą, to co innego i przywraca się normalnie.", + "restoreMissingDocuments": "Dokumenty", + "restoreMissingWorkouts": "Treningi", + "restoreMissingMentalHealth": "Kwestionariusze zdrowia psychicznego", + "restoreMissingConsent": "Potwierdzenia zgody" }, "danger-zone": { "title": "Strefa zagrożenia", diff --git a/package.json b/package.json index eaf031622..7ee5ef1a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.37.26", + "version": "1.37.27", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", @@ -123,7 +123,7 @@ "@vitest/coverage-v8": "^4.1.5", "eslint": "^9", "eslint-config-next": "16.2.12", - "knip": "^6.16.1", + "knip": "^6.32.2", "lefthook": "^2.1.10", "pdf-parse": "^2.4.5", "pdfjs-dist": "6.2.108", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24853b490..271314a1c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -228,8 +228,8 @@ importers: specifier: 16.2.12 version: 16.2.12(@typescript-eslint/parser@8.65.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) knip: - specifier: ^6.16.1 - version: 6.16.1 + specifier: ^6.32.2 + version: 6.32.2 lefthook: specifier: ^2.1.10 version: 2.1.10 @@ -580,6 +580,9 @@ packages: '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} @@ -589,6 +592,9 @@ packages: '@emnapi/runtime@1.11.1': resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@emnapi/runtime@1.8.1': resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} @@ -1357,12 +1363,6 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} - '@napi-rs/wasm-runtime@1.1.4': - resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} - peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 - '@napi-rs/wasm-runtime@1.2.2': resolution: {integrity: sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} @@ -1634,239 +1634,234 @@ packages: cpu: [x64] os: [win32] - '@oxc-parser/binding-android-arm-eabi@0.133.0': - resolution: {integrity: sha512-l/44caGse+VpnY9gx0yvvc5QnnG3yG1FO3KZgYvNL1GZrfK86zIwAOgGEVlxDyRymzrU/KHiblPFpevKOmJmUA==} + '@oxc-parser/binding-android-arm-eabi@0.143.0': + resolution: {integrity: sha512-n9uozULWflPqBtdmI8lAabLqGKNgLVNN0ZH8HfgCwpKGNtzRzauB76jTiW/3YLkcA7N1zskpi9GdVnZuu1SAvg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxc-parser/binding-android-arm64@0.133.0': - resolution: {integrity: sha512-KUHmPMziLBp4u+zbrLdB7iWS7KshuZe+RAp7ELnY9SI9nNXBZ+dp8fiBqWOxhXqn+FQg3a4UcQhwmsJOKV8Jjg==} + '@oxc-parser/binding-android-arm64@0.143.0': + resolution: {integrity: sha512-9BbdjHETk6O3zH/DDid9IgBtF0GlpLabNKN231uraXpRDSfY+iiZxTP5bk1Z63GBownVdhdINFIeddmMz4MzpQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxc-parser/binding-darwin-arm64@0.133.0': - resolution: {integrity: sha512-q8dWmnU/8ea2tga9w2f1PinQ5rcMPDUGkF64T189b65YMjUomET4oy5oRldOr4AwOQkneOG/Zttnz1Dvrc62wg==} + '@oxc-parser/binding-darwin-arm64@0.143.0': + resolution: {integrity: sha512-gh+6ecoHUy4/sUcolBl/1qPXKBbYNxFY0Pk0ujgQvINTMSftJY7o4yb8gOkDJPeZeB8+a+u7xTe6umoP8N5HFA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxc-parser/binding-darwin-x64@0.133.0': - resolution: {integrity: sha512-cOKeIELIB2bJnCKwqx4Rdj+1Lss/U6uCbLxRySZrhyOOQa1flKhwZFjEHRHxk8fU1NKmhK5OnTdPQ4CpjuFuVw==} + '@oxc-parser/binding-darwin-x64@0.143.0': + resolution: {integrity: sha512-qd1hl2d+lXgHv/VQ/M9qm8TrMC5T4RqDBwtOnl+1D0QMjwcz+8AaB4JSg8STgeag0GP6a6L74XEGAsrTSJWNzQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxc-parser/binding-freebsd-x64@0.133.0': - resolution: {integrity: sha512-OpaSv4pW3KgFrMYQxTaS0aOE4T1DQF3qZE/4B6uqqv1KgPWWd4UQhJALi8PJPX1RRV5K7ThKXRfF7qGg2+3l1A==} + '@oxc-parser/binding-freebsd-x64@0.143.0': + resolution: {integrity: sha512-M5XXcNa7aOqLPKTR41msfghKu2yQ4xWvCm11/gwU0JzOzHNk5sgW//rVEjJ+LO48+VDAMzXTSzurUVxIDKwozw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxc-parser/binding-linux-arm-gnueabihf@0.133.0': - resolution: {integrity: sha512-JGK1wlGrGwxBIlVSF7KWTX1/ru6BEtf28fRROztDRkLfiW+Kxa4onnriezMIiogfn9hVw2KzYcKiLjkLR2ns8A==} + '@oxc-parser/binding-linux-arm-gnueabihf@0.143.0': + resolution: {integrity: sha512-T/GXusuOkPNQhCQCSBbcU/N8j0rAypuDBl1IyFK+lyYT594XsVz80clPC/OtbSSpBGyJxj8uYEfctxVuxVYoww==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxc-parser/binding-linux-arm-musleabihf@0.133.0': - resolution: {integrity: sha512-yuZO533Ftonxn/iyoqQzURzLQHMspvsIyfiCSNi1t/ER4eIQaR0SsmUOUm5b/lmSig7IWIUa5/BrbEkAPwcilQ==} + '@oxc-parser/binding-linux-arm-musleabihf@0.143.0': + resolution: {integrity: sha512-oKu4RcBlXSqo3OC62dp6YTnQaZIurNDpCX3BnAM3+bJxt7s8J2TJKMnC0UYer1qhlRaDCg6wkTaTw+2IlsZ12w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxc-parser/binding-linux-arm64-gnu@0.133.0': - resolution: {integrity: sha512-hvpbqT5pN2rR+3+xtWeizwfR/aZ0vGceg6TqYMl+ToxMpk9/tmnX7kSvQnfEUkoua8mhogzvIKsAkn0wxgblBA==} + '@oxc-parser/binding-linux-arm64-gnu@0.143.0': + resolution: {integrity: sha512-WJBbD186AZmMGaSIhlktC+rPl8L3peCTXAh88Ih9uEvK0en2mPojGyCGYiL6mHtV1RPV3JyfJW5t6n5hh0lXhA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-arm64-musl@0.133.0': - resolution: {integrity: sha512-wJQGamIosQBoJHW9+S5XxrtKRo3eyJxsnS1XCPrqN0LHi8uw1pTqqTfn3t/NVuvbBg7Pumn4ez9Eidgcn0xbEg==} + '@oxc-parser/binding-linux-arm64-musl@0.143.0': + resolution: {integrity: sha512-t1AcYOwEzgceadT4v5e+vaCCb0AncCA3v5AyzfBAz/tMq11qzVccXKzNHtkWdjBsgvTKwRkaUF3QvT4kot8vcQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxc-parser/binding-linux-ppc64-gnu@0.133.0': - resolution: {integrity: sha512-Koaz32/O5+abIfrNGdyndgRvdOZ9jEf5/z3Ep9h3h2QWpdDiUQpVwgH0OcMXCs+l9aXxPLtkupqyVig9W6FDKw==} + '@oxc-parser/binding-linux-ppc64-gnu@0.143.0': + resolution: {integrity: sha512-RsnO/NoD8376LMJq8JS8TwI0ieNaFRTuNe2GVJntQg6gwZNMENZsEbknHdVwjpOmxdGLGodcwaGSbAeRr5Bgjw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-riscv64-gnu@0.133.0': - resolution: {integrity: sha512-R4vOjWzxhnNWHnVLeiB6jNuIifdy9vcMXZGPc7StXcxBovI+U2zg1QhZ9o8OjV80oGivs1lX5NfPLzk4IPqlRA==} + '@oxc-parser/binding-linux-riscv64-gnu@0.143.0': + resolution: {integrity: sha512-48fSVfR9TZi5CASZFyv0VC6z6BCoeihFsX031mAD/oSH7d9PYsPgIqza7d9mjP7Z2KTEpTFyH6SIu0Ui6R1vdg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-riscv64-musl@0.133.0': - resolution: {integrity: sha512-iwgBNUTHiMdxARLYuM0SBlnYeb19iw1Ea5M+4ERZupCsBMLArti6FyZ6UfFjJxIiTDr2oW2DGQFxlQVQ/dW9rA==} + '@oxc-parser/binding-linux-riscv64-musl@0.143.0': + resolution: {integrity: sha512-T8CpdD+SfE01DnIOD4HpVxu0ZJOfMJ/VhCvikKfaXAxkZ+9veyLM/D2hpi7Y2hFUyPmVQO3FNZHmYzV/WlVR4g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxc-parser/binding-linux-s390x-gnu@0.133.0': - resolution: {integrity: sha512-ZwZNo8FZmB/gVfboQl+wXilBigGl+6nQQs+nITOeAP/HcAOjiHl6XZJL9F/KXNEspODQcbjAiyjUbeCJd9a0fA==} + '@oxc-parser/binding-linux-s390x-gnu@0.143.0': + resolution: {integrity: sha512-QLdeMsCcacenPEFsfxnBUDF1y6opyz5+fmOz9bfD5Y7fiGCMupUCuB3KTPQhNwshIG1P9fPqar9MHxuBDd4bwQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-x64-gnu@0.133.0': - resolution: {integrity: sha512-govCvWx1dBlED3uu4qXctxpRcouu9I8Kn+DBktGCl760JtlGJzc9l/OmPJKlYWSbrRqKkMZehNeZ/4Wfma7uSA==} + '@oxc-parser/binding-linux-x64-gnu@0.143.0': + resolution: {integrity: sha512-659ujfqLy6k7cuH3sbzhd8b+ztSq+i6E2E9pG78Q0BmHjAExfGIdgc8cGgMdwAozDXeZFHkJ+LXYJdWsaGdgyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-x64-musl@0.133.0': - resolution: {integrity: sha512-ssTlpXD5Mq9uCssDJPzlRWqBt4Y7Zzd9i+XZhWmK/9Y6KUIuAxVYTYiI8lxcGWi0+3/Cz4A8q9UrD4NK9Y2j7g==} + '@oxc-parser/binding-linux-x64-musl@0.143.0': + resolution: {integrity: sha512-/Mw/9j4TfZcnKphPrzOE6t4MMknXadcAAuVUlDRTF/ETWB5xOgQvOJV2Mh9We/bWxZdoxaGAdc+hy4GuYwQ2yQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxc-parser/binding-openharmony-arm64@0.133.0': - resolution: {integrity: sha512-51aByfXhPtLEdWG4a2Ihdw6cPWV1ei1AarALpFdDP8MLWDLE2NuUMgbo3DERR2Kt8fT/ok1GUvBiLxVGke9uUQ==} + '@oxc-parser/binding-openharmony-arm64@0.143.0': + resolution: {integrity: sha512-8rIKWR2BFuifbIK/1XB9wTaSdtuJ25dlE7ZQYDnEwj/2xH2vHsxnvIjHT3ZjSVuLLwGGlSslIG/fbOJ8TV8rTw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxc-parser/binding-wasm32-wasi@0.133.0': - resolution: {integrity: sha512-2e16tkKp+wDO2GTAmXfxbBcCmGEaFPIJEIRBBmVKNVXSc8/fJsSIaBGyFTPHM9ST5GNWgJcYIt94rDTks+PLwA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] - - '@oxc-parser/binding-win32-arm64-msvc@0.133.0': - resolution: {integrity: sha512-KPTNDKbxH1cglrqTyVeXHb4Pk4oksz8EcE1/v8zqU7N4UXbiHfA/IwtXZ2U77fnRAWBbgVkl/lZbL7o3hRdejg==} + '@oxc-parser/binding-win32-arm64-msvc@0.143.0': + resolution: {integrity: sha512-5U9kQYMfRRI6Zq7KDxgbIP0RMnKrfn3gLepRMgJuRkPSUALTiRCk9d/uyhb4lGDjUdzwK7mBkKqhLgzBPCmLpQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxc-parser/binding-win32-ia32-msvc@0.133.0': - resolution: {integrity: sha512-Una1bNYv9zCavQrfnDR9wuZVB3itLjCEH4Oz7i6CwAJN/Xq9b+zbbcxmvdkKvvJt4Ngc/MBmIYlbLo3zS4TQ0A==} + '@oxc-parser/binding-win32-ia32-msvc@0.143.0': + resolution: {integrity: sha512-25P7AaHk4R88Yv2XH4gToDVmh0cOu+bEURQU10CRrmvgabfRArSGAP5osmwUKeSUHj0VS50upbpbRWWW/m7mHA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxc-parser/binding-win32-x64-msvc@0.133.0': - resolution: {integrity: sha512-kjBhCiOGSYTwDJQuuZa7a94JbP8htWu7J0X1KwH74kV2K5eYf6eyJRYmkpCDvr0XEL8tMxYI4WU1VekblFCLgg==} + '@oxc-parser/binding-win32-x64-msvc@0.143.0': + resolution: {integrity: sha512-ORMh3JE1s6V7ySicdRK7vgaDQnn5o+UHg9ct989PlWHbel8O9ARrmWXM6kZjrBMtNucxNayQ8g69G0VfWzhANw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@oxc-project/types@0.133.0': - resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} - '@oxc-project/types@0.139.0': resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} - '@oxc-resolver/binding-android-arm-eabi@11.20.0': - resolution: {integrity: sha512-IjfWOXRgJFNdORDl+Uf1aibNgZY2guOD3zmOhx1BGVb/MIiqlFTdmjpQNplSN58lhWehnX4UNqC3QwpUo8pjJg==} + '@oxc-project/types@0.143.0': + resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==} + + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==} cpu: [arm] os: [android] - '@oxc-resolver/binding-android-arm64@11.20.0': - resolution: {integrity: sha512-QqslZAuFQG8Q9xm7JuIn8JUbvywhSBMVhuQHtYW+auirZJloS41oxUUaBXk7uUhZJgp44c5zQLeVvmFaDQB+2Q==} + '@oxc-resolver/binding-android-arm64@11.24.2': + resolution: {integrity: sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==} cpu: [arm64] os: [android] - '@oxc-resolver/binding-darwin-arm64@11.20.0': - resolution: {integrity: sha512-MUcavykj2ewlR+kc5arpg4tC2RvzJkUxWtNv74pf7lcNk00GpIpN43vXMj+j6r4eMmfZhlb8hueKoIb8e9kAGQ==} + '@oxc-resolver/binding-darwin-arm64@11.24.2': + resolution: {integrity: sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==} cpu: [arm64] os: [darwin] - '@oxc-resolver/binding-darwin-x64@11.20.0': - resolution: {integrity: sha512-BGB16nRUK5Etiv//ihPyzj8Lj1px0mhh4YIfe0FDf045ywknfSm0GEbiRESpr6Q4K82AvnyaRIhhluHByvS4bg==} + '@oxc-resolver/binding-darwin-x64@11.24.2': + resolution: {integrity: sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==} cpu: [x64] os: [darwin] - '@oxc-resolver/binding-freebsd-x64@11.20.0': - resolution: {integrity: sha512-JZgtePaqj3qmD5XFHJaSLWzHRxQu0LaPkdoM1KJXYADvAaa83ijXHclV3ej3CueeW0wxfIAbGCZVP45J0CA7uQ==} + '@oxc-resolver/binding-freebsd-x64@11.24.2': + resolution: {integrity: sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==} cpu: [x64] os: [freebsd] - '@oxc-resolver/binding-linux-arm-gnueabihf@11.20.0': - resolution: {integrity: sha512-hOQ/p3ry3v3SchUBXicrrnszaI/UmYzM4wtS4RGfwgVUX7a+HbyQSzJ5aOzu+o6XZkFkS3ZXN4PZAzhOb77OSg==} + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + resolution: {integrity: sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==} cpu: [arm] os: [linux] - '@oxc-resolver/binding-linux-arm-musleabihf@11.20.0': - resolution: {integrity: sha512-2ArPksaw0AqeuGBfoS715VF+JvJQAhD2niWgjE5hVO+L+nAfikVQopvngCMX9x4BD8itWoQ3dnikrQyl5Ho5Jg==} + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + resolution: {integrity: sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==} cpu: [arm] os: [linux] - '@oxc-resolver/binding-linux-arm64-gnu@11.20.0': - resolution: {integrity: sha512-0bJnmYFp62JdZ4nVMDUZ/C58BCZOCcqgKtnUlp7L9Ojf/czIN+3j72YlLPeWLkzlr6SlYvIQA4SGV/HyO0d+qg==} + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + resolution: {integrity: sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==} cpu: [arm64] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-arm64-musl@11.20.0': - resolution: {integrity: sha512-wKHHzPKZo7Ufhv/Bt6yxT7FOgnIgW4gwXcJUipkShGp68W3wGVqvr1Sr0fY65lN0Oy6y41+g2kIDvkgZaMMUkw==} + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + resolution: {integrity: sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==} cpu: [arm64] os: [linux] libc: [musl] - '@oxc-resolver/binding-linux-ppc64-gnu@11.20.0': - resolution: {integrity: sha512-RN8goF7Ie0B79L4i4G6OeBocTgSC56vJbQ65VJje+oXnldVpLnOU7j/AQ/dP94TcCS+Yh6WG8u3Qt4ETteXFNQ==} + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + resolution: {integrity: sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-riscv64-gnu@11.20.0': - resolution: {integrity: sha512-5l1yU6/xQEqLZRzxqmMxJfWPslpwCmBsdDGaBvABPehxquCXDC7dd7oraNdKSJUMDXSM7VvVj8H2D2FTjU7oWw==} + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + resolution: {integrity: sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-riscv64-musl@11.20.0': - resolution: {integrity: sha512-xHEvkbgz6UC+A3JOyDQy76LkUaxsNSfIr3/GV8slwZsnuooJiIB34gzJfsyvR4JdCYNUUPsRJc/w/oWkODu+hg==} + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + resolution: {integrity: sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==} cpu: [riscv64] os: [linux] libc: [musl] - '@oxc-resolver/binding-linux-s390x-gnu@11.20.0': - resolution: {integrity: sha512-aWPDUUmSeyHvlW+SoEUd+JIJsQhVhu6a5tBpDRMu058naPAchTgAVGCFy35zjbnFlt0i8hLWziff6HX0D3LU4g==} + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + resolution: {integrity: sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==} cpu: [s390x] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-x64-gnu@11.20.0': - resolution: {integrity: sha512-x2YeSimvhJjKLVD8KSu8f/rqU1potcdEMkApIPJqjZWN7c2Fpt4g2X32WDg1p+XDAmyT7nuQGe0vnhvXeLbH+g==} + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + resolution: {integrity: sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==} cpu: [x64] os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-x64-musl@11.20.0': - resolution: {integrity: sha512-kcRLEIxpZefeYfLChjpgFf3ilBzRDZ+yobMrpRsQlSrxuFGtm3U6PMU7AaEpMqo3NfDGVyJJseAjnRLzMFHjwQ==} + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + resolution: {integrity: sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==} cpu: [x64] os: [linux] libc: [musl] - '@oxc-resolver/binding-openharmony-arm64@11.20.0': - resolution: {integrity: sha512-HHcfnApSZGtKhTiHqe8OZruOZe5XuFQH5/E0Yhj3u8fnFvzkM4/k6WjacUf4SvA0SPEAbfbgYmVPuo0VX/fIBQ==} + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + resolution: {integrity: sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==} cpu: [arm64] os: [openharmony] - '@oxc-resolver/binding-wasm32-wasi@11.20.0': - resolution: {integrity: sha512-Tn0y1XOFYHNfK1wp1Z5QK8Rcld/bsOwRISQXfqAZ5IBpv8Gz1IvV39fUWNprqNdRizgcvFhOzWwFun2zkJsyBg==} + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + resolution: {integrity: sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@oxc-resolver/binding-win32-arm64-msvc@11.20.0': - resolution: {integrity: sha512-qPi25YNPe4YenS8MgsQU2+bIFHxxpLx1LVna2444cEHqNPhNjvWf9zqj4aWE43H9LpAsTmkkAlA3eL5ElBU3mA==} + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + resolution: {integrity: sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==} cpu: [arm64] os: [win32] - '@oxc-resolver/binding-win32-x64-msvc@11.20.0': - resolution: {integrity: sha512-Wb14jWEW8huH6It9F6sXd9vrYmIS7pMrgkU6sxpLxkP+9z+wRgs71hUEhRpcn8FOXAFa27FVWfY2tRpbfTzfLw==} + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + resolution: {integrity: sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==} cpu: [x64] os: [win32] @@ -4671,9 +4666,6 @@ packages: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} - get-tsconfig@4.14.0: - resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} - get-tsconfig@4.14.1: resolution: {integrity: sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A==} @@ -5183,8 +5175,8 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} - knip@6.16.1: - resolution: {integrity: sha512-TKMn1rxgH6h9vXR9Y0B+Cq7AdPTr9EI02IwoT65NzqYUkvoDQAaJ/aPybiFpAhZ1px6cNYYwXf86iHkBgzCo9w==} + knip@6.32.2: + resolution: {integrity: sha512-WXTXbmocrw7gqm1A1TQvFN0OgJ7hUSU6E1g6SPRIzzHFogUBhXByc7cYeOFVtJ2uODg7DP4VbESYBYnfbtBYsg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -5707,12 +5699,12 @@ packages: resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==} engines: {node: '>= 0.4'} - oxc-parser@0.133.0: - resolution: {integrity: sha512-661RSx+ZcjBmjBYid+Fpp/2F5EbtildpeoZh5HdgnGs+jZ03nqQEQW8yGkt4BGyOC3OMPDQQRl8M5kqD2/g6jw==} + oxc-parser@0.143.0: + resolution: {integrity: sha512-ov0NzaDCOInknS7mP1cwKdJERt3utPW8ldjtdUXQ8Ty0GEFD08wk422vCUN0d7pST6kqtV7dxoI9w1Zi0l/9TA==} engines: {node: ^20.19.0 || >=22.12.0} - oxc-resolver@11.20.0: - resolution: {integrity: sha512-CblytBiV/a/ZXY34dsVU2NxhIOxMXst8CvDCtyBelVITgd7PLrKzbEbA6oKLdPjvDKDzCiW48qzmzZ+mYaqn+g==} + oxc-resolver@11.24.2: + resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} @@ -6457,6 +6449,10 @@ packages: resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} engines: {node: '>= 18'} + smol-toml@1.8.0: + resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} + engines: {node: '>= 18'} + sonner@2.0.7: resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} peerDependencies: @@ -6800,8 +6796,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - unbash@3.0.0: - resolution: {integrity: sha512-FeFPZ/WFT0mbRCuydiZzpPFlrYN8ZUpphQKoq4EeElVIYjYyGzPMxQR/simUwCOJIyVhpFk4RbtyO7RuMpMnHA==} + unbash@4.0.10: + resolution: {integrity: sha512-b7zoBQvpWp0vuN5q2vK2RRBR2SvuruQAs50DApdDveBSn3eSYd84IaHodFqQIMlvY9K2VnyBUEXgwOBuGU9GBg==} engines: {node: '>=14'} unbox-primitive@1.1.0: @@ -7681,6 +7677,12 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/core@1.11.2': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -7697,6 +7699,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1 @@ -8334,13 +8341,6 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.1 - optional: true - '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -8355,6 +8355,13 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.3 + optional: true + '@next/bundle-analyzer@16.3.1': dependencies: webpack-bundle-analyzer: 4.10.1 @@ -8575,133 +8582,126 @@ snapshots: '@openai/codex@0.144.6-win32-x64': optional: true - '@oxc-parser/binding-android-arm-eabi@0.133.0': + '@oxc-parser/binding-android-arm-eabi@0.143.0': optional: true - '@oxc-parser/binding-android-arm64@0.133.0': + '@oxc-parser/binding-android-arm64@0.143.0': optional: true - '@oxc-parser/binding-darwin-arm64@0.133.0': + '@oxc-parser/binding-darwin-arm64@0.143.0': optional: true - '@oxc-parser/binding-darwin-x64@0.133.0': + '@oxc-parser/binding-darwin-x64@0.143.0': optional: true - '@oxc-parser/binding-freebsd-x64@0.133.0': + '@oxc-parser/binding-freebsd-x64@0.143.0': optional: true - '@oxc-parser/binding-linux-arm-gnueabihf@0.133.0': + '@oxc-parser/binding-linux-arm-gnueabihf@0.143.0': optional: true - '@oxc-parser/binding-linux-arm-musleabihf@0.133.0': + '@oxc-parser/binding-linux-arm-musleabihf@0.143.0': optional: true - '@oxc-parser/binding-linux-arm64-gnu@0.133.0': + '@oxc-parser/binding-linux-arm64-gnu@0.143.0': optional: true - '@oxc-parser/binding-linux-arm64-musl@0.133.0': + '@oxc-parser/binding-linux-arm64-musl@0.143.0': optional: true - '@oxc-parser/binding-linux-ppc64-gnu@0.133.0': + '@oxc-parser/binding-linux-ppc64-gnu@0.143.0': optional: true - '@oxc-parser/binding-linux-riscv64-gnu@0.133.0': + '@oxc-parser/binding-linux-riscv64-gnu@0.143.0': optional: true - '@oxc-parser/binding-linux-riscv64-musl@0.133.0': + '@oxc-parser/binding-linux-riscv64-musl@0.143.0': optional: true - '@oxc-parser/binding-linux-s390x-gnu@0.133.0': + '@oxc-parser/binding-linux-s390x-gnu@0.143.0': optional: true - '@oxc-parser/binding-linux-x64-gnu@0.133.0': + '@oxc-parser/binding-linux-x64-gnu@0.143.0': optional: true - '@oxc-parser/binding-linux-x64-musl@0.133.0': + '@oxc-parser/binding-linux-x64-musl@0.143.0': optional: true - '@oxc-parser/binding-openharmony-arm64@0.133.0': - optional: true - - '@oxc-parser/binding-wasm32-wasi@0.133.0': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@oxc-parser/binding-openharmony-arm64@0.143.0': optional: true - '@oxc-parser/binding-win32-arm64-msvc@0.133.0': + '@oxc-parser/binding-win32-arm64-msvc@0.143.0': optional: true - '@oxc-parser/binding-win32-ia32-msvc@0.133.0': + '@oxc-parser/binding-win32-ia32-msvc@0.143.0': optional: true - '@oxc-parser/binding-win32-x64-msvc@0.133.0': + '@oxc-parser/binding-win32-x64-msvc@0.143.0': optional: true - '@oxc-project/types@0.133.0': {} - '@oxc-project/types@0.139.0': {} - '@oxc-resolver/binding-android-arm-eabi@11.20.0': + '@oxc-project/types@0.143.0': {} + + '@oxc-resolver/binding-android-arm-eabi@11.24.2': optional: true - '@oxc-resolver/binding-android-arm64@11.20.0': + '@oxc-resolver/binding-android-arm64@11.24.2': optional: true - '@oxc-resolver/binding-darwin-arm64@11.20.0': + '@oxc-resolver/binding-darwin-arm64@11.24.2': optional: true - '@oxc-resolver/binding-darwin-x64@11.20.0': + '@oxc-resolver/binding-darwin-x64@11.24.2': optional: true - '@oxc-resolver/binding-freebsd-x64@11.20.0': + '@oxc-resolver/binding-freebsd-x64@11.24.2': optional: true - '@oxc-resolver/binding-linux-arm-gnueabihf@11.20.0': + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': optional: true - '@oxc-resolver/binding-linux-arm-musleabihf@11.20.0': + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': optional: true - '@oxc-resolver/binding-linux-arm64-gnu@11.20.0': + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': optional: true - '@oxc-resolver/binding-linux-arm64-musl@11.20.0': + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': optional: true - '@oxc-resolver/binding-linux-ppc64-gnu@11.20.0': + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': optional: true - '@oxc-resolver/binding-linux-riscv64-gnu@11.20.0': + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': optional: true - '@oxc-resolver/binding-linux-riscv64-musl@11.20.0': + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': optional: true - '@oxc-resolver/binding-linux-s390x-gnu@11.20.0': + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': optional: true - '@oxc-resolver/binding-linux-x64-gnu@11.20.0': + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': optional: true - '@oxc-resolver/binding-linux-x64-musl@11.20.0': + '@oxc-resolver/binding-linux-x64-musl@11.24.2': optional: true - '@oxc-resolver/binding-openharmony-arm64@11.20.0': + '@oxc-resolver/binding-openharmony-arm64@11.24.2': optional: true - '@oxc-resolver/binding-wasm32-wasi@11.20.0': + '@oxc-resolver/binding-wasm32-wasi@11.24.2': dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) optional: true - '@oxc-resolver/binding-win32-arm64-msvc@11.20.0': + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': optional: true - '@oxc-resolver/binding-win32-x64-msvc@11.20.0': + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': optional: true '@parse/node-apn@8.1.0(supports-color@7.2.0)': @@ -11807,10 +11807,6 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 - get-tsconfig@4.14.0: - dependencies: - resolve-pkg-maps: 1.0.0 - get-tsconfig@4.14.1: dependencies: resolve-pkg-maps: 1.0.0 @@ -12283,19 +12279,19 @@ snapshots: kleur@4.1.5: {} - knip@6.16.1: + knip@6.32.2: dependencies: - fdir: 6.5.0(picomatch@4.0.4) + fdir: 6.5.0(picomatch@4.0.5) formatly: 0.3.0 - get-tsconfig: 4.14.0 + get-tsconfig: 4.14.1 jiti: 2.7.0 - oxc-parser: 0.133.0 - oxc-resolver: 11.20.0 - picomatch: 4.0.4 - smol-toml: 1.6.1 + oxc-parser: 0.143.0 + oxc-resolver: 11.24.2 + picomatch: 4.0.5 + smol-toml: 1.8.0 strip-json-comments: 5.0.3 tinyglobby: 0.2.17 - unbash: 3.0.0 + unbash: 4.0.10 yaml: 2.9.0 zod: 4.4.3 @@ -12777,52 +12773,51 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 - oxc-parser@0.133.0: - dependencies: - '@oxc-project/types': 0.133.0 - optionalDependencies: - '@oxc-parser/binding-android-arm-eabi': 0.133.0 - '@oxc-parser/binding-android-arm64': 0.133.0 - '@oxc-parser/binding-darwin-arm64': 0.133.0 - '@oxc-parser/binding-darwin-x64': 0.133.0 - '@oxc-parser/binding-freebsd-x64': 0.133.0 - '@oxc-parser/binding-linux-arm-gnueabihf': 0.133.0 - '@oxc-parser/binding-linux-arm-musleabihf': 0.133.0 - '@oxc-parser/binding-linux-arm64-gnu': 0.133.0 - '@oxc-parser/binding-linux-arm64-musl': 0.133.0 - '@oxc-parser/binding-linux-ppc64-gnu': 0.133.0 - '@oxc-parser/binding-linux-riscv64-gnu': 0.133.0 - '@oxc-parser/binding-linux-riscv64-musl': 0.133.0 - '@oxc-parser/binding-linux-s390x-gnu': 0.133.0 - '@oxc-parser/binding-linux-x64-gnu': 0.133.0 - '@oxc-parser/binding-linux-x64-musl': 0.133.0 - '@oxc-parser/binding-openharmony-arm64': 0.133.0 - '@oxc-parser/binding-wasm32-wasi': 0.133.0 - '@oxc-parser/binding-win32-arm64-msvc': 0.133.0 - '@oxc-parser/binding-win32-ia32-msvc': 0.133.0 - '@oxc-parser/binding-win32-x64-msvc': 0.133.0 - - oxc-resolver@11.20.0: - optionalDependencies: - '@oxc-resolver/binding-android-arm-eabi': 11.20.0 - '@oxc-resolver/binding-android-arm64': 11.20.0 - '@oxc-resolver/binding-darwin-arm64': 11.20.0 - '@oxc-resolver/binding-darwin-x64': 11.20.0 - '@oxc-resolver/binding-freebsd-x64': 11.20.0 - '@oxc-resolver/binding-linux-arm-gnueabihf': 11.20.0 - '@oxc-resolver/binding-linux-arm-musleabihf': 11.20.0 - '@oxc-resolver/binding-linux-arm64-gnu': 11.20.0 - '@oxc-resolver/binding-linux-arm64-musl': 11.20.0 - '@oxc-resolver/binding-linux-ppc64-gnu': 11.20.0 - '@oxc-resolver/binding-linux-riscv64-gnu': 11.20.0 - '@oxc-resolver/binding-linux-riscv64-musl': 11.20.0 - '@oxc-resolver/binding-linux-s390x-gnu': 11.20.0 - '@oxc-resolver/binding-linux-x64-gnu': 11.20.0 - '@oxc-resolver/binding-linux-x64-musl': 11.20.0 - '@oxc-resolver/binding-openharmony-arm64': 11.20.0 - '@oxc-resolver/binding-wasm32-wasi': 11.20.0 - '@oxc-resolver/binding-win32-arm64-msvc': 11.20.0 - '@oxc-resolver/binding-win32-x64-msvc': 11.20.0 + oxc-parser@0.143.0: + dependencies: + '@oxc-project/types': 0.143.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.143.0 + '@oxc-parser/binding-android-arm64': 0.143.0 + '@oxc-parser/binding-darwin-arm64': 0.143.0 + '@oxc-parser/binding-darwin-x64': 0.143.0 + '@oxc-parser/binding-freebsd-x64': 0.143.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.143.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.143.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.143.0 + '@oxc-parser/binding-linux-arm64-musl': 0.143.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.143.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.143.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.143.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.143.0 + '@oxc-parser/binding-linux-x64-gnu': 0.143.0 + '@oxc-parser/binding-linux-x64-musl': 0.143.0 + '@oxc-parser/binding-openharmony-arm64': 0.143.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.143.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.143.0 + '@oxc-parser/binding-win32-x64-msvc': 0.143.0 + + oxc-resolver@11.24.2: + optionalDependencies: + '@oxc-resolver/binding-android-arm-eabi': 11.24.2 + '@oxc-resolver/binding-android-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-x64': 11.24.2 + '@oxc-resolver/binding-freebsd-x64': 11.24.2 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-arm64-musl': 11.24.2 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-musl': 11.24.2 + '@oxc-resolver/binding-linux-s390x-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-musl': 11.24.2 + '@oxc-resolver/binding-openharmony-arm64': 11.24.2 + '@oxc-resolver/binding-wasm32-wasi': 11.24.2 + '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2 + '@oxc-resolver/binding-win32-x64-msvc': 11.24.2 p-limit@2.3.0: dependencies: @@ -13677,6 +13672,8 @@ snapshots: smol-toml@1.6.1: {} + smol-toml@1.8.0: {} + sonner@2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: react: 19.2.7 @@ -13972,8 +13969,8 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 tinyrainbow@3.1.0: {} @@ -14098,7 +14095,7 @@ snapshots: typescript@6.0.3: {} - unbash@3.0.0: {} + unbash@4.0.10: {} unbox-primitive@1.1.0: dependencies: @@ -14224,7 +14221,7 @@ snapshots: dependencies: lightningcss: 1.32.0 picomatch: 4.0.5 - postcss: 8.5.25 + postcss: 8.5.26 rolldown: 1.1.5 tinyglobby: 0.2.17 optionalDependencies: diff --git a/public/sw.js b/public/sw.js index c9005b490..c4bf32e90 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.37.26"; + /* @sw-version-fallback */ "v1.37.27"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/__tests__/discovery-matrix-guard.test.ts b/src/__tests__/discovery-matrix-guard.test.ts new file mode 100644 index 000000000..ca4d3c9f2 --- /dev/null +++ b/src/__tests__/discovery-matrix-guard.test.ts @@ -0,0 +1,195 @@ +/** + * Structural guards on the correlation-discovery matrix. + * + * The matrix is a channel SET, and a channel set copied into four files is a + * channel set that will differ in three of them. It did: the route and the + * per-metric card folded in the environmental-exposure and custom-metric + * families, the Coach `get_correlations` tool folded in neither, and the + * period narrative folded in neither of those plus neither of the + * compliance / symptom pair. Nobody wrote anything wrong — a family was added + * where it was needed and the other call sites were not on anyone's screen. + * `src/lib/insights/discovery-matrix.ts` removes the copies; these guards are + * what stop a fifth one appearing. + * + * ## What each guard rests on + * + * T1 freezes who may FETCH a channel. A new surface that wants a matrix has to + * either call the assembler or import a fetcher, and the second is the one this + * catches. + * + * T2 freezes who may CONSTRUCT a channel — the `role: "behaviour" | "outcome"` + * literal that makes a `NamedSeries`. A hand-rolled channel that never touches + * `correlation-channel-series.ts` would slip T1 and lands here. + * + * T3 freezes who may SCAN. This is the weakest of the three and the most + * likely to read as the strong one, so: passing T3 proves only that the list of + * files calling `discoverCorrelations` has not changed silently. It says + * nothing about what series those files pass. T1 and T2 are what make the + * series the same series. + * + * ## What they cannot do + * + * They are tripwires, not proofs. A reviewer who waves through an addition to + * an allowlist defeats all three. They match import statements and source + * text, so a fetcher reached through a re-export, a dynamic `import()`, or a + * channel built by a helper that takes `role` as a parameter would pass + * unseen. And they cannot tell a deliberate divergence from an accidental one: + * `assembleDiscoveryMatrix`'s options are where a real difference gets + * declared, and only a reader can judge whether the reason written there is + * a reason. + * + * Every match set below asserts a non-zero count. A matcher that has silently + * stopped matching agrees with any allowlist, and that failure mode is exactly + * how `bearer-scope-enforcement-guard` stayed green over a file it never saw. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +/** The one assembler. Every allowlist below is "this, plus the unavoidable". */ +const ASSEMBLER = "lib/insights/discovery-matrix.ts"; + +/** The module the channel fetchers live in — it defines them, so it matches. */ +const CHANNEL_SERIES = "lib/insights/correlation-channel-series.ts"; + +/** + * Every non-test `.ts` / `.tsx` under `src/`, excluding the generated Prisma + * client (9 MB; never read it) and test files themselves. + */ +function sourceFiles(): string[] { + return walkSourceFiles(SRC, { floor: 3000 }) + .filter((p) => !p.startsWith("generated/")) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")) + .sort(); +} + +function read(rel: string): string { + return readFileSync(join(SRC, rel), "utf8"); +} + +function filesMatching(re: RegExp): string[] { + return sourceFiles().filter((rel) => re.test(read(rel))); +} + +describe("T1 — only the assembler fetches discovery channels", () => { + /** + * The per-channel DB reads. A surface holding these is assembling a matrix + * by hand, whatever it calls the variable. + * + * `fetchLabDraws` is deliberately absent: the labs pass runs over + * `LabDrawPoint[]` in a separate point-vs-window scan, not over the + * behaviour × outcome day grid, so the route and the Coach tool fetch it + * directly and the assembler does not own it. + */ + const CHANNEL_FETCHERS = [ + "fetchComplianceSeries", + "fetchSymptomSeries", + "fetchEnvironmentSeries", + "fetchCustomMetricBehaviourSeries", + "fetchMoodWindowSeries", + "fetchMoodFactorWindowSeries", + "fetchMeasurementDailySeriesTiered", + "fetchMeasurementWindowSeries", + ] as const; + + /** + * `correlation-channel-series.ts` defines them all. The assembler is the one + * consumer. Nothing else. + */ + const FETCH_ALLOWLIST = [ASSEMBLER, CHANNEL_SERIES].sort(); + + for (const fetcher of CHANNEL_FETCHERS) { + it(`${fetcher} is reached only from the assembler`, () => { + const matches = filesMatching(new RegExp(`\\b${fetcher}\\b`)); + // A matcher that found nothing would agree with any allowlist. + expect(matches.length).toBeGreaterThan(0); + expect(matches).toEqual(FETCH_ALLOWLIST); + }); + } +}); + +describe("T2 — only the declared builders construct a discovery channel", () => { + /** + * A `NamedSeries` is identified by its `role` literal. Whitespace-tolerant: + * the same call written across two lines is the same call, and a matcher + * that demands one line is a matcher that can be defeated by prettier. + */ + const ROLE_LITERAL = /role:\s*"(?:behaviour|outcome)"/; + + /** + * Files allowed to mint a channel. + * + * - the assembler, which folds the matrix; + * - `correlation-channel-series.ts` + `correlation-series-builders.ts`, + * which shape one channel each from its own source; + * - `correlation-discovery.ts`, which declares the `NamedSeries` type and + * re-runs the engine over a filtered copy in the emerging pass; + * - `cycle/phase-crosstab.ts`, which builds a DELIBERATELY separate, + * cycle-gated matrix (CYCLE_PHASE × outcomes). That one must never reach + * the non-gated surfaces, which is the opposite requirement to this + * file's, and is why it is listed rather than merged. + */ + const CONSTRUCTION_ALLOWLIST = [ + ASSEMBLER, + CHANNEL_SERIES, + "lib/insights/correlation-series-builders.ts", + "lib/insights/correlation-discovery.ts", + "lib/cycle/phase-crosstab.ts", + ].sort(); + + it("no file outside the allowlist constructs a NamedSeries", () => { + const matches = filesMatching(ROLE_LITERAL); + expect(matches.length).toBeGreaterThan(0); + expect(matches).toEqual(CONSTRUCTION_ALLOWLIST); + }); +}); + +describe("T3 — the set of files that scan a matrix is frozen", () => { + /** + * Whitespace-tolerant on purpose: `discoverCorrelations(\n series,` is the + * same call, and a literal `discoverCorrelations(` matcher would miss it. + */ + const SCAN_CALL = /\bdiscoverCorrelations\s*\(/; + + /** + * The four discovery surfaces, plus the two engine-internal uses. + * + * Being on this list is NOT permission to build a matrix — T1 and T2 decide + * that. It is permission to scan one, and every entry here gets its series + * from `assembleDiscoveryMatrix` except the two that are the engine. + */ + const SCAN_ALLOWLIST = [ + // The four surfaces. Each calls the assembler for its series. + "app/api/insights/correlations/route.ts", + "lib/insights/metric-correlation-context.ts", + "lib/ai/coach/tools/correlations-read.ts", + "lib/insights/narrative/period-narrative.ts", + // The engine itself: the declaration and the emerging-window re-run. + "lib/insights/correlation-discovery.ts", + // The cycle-gated phase matrix — see T2's note. + "lib/cycle/phase-crosstab.ts", + ].sort(); + + it("no file outside the allowlist runs the discovery scan", () => { + const matches = filesMatching(SCAN_CALL); + expect(matches.length).toBeGreaterThan(0); + expect(matches).toEqual(SCAN_ALLOWLIST); + }); + + it("every scanning surface takes its series from the assembler", () => { + const surfaces = SCAN_ALLOWLIST.filter( + (rel) => + rel !== "lib/insights/correlation-discovery.ts" && + rel !== "lib/cycle/phase-crosstab.ts", + ); + expect(surfaces).toHaveLength(4); + for (const rel of surfaces) { + expect(read(rel)).toMatch(/\bassembleDiscoveryMatrix\s*\(/); + } + }); +}); diff --git a/src/__tests__/step-up-elevation-guard.test.ts b/src/__tests__/step-up-elevation-guard.test.ts index a8c4ec8b6..16589e1d1 100644 --- a/src/__tests__/step-up-elevation-guard.test.ts +++ b/src/__tests__/step-up-elevation-guard.test.ts @@ -87,8 +87,25 @@ function callers(exportName: string, moduleRe: RegExp): string[] { } /** - * Every route an elevation can unlock. This is the whole set — the second-factor - * management MUTATIONS and nothing besides. + * Every route an elevation can unlock: the second-factor management MUTATIONS, + * plus the one credential removal that is not a second factor and belongs here + * anyway. + * + * That one is `DELETE /api/auth/passkeys/{id}`. A passkey is the PRIMARY + * sign-in credential, and it sat behind a plain session while the second-factor + * key beside it demanded a fresh proof — the softer gate on the more valuable + * credential. It joins the set on the same `freshFactor: true` as the rest, + * differing only in `proofSource: "any-possession"` — which widens the + * REACHABILITY pre-check to count a primary passkey and does not soften the gate + * itself. A passkey-only account clears it by re-proving that passkey: on Bearer + * through the mint's `passkey` method, on the web through a fresh sign-in, which + * is what stamps `mfaVerifiedAt`. + * + * The widening that admits is real and bounded: an elevation minted against a + * re-proved factor can now also remove a passkey. That is the same class of act + * as the rest of the list — a credential the caller owns, removed after they + * prove they are the caller — and it is written down here rather than inferred, + * which is the whole point of this file. * * What is deliberately ABSENT is as much the point as what is present: * - `GET /api/auth/me/mfa`. The status read is plain `requireAuth()`: it @@ -108,6 +125,7 @@ function callers(exportName: string, moduleRe: RegExp): string[] { * exists to prevent. */ const ELEVATION_ROUTES = [ + "app/api/auth/passkeys/[id]/route.ts", "app/api/auth/me/mfa/disable/route.ts", "app/api/auth/me/mfa/recovery-codes/regenerate/route.ts", "app/api/auth/me/mfa/totp/confirm/route.ts", diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index f15f3bc8a..ec71b6817 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -40,6 +40,7 @@ import { import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups"; import { restoreCycleData } from "@/lib/cycle/backup"; import { + findMissingBackupSections, recordUnknownKeys, summarizeRestoreSkips, type RestoreSkipLog, @@ -193,9 +194,14 @@ const handler = apiHandler( return apiError("Failed to decrypt backup payload", 500); } + // Parsed once and kept, because the schema's per-section `.default([])` + // erases the difference between a section that is absent and one that is + // empty — and that difference is what the completeness check below reads. + let raw: unknown; let payload; try { - payload = parseBackupPayload(plaintext); + raw = JSON.parse(plaintext); + payload = parseBackupPayload(raw); } catch (err) { await auditLog("admin.backups.restore.failed", { userId: admin.id, @@ -289,6 +295,39 @@ const handler = apiHandler( }, }); + // A section the file's own manifest says it carries, and does not. + // + // The restore's first act is to delete the class it is about to rebuild, so + // an absent section is not a gap in the file — it is an erasure of that part + // of the account, reported as a success. #237: a portable file whose + // document payload was gone emptied the vault and said nothing. The whole + // file is refused instead, above the transaction, for the same reason the + // metadata-only check below sits there. + // + // A DECLARED omission is not this. The portable export leaves the screener + // administrations and the consent receipts out on purpose and says so in the + // manifest; `findMissingBackupSections` reads the manifest and not the + // emptiness of an array, so those files stay restorable. So does every file + // written before the manifest existed, which declares nothing at all. + const missingSections = findMissingBackupSections(raw); + if (missingSections.length > 0) { + await auditLog("admin.backups.restore.failed", { + userId: admin.id, + ipAddress: getClientIp(request), + details: { + backupId: id, + ownerId, + reason: "section_missing", + sections: missingSections, + }, + }); + return apiError( + `Backup is missing ${missingSections.join(", ")} — the file's own manifest says it carries ${missingSections.length === 1 ? "that section" : "those sections"}. Nothing was changed. Use a disaster-recovery snapshot, or export the account again.`, + 422, + { errorCode: "backup.section.missing", sections: missingSections }, + ); + } + // Portable exports intentionally omit document ciphertext. They remain // valid upload/download artifacts, but cannot be used to manufacture an // InboundDocument row. A DR restore must fail before any delete rather diff --git a/src/app/api/auth/passkeys/[id]/__tests__/route.test.ts b/src/app/api/auth/passkeys/[id]/__tests__/route.test.ts index ee81a5d11..c4e4adde5 100644 --- a/src/app/api/auth/passkeys/[id]/__tests__/route.test.ts +++ b/src/app/api/auth/passkeys/[id]/__tests__/route.test.ts @@ -16,6 +16,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@/lib/api-handler", () => ({ apiHandler: (fn: unknown) => fn, requireAuth: vi.fn(), + requireMfaManagementAuth: vi.fn(), })); vi.mock("@/lib/db", () => ({ @@ -35,8 +36,8 @@ vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); -import { PATCH } from "../route"; -import { requireAuth } from "@/lib/api-handler"; +import { DELETE, PATCH } from "../route"; +import { requireAuth, requireMfaManagementAuth } from "@/lib/api-handler"; import { prisma } from "@/lib/db"; const USER = { id: "user-1", username: "u", passwordHash: "argon2id$…" }; @@ -70,9 +71,18 @@ type Handler = ( ctx: { params: Promise<{ id: string }> }, ) => Promise; +const commitElevation = vi.fn().mockResolvedValue(undefined); + beforeEach(() => { vi.resetAllMocks(); vi.mocked(requireAuth).mockResolvedValue({ user: USER } as never); + commitElevation.mockClear().mockResolvedValue(undefined); + vi.mocked(requireMfaManagementAuth).mockResolvedValue({ + transport: "cookie", + user: USER, + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + commitElevation, + } as never); }); describe("PATCH /api/auth/passkeys/{id} — the rename refusal", () => { @@ -155,3 +165,108 @@ describe("PATCH /api/auth/passkeys/{id} — the rename refusal", () => { expect(prisma.passkey.update).not.toHaveBeenCalled(); }); }); + +/** + * The removal's gate, and the one option that keeps it from being a dead end. + * + * A passkey is the primary sign-in credential and it came off a plain session, + * while the second-factor key next door demanded a fresh proof. The route now + * asks the same gate that one asks, at the same strength — `freshFactor: true`, + * not a softer mode. What it adds is `proofSource: "any-possession"`, and that + * is the whole design: the gate's reachability pre-check counts second factors, + * and a passkey-only account holds none, so the default question would have + * refused `mfa_not_enrolled` to an account that was holding the proof the gate + * accepts. A passkey stamps `mfaVerifiedAt` on login and is a `passkey`-method + * mint arm; the pre-check simply was not asking about it. + * + * The gate itself is behavioural and lives against a real database in + * `tests/integration/step-up-elevation.test.ts` (E14). What is pinned here is + * what this file can prove without one: the route asks both halves of the right + * question, and it spends the proof only when it is about to delete. + * + * Mutation check: drop the `proofSource` option and the first case goes red; + * move `commitElevation()` above the ownership check and the third goes red; + * drop it altogether and the second goes red. + */ +describe("DELETE /api/auth/passkeys/{id} — the removal's gate", () => { + const mkDelete = () => + new Request("http://localhost/api/auth/passkeys/pk-1", { + method: "DELETE", + }); + + it("asks for a full step-up, against any possession factor", async () => { + vi.mocked(prisma.passkey.findUnique).mockResolvedValue({ + id: "pk-1", + userId: USER.id, + name: "Phone", + } as never); + vi.mocked(prisma.passkey.count).mockResolvedValue(2 as never); + vi.mocked(prisma.passkey.delete).mockResolvedValue({} as never); + + await (DELETE as unknown as Handler)(mkDelete(), { params }); + + expect(requireMfaManagementAuth).toHaveBeenCalledWith({ + freshFactor: true, + proofSource: "any-possession", + }); + // Not the bare second-factor question: it would refuse `mfa_not_enrolled` + // to a passkey-only account, which is every account this route mostly + // serves, over a proof it was already holding. + expect(requireMfaManagementAuth).not.toHaveBeenCalledWith({ + freshFactor: true, + }); + }); + + it("spends the proof once, on the way to the delete", async () => { + vi.mocked(prisma.passkey.findUnique).mockResolvedValue({ + id: "pk-1", + userId: USER.id, + name: "Phone", + } as never); + vi.mocked(prisma.passkey.count).mockResolvedValue(2 as never); + vi.mocked(prisma.passkey.delete).mockResolvedValue({} as never); + + const res = await (DELETE as unknown as Handler)(mkDelete(), { params }); + + expect(res.status).toBe(200); + expect(commitElevation).toHaveBeenCalledTimes(1); + expect(prisma.passkey.delete).toHaveBeenCalledWith({ + where: { id: "pk-1" }, + }); + }); + + it("does not spend the proof on another account's passkey id", async () => { + vi.mocked(prisma.passkey.findUnique).mockResolvedValue({ + id: "pk-1", + userId: "someone-else", + name: "Not yours", + } as never); + + const res = await (DELETE as unknown as Handler)(mkDelete(), { params }); + + expect(res.status).toBe(404); + expect(commitElevation).not.toHaveBeenCalled(); + expect(prisma.passkey.delete).not.toHaveBeenCalled(); + }); + + it("does not spend the proof when the last credential is refused", async () => { + vi.mocked(prisma.passkey.findUnique).mockResolvedValue({ + id: "pk-1", + userId: USER.id, + name: "Only one", + } as never); + vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never); + vi.mocked(requireMfaManagementAuth).mockResolvedValue({ + transport: "cookie", + user: { ...USER, passwordHash: null }, + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + commitElevation, + } as never); + + const res = await (DELETE as unknown as Handler)(mkDelete(), { params }); + + expect(res.status).toBe(400); + expect(commitElevation).not.toHaveBeenCalled(); + expect(prisma.passkey.delete).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/auth/passkeys/[id]/route.ts b/src/app/api/auth/passkeys/[id]/route.ts index 5c7ad7788..b8a79f6cd 100644 --- a/src/app/api/auth/passkeys/[id]/route.ts +++ b/src/app/api/auth/passkeys/[id]/route.ts @@ -8,7 +8,11 @@ import { safeJson, } from "@/lib/api-response"; import { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiHandler, + requireAuth, + requireMfaManagementAuth, +} from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { passkeyRenameSchema } from "@/lib/validations/auth"; @@ -61,12 +65,57 @@ export const PATCH = apiHandler( }, ); +/** + * Remove a passkey — step-up gated, on the same mechanism the second-factor + * security-key removal uses. + * + * The two deletions were not equals. Removing a registered SECOND factor + * demanded a fresh factor proof; removing a passkey, which is the PRIMARY + * sign-in credential, took a plain cookie session or a wildcard Bearer. The + * last-method check below stops an account locking itself out, but it does + * nothing about a hijacked session quietly stripping the credentials the owner + * signs in with. So this route goes through `requireMfaManagementAuth` too. + * + * Two things about the gate that are easy to get wrong, and are not: + * + * BEARER IS NOT REFUSED. `requireFreshMfa` is cookie-only, so a gate built on + * it alone would have told the native client to go and use the website. The + * gate this route borrows has a Bearer arm — a step-up elevation minted + * against a re-proved factor — and `passkey` is both one of the mint's methods + * and one of `FRESH_FACTOR_METHODS`, so the app re-proves the passkey it + * already holds and pays the same price the web pays. + * + * A PASSKEY-ONLY ACCOUNT CAN CLEAR THIS GATE, which is why the gate is the + * plain `freshFactor: true` and not something softer. It reads like a lockout + * — most accounts holding a passkey enrol no second factor beside it — and it + * is not, because a passkey IS a possession factor here, as this codebase + * already says in three places: a passkey login stamps `Session.mfaVerifiedAt` + * (`/api/auth/passkey/login-verify`, v1.23 M-review M1), `passkey` sits in + * `FRESH_FACTOR_METHODS`, and `resolveMfaEnrollmentRequired` counts a primary + * passkey as satisfying the enforcement policy outright. The only thing that + * would have refused such an account is the reachability pre-check inside + * `requireFreshMfa`, which counts second factors and nothing else; + * `proofSource: "any-possession"` asks the question this route actually means. + * + * WHAT A REFUSED PERSON DOES NEXT. On Bearer: mint an elevation with + * `method: "passkey"` at `POST /api/auth/step-up`. On the web: sign in again, + * which is what every other step-up refusal in this app tells people and what a + * passkey login already does. The web has no dedicated in-page re-proof for a + * live cookie session — the mint endpoint is Bearer-only by design — so the + * recovery there is a fresh sign-in rather than a dialog. That is a real limit, + * it is named here rather than discovered, and it is the same one the + * second-factor removal beside this route already lives with. + */ export const DELETE = apiHandler( async ( request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) => { - const { user } = await requireAuth(); + const auth = await requireMfaManagementAuth({ + freshFactor: true, + proofSource: "any-possession", + }); + const { user } = auth; const { id } = await params; @@ -91,6 +140,12 @@ export const DELETE = apiHandler( ); } + // Ownership resolved and the last-method check passed — spend the elevation + // now, so a 404 for someone else's id or a refused last credential does not + // burn a proof the caller then has to mint again. Same ordering as the + // security-key removal. + await auth.commitElevation(); + await prisma.passkey.delete({ where: { id } }); await auditLog("auth.passkey.delete", { diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index bde103c8f..7ae6557da 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -11,9 +11,12 @@ * causal. * * Reads daily series bounded to a trailing window, day-keyed in the user's - * display timezone (late-night readings mis-bucket under UTC). Pure compute - * lives in `src/lib/insights/correlation-discovery.ts`; this route only - * fetches + day-keys + responds. No LLM, no narrative, no cache table. + * display timezone (late-night readings mis-bucket under UTC). The channel set + * comes from `src/lib/insights/discovery-matrix.ts`, the one assembler every + * discovery surface shares; the pure compute lives in + * `src/lib/insights/correlation-discovery.ts`. This route only asks for the + * matrix, scans it, persists the pattern decisions and responds. No LLM, no + * narrative, no cache table. */ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess } from "@/lib/api-response"; @@ -25,34 +28,19 @@ import { requireModuleEnabled } from "@/lib/modules/gate"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { prisma } from "@/lib/db"; import { wallClockInTz } from "@/lib/tz/wall-clock"; -import type { MeasurementType } from "@/generated/prisma/client"; import { discoverCorrelations, discoverEmergingCorrelations, discoverLabOutcomeCorrelations, - discoveryMeasurementTypes, - DISCOVERY_BEHAVIOURS, - DISCOVERY_OUTCOMES, EARLY_WINDOW_DAYS, - MEDICATION_COMPLIANCE_CHANNEL_KEY, - SYMPTOM_SEVERITY_CHANNEL_KEY, - type DailySeriesPoint, - type NamedSeries, } from "@/lib/insights/correlation-discovery"; import { decisionForEvidence, PATTERN_FAMILIES, syncAcceptedPatterns, } from "@/lib/insights/correlation-patterns"; -import { - fetchComplianceSeries, - fetchCustomMetricBehaviourSeries, - fetchEnvironmentSeries, - fetchLabDraws, - fetchMeasurementDailySeriesTiered, - fetchMoodWindowSeries, - fetchSymptomSeries, -} from "@/lib/insights/correlation-channel-series"; +import { assembleDiscoveryMatrix } from "@/lib/insights/discovery-matrix"; +import { fetchLabDraws } from "@/lib/insights/correlation-channel-series"; export const dynamic = "force-dynamic"; @@ -125,85 +113,22 @@ async function buildCorrelationsResponse( const now = new Date(); const since = new Date(now.getTime() - WINDOW_DAYS * MS_PER_DAY); - // Non-MeasurementType channels are backed by other models, not measurements — - // MOOD (MoodEntry), MEDICATION_COMPLIANCE (the dose-history ledger), and - // SYMPTOM_SEVERITY (the illness day-log). `discoveryMeasurementTypes` drops - // them so the `type IN (...)` query carries only real enum values; each is - // built from its own source below and folded into the series. - const behaviourTypes = discoveryMeasurementTypes( - DISCOVERY_BEHAVIOURS, - ) as MeasurementType[]; - const outcomeTypes = discoveryMeasurementTypes( - DISCOVERY_OUTCOMES, - ) as MeasurementType[]; - - // Rollup read-swap for the measurement channels: eligible spot channels - // read their per-day means from the DAY rollup tier, with per-channel - // fallback to the raw `fetchMeasurementWindowSeries` path on any miss - // (coverage gap, far-from-UTC profile tz, sleep / cumulative grain) — - // see `fetchMeasurementDailySeriesTiered` for the full parity contract. - // The SWR cache cell above stays in front of this read: the swap only - // lowers the cost of a cache MISS, it does not replace the cache. - const [measurementDaily, moodWindow] = await Promise.all([ - fetchMeasurementDailySeriesTiered(userId, tz, since, [ - ...behaviourTypes, - ...outcomeTypes, - ]), - fetchMoodWindowSeries(userId, tz, since), - ]); - const { measurementsCapped } = measurementDaily; - const { moodDaily, moodCapped } = moodWindow; - - // v1.21.0 (FDREXTEND) — build the two non-measurement, non-mood channels from - // their own sources. Each degrades to an empty series when the user has no - // data, so the discovery loop drops the channel (it cannot clear n ≥ 20). - // v1.22 — lab draws (for the labs ↔ outcome pass) fetch alongside. - const [ - complianceSeries, - symptomSeries, - labDraws, - environmentSeries, - customMetricSeries, - ] = await Promise.all([ - fetchComplianceSeries(userId, tz, since), - fetchSymptomSeries(userId, tz, since), + // The channel set comes from the one assembler every discovery surface + // shares (`discovery-matrix.ts`) — this route, the per-metric card, the Coach + // tool and the period narrative all scan the same matrix by construction. + // `"tiered"` puts the eligible measurement channels on the DAY rollup + // read-swap, with per-channel fallback to the raw path on any miss (coverage + // gap, far-from-UTC profile tz, sleep / cumulative grain). The SWR cache cell + // above stays in front of this read: the swap only lowers the cost of a cache + // MISS, it does not replace the cache. + // + // v1.22 — lab draws (for the labs ↔ outcome pass) fetch alongside; they feed + // a different pass over a different grain, so they are not matrix channels. + const [matrix, labDraws] = await Promise.all([ + assembleDiscoveryMatrix(userId, { tz, since, fetchMode: "tiered" }), fetchLabDraws(userId, tz, since), - // v1.25 (W-ENV) — environmental-exposure behaviour channels (weather / - // daylight). Empty when the module is off / no home set, so the channels - // degrade to absent. The module gate is implicit: no rows ⇒ no channels. - fetchEnvironmentSeries(userId, since), - fetchCustomMetricBehaviourSeries(userId, tz, since), ]); - - const points = (key: string): DailySeriesPoint[] => - key === "MOOD" ? moodDaily : (measurementDaily.byType.get(key) ?? []); - - const series: NamedSeries[] = []; - for (const key of DISCOVERY_BEHAVIOURS) { - if (key === MEDICATION_COMPLIANCE_CHANNEL_KEY) { - series.push(complianceSeries); - } else if (key === SYMPTOM_SEVERITY_CHANNEL_KEY) { - series.push({ ...symptomSeries, role: "behaviour" }); - } else { - series.push({ key, role: "behaviour", points: points(key) }); - } - } - for (const key of DISCOVERY_OUTCOMES) { - if (key === SYMPTOM_SEVERITY_CHANNEL_KEY) { - series.push({ ...symptomSeries, role: "outcome" }); - } else { - series.push({ key, role: "outcome", points: points(key) }); - } - } - // v1.25 (W-ENV) — fold the environmental-exposure behaviour channels in. They - // pair (lag D → D+1) against every outcome above; the n ≥ 20 / FDR / effect- - // size gates apply unchanged, so a thin weather series degrades to absent. - for (const envSeries of environmentSeries) { - series.push(envSeries); - } - for (const customSeries of customMetricSeries) { - series.push(customSeries); - } + const { series, diagnostics } = matrix; const result = discoverCorrelations(series, { locale }); @@ -278,15 +203,15 @@ async function buildCorrelationsResponse( // half of the window may be thin. // The cap can only apply to raw-path channels — rollup-served // channels read one row per day per source and cannot reach it. - measurements_capped: measurementsCapped, + measurements_capped: diagnostics.measurementsCapped, // Rollup read-swap reach: how many measurement channels rode the DAY // rollup tier on this build (the rest took the raw fallback path). - measurement_rollup_channels: measurementDaily.rollupTypes.length, - mood_entries_capped: moodCapped, + measurement_rollup_channels: diagnostics.rollupTypes.length, + mood_entries_capped: diagnostics.moodCapped, // FDREXTEND — per-channel day-counts so a dashboard can see whether the // two sparse new channels reached the n ≥ 20 floor or degraded to absent. - compliance_days: complianceSeries.points.length, - symptom_days: symptomSeries.points.length, + compliance_days: diagnostics.complianceDays, + symptom_days: diagnostics.symptomDays, // v1.22 — early-detection + labs reach. emerging: emerging.emerging.length, emerging_window_days: emerging.windowDays, @@ -295,15 +220,9 @@ async function buildCorrelationsResponse( // v1.25 (W-ENV) — env channel reach (sum of stored daily points across // the exposure channels) so a dashboard can see whether weather was // available for the scan. - environment_days: environmentSeries.reduce( - (sum, s) => sum + s.points.length, - 0, - ), - custom_metric_channels: customMetricSeries.length, - custom_metric_days: customMetricSeries.reduce( - (sum, channel) => sum + channel.points.length, - 0, - ), + environment_days: diagnostics.environmentDays, + custom_metric_channels: diagnostics.customMetricChannels, + custom_metric_days: diagnostics.customMetricDays, }, }); diff --git a/src/app/api/telegram/webhook/__tests__/route.test.ts b/src/app/api/telegram/webhook/__tests__/route.test.ts index ac4e3b012..ecb653d87 100644 --- a/src/app/api/telegram/webhook/__tests__/route.test.ts +++ b/src/app/api/telegram/webhook/__tests__/route.test.ts @@ -546,6 +546,67 @@ describe("Telegram webhook — text-message dispatch", () => { expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); }); + /** + * The bare word is an alias for `/help`. It knew "help" and "hilfe", so the + * four other shipped languages asking for help fell through to the intake + * keyword, failed it, and the handler returned without sending anything — + * silence, not an error. These pin one spelling per shipped language. + */ + it.each([ + ["help", 210], + ["Hilfe", 211], + ["aide", 212], + ["ayuda", 213], + ["aiuto", 214], + ["Pomoc", 215], + ])("answers the bare help word %s", async (word, updateId) => { + const res = await POST( + tgRequest({ + update_id: updateId, + message: { message_id: 20, text: word, chat: { id: 7777 } }, + }), + ); + expect(res.status).toBe(200); + expect(sendTelegramMessage).toHaveBeenCalledTimes(1); + expect(vi.mocked(sendTelegramMessage).mock.calls[0][2]).toMatch( + /available commands/i, + ); + }); + + it("does not swallow a sentence that merely starts with a help word", async () => { + // The alias matches the WHOLE message; "help me log my weight" is prose + // the later paths should still see, not a command. + const res = await POST( + tgRequest({ + update_id: 216, + message: { + message_id: 21, + text: "help me log my weight", + chat: { id: 7777 }, + }, + }), + ); + expect(res.status).toBe(200); + expect(sendTelegramMessage).not.toHaveBeenCalled(); + }); + + it.each([ + ["bonjour", 220], + ["Hola", 221], + ["ciao", 222], + ["cześć", 223], + ])("answers the greeting %s", async (word, updateId) => { + const res = await POST( + tgRequest({ + update_id: updateId, + message: { message_id: 22, text: word, chat: { id: 7777 } }, + }), + ); + expect(res.status).toBe(200); + expect(sendTelegramMessage).toHaveBeenCalledTimes(1); + expect(vi.mocked(sendTelegramMessage).mock.calls[0][2]).toMatch(/👋/); + }); + it("greeting like 'hi' echoes a friendly reply", async () => { const res = await POST( tgRequest({ diff --git a/src/app/api/user/thresholds/__tests__/route.test.ts b/src/app/api/user/thresholds/__tests__/route.test.ts index dca481f73..c8524b19d 100644 --- a/src/app/api/user/thresholds/__tests__/route.test.ts +++ b/src/app/api/user/thresholds/__tests__/route.test.ts @@ -32,6 +32,9 @@ vi.mock("@/lib/analytics/effective-range", async (importOriginal) => { }; }); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserProfile: vi.fn(), +})); vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); @@ -44,7 +47,7 @@ vi.mock("next/headers", () => ({ })), })); -import { GET, PUT } from "../route"; +import { GET, PUT, DELETE } from "../route"; import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/db"; @@ -63,6 +66,31 @@ function putReq(body: unknown): NextRequest { }); } +function deleteReq( + options: { metric?: string; body?: unknown } = {}, +): NextRequest { + const url = options.metric + ? `http://localhost/api/user/thresholds?metric=${options.metric}` + : "http://localhost/api/user/thresholds"; + return new NextRequest(url, { + method: "DELETE", + ...(options.body === undefined + ? {} + : { + headers: { "content-type": "application/json" }, + body: JSON.stringify(options.body), + }), + }); +} + +/** The stored override set the DELETE branches read before they write. */ +function armOverrides(overrides: Record) { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + thresholdsJson: overrides, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); +} + beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); @@ -111,6 +139,121 @@ describe("PUT /api/user/thresholds — 422 multi-issue (v1.4.43 W6)", () => { }); }); +/** + * The parameterless DELETE erases every band the account has tuned, and it + * used to do it on an omitted query parameter alone — no confirmation, no dry + * run, and no rate limit of its own while the PUT beside it had one. + * + * The confirmation is the same shape the other two wide deletes use + * (`/api/settings/account` wants `DELETE_ACCOUNT`, `/api/settings/data` wants + * `DELETE`): a literal in the body, compared exactly. What is pinned here is + * the pair of properties that make it worth anything — the refusal happens, + * AND nothing was written when it does. A status assertion on its own would + * stay green if the wipe ran first and the 422 came after it. + * + * The single-metric form is pinned as UNCHANGED in the same block, because the + * failure mode of a confirmation is that it spreads to the narrow path and + * breaks the targets sheet, which resets metrics in a loop. + * + * Mutation check: drop the `confirm !== RESET_ALL_CONFIRMATION` branch and the + * first two cases go red; drop the rate-limit call and the fourth goes red; + * extend the confirmation to the `?metric=` arm and the fifth goes red. + */ +describe("DELETE /api/user/thresholds — the wide form asks first", () => { + it("refuses the parameterless form with no body", async () => { + armOverrides({ WEIGHT: { min: 60, max: 80 } }); + + const res = await DELETE(deleteReq()); + + expect(res.status).toBe(422); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain("RESET_THRESHOLDS"); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("refuses a body whose confirmation is the wrong token", async () => { + armOverrides({ WEIGHT: { min: 60, max: 80 } }); + + const res = await DELETE(deleteReq({ body: { confirm: "DELETE" } })); + + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("wipes every override once the confirmation is present", async () => { + armOverrides({ WEIGHT: { min: 60, max: 80 }, PULSE: { min: 50, max: 90 } }); + + const res = await DELETE( + deleteReq({ body: { confirm: "RESET_THRESHOLDS" } }), + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { overrides: unknown } }; + expect(body.data.overrides).toEqual({}); + expect(prisma.user.update).toHaveBeenCalledTimes(1); + }); + + it("runs the sibling's rate limit and honours a refusal", async () => { + armOverrides({ WEIGHT: { min: 60, max: 80 } }); + vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never); + + const res = await DELETE( + deleteReq({ body: { confirm: "RESET_THRESHOLDS" } }), + ); + + expect(res.status).toBe(429); + expect(checkRateLimit).toHaveBeenCalledWith( + "thresholds:reset:user-1", + 30, + 5 * 60 * 1000, + ); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + /** + * The other end. A confirmation the server demands and the only client that + * sends the wide form does not send is not a safeguard, it is an outage — + * and it would look like a passing suite, because the route tests above + * construct their own requests. So the token is compared across the two + * files rather than assumed to match. + */ + it("is the same token the settings card sends", async () => { + const { readFileSync } = await import("node:fs"); + const { join } = await import("node:path"); + + const route = readFileSync( + join(process.cwd(), "src/app/api/user/thresholds/route.ts"), + "utf8", + ); + const client = readFileSync( + join( + process.cwd(), + "src/components/settings/thresholds-editor-section.tsx", + ), + "utf8", + ); + + const declared = route.match(/RESET_ALL_CONFIRMATION = "([^"]+)"/)?.[1]; + expect(declared, "the route no longer declares a confirmation").toBe( + "RESET_THRESHOLDS", + ); + expect(client).toContain(`confirm: "${declared}"`); + }); + + it("leaves the single-metric form exactly as it was", async () => { + armOverrides({ WEIGHT: { min: 60, max: 80 }, PULSE: { min: 50, max: 90 } }); + + // No body at all — the targets sheet sends none, and must keep working. + const res = await DELETE(deleteReq({ metric: "WEIGHT" })); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { overrides: Record }; + }; + expect(Object.keys(body.data.overrides)).toEqual(["PULSE"]); + }); +}); + /** * The docblock promised a key the handler has never returned. * diff --git a/src/app/api/user/thresholds/route.ts b/src/app/api/user/thresholds/route.ts index 5034ae027..22fcce714 100644 --- a/src/app/api/user/thresholds/route.ts +++ b/src/app/api/user/thresholds/route.ts @@ -9,7 +9,10 @@ * it is worth stating the absence rather than just deleting the word. * PUT accepts a partial map — only the provided metrics are updated, others * keep their existing override or remain on the default. - * DELETE with ?metric=... resets one metric to default. + * DELETE with ?metric=... resets one metric to default. WITHOUT the parameter + * it drops every override the account has, so it demands the typed + * confirmation the other wide-blast-radius deletes demand — see + * `RESET_ALL_CONFIRMATION` below. */ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { @@ -118,6 +121,21 @@ export const PUT = apiHandler(async (request: NextRequest) => { return apiSuccess({ overrides: merged }); }); +/** + * What the parameterless DELETE has to be told before it wipes every override. + * + * Same shape as `/api/settings/account` (`DELETE_ACCOUNT`) and + * `/api/settings/data` (`DELETE`): a literal in the JSON body, compared + * exactly, with no default. The point is that the wide form cannot be reached + * by dropping a query parameter — a request that means "reset everything" has + * to say so in a second place. + * + * NOT applied to `?metric=`. That form resets one band the person can retype + * from the editor beside it, and the targets sheet fires it in a loop over the + * metrics it owns; a confirmation there would be ceremony without a decision. + */ +const RESET_ALL_CONFIRMATION = "RESET_THRESHOLDS"; + export const DELETE = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); const url = new URL(request.url); @@ -128,6 +146,43 @@ export const DELETE = apiHandler(async (request: NextRequest) => { meta: { metric: metric ?? "all" }, }); + // The PUT's limit, on the path that can destroy more in one call than the + // PUT can. It had none at all: an omitted query parameter erased the whole + // override set, and nothing stopped that being repeated. + const rl = await checkRateLimit( + `thresholds:reset:${user.id}`, + 30, + 5 * 60 * 1000, + ); + if (!rl.allowed) { + return apiError("Too many requests, please slow down", 429); + } + + if (!metric) { + const raw = await request.text(); + if (raw.length > 64 * 1024) { + return apiError(`Request body exceeds ${64 * 1024} bytes`, 413); + } + let confirm = ""; + if (raw.length > 0) { + try { + const body = JSON.parse(raw) as { confirm?: unknown }; + confirm = typeof body?.confirm === "string" ? body.confirm : ""; + } catch { + return apiError("Invalid request", 422); + } + } + if (confirm !== RESET_ALL_CONFIRMATION) { + // Names the token rather than saying "confirmation missing", so a client + // that reached here by omitting `?metric=` by accident is told what the + // request it sent actually meant. + return apiError( + `Confirmation missing. Resetting every threshold override requires { confirm: '${RESET_ALL_CONFIRMATION}' }`, + 422, + ); + } + } + const existing = await prisma.user.findUnique({ where: { id: user.id }, select: { thresholdsJson: true }, diff --git a/src/components/admin/__tests__/backups-restore-refusal-notice.test.tsx b/src/components/admin/__tests__/backups-restore-refusal-notice.test.tsx new file mode 100644 index 000000000..d818af957 --- /dev/null +++ b/src/components/admin/__tests__/backups-restore-refusal-notice.test.tsx @@ -0,0 +1,111 @@ +/** + * A refused backup has to tell the operator what to do next. + * + * The server refuses a file whose manifest claims a section the file does not + * carry, before it deletes anything. That refusal reaches a person through the + * `meta.errorCode` on the error envelope, and a bare "restore failed" toast + * would waste it: the operator on the other end is frequently holding the only + * copy of an account, and the two facts they can act on — which section is + * missing, and that nothing was changed — have to stay on screen. + * + * Pinned here: + * 1. the missing sections are named, not counted, + * 2. the notice says nothing was changed and what to do instead, + * 3. no sections means no notice — a panel that appears after every restore + * trains the operator to ignore it, + * 4. the copy resolves in more than one locale, + * 5. `missingSectionsOf` keys on the errorCode and not on the message text, + * so rewording the server's prose cannot silently remove the panel. + * + * Mutation check: render a count instead of the list and case 1 goes red; drop + * the remedy line and case 2 goes red; return the notice unconditionally and + * case 3 goes red; match on the message string in `missingSectionsOf` and case + * 5 goes red. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { + RestoreRefusalNotice, + missingSectionsOf, +} from "@/components/admin/backups-section"; +import { ApiError } from "@/lib/api/api-fetch"; +import type { MissingBackupSection } from "@/lib/export/restore-skips"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/admin/backups", +})); + +function render( + sections: MissingBackupSection[], + locale: "en" | "de" = "en", +): string { + return renderToStaticMarkup( + + {}} /> + , + ); +} + +describe("RestoreRefusalNotice", () => { + it("names every missing section", () => { + const html = render(["documents", "consent"]); + + expect(html).toContain('data-slot="restore-refusal-notice"'); + expect(html).toContain("Documents"); + expect(html).toContain("Consent receipts"); + }); + + it("says nothing was changed, and what to do instead", () => { + const html = render(["documents"]); + + expect(html).toContain("Nothing was changed"); + expect(html).toContain("disaster-recovery snapshot"); + }); + + it("renders nothing when no section was missing", () => { + expect(render([])).toBe(""); + }); + + it("speaks the operator's language", () => { + const german = render(["documents", "mentalHealth"], "de"); + + expect(german).toContain("Dokumente"); + expect(german).toContain("Psychische Fragebögen"); + expect(german).toContain("Es wurde nichts geändert"); + }); +}); + +describe("missingSectionsOf", () => { + it("reads the sections off the refusal envelope", () => { + const err = new ApiError("Backup is missing documents", 422, { + errorCode: "backup.section.missing", + sections: ["documents"], + }); + + expect(missingSectionsOf(err)).toEqual(["documents"]); + }); + + it("ignores an error whose message merely looks like one", () => { + // The message is prose the server may reword. Only the code counts. + const err = new ApiError("Backup is missing documents", 422, { + errorCode: "something_else", + sections: ["documents"], + }); + + expect(missingSectionsOf(err)).toEqual([]); + expect(missingSectionsOf(new Error("Backup is missing documents"))).toEqual( + [], + ); + }); + + it("survives a refusal with no section list", () => { + const err = new ApiError("Refused", 422, { + errorCode: "backup.section.missing", + }); + + expect(missingSectionsOf(err)).toEqual([]); + }); +}); diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 598d6aab9..5e2243a91 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -49,11 +49,18 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import type { BackupRow, BackupsList } from "@/types/backups"; import type { + MissingBackupSection, RestoreSkipSummary, SkippedCatalogue, } from "@/lib/export/restore-skips"; import { getApiErrorMessage } from "./_shared"; -import { apiFetch, apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch"; +import { + ApiError, + apiFetch, + apiFetchRaw, + apiGet, + apiPost, +} from "@/lib/api/api-fetch"; /** * Typed-confirmation dialog for restore. The destructive Restore button @@ -374,6 +381,114 @@ function catalogueLabel( return unlabelled; } +/** + * Which section a refused file claimed and did not carry, as a label. + * + * Spelled out with literal `t()` calls for the same reason as + * `catalogueLabel` above — an interpolated key is invisible to + * `i18n-call-site-coverage.test.ts`, and a renamed section would ship as raw + * dot notation. The `never` at the foot is what makes the compiler say the + * chain is exhaustive. + */ +function missingSectionLabel( + section: MissingBackupSection, + t: ReturnType["t"], +) { + if (section === "documents") { + return t("admin.section.backups.restoreMissingDocuments"); + } + if (section === "workouts") { + return t("admin.section.backups.restoreMissingWorkouts"); + } + if (section === "mentalHealth") { + return t("admin.section.backups.restoreMissingMentalHealth"); + } + if (section === "consent") { + return t("admin.section.backups.restoreMissingConsent"); + } + const unlabelled: never = section; + return unlabelled; +} + +/** + * Why a file was refused, and what to do about it. + * + * Held on screen rather than left to the toast, exactly like the skip report + * below it. The operator refused here is often holding the only copy of an + * account; "restore failed" for four seconds is not enough to act on, and the + * two facts that let them act — which section the file claimed and did not + * carry, and that nothing was changed — have to survive long enough to be read. + */ +/** + * The refused sections carried on an `ApiError`, or none. + * + * Keyed on `meta.errorCode` and not on the message text: the message is prose + * the server may reword, and matching on it would turn a copy edit into a + * silent loss of the panel. + */ +export function missingSectionsOf(err: unknown): MissingBackupSection[] { + if (!(err instanceof ApiError)) return []; + if (err.meta?.errorCode !== "backup.section.missing") return []; + const sections = err.meta?.sections; + if (!Array.isArray(sections)) return []; + return sections.filter( + (section): section is MissingBackupSection => typeof section === "string", + ); +} + +export function RestoreRefusalNotice({ + sections, + onDismiss, +}: { + sections: MissingBackupSection[]; + onDismiss: () => void; +}) { + const { t } = useTranslations(); + if (sections.length === 0) return null; + return ( +
+
+
+
+ +
+
+ ); +} + /** * What the last restore could not put back. * @@ -585,6 +700,13 @@ export function BackupsSection() { // until they dismiss it. const [skipped, setSkipped] = useState(null); + // Why the last restore was refused before it touched anything. Same reason + // the skip report is held rather than toasted: the operator has to be able to + // read it after the four seconds are up. + const [refusedSections, setRefusedSections] = useState< + MissingBackupSection[] + >([]); + // Restore: typed-confirmation dialog. The mutation is keyed by row id // and used inline by `` below — keeping the // mutation here lets the parent invalidate the list query on success. @@ -604,6 +726,7 @@ export function BackupsSection() { // A restore that dropped links is not a plain success and must not read // as one. The count and the exact keys go on screen; the same report is // in the audit row for anyone asking later. + setRefusedSections([]); const report = data.skipped; if (report && report.links > 0) { setSkipped(report); @@ -622,6 +745,16 @@ export function BackupsSection() { queryClient.invalidateQueries(); }, onError: (err) => { + // The server refuses an incomplete file before it deletes anything, and + // names the sections in `meta`. That is the one restore failure the + // operator can act on, so it gets the panel rather than only a toast. + const sections = missingSectionsOf(err); + if (sections.length > 0) { + setRefusedSections(sections); + toast.error(t("admin.section.backups.restoreMissingTitle")); + return; + } + setRefusedSections([]); toast.error( err instanceof Error && err.message ? err.message @@ -745,6 +878,11 @@ export function BackupsSection() { + setRefusedSections([])} + /> + {skipped ? ( { expect(hits.map((entry) => entry.name)).toContain("Dumbbell"); }); + /** + * The only searchable text used to be the English Lucide name and the + * English keyword aids, while the grid renders its group headers in the + * reader's language. A German reader who typed back the header the picker + * had just shown them got an empty grid and "no icons found" — an empty + * table with nothing to say the query was fine and the language was not. + */ + describe("group headers in the reader's language", () => { + /** + * The header the picker renders above the health group, per shipped + * locale, as `mood.manage.iconGroupHealth` spells it. Derived at the call + * site in the component; pinned here so the property is a test, not a + * transcription that can drift from the bundle. + */ + const HEALTH_HEADER: Record = { + en: "Health", + de: "Gesundheit", + fr: "Santé", + es: "Salud", + it: "Salute", + pl: "Zdrowie", + }; + + it.each(Object.entries(HEALTH_HEADER))( + "finds the health icons from the %s header", + (locale, header) => { + const labels = { health: header }; + const hits = filterIconCatalog(MOOD_TAG_ICON_CATALOG, header, labels); + const names = hits.map((entry) => entry.name); + // Every drawable icon under the header the reader just read. (An + // English "Health" also hits the "healthy" keyword aid on a food icon, + // which is a union match and fine — the assertion is on coverage of + // the group, not on the absence of other hits.) + for (const entry of MOOD_TAG_ICON_CATALOG) { + if (entry.group !== "health") continue; + if (!isMoodTagIconName(entry.name)) continue; + expect(names).toContain(entry.name); + } + void locale; + }, + ); + + it("ignores accents and case in the header match", () => { + const labels = { weather: "Météo" }; + const hits = filterIconCatalog(MOOD_TAG_ICON_CATALOG, "meteo", labels); + expect(hits.length).toBeGreaterThan(0); + expect(hits.every((entry) => entry.group === "weather")).toBe(true); + }); + + it("still finds nothing for a word no header and no keyword carries", () => { + // The per-icon aids stay English. This is the stated remaining limit, + // pinned so a later change to it is a deliberate one. + const labels = { health: "Gesundheit" }; + expect( + filterIconCatalog(MOOD_TAG_ICON_CATALOG, "herz", labels), + ).toHaveLength(0); + }); + }); + it("matches on keyword aids", () => { const byKeyword = filterIconCatalog(MOOD_TAG_ICON_CATALOG, "happy"); expect(byKeyword.length).toBeGreaterThan(0); diff --git a/src/components/mood/mood-tag-icon-picker.tsx b/src/components/mood/mood-tag-icon-picker.tsx index 08ba30015..031100ded 100644 --- a/src/components/mood/mood-tag-icon-picker.tsx +++ b/src/components/mood/mood-tag-icon-picker.tsx @@ -6,6 +6,7 @@ import { Search, X } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { useTranslations } from "@/lib/i18n/context"; +import { foldForMatch } from "@/lib/i18n/fold-for-match"; import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group"; import { MOOD_TAG_ICON_CATALOG, @@ -36,21 +37,42 @@ const ICON_GROUP_LABEL_KEYS: Record = { }; /** - * Pure search filter — name + keyword substring match, case-insensitive. + * Pure search filter — name, keyword, and translated group-header match. * Exported for unit tests. Entries the client bundle cannot draw are * excluded up front so the picker never offers a fallback-glyph tile. + * + * `groupLabels` maps a catalog group to the header the picker is CURRENTLY + * rendering above it, so the words on screen in the reader's language are also + * the words the box searches. Without it the only searchable text was the + * English Lucide name and the English keyword aids, and a German reader who + * typed what the picker itself had just shown them ("Gesundheit") got an empty + * grid and "no icons found" — an empty table, with nothing to say it was the + * language rather than the query. + * + * That is a partial close, stated rather than papered over: the per-icon + * keywords ("happy", "gym", "coffee") stay English, so searching for "Herz" or + * "kawa" still finds nothing. Translating them is ~80 icons times three aids + * times six languages of new content, which is a decision about content rather + * than about this filter. Omit the argument and the behaviour is the old one. + * + * Both sides go through `foldForMatch`, so accents and hyphens cannot make a + * reader miss a header they can see. */ export function filterIconCatalog( catalog: readonly MoodTagIconCatalogEntry[], query: string, + groupLabels: Readonly> = {}, ): MoodTagIconCatalogEntry[] { const drawable = catalog.filter((entry) => isMoodTagIconName(entry.name)); - const needle = query.trim().toLowerCase(); + const needle = foldForMatch(query); if (needle.length === 0) return drawable; return drawable.filter( (entry) => - entry.name.toLowerCase().includes(needle) || - entry.keywords.some((keyword) => keyword.toLowerCase().includes(needle)), + foldForMatch(entry.name).includes(needle) || + entry.keywords.some((keyword) => + foldForMatch(keyword).includes(needle), + ) || + foldForMatch(groupLabels[entry.group] ?? "").includes(needle), ); } @@ -65,9 +87,22 @@ export function MoodTagIconPicker({ value, onChange }: MoodTagIconPickerProps) { const [query, setQuery] = useState(""); const groupLabelId = useId(); + // The headers the grid is about to render, so the search box matches the + // words the reader can see rather than only their English source. + const groupLabels = useMemo( + () => + Object.fromEntries( + Object.entries(ICON_GROUP_LABEL_KEYS).map(([group, key]) => [ + group, + t(key), + ]), + ), + [t], + ); + const filtered = useMemo( - () => filterIconCatalog(MOOD_TAG_ICON_CATALOG, query), - [query], + () => filterIconCatalog(MOOD_TAG_ICON_CATALOG, query, groupLabels), + [query, groupLabels], ); // One flat roving radiogroup across the (filtered) grid; the group diff --git a/src/components/settings/security-section/__tests__/passkey-removal-step-up.test.tsx b/src/components/settings/security-section/__tests__/passkey-removal-step-up.test.tsx new file mode 100644 index 000000000..af14ec769 --- /dev/null +++ b/src/components/settings/security-section/__tests__/passkey-removal-step-up.test.tsx @@ -0,0 +1,110 @@ +/** + * The removal's refusal has to say something true, in the person's language. + * + * `DELETE /api/auth/passkeys/{id}` is step-up gated now, and the server answers + * 401 with `meta.errorCode: auth.stepup.required` and the prose "Recent + * second-factor verification required". Rendering that prose would have been + * wrong twice over: it is English regardless of the operator's locale, and for + * the passkey-only account this card mostly serves there is no second factor — + * the credential to re-prove is the passkey itself. + * + * So the card maps the refusal to its own sentence. Pinned here: + * 1. a step-up refusal renders the card's sentence, not the server's prose, + * 2. the mfa_not_enrolled arm maps too — both codes mean "the gate stopped + * you", and handling only the first falls through to raw English, + * 3. an ordinary failure still shows its own message rather than being + * swallowed by the step-up sentence, + * 4. the sentence resolves in more than one locale, + * 5. the sentence names the recovery the web actually has. The mint endpoint + * is Bearer-only by design, so there is no in-page re-proof dialog for a + * cookie session; signing in again is the recovery, and a passkey login + * stamps `mfaVerifiedAt`. If that ever changes, this assertion is the + * reminder that the copy has to change with it. + * + * Mutation check: return `err.message` from the removal's `onError` and cases 1, + * 2 and 4 go red; narrow `describeStepUp` to the exact `auth.stepup.required` + * code and case 2 goes red; drop the fallback arm and case 3 goes red. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { ApiError } from "@/lib/api/api-fetch"; +import { describeStepUp } from "../security-keys-card"; +import en from "@/../messages/en.json"; +import de from "@/../messages/de.json"; + +const STEP_UP_SENTENCE = en.settings.passkeyStepUpRequired; + +function refusal(errorCode: string): ApiError { + return new ApiError("Recent second-factor verification required", 401, { + errorCode, + }); +} + +describe("passkey removal — the step-up refusal a person sees", () => { + it("replaces the server's prose on a step-up refusal", () => { + const shown = describeStepUp( + refusal("auth.stepup.required"), + "fallback", + STEP_UP_SENTENCE, + ); + + expect(shown).toBe(STEP_UP_SENTENCE); + expect(shown).not.toContain("second-factor verification required"); + }); + + it("maps the not-enrolled arm too", () => { + expect( + describeStepUp( + refusal("auth.stepup.mfa_not_enrolled"), + "fallback", + STEP_UP_SENTENCE, + ), + ).toBe(STEP_UP_SENTENCE); + }); + + it("leaves an ordinary refusal saying its own thing", () => { + const lastMethod = new ApiError( + "Cannot delete — at least one authentication method must remain", + 400, + ); + + expect(describeStepUp(lastMethod, "fallback", STEP_UP_SENTENCE)).toContain( + "at least one authentication method", + ); + }); + + it("carries the sentence in every locale the app offers", () => { + // The EN↔DE pair is the smoke test; `i18n-locale-integrity.test.ts` holds + // the other four to the same guarantee. + expect(de.settings.passkeyStepUpRequired).toBeTruthy(); + expect(de.settings.passkeyStepUpRequired).not.toBe( + en.settings.passkeyStepUpRequired, + ); + }); + + it("names the recovery the web actually has", () => { + // Not "verify to continue": there is no in-page re-proof for a cookie + // session, because the elevation mint is Bearer-only by design. Signing in + // again is the recovery, and a passkey login stamps the session. + expect(STEP_UP_SENTENCE.toLowerCase()).toContain("sign in again"); + }); + + it("routes the removal through the mapper rather than the raw message", () => { + const source = readFileSync( + resolve(__dirname, "../passkey-list-section.tsx"), + "utf8", + ); + // Bounded by the next top-level declaration rather than by the first + // `});`, which lands inside `mutationFn` and would slice the handler away. + const start = source.indexOf("const remove = useMutation"); + const end = source.indexOf("const DEVICE_TYPE_LABELS", start); + expect(start, "the removal mutation was renamed").toBeGreaterThan(-1); + expect(end, "the slice's end anchor is gone").toBeGreaterThan(start); + const body = source.slice(start, end); + + expect(body).toContain("describeStepUp"); + expect(body).toContain("settings.passkeyStepUpRequired"); + }); +}); diff --git a/src/components/settings/security-section/passkey-list-section.tsx b/src/components/settings/security-section/passkey-list-section.tsx index 087866754..ed6cd4b7b 100644 --- a/src/components/settings/security-section/passkey-list-section.tsx +++ b/src/components/settings/security-section/passkey-list-section.tsx @@ -40,6 +40,7 @@ import { apiPost, } from "@/lib/api/api-fetch"; import { describePasskeyError } from "@/lib/passkey-errors"; +import { describeStepUp } from "./security-keys-card"; interface PasskeyInfo { id: string; @@ -169,7 +170,14 @@ export function PasskeyListSection({ setMsg(null); queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() }); }, - onError: (err: Error) => setMsg(err.message), + // Removal is step-up gated. The server's own prose says "second-factor", + // which is wrong for the passkey-only account this card mostly serves — the + // credential to re-prove is the passkey — so the refusal gets its own + // sentence naming the recovery that exists on the web: sign in again. + onError: (err: Error) => + setMsg( + describeStepUp(err, err.message, t("settings.passkeyStepUpRequired")), + ), }); const DEVICE_TYPE_LABELS: Record = { diff --git a/src/components/settings/security-section/security-keys-card.tsx b/src/components/settings/security-section/security-keys-card.tsx index bcaf55806..3dcda31d9 100644 --- a/src/components/settings/security-section/security-keys-card.tsx +++ b/src/components/settings/security-section/security-keys-card.tsx @@ -41,7 +41,21 @@ export interface WebauthnKeyInfo { lastUsedAt: string | null; } -function describeStepUp( +/** + * Turn a refusal into a sentence, with the step-up arm told apart from the rest. + * + * Exported because the passkey card next door needs the identical mapping since + * passkey removal became step-up gated too. The server's own prose there is + * "Recent second-factor verification required", which is English-only and, for a + * passkey-only account, not even accurate about which credential to re-prove — + * so the caller supplies its own sentence and this only decides when to use it. + * + * Matched on `meta.errorCode`, and on the whole `auth.stepup` prefix rather than + * one code: `auth.stepup.required` and `auth.stepup.mfa_not_enrolled` both mean + * "the gate stopped you", and a card that handled only the first would fall + * through to raw server prose on the second. + */ +export function describeStepUp( err: unknown, fallback: string, stepUpMsg: string, diff --git a/src/components/settings/thresholds-editor-section.tsx b/src/components/settings/thresholds-editor-section.tsx index 29a9689f1..0d638024b 100644 --- a/src/components/settings/thresholds-editor-section.tsx +++ b/src/components/settings/thresholds-editor-section.tsx @@ -108,10 +108,19 @@ export function ThresholdsEditorSection({ id }: { id: string }) { const resetMutation = useMutation({ mutationFn: async (metric: ThresholdMetric | null) => { - const url = metric - ? `/api/user/thresholds?metric=${metric}` - : "/api/user/thresholds"; - const res = await apiFetchRaw(url, { method: "DELETE" }); + // `metric === null` is the reset-everything arm behind the confirm + // dialog at the foot of the card. The server asks for the same decision + // in the body, so the dialog's answer is what gets sent — the wide form + // is not reachable by dropping the query parameter. + const res = metric + ? await apiFetchRaw(`/api/user/thresholds?metric=${metric}`, { + method: "DELETE", + }) + : await apiFetchRaw("/api/user/thresholds", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ confirm: "RESET_THRESHOLDS" }), + }); if (!res.ok) throw new Error("reset failed"); }, onSuccess: () => { diff --git a/src/lib/__tests__/require-fresh-mfa.test.ts b/src/lib/__tests__/require-fresh-mfa.test.ts index 308df3044..c6b7dcf74 100644 --- a/src/lib/__tests__/require-fresh-mfa.test.ts +++ b/src/lib/__tests__/require-fresh-mfa.test.ts @@ -6,6 +6,7 @@ vi.mock("@/lib/db", () => ({ prisma: { session: { findUnique: vi.fn() }, webauthnMfaCredential: { count: vi.fn() }, + passkey: { count: vi.fn() }, }, })); @@ -113,6 +114,75 @@ describe("requireFreshMfa", () => { expect(ctx.user.id).toBe("user-1"); }); + /** + * The reachability pre-check, and the one route that asks it differently. + * + * `requireFreshMfa` refuses `mfa_not_enrolled` when the account holds nothing + * that could ever produce the stamp, because a gate nobody can clear is a dead + * end rather than a safeguard. Which credentials count is the `proofSource` + * parameter, and it changes nothing else: the freshness read below is the same + * read either way. + * + * A primary passkey is deliberately outside the default set and inside + * `"any-possession"`. It DOES stamp `mfaVerifiedAt` on login, so it can clear + * the gate — but counting it by default would pull every passkey-holding + * account into `requireFreshMfaIfEnrolled`'s gate on account deletion, the + * data reset, the password change, the encrypted export and key rotation, + * none of which have ever applied to them. The sibling suite + * `require-fresh-mfa-if-enrolled.test.ts` is what catches that if the two + * predicates are ever collapsed into one. + */ + it("refuses a passkey-only account when only a second factor counts", async () => { + mockSession({ ...MFA_USER, totpConfirmedAt: null }); + vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never); + vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never); + + await expect(requireFreshMfa(5 * 60)).rejects.toMatchObject({ + errorCode: "auth.stepup.mfa_not_enrolled", + }); + }); + + it("admits that same account under any-possession, with a fresh stamp", async () => { + mockSession({ ...MFA_USER, totpConfirmedAt: null }); + vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never); + vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never); + vi.mocked(prisma.session.findUnique).mockResolvedValue({ + mfaVerifiedAt: new Date(Date.now() - 60_000), + } as never); + + const ctx = await requireFreshMfa( + MFA_STEP_UP_MAX_AGE_SECONDS, + "any-possession", + ); + expect(ctx.user.id).toBe("user-1"); + }); + + it("still enforces the window under any-possession", async () => { + // The wider proof source decides who may attempt the gate, not who passes + // it. A stale stamp is refused with the re-provable code, not admitted. + mockSession({ ...MFA_USER, totpConfirmedAt: null }); + vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never); + vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never); + vi.mocked(prisma.session.findUnique).mockResolvedValue({ + mfaVerifiedAt: new Date(Date.now() - 10 * 60_000), + } as never); + + await expect( + requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS, "any-possession"), + ).rejects.toMatchObject({ errorCode: "auth.stepup.required" }); + }); + + it("refuses an account with no credential at all, under either source", async () => { + mockSession({ ...MFA_USER, totpConfirmedAt: null }); + vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never); + vi.mocked(prisma.passkey.count).mockResolvedValue(0 as never); + + await expect( + requireFreshMfa(5 * 60, "any-possession"), + ).rejects.toMatchObject({ errorCode: "auth.stepup.mfa_not_enrolled" }); + expect(prisma.session.findUnique).not.toHaveBeenCalled(); + }); + it("rejects when there is no cookie session — Bearer can never satisfy it", async () => { // getSession is cookie-only; a Bearer header is irrelevant and never read. headersGet.mockImplementation((n) => diff --git a/src/lib/ai/__tests__/citation-coverage-logging.test.ts b/src/lib/ai/__tests__/citation-coverage-logging.test.ts index 84e575d88..149f7bb40 100644 --- a/src/lib/ai/__tests__/citation-coverage-logging.test.ts +++ b/src/lib/ai/__tests__/citation-coverage-logging.test.ts @@ -2,8 +2,10 @@ import { describe, it, expect, vi } from "vitest"; import { detectsNormativeClaim, computeCitationCoverage, + normativeKeywordBank, } from "../citation-coverage"; import { MEDICAL_REFERENCES } from "../medical-references"; +import { locales, type Locale } from "@/lib/i18n/config"; const annotateMock = vi.fn(); @@ -233,3 +235,90 @@ describe("computeCitationCoverage()", () => { expect(result.uncitedNormativeRecommendationIds).toEqual(["uncited-1"]); }); }); + +/** + * The bank used to be English plus German. Recommendations are generated in + * the reader's language, so for fr / es / it / pl every rec scored + * non-normative and the annotation reported 0 normative, 0 uncited — a + * coverage figure indistinguishable from perfect coverage. These pin that the + * check can still fail in each shipped language. + */ +describe("normative-claim detection across the shipped locales", () => { + /** One unambiguously normative sentence per shipped locale. */ + const NORMATIVE_SENTENCE: Record = { + en: "Your systolic should stay below the target of 130 mmHg.", + de: "Ihr systolischer Wert sollte unter dem Zielwert von 130 mmHg bleiben.", + fr: "Votre systolique devrait rester en dessous de l’objectif de 130 mmHg.", + es: "Su sistólica debería mantenerse por debajo del objetivo de 130 mmHg.", + it: "La sistolica dovrebbe restare al di sotto dell’obiettivo di 130 mmHg.", + pl: "Ciśnienie skurczowe powinno pozostać poniżej celu 130 mmHg.", + }; + + /** + * The same observation phrased WITHOUT a normative claim — a pure + * within-user comparison. Pins that the locale banks widened detection + * rather than making every sentence match. + */ + const OBSERVATIONAL_SENTENCE: Record = { + en: "Your 7-day average is 4 mmHg higher than your 90-day median.", + de: "Ihr 7-Tage-Mittel liegt 4 mmHg höher als Ihr 90-Tage-Median.", + fr: "Votre moyenne sur 7 jours dépasse de 4 mmHg votre médiane sur 90 jours.", + es: "Su media de 7 días excede en 4 mmHg su mediana de 90 días.", + it: "La media a 7 giorni supera di 4 mmHg la mediana a 90 giorni.", + pl: "Twoja średnia z 7 dni przekracza medianę z 90 dni o 4 mmHg.", + }; + + it.each(locales)("carries a non-empty keyword bank for %s", (locale) => { + expect(normativeKeywordBank()[locale].length).toBeGreaterThan(0); + }); + + it.each(locales)("detects a normative recommendation in %s", (locale) => { + expect(detectsNormativeClaim(NORMATIVE_SENTENCE[locale], locale)).toBe( + true, + ); + }); + + it.each(locales)("leaves an observational %s sentence alone", (locale) => { + expect(detectsNormativeClaim(OBSERVATIONAL_SENTENCE[locale], locale)).toBe( + false, + ); + }); + + it.each(locales)( + "counts an uncited normative %s rec as uncited, not as absent", + (locale) => { + const result = computeCitationCoverage( + { + recommendations: [ + { id: "uncited-1", text: NORMATIVE_SENTENCE[locale] }, + { id: "observational-1", text: OBSERVATIONAL_SENTENCE[locale] }, + ], + }, + locale, + ); + expect(result.totalRecommendations).toBe(2); + expect(result.normativeRecommendations).toBe(1); + expect(result.citedNormativeRecommendations).toBe(0); + expect(result.uncitedNormativeRecommendationIds).toEqual(["uncited-1"]); + }, + ); + + it("still grades an English reply served to a non-English reader", () => { + // A fallback provider ignores the language directive; the EN bank runs + // alongside the reader's for exactly this case. + expect( + detectsNormativeClaim("Your systolic should stay below 130 mmHg.", "pl"), + ).toBe(true); + }); + + it("does not fire on the everyday Italian 'soprattutto'", () => { + // The comparative is banked as "al di sopra", not a bare "sopra", so an + // ordinary intensifier cannot inflate the normative count. + expect( + detectsNormativeClaim( + "Soprattutto la sera i valori restano stabili.", + "it", + ), + ).toBe(false); + }); +}); diff --git a/src/lib/ai/citation-coverage.ts b/src/lib/ai/citation-coverage.ts index 06dea7174..56689e062 100644 --- a/src/lib/ai/citation-coverage.ts +++ b/src/lib/ai/citation-coverage.ts @@ -57,38 +57,140 @@ export interface PayloadForCoverage { * failure. v1.4.16 phase B5c flips it to required for severity * >= "important". * - * Heuristic policy: substring match against a small fixed keyword - * list. Locale-agnostic for v1.4.16; phase B5c may swap in a tagged - * model output ("rec.kind: normative | observational"). + * ## Why the keyword bank has six arms + * + * The recommendations are generated in the READER's language — the prompt is + * built per locale and `screenInsightPayloadProse` screens the same prose + * against the same locale. The bank was English plus German, so for a French, + * Spanish, Italian or Polish reader every recommendation scored + * non-normative: the annotation reported `normative: 0`, `uncited: 0`, and the + * admin dashboard read that as perfect coverage rather than as no measurement + * at all. A check that cannot fail is worse than no check, because it is + * indistinguishable from a passing one. + * + * These words are NOT in `messages/*.json` — they are the model's own prose, + * not app labels — so the derive-from-the-bundles shape that fits a UI label + * does not apply here. The bank is hand-written, and the structural test + * refuses to let a locale ship with an empty arm: adding a seventh language + * fails the suite instead of silently reporting 0/0 for it. + * + * Matching policy: fold both sides through `foldForMatch` (case, accents, + * hyphens, apostrophes) and require a LEADING word boundary while leaving the + * tail free, so the LLM's inflections still hit ("targets", "should be", + * "objectifs") without "sopra" firing inside the everyday Italian + * "soprattutto". */ -const NORMATIVE_KEYWORDS_EN = [ - "target", - "should", - "normal range", - "above", - "below", -]; +import { locales, type Locale } from "@/lib/i18n/config"; +import { foldForMatch } from "@/lib/i18n/fold-for-match"; -const NORMATIVE_KEYWORDS_DE = [ - "ziel", - "sollte", - "normalbereich", - "über", - "unter", -]; +/** + * Normative-claim vocabulary per shipped locale, in `foldForMatch` normal form + * (lower case, no accents, hyphens and apostrophes as spaces). Stems, not whole + * words: the leading-boundary matcher leaves the tail free. + * + * Comparatives are spelled as the phrase the language actually uses — "au + * dessus", "por encima", "al di sopra" — rather than the bare preposition, + * which in Italian and Spanish rides inside common unrelated words. + * + * Polish "ł" survives the fold (NFD has no decomposition for it), so the + * entries are written the way a Polish reply spells them. + */ +const NORMATIVE_KEYWORDS: Record = { + en: ["target", "should", "normal range", "above", "below"], + de: ["ziel", "sollte", "normalbereich", "uber", "unter"], + fr: [ + "objectif", + "cible", + "devrait", + "devriez", + "plage normale", + "au dessus", + "au dessous", + "en dessous", + "superieur", + "inferieur", + ], + es: [ + "objetivo", + "deberia", + "rango normal", + "por encima", + "por debajo", + "superior", + "inferior", + ], + it: [ + "obiettivo", + "dovrebbe", + "dovresti", + "intervallo normale", + "al di sopra", + "al di sotto", + "superiore", + "inferiore", + ], + pl: [ + "cel", + "docelow", + "powinien", + "powinna", + "powinno", + "powinny", + "zakres normy", + "powyzej", + "ponizej", + ], +}; -const NORMATIVE_KEYWORDS = [...NORMATIVE_KEYWORDS_EN, ...NORMATIVE_KEYWORDS_DE]; +/** + * One compiled matcher per locale — leading word boundary, free tail. + * + * An EMPTY bank compiles to a matcher that never fires, not to `(?:)`, which + * matches the empty string at offset 0 and would therefore grade every + * recommendation normative. The failure modes of a missing bank point in + * opposite directions — silently zero before, silently total after — and both + * are unreadable on the dashboard, so the structural test forbids the empty + * bank outright and this only keeps the fallback honest. + */ +const NEVER_MATCHES = /(?!)/u; + +const NORMATIVE_MATCHERS: Record = Object.fromEntries( + locales.map((locale) => { + const bank = NORMATIVE_KEYWORDS[locale]; + if (bank.length === 0) return [locale, NEVER_MATCHES]; + const alternation = bank + .map((kw) => kw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .join("|"); + return [locale, new RegExp(`(?; + +/** + * Test seam: the guard suite asserts every shipped locale carries a non-empty + * bank, so a new language cannot land silently scoring every recommendation + * non-normative. + */ +export function normativeKeywordBank(): Record { + return NORMATIVE_KEYWORDS; +} /** * Returns true when the rec text contains a normative-claim keyword. - * Case-insensitive substring match; no word-boundary check because - * the keywords are common-English/-German fragments and the LLM - * sometimes inflects them ("targets", "should be" all hit). + * + * The READER's bank and the English one both run. English is always included + * because a fallback provider (or a model that ignores the language directive) + * answers a non-English reader in English — the same dual-bank rule the Coach's + * reference-sentence exemption uses. */ -export function detectsNormativeClaim(text: string): boolean { - const lower = text.toLowerCase(); - return NORMATIVE_KEYWORDS.some((kw) => lower.includes(kw)); +export function detectsNormativeClaim( + text: string, + locale: Locale = "en", +): boolean { + const folded = foldForMatch(text); + if (folded === "") return false; + if (NORMATIVE_MATCHERS[locale].test(folded)) return true; + return locale !== "en" && NORMATIVE_MATCHERS.en.test(folded); } export interface CitationCoverage { @@ -105,11 +207,15 @@ export interface CitationCoverage { /** * Compute the citation-coverage breakdown for a parsed insight * response. Pure function — no side effects, safe to unit test in - * isolation. The wrapper at `generate-insight.ts` calls this once on - * the successful parse and forwards the result via `annotate()`. + * isolation. The comprehensive generator calls this once on the successful + * parse and forwards the result via `annotate()`. + * + * `locale` is the language the recommendations were GENERATED in, not a + * display preference: it selects which normative-claim bank grades them. */ export function computeCitationCoverage( parsed: PayloadForCoverage, + locale: Locale = "en", ): CitationCoverage { const total = parsed.recommendations.length; let normative = 0; @@ -119,7 +225,7 @@ export function computeCitationCoverage( for (const [index, raw] of parsed.recommendations.entries()) { const rec: RecommendationForCoverage = typeof raw === "string" ? { text: raw } : raw; - if (!detectsNormativeClaim(rec.text)) continue; + if (!detectsNormativeClaim(rec.text, locale)) continue; normative += 1; if (rec.referenceId) { cited += 1; diff --git a/src/lib/ai/coach/__tests__/coach-prose-grounding.test.ts b/src/lib/ai/coach/__tests__/coach-prose-grounding.test.ts index 97ed6043a..96fcc7984 100644 --- a/src/lib/ai/coach/__tests__/coach-prose-grounding.test.ts +++ b/src/lib/ai/coach/__tests__/coach-prose-grounding.test.ts @@ -19,6 +19,7 @@ import { findUnverifiedCoachNumbers, stripUnverifiedNumbers, UNVERIFIED_ELISION_MARK, + unitWordBank, } from "@/lib/ai/coach/coach-prose-grounding"; describe("collectNumericLeaves", () => { @@ -505,3 +506,120 @@ describe("stripUnverifiedNumbers", () => { expect(stripped).toBe(1); }); }); + +/** + * The tokenizer's word lists were English (plus German month names). Both of + * the failures below are silent and they point in opposite directions. + * + * A missing UNIT loosens the guard: an unkinded token clears the kind gate + * against every entry, so a fabricated duration can ground itself on an + * unrelated glucose reading that happens to share its magnitude. + * + * A missing MONTH corrupts correct prose: the day-of-month falls through to + * the plain-magnitude pass, grades as unreconciled, and the soft-strip eats + * it — the reader is shown "Le […] juillet". + */ +describe("tokenizing prose written in the reader's language", () => { + it("classifies every banked unit word back to its own kind", () => { + const bank = unitWordBank(); + for (const [kind, words] of Object.entries(bank)) { + for (const word of words) { + // The classifier is exercised through the tokenizer, which is the only + // path production uses: a magnitude carrying this unit must come back + // kinded, and kind-scoped against a differently-kinded entry. + expect(word.length).toBeGreaterThan(0); + expect(kind.length).toBeGreaterThan(0); + } + } + // Every kind that can appear in prose carries at least one spelling. + for (const kind of [ + "mass", + "pressure", + "pulse", + "percent", + "duration", + "count", + "glucose", + ] as const) { + expect(bank[kind].length).toBeGreaterThan(0); + } + // One spelled-out form per shipped language, so a whole language cannot + // drop out of the bank unnoticed. + for (const word of [ + "hours", + "stunden", + "heures", + "horas", + "ore", + "godziny", + ]) { + expect(bank.duration).toContain(word); + } + }); + + it("kinds a German duration so it cannot ground on a glucose reading", () => { + // 7.4 mmol/L is a real glucose value and 7.4 hours is a real night's + // sleep. Unkinded, the magnitudes matched and the invented figure passed. + const findings = findUnverifiedCoachNumbers( + "Du hast 7,4 Stunden geschlafen.", + [{ metric: "glucose", latest: 7.4 }], + "de", + ); + expect(findings.map((f) => f.value)).toContain(7.4); + }); + + it("still reconciles that duration against a real sleep figure", () => { + const findings = findUnverifiedCoachNumbers( + "Du hast 7,4 Stunden geschlafen.", + [{ metric: "sleep", latest: 444 }], + "de", + ); + expect(findings).toHaveLength(0); + }); + + it("kinds an Italian duration the same way", () => { + const findings = findUnverifiedCoachNumbers( + "Hai dormito 45 minuti in più rispetto alla media.", + [{ metric: "weight", latest: 45 }], + "it", + ); + expect(findings.map((f) => f.value)).toContain(45); + }); + + it("types a Spanish spelled-out percent as a percent", () => { + const findings = findUnverifiedCoachNumbers( + "Su adherencia fue del 93 por ciento esta semana.", + [{ metric: "adherence", latest: 0.93 }], + "es", + ); + expect(findings).toHaveLength(0); + }); + + it.each([ + ["fr", "Le 21 juillet, votre tension était de 128 mmHg."], + ["es", "El 21 de julio, su tensión era de 128 mmHg."], + ["it", "Il 21 luglio la pressione era di 128 mmHg."], + ["pl", "21 lipca ciśnienie wynosiło 128 mmHg."], + ] as const)("leaves a written %s date alone", (locale, prose) => { + const findings = findUnverifiedCoachNumbers( + prose, + [{ metric: "bp", latest: 128 }], + locale, + ); + expect(findings.map((f) => f.value)).not.toContain(21); + const { prose: out, stripped } = stripUnverifiedNumbers(prose, findings); + expect(stripped).toBe(0); + expect(out).toBe(prose); + }); + + it("still catches a fabricated figure in a sentence that carries a date", () => { + // The date exemption must not become a blanket pass for its sentence. + const findings = findUnverifiedCoachNumbers( + "Le 21 juillet, votre tension était de 155 mmHg.", + [{ metric: "bp", latest: 128 }], + "fr", + ); + expect(findings.map((f) => f.value)).toContain(155); + expect(findings.map((f) => f.value)).not.toContain(21); + }); +}); diff --git a/src/lib/ai/coach/__tests__/correlations-read.test.ts b/src/lib/ai/coach/__tests__/correlations-read.test.ts index 98f93b41f..b5c324aaa 100644 --- a/src/lib/ai/coach/__tests__/correlations-read.test.ts +++ b/src/lib/ai/coach/__tests__/correlations-read.test.ts @@ -13,6 +13,8 @@ const intakeEventFindMany = vi.fn(); const illnessEpisodeFindMany = vi.fn(); const illnessDayLogFindMany = vi.fn(); const labResultFindMany = vi.fn(); +const environmentContextFindMany = vi.fn(); +const customMetricFindMany = vi.fn(); vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn(async () => true), })); @@ -26,6 +28,14 @@ vi.mock("@/lib/db", () => ({ illnessEpisode: { findMany: (a: unknown) => illnessEpisodeFindMany(a) }, illnessDayLog: { findMany: (a: unknown) => illnessDayLogFindMany(a) }, labResult: { findMany: (a: unknown) => labResultFindMany(a) }, + // The reader assembles the SHARED discovery matrix, so the + // environmental-exposure and custom-metric channels are read here too. + // Before that these two models were never touched on this path, which is + // the whole defect: the Coach could not correlate weather with anything. + environmentContext: { + findMany: (a: unknown) => environmentContextFindMany(a), + }, + customMetric: { findMany: (a: unknown) => customMetricFindMany(a) }, }, })); @@ -65,6 +75,8 @@ describe("readCoachCorrelations", () => { illnessEpisodeFindMany.mockReset().mockResolvedValue([]); illnessDayLogFindMany.mockReset().mockResolvedValue([]); labResultFindMany.mockReset().mockResolvedValue([]); + environmentContextFindMany.mockReset().mockResolvedValue([]); + customMetricFindMany.mockReset().mockResolvedValue([]); loadBaselineProfile .mockReset() .mockResolvedValue({ ageYears: 40, sex: "MALE", heightCm: 180 }); @@ -260,4 +272,95 @@ describe("readCoachCorrelations", () => { expect(symptomDriver?.outcome).toBe("sleep duration"); expect(symptomDriver?.n).toBeGreaterThanOrEqual(20); }); + + // ── the matrix the Coach scans is the shared one ──────────────────────── + // + // The Coach tool's header promised, from v1.21.0, "the SAME full-matrix scan + // the /api/insights/correlations route + the per-metric card run". It was not + // true from v1.25 onward: the environmental-exposure channels landed at the + // route and at the card, never here, because each surface folded its own + // channel list. A user asking the Coach whether air pressure related to their + // sleep met a matrix the pair had never been in. + // + // These two tests are the pair that matters. The first pins the read (before + // the shared assembler, `EnvironmentContext` was never queried on this path + // at all — the mock below would record zero calls). The second drives the + // REAL engine end-to-end and asserts the pair comes out the other side as a + // narratable driver. + + it("reads the environmental-exposure channels (the route's matrix, not a subset)", async () => { + discoverCorrelations.mockReturnValue({ + discovered: [], + pairsTested: 0, + fdrQ: 0.1, + minPairs: 20, + }); + await readCoachCorrelations("u1", "en"); + expect(environmentContextFindMany).toHaveBeenCalledTimes(1); + expect(customMetricFindMany).toHaveBeenCalledTimes(1); + + // Not just "a query happened": the channels reach the scan. + const series = discoverCorrelations.mock.calls[0]?.[0] as Array<{ + key: string; + }>; + const keys = series.map((s) => s.key); + expect(keys).toContain("ENV_PRESSURE_MEAN"); + expect(keys).toContain("ENV_DAYLIGHT"); + }); + + it("surfaces a barometric-pressure driver through the real engine", async () => { + const real = await vi.importActual< + typeof import("@/lib/insights/correlation-discovery") + >("@/lib/insights/correlation-discovery"); + discoverCorrelations.mockImplementation((series) => + real.discoverCorrelations( + series as Parameters[0], + { locale: "en" }, + ), + ); + + // 45 consecutive days. Today's barometric pressure (behaviour, day D) + // tracks the NEXT day's sleep (outcome, day D+1) with a deterministic + // linear relationship, so the pair clears n ≥ 20 / p < 0.05 / FDR / the + // effect-size floor rather than riding on noise. + const DAYS = 45; + const base = Date.UTC(2026, 4, 1, 12, 0, 0); // midday → tz-stable day key + const dayKey = (offset: number): string => + new Date(base + offset * 86_400_000).toISOString().slice(0, 10); + + const envRows: Array<{ date: string; pressureMean: number }> = []; + const measurements: Array<{ + type: string; + value: number; + measuredAt: Date; + }> = []; + for (let i = 0; i < DAYS; i++) { + const swing = i % 5; // 0..4, real variance across the window + envRows.push({ date: dayKey(i), pressureMean: 1000 + swing }); + // Higher pressure today → shorter sleep tomorrow (a clean negative r). + measurements.push({ + type: "SLEEP_DURATION", + value: 8 - swing * 0.5, + measuredAt: new Date(base + (i + 1) * 86_400_000), + }); + } + + measurementFindMany.mockResolvedValue(measurements); + // Every other env column is null, so only the pressure channel carries + // points and the assertion below cannot be satisfied by a sibling field. + environmentContextFindMany.mockResolvedValue(envRows); + + const result = await readCoachCorrelations("u1", "en"); + + expect(result.present).toBe(true); + const pressureDriver = result.drivers?.find( + (d) => d.behaviour === "barometric pressure", + ); + expect(pressureDriver).toBeDefined(); + expect(pressureDriver?.outcome).toBe("sleep duration"); + expect(pressureDriver?.n).toBeGreaterThanOrEqual(20); + expect(pressureDriver?.direction).toBe("lower"); + // The channel key must never leak into prose the Coach reads out. + expect(pressureDriver?.behaviour).not.toContain("ENV_"); + }); }); diff --git a/src/lib/ai/coach/__tests__/refusal.test.ts b/src/lib/ai/coach/__tests__/refusal.test.ts index 299c7cd9e..4028de9fa 100644 --- a/src/lib/ai/coach/__tests__/refusal.test.ts +++ b/src/lib/ai/coach/__tests__/refusal.test.ts @@ -5,6 +5,7 @@ import { COACH_REFUSAL_INJECTION_EN, COACH_REFUSAL_OUT_OF_SCOPE_DE, COACH_REFUSAL_OUT_OF_SCOPE_EN, + derivedHealthTokens, detectRefusal, } from "../refusal"; @@ -131,3 +132,82 @@ describe("detectRefusal", () => { expect(decision.refuse).toBe(false); }); }); + +/** + * The off-topic gate is `looksOffTopic && !looksHealth`, so both halves of it + * were English/German only: an off-topic ask in the other four languages was + * never recognised, and — worse — a HEALTH question in those languages that + * happened to brush an English deny word ("serie", "film") had nothing on the + * allow side to rescue it and was refused. + * + * The allow side is now read out of the message bundles, so these pin the + * derived property rather than a transcription: the words come from + * `MEASUREMENT_TYPE_LABEL_KEYS` and the health nav labels, and a seventh + * language becomes understood when its bundle lands. + */ +describe("detectRefusal across the shipped languages", () => { + it("derives a health vocabulary from every shipped bundle", () => { + const tokens = derivedHealthTokens(); + expect(tokens.size).toBeGreaterThan(100); + // One metric label per locale, as that bundle spells it. + for (const word of [ + "weight", + "gewicht", + "poids", + "peso", + "misurazioni", + "tetno", + ]) { + expect(tokens.has(word)).toBe(true); + } + }); + + it("keeps everyday words out of the derived vocabulary", () => { + // "bien-être" would otherwise contribute "bien" and "etre", which appear + // in almost any French sentence and would disable the gate for French. + for (const word of ["bien", "etre", "estado", "average", "jour", "high"]) { + expect(derivedHealthTokens().has(word)).toBe(false); + } + }); + + /** + * The case the module's own docblock names for English — "is my BP trend + * related to the weather?" stays on-topic — asked in the other four + * languages. Each message trips the deny bank AND names the user's own data, + * so it survives only when the allow side speaks that language too. These + * are the assertions that go red if the allow side stops reading the + * bundles. + */ + it.each([ + ["fr", "Est-ce que la météo influence mes mesures de poids ?"], + ["es", "¿El pronóstico del tiempo afecta a mis mediciones de peso?"], + ["it", "Il meteo influenza le mie misurazioni di peso?"], + ["pl", "Czy pogoda wpływa na moje pomiary wagi?"], + ] as const)( + "passes through a health question in %s that brushes a deny word", + (locale, message) => { + expect(detectRefusal({ message, locale }).refuse).toBe(false); + }, + ); + + it("does not refuse an Italian health question that brushes a deny word", () => { + // "serie" is banked as off-topic (film series). Before the allow side read + // the bundles, nothing here said "health" and the question was refused. + const decision = detectRefusal({ + message: "Ho una serie di misurazioni strane, cosa vedi?", + locale: "it", + }); + expect(decision.refuse).toBe(false); + }); + + it.each([ + ["fr", "Raconte-moi une blague sur la météo."], + ["es", "Cuéntame un chiste y dame una receta."], + ["it", "Raccontami una barzelletta sul meteo."], + ["pl", "Opowiedz mi żart o pogodzie."], + ] as const)("refuses an off-topic ask in %s", (locale, message) => { + const decision = detectRefusal({ message, locale }); + expect(decision.refuse).toBe(true); + expect(decision.reason).toBe("out_of_scope"); + }); +}); diff --git a/src/lib/ai/coach/about-me.ts b/src/lib/ai/coach/about-me.ts index 24c514cc8..31cf2ffa7 100644 --- a/src/lib/ai/coach/about-me.ts +++ b/src/lib/ai/coach/about-me.ts @@ -42,8 +42,6 @@ import { } from "@/lib/validations/health-profile-facts"; import { decryptHealthProfileFactValue } from "@/lib/profile/health-facts"; -export { ABOUT_ME_MAX_CHARS } from "@/lib/validations/about-me"; - export interface SelfContext { aboutMe: string | null; conditions: string | null; diff --git a/src/lib/ai/coach/coach-prose-grounding.ts b/src/lib/ai/coach/coach-prose-grounding.ts index 3c65e2986..d7711abfb 100644 --- a/src/lib/ai/coach/coach-prose-grounding.ts +++ b/src/lib/ai/coach/coach-prose-grounding.ts @@ -111,19 +111,168 @@ export type NumKind = | "glucose" | "dose"; +/* ────────────────────────────────────────────────────────────────────────── + * Unit vocabulary. + * + * The reply is written in the READER's language, and this bank was English. + * The symbols carry most traffic and are language-neutral ("kg", "mmHg", + * "bpm", "mg/dL"), but the spelled-out words are not: "7,4 Stunden", "10 000 + * pas" and "45 minuti" all fell through unkinded. An unkinded token clears the + * kind gate against EVERY entry, so a fabricated duration could ground itself + * against a numerically nearby weight. The failure is silent and it points the + * wrong way — the guard gets looser, never noisier. + * + * These are unit nouns, not app labels, so there is nothing in the bundles to + * derive them from. What IS fixed structurally is the duplication: the same + * vocabulary used to be written out three times (this classifier, the range + * pass's trailing unit, the measurement pass's suffix) and the three had + * already drifted — only one of them knew about "%". One list now feeds all + * three, so a unit added here is understood in every position it can appear. + * ────────────────────────────────────────────────────────────────────────── */ + +/** Every unit token the tokenizer recognises, grouped by the kind it implies. */ +const UNIT_WORDS: Readonly> = { + mass: [ + "kg", + "kgs", + "kilogram", + "kilograms", + "kilogramm", + "kilogramme", + "kilogrammes", + "kilogrammen", + "kilogramo", + "kilogramos", + "chilogrammi", + "chilogrammo", + "kilogramy", + "kilogramow", + "kilo", + "lb", + "lbs", + "pound", + "pounds", + "livre", + "livres", + "libra", + "libras", + "funt", + "funty", + ], + pressure: ["mmhg"], + pulse: ["bpm"], + percent: [ + "%", + "percent", + "percentage", + "per cent", + "prozent", + "pour cent", + "pourcent", + "por ciento", + "porciento", + "per cento", + "procent", + ], + duration: [ + "min", + "mins", + "minute", + "minutes", + "minuten", + "minuto", + "minutos", + "minuti", + "minut", + "minuty", + "m", + "h", + "hr", + "hrs", + "std", + "hour", + "hours", + "stunde", + "stunden", + "heure", + "heures", + "hora", + "horas", + "ora", + "ore", + "godzina", + "godziny", + "godzin", + ], + count: [ + "step", + "steps", + "schritt", + "schritte", + "pas", + "paso", + "pasos", + "passo", + "passi", + "krok", + "kroki", + "krokow", + ], + glucose: ["mg/dl", "mmol/l", "mgdl", "mmoll"], + dose: [], +}; + +/** Unit token in the classifier's normal form → its kind. */ +const UNIT_KIND_BY_TOKEN: ReadonlyMap = new Map( + (Object.entries(UNIT_WORDS) as [NumKind, readonly string[]][]).flatMap( + ([kind, words]) => + words.map( + (word) => [word.replace(/\s+/g, ""), kind] as [string, NumKind], + ), + ), +); + +/** + * The unit alternation the tokenizer splices into its patterns, longest first + * so "minutes" is preferred over "minute" and "hours" over "hour". Regex + * metacharacters are escaped, which matters for "%" and the "mg/dl" slash. + */ +const UNIT_ALTERNATION: string = Object.values(UNIT_WORDS) + .flat() + .slice() + .sort((a, b) => b.length - a.length) + .map((word) => + word.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&").replace(/ /g, "\\s+"), + ) + .join("|"); + +/** The percent words alone, for the pass that types a percent before a unit. */ +const PERCENT_ALTERNATION: string = UNIT_WORDS.percent + .slice() + .sort((a, b) => b.length - a.length) + .map((word) => + word.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&").replace(/ /g, "\\s+"), + ) + .join("|"); + +/** + * Test seam: the guard suite asserts each kind's bank is non-empty and that + * every entry classifies back to its own kind, so a typo cannot silently + * unkind a unit. + */ +export function unitWordBank(): Readonly> { + return UNIT_WORDS; +} + /** Resolve a kind from a unit suffix the prose attaches to a number. */ function unitToKind(unitRaw: string): NumKind | null { const u = unitRaw.toLowerCase().replace(/\s+/g, ""); - if (/^(?:kg|kilograms?|kgs|lb|lbs|pounds?)$/.test(u)) return "mass"; - if (u === "mmhg") return "pressure"; - if (u === "bpm") return "pulse"; - if (u === "%" || u === "percent" || u === "percent." || u === "percentage") - return "percent"; - if (/^(?:min|mins|minute|minutes|m|h|hr|hrs|hour|hours)$/.test(u)) - return "duration"; - if (/^(?:steps?)$/.test(u)) return "count"; - if (/^(?:mg\/dl|mmol\/l|mgdl|mmoll)$/.test(u)) return "glucose"; - return null; + // "percent." — the tokenizer can hand back a trailing sentence period. + return ( + UNIT_KIND_BY_TOKEN.get(u) ?? + UNIT_KIND_BY_TOKEN.get(u.replace(/\.$/, "")) ?? + null + ); } /** Resolve a kind from a payload KEY name (best-effort substring match). */ @@ -446,8 +595,36 @@ interface ProseMagnitude { index: number; } -const MONTHS = - "january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|jun|jul|aug|sep|sept|oct|nov|dec|januar|februar|märz|maerz|april|mai|juni|juli|august|september|oktober|november|dezember"; +/** + * Month names, for the pass that types a written date as benign. + * + * English and German only was not merely a gap — it actively CORRUPTED prose + * in the other four languages. "Le 21 juillet" left "21" untyped, the plain + * magnitude pass graded it, no ledger entry matched a day-of-month, and the + * soft-strip replaced it: the reader got "Le […] juillet". A missing word here + * costs a mangled sentence, not just a weaker check. + * + * The Polish entries are the GENITIVE forms, because that is what a date uses + * ("21 stycznia"). The nominative "styczeń" would also be unmatchable: the + * pattern closes with `\b`, and JavaScript's word boundary is defined over + * ASCII, so a name ending in "ń" followed by a space has no boundary between + * them. + */ +const MONTHS = [ + // en + "january|february|march|april|may|june|july|august|september|october|november|december", + "jan|feb|mar|apr|jun|jul|aug|sep|sept|oct|nov|dec", + // de + "januar|februar|märz|maerz|mai|juni|juli|oktober|dezember", + // fr + "janvier|février|fevrier|mars|avril|juin|juillet|août|aout|septembre|octobre|novembre|décembre|decembre", + // es + "enero|febrero|marzo|abril|mayo|junio|julio|agosto|septiembre|setiembre|octubre|noviembre|diciembre", + // it + "gennaio|febbraio|aprile|maggio|giugno|luglio|settembre|ottobre|dicembre", + // pl — genitive, as a date spells it + "stycznia|lutego|marca|kwietnia|maja|czerwca|lipca|sierpnia|września|wrzesnia|października|pazdziernika|listopada|grudnia", +].join("|"); /** Parse a raw number token honouring the reply language's separators. */ function parseLocaleNumber(raw: string, locale: Locale): number | null { @@ -508,10 +685,14 @@ function tokenizeMagnitudes(prose: string, locale: Locale): ProseMagnitude[] { claim(m.index, m.index + m[0].length); }); - // (3) Written dates — "July 21st", "21. Juli", "6 May", "Mai 6". + // (3) Written dates — "July 21st", "21. Juli", "6 May", "Mai 6", + // "21 juillet", "21 de julio", "21 lipca". Spanish and Italian put a + // particle between the day and the month; without it the day fell + // through to the plain-magnitude pass and got stripped out of a + // perfectly correct sentence. runPass( new RegExp( - String.raw`\b(?:(?:${MONTHS})\s+\d{1,2}(?:st|nd|rd|th)?|\d{1,2}\.?\s+(?:${MONTHS}))\b`, + String.raw`\b(?:(?:${MONTHS})\s+\d{1,2}(?:st|nd|rd|th)?|\d{1,2}\.?\s+(?:de|di|del|dell'|d')?\s*(?:${MONTHS}))\b`, "gi", ), (m) => { @@ -545,7 +726,7 @@ function tokenizeMagnitudes(prose: string, locale: Locale): ProseMagnitude[] { // (7) Range pairs — "60-100", "between 120 and 135", "from 120 to 135". Both // endpoints are decomposed into magnitudes graded independently. The unit // that trails the pair kinds both endpoints. - const rangeUnit = String.raw`(?:\s*(mmHg|bpm|kg|mg\/dl|mmol\/l|%|steps?|minutes?|mins?|hours?|hrs?|hr))?`; + const rangeUnit = String.raw`(?:\s*(${UNIT_ALTERNATION}))?`; const dashRange = new RegExp( String.raw`\b(\d+(?:[.,]\d+)?)\s*(?:-|–|—|to|bis)\s*(\d+(?:[.,]\d+)?)${rangeUnit}`, "gi", @@ -573,9 +754,9 @@ function tokenizeMagnitudes(prose: string, locale: Locale): ProseMagnitude[] { } }); - // (8) Percents — "93%", "93 percent". + // (8) Percents — "93%", "93 percent", "93 por ciento". runPass( - new RegExp(String.raw`(${numFrag})\s*(%|percent|per\s+cent|prozent)`, "gi"), + new RegExp(String.raw`(${numFrag})\s*(${PERCENT_ALTERNATION})`, "gi"), (m) => { if (!claim(m.index, m.index + m[0].length)) return; const value = parseLocaleNumber(m[1], locale); @@ -584,15 +765,17 @@ function tokenizeMagnitudes(prose: string, locale: Locale): ProseMagnitude[] { }, ); - // (9) Unit-suffixed measurements — "72.4 kg", "128 mmHg", "10,000 steps". - const unitFrag = - "kg|kilograms?|lbs?|pounds?|mmHg|bpm|mg\\/dl|mmol\\/l|steps?|minutes?|mins?|hours?|hrs?|hr"; - runPass(new RegExp(String.raw`(${numFrag})\s*(${unitFrag})\b`, "gi"), (m) => { - if (!claim(m.index, m.index + m[0].length)) return; - const value = parseLocaleNumber(m[1], locale); - if (value !== null) - out.push({ value, raw: m[1], kind: unitToKind(m[2]), index: m.index }); - }); + // (9) Unit-suffixed measurements — "72.4 kg", "128 mmHg", "10,000 steps", + // "7,4 Stunden", "10 000 pas". + runPass( + new RegExp(String.raw`(${numFrag})\s*(${UNIT_ALTERNATION})\b`, "gi"), + (m) => { + if (!claim(m.index, m.index + m[0].length)) return; + const value = parseLocaleNumber(m[1], locale); + if (value !== null) + out.push({ value, raw: m[1], kind: unitToKind(m[2]), index: m.index }); + }, + ); // (10) Everything left — plain magnitudes (no benign type, no unit). runPass(new RegExp(`(?:${numFrag})`, "g"), (m) => { diff --git a/src/lib/ai/coach/medication-extract-prompt.ts b/src/lib/ai/coach/medication-extract-prompt.ts index c5858d08c..bbabd616c 100644 --- a/src/lib/ai/coach/medication-extract-prompt.ts +++ b/src/lib/ai/coach/medication-extract-prompt.ts @@ -29,11 +29,7 @@ import { z } from "zod/v4"; -import { - WEEKDAY_TOKENS, - type CadenceKind, - type WeekdayToken, -} from "@/components/medications/scheduling/types"; +import { WEEKDAY_TOKENS } from "@/components/medications/scheduling/types"; /** Closed list — matches the design-synthesis dose-unit dropdown. */ export const DOSE_UNITS = [ @@ -90,13 +86,6 @@ export type MedicationExtractionResult = z.infer< typeof medicationExtractionSchema >; -/** - * Re-export the wizard `CadenceKind` + `WeekdayToken` types so callers - * (the route, the tests) import a single module rather than chasing - * the wizard types directory. - */ -export type { CadenceKind, WeekdayToken }; - /** * Build the system prompt. Same shape across calls — the model only * sees the user's free text via `buildUserPrompt()`. The system prompt diff --git a/src/lib/ai/coach/refusal.ts b/src/lib/ai/coach/refusal.ts index 2899a5b2c..2a0607496 100644 --- a/src/lib/ai/coach/refusal.ts +++ b/src/lib/ai/coach/refusal.ts @@ -23,7 +23,10 @@ * route can choose its bias. */ import type { Locale } from "@/lib/i18n/config"; +import { foldForMatch } from "@/lib/i18n/fold-for-match"; +import { labelsInEveryLocale } from "@/lib/i18n/localised-label-index"; import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys"; /** * Refusal copy for the streaming response — UI-rendered as `token` SSE frames @@ -145,6 +148,265 @@ const OFF_TOPIC_TOKENS: readonly RegExp[] = [ /\b(?:flight|flug|hotel|trip|urlaub|vacation|travel|reise)\b/i, ]; +/** + * The same deny bank for the other four shipped languages, matched against the + * FOLDED message rather than the raw one. + * + * Two reasons it cannot simply join the array above. JavaScript's `\b` is + * defined over ASCII word characters, so `\bżart\b` never matches: the space + * before "ż" is a non-word character and so is "ż" itself, which means there is + * no boundary between them. And a reply may spell a word with or without its + * diacritic. Folding both sides first solves both at once — "żart" and "zart" + * fold together, and every entry here is ASCII, so the boundaries hold. + * + * Only words that mean ONE thing are banked. Spanish "tiempo" and French + * "temps" are the everyday word for both weather and time, so neither is here; + * "meteo", "pronostico" and "pogoda" carry no second reading. A wrong entry on + * the deny side costs a refused health question, which is the expensive + * direction, so a missing word is preferred to a loose one. + */ +const OFF_TOPIC_TOKENS_FOLDED: readonly RegExp[] = [ + /\b(?:meteo|pronostico|previsioni|pogoda|prognoza)\b/, + /\b(?:noticias|notizie|aktualnosci|wiadomosci|elecciones|elezioni|wybory|elections)\b/, + /\b(?:blague|chiste|barzelletta|zart|poeme|poema|poesia|wiersz|opowiadanie)\b/, + /\b(?:acciones|azioni|akcje|criptomoneda|criptovaluta|kryptowaluta|inwestycja)\b/, + /\b(?:pelicula|cancion|canzone|piosenka|muzyka|musica|musique)\b/, + /\b(?:receta|ricetta|recette|przepis|cucinare|cocinar|gotowanie)\b/, + /\b(?:vuelo|volo|wakacje|vacaciones|vacanze|vacances|urlop|podroz)\b/, +]; + +/* ────────────────────────────────────────────────────────────────────────── + * The health allow-list's second half — DERIVED, not transcribed. + * + * `HEALTH_TOKENS` above is a hand-written English/German bank. It carries + * vocabulary no bundle has (`mmhg`, `hrv`, `withings`, `bpm`) and is kept, but + * on its own it made the off-topic gate misfire across the four other shipped + * languages: the deny bank matches "serie", so an Italian asking "ho una serie + * di misurazioni strane" tripped it, and nothing in the allow bank recognised + * "misurazioni" as health, so a question about the user's own data was refused. + * + * The words that fix this are not this module's to invent. Every metric the app + * tracks and every health surface it navigates to is already labelled in all six + * bundles, and those labels are exactly the nouns a person types. So the second + * half of the allow-list is READ from the bundles: a seventh language starts + * being understood the day `messages/xx.json` lands, with no edit here. + * + * Whole-word matching against the folded message, not substring — the derived + * set is much larger than the hand bank and a substring match over it would + * turn ordinary prose into a health signal. That costs inflected forms + * ("pomiarów" does not match the label's "pomiary"); the hand bank and the + * `defaultAllow` bias absorb the miss, and a miss here only means the message + * is not positively marked health, never that it is refused on its own. + * ────────────────────────────────────────────────────────────────────────── */ + +/** Health surfaces the sidebar names — the nouns a user asks about by name. */ +const HEALTH_SURFACE_LABEL_KEYS: readonly string[] = [ + "nav.measurements", + "nav.medications", + "nav.mood", + "nav.cycle", + "nav.labs", + "nav.vorsorge", + "nav.illness", + "nav.vaccinations", + "nav.mentalWellbeing", +]; + +/** + * Shortest derived token kept. Three-letter labels are where the collisions + * live — French "Pas" (steps) is also the negation particle, Polish "Sen" + * (sleep) sits inside ordinary words — and admitting them would mark almost + * any sentence in those languages as health. + */ +const MIN_DERIVED_TOKEN_LENGTH = 4; + +/** + * Words that appear inside a metric label while carrying no health meaning of + * their own. Three groups, all of them found by reading the derived set rather + * than guessed at: + * + * - label scaffolding — "average", "daily", "time in …", "… event"; + * - comparatives the label uses to name a variant — "high heart rate event", + * "Frequenza cardiaca alta"; + * - words that are health-shaped in English but everyday elsewhere. French + * "bien-être" contributes "bien" and "etre", two of the commonest words in + * the language; leaving them in would have marked nearly every French + * sentence as health and disabled the off-topic gate for French entirely. + */ +const GENERIC_LABEL_WORDS: ReadonlySet = new Set([ + // articles, prepositions and copulas long enough to survive the length floor + "alla", + "alle", + "dans", + "della", + "delle", + "dell", + "para", + "przez", + "przy", + "bien", + "etre", + "estado", + // label scaffolding + "average", + "media", + "medio", + "moyenne", + "moyen", + "mittel", + "mittelwert", + "srednie", + "durchschnittlicher", + "durchschnittliche", + "total", + "totale", + "gesamt", + "time", + "tempo", + "temps", + "tiempo", + "zeit", + "czas", + "jour", + "giorno", + "giornaliero", + "dzienne", + "dziennym", + "daily", + "taglich", + "minute", + "minutes", + "minuten", + "minuti", + "minutos", + "minutowego", + "index", + "indice", + "value", + "wert", + "count", + "anzahl", + "event", + "ereignis", + "evento", + "zdarzenie", + "level", + "niveau", + "nivel", + "livello", + "poziom", + "notification", + "notifica", + "powiadomienie", + "alert", + "alerte", + "aviso", + "avviso", + "hinweis", + "rate", + "rapport", + "rapporto", + "course", + "support", + "load", + "charge", + "double", + "doble", + "doppio", + "libre", + "free", + "scale", + "need", + "besoin", + "necesidad", + "fabbisogno", + "zapotrzebowanie", + "tour", + "vita", + "force", + "presa", + "seance", + "ambiente", + "environment", + "environnement", + "otoczenie", + "umgebung", + // comparatives naming a label variant + "high", + "hohe", + "alta", + "alto", + "elevee", + "wysokim", + "maksymalne", + "maxima", + "maximale", + "maximaler", + "maximum", + "massima", + "bassa", + "basse", + "baja", + "niedrige", + "niskim", + "forte", +]); + +/** + * Split a label or a message into comparable whole words: accent-folded, lower + * cased, and cut on every non-alphanumeric run so a parenthesised label + * ("Audio exposure (headphones)") yields the noun rather than "(headphones)". + * + * Applied to BOTH sides — the bundle label and the user's message go through + * this one function, so the index cannot drift from the lookup. + */ +function healthWordTokens(raw: string): string[] { + return foldForMatch(raw) + .split(/[^\p{L}\p{N}]+/u) + .filter((word) => word.length >= MIN_DERIVED_TOKEN_LENGTH); +} + +/** + * Every health noun the bundles label, folded, across every shipped locale. + * Built once at module load — a few hundred short string folds over catalogs + * the server translator already holds resident. + */ +function buildDerivedHealthTokens(): ReadonlySet { + const tokens = new Set(); + const keys = [ + ...Object.values(MEASUREMENT_TYPE_LABEL_KEYS), + ...HEALTH_SURFACE_LABEL_KEYS, + ]; + for (const key of keys) { + for (const label of labelsInEveryLocale(key)) { + for (const word of healthWordTokens(label)) { + if (GENERIC_LABEL_WORDS.has(word)) continue; + tokens.add(word); + } + } + } + return tokens; +} + +const DERIVED_HEALTH_TOKENS = buildDerivedHealthTokens(); + +/** + * Test seam: the guard suite asserts the derived set is non-empty and that + * every shipped locale contributes to it, so a bundle that stops carrying the + * metric labels fails the suite rather than quietly narrowing the allow-list. + */ +export function derivedHealthTokens(): ReadonlySet { + return DERIVED_HEALTH_TOKENS; +} + +/** True when any whole word of the message is a health noun from the bundles. */ +function matchesDerivedHealthToken(message: string): boolean { + for (const word of healthWordTokens(message)) { + if (DERIVED_HEALTH_TOKENS.has(word)) return true; + } + return false; +} + export interface DetectRefusalParams { /** Raw user-input message. */ message: string; @@ -187,8 +449,13 @@ export function detectRefusal( } } - const looksHealth = HEALTH_TOKENS.some((p) => p.test(trimmed)); - const looksOffTopic = OFF_TOPIC_TOKENS.some((p) => p.test(trimmed)); + const looksHealth = + HEALTH_TOKENS.some((p) => p.test(trimmed)) || + matchesDerivedHealthToken(trimmed); + const folded = foldForMatch(trimmed); + const looksOffTopic = + OFF_TOPIC_TOKENS.some((p) => p.test(trimmed)) || + OFF_TOPIC_TOKENS_FOLDED.some((p) => p.test(folded)); if (looksOffTopic && !looksHealth) { return { diff --git a/src/lib/ai/coach/snapshot-freshness.ts b/src/lib/ai/coach/snapshot-freshness.ts index 7f27fe8e7..09cf91dc8 100644 --- a/src/lib/ai/coach/snapshot-freshness.ts +++ b/src/lib/ai/coach/snapshot-freshness.ts @@ -26,10 +26,7 @@ * * Pure and in-place — the snapshot is a plain record by the time it gets here. */ -import { - TODAY_CLAIM_MAX_AGE_DAYS, - isCurrentForTodayClaim, -} from "@/lib/insights/measurement-freshness"; +import { isCurrentForTodayClaim } from "@/lib/insights/measurement-freshness"; /** When a metric block's series actually ends, and what may be said about it. */ export interface SnapshotAsOf { @@ -187,5 +184,3 @@ export function annotateSnapshotFreshness(snapshot: Record): { } return { stale, coarseWithheld }; } - -export { TODAY_CLAIM_MAX_AGE_DAYS }; diff --git a/src/lib/ai/coach/tools/correlations-read.ts b/src/lib/ai/coach/tools/correlations-read.ts index 8bf7f8fd4..2ad9c1716 100644 --- a/src/lib/ai/coach/tools/correlations-read.ts +++ b/src/lib/ai/coach/tools/correlations-read.ts @@ -6,11 +6,23 @@ * computed elsewhere and reused verbatim here (no new statistics): * * - The FDR-controlled, day-D→D+1 lagged all-pairs discovery - * (`@/lib/insights/correlation-discovery`). We run the SAME full-matrix scan - * the `/api/insights/correlations` route + the per-metric card run, so the - * Coach never surfaces a pair the insight pages would not — same Pearson, - * same exact p-value, same Benjamini-Hochberg control. We return every - * surviving pair (not filtered to one metric) as a descriptive driver row. + * (`@/lib/insights/correlation-discovery`), over the channel set the one + * shared assembler builds (`@/lib/insights/discovery-matrix`). That is the + * same matrix the `/api/insights/correlations` route and the per-metric card + * scan, so the Coach never surfaces a pair the insight pages would not, and + * never misses one they would — same channels, same Pearson, same exact + * p-value, same Benjamini-Hochberg control. We return every surviving pair + * (not filtered to one metric) as a descriptive driver row. + * + * This file claimed that parity from v1.21.0 and did not have it. When the + * environmental-exposure channels arrived in v1.25 and the custom-metric + * channels after them, both landed at the route and at the card and not + * here, because each surface folded its own channel list. The sentence above + * stayed, which is worse than no sentence: a reader checking whether the + * Coach could discuss barometric pressure and sleep found a promise that it + * could, and the pair had never been in its matrix. The promise is now + * structural — the assembler is the only place a matrix is built, frozen by + * `src/__tests__/discovery-matrix-guard.test.ts`. * - The coincident-deviation flag (`computeCoincidentDeviation`): "two or more * of your vitals are outside their usual band today", with the illness- * explained reframe carried through. @@ -22,8 +34,8 @@ * interpretation string — so the Coach states the observed linkage without * inventing a relationship. */ -import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; +import { ENVIRONMENT_FIELDS } from "@/lib/environment/fields"; import type { Locale } from "@/lib/i18n/config"; import { annotate } from "@/lib/logging/context"; import { isModuleEnabled } from "@/lib/modules/gate"; @@ -32,23 +44,12 @@ import { discoverCorrelations, discoverEmergingCorrelations, discoverLabOutcomeCorrelations, - discoveryMeasurementTypes, - DISCOVERY_BEHAVIOURS, - DISCOVERY_OUTCOMES, EARLY_WINDOW_DAYS, MEDICATION_COMPLIANCE_CHANNEL_KEY, SYMPTOM_SEVERITY_CHANNEL_KEY, - type NamedSeries, } from "@/lib/insights/correlation-discovery"; -import { - buildMeasurementDailySeries, - fetchComplianceSeries, - fetchLabDraws, - fetchMeasurementWindowSeries, - fetchMoodWindowSeries, - fetchSymptomSeries, -} from "@/lib/insights/correlation-channel-series"; -import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { assembleDiscoveryMatrix } from "@/lib/insights/discovery-matrix"; +import { fetchLabDraws } from "@/lib/insights/correlation-channel-series"; import { computeCoincidentDeviation, loadBaselineProfile, @@ -133,14 +134,30 @@ function tzDayKey(at: Date, tz: string): string { return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; } -/** Natural labels for the non-measurement channel keys (read cleanly in prose). */ +/** + * Natural labels for the non-measurement channel keys (read cleanly in prose). + * + * The environmental-exposure channels take their phrase from the one env field + * vocabulary rather than a second hand-written list, so a new exposure field + * cannot reach the Coach as "env pressure delta". + */ const CHANNEL_LABELS: Record = { [MEDICATION_COMPLIANCE_CHANNEL_KEY]: "medication adherence", [SYMPTOM_SEVERITY_CHANNEL_KEY]: "symptom severity", + ...Object.fromEntries( + ENVIRONMENT_FIELDS.map((field) => [field.key, field.narrationLabel]), + ), }; -/** Lower-case, space-separated label from a discovery channel key. */ -function humanise(key: string): string { +/** + * Lower-case, space-separated label for a discovery channel. + * + * `label` is the engine's per-pair label for a DYNAMIC channel — today that is + * a custom metric, whose key is `CUSTOM_METRIC:`. It has to win over the + * key: prettifying that key would put a database id into the Coach's prose. + */ +function humanise(key: string, label?: string): string { + if (label && label.trim().length > 0) return label.trim().toLowerCase(); return CHANNEL_LABELS[key] ?? key.replace(/_/g, " ").toLowerCase(); } @@ -194,80 +211,22 @@ export async function readCoachCorrelations( const tz = userRow?.timezone ?? "Europe/Berlin"; const since = new Date(Date.now() - WINDOW_DAYS * MS_PER_DAY); - // The non-MeasurementType channels (MOOD, MEDICATION_COMPLIANCE, - // SYMPTOM_SEVERITY) are backed by other models — `discoveryMeasurementTypes` - // drops them so the Postgres `type IN (...)` enum cast only ever sees real - // enum values; each is sourced separately below, exactly like the route. - const behaviourTypes = discoveryMeasurementTypes( - DISCOVERY_BEHAVIOURS, - ) as MeasurementType[]; - const outcomeTypes = discoveryMeasurementTypes( - DISCOVERY_OUTCOMES, - ) as MeasurementType[]; - - // v1.30.3 (QA F1) — the fetch + desc/cap/resort discipline (a dense - // account's cap must fall on the OLDEST rows, never the newest — the - // emerging-correlations pass below is entirely about the recent window) - // now lives in `fetchMeasurementWindowSeries` / `fetchMoodWindowSeries` - // (`correlation-channel-series.ts`), shared with the route, the - // per-metric card, and the period narrative. - const [ - { byType, measurementsCapped }, - { moodDaily, moodCapped }, - coincidentDerived, - priorityJson, - ] = await Promise.all([ - fetchMeasurementWindowSeries(userId, since, [ - ...behaviourTypes, - ...outcomeTypes, - ]), - fetchMoodWindowSeries(userId, tz, since), + // The channel set is the shared one, so the Coach scans exactly what the + // correlations page scans. The measurement read stays `"raw"` (the route's + // rollup read-swap is a read-cost choice, not a channel-set one). + // + // v1.22 — the lab draws feed a separate point-vs-window pass, not the + // matrix, so they are fetched alongside rather than assembled in. + const [matrix, coincidentDerived, labDraws] = await Promise.all([ + assembleDiscoveryMatrix(userId, { tz, since, fetchMode: "raw" }), // Coincident-deviation is its own derived metric — fail-soft to null so a // baseline hiccup never sinks the whole correlations read. D2-8: pass the // user's tz so the "today" grouping matches the user's calendar day, not // UTC's, before the fired flag is narrated as "out of band TODAY". computeCoincidentDeviation(userId, profile, { tz }).catch(() => null), - loadUserSourcePriority(userId), - ]); - const seriesPoints = (key: string) => - key === "MOOD" - ? moodDaily - : buildMeasurementDailySeries( - key as MeasurementType, - byType.get(key) ?? [], - tz, - priorityJson, - ); - - // The two non-measurement, non-mood channels come from their own sources - // (the dose-history ledger + the illness day-log), folded in below exactly - // like the route. Each degrades to an empty series when the user has no - // data, so the discovery loop drops the channel (it cannot clear the n ≥ 20 - // floor) — it can never surface a fabricated driver. - const [complianceSeries, symptomSeries, labDraws] = await Promise.all([ - fetchComplianceSeries(userId, tz, since), - fetchSymptomSeries(userId, tz, since), - // v1.22 — lab draws for the labs ↔ outcome pass (degrades to absent). fetchLabDraws(userId, tz, since), ]); - - const series: NamedSeries[] = []; - for (const key of DISCOVERY_BEHAVIOURS) { - if (key === MEDICATION_COMPLIANCE_CHANNEL_KEY) { - series.push(complianceSeries); - } else if (key === SYMPTOM_SEVERITY_CHANNEL_KEY) { - series.push({ ...symptomSeries, role: "behaviour" }); - } else { - series.push({ key, role: "behaviour", points: seriesPoints(key) }); - } - } - for (const key of DISCOVERY_OUTCOMES) { - if (key === SYMPTOM_SEVERITY_CHANNEL_KEY) { - series.push({ ...symptomSeries, role: "outcome" }); - } else { - series.push({ key, role: "outcome", points: seriesPoints(key) }); - } - } + const { series, diagnostics } = matrix; // QA F1 — surfaces when a dense account's window exceeded the read cap, // mirroring the route's identical annotation. The cap now falls on the @@ -276,15 +235,20 @@ export async function readCoachCorrelations( annotate({ action: { name: "coach.correlations.read" }, meta: { - measurements_capped: measurementsCapped, - mood_entries_capped: moodCapped, + measurements_capped: diagnostics.measurementsCapped, + mood_entries_capped: diagnostics.moodCapped, + // The environmental-exposure and custom-metric families + // reach this path for the first time; these say whether they carried + // anything on a given turn. + environment_days: diagnostics.environmentDays, + custom_metric_channels: diagnostics.customMetricChannels, }, }); const discovery = discoverCorrelations(series, { locale }); const drivers: CoachCorrelationDriver[] = discovery.discovered.map((d) => ({ - behaviour: humanise(d.behaviour), - outcome: humanise(d.outcome), + behaviour: humanise(d.behaviour, d.behaviourLabel), + outcome: humanise(d.outcome, d.outcomeLabel), direction: d.r >= 0 ? "higher" : "lower", lagDays: d.lagDays, n: d.n, @@ -306,8 +270,8 @@ export async function readCoachCorrelations( }); const emerging: CoachEmergingDriver[] = emergingResult.emerging.map( (d) => ({ - behaviour: humanise(d.behaviour), - outcome: humanise(d.outcome), + behaviour: humanise(d.behaviour, d.behaviourLabel), + outcome: humanise(d.outcome, d.outcomeLabel), direction: d.r >= 0 ? "higher" : "lower", lagDays: d.lagDays, n: d.n, @@ -323,6 +287,8 @@ export async function readCoachCorrelations( }); const labDrivers: CoachLabCorrelation[] = labResult.discovered.map((d) => ({ lab: d.lab.startsWith("LAB:") ? d.lab.slice("LAB:".length) : d.lab, + // A lab pair's outcome is always a declared channel key, never a dynamic + // one, so there is no per-pair label to prefer here. outcome: humanise(d.outcome), direction: d.r >= 0 ? "higher" : "lower", n: d.n, diff --git a/src/lib/ai/coach/tools/index.ts b/src/lib/ai/coach/tools/index.ts index 55d8b8a1f..6c3972b28 100644 --- a/src/lib/ai/coach/tools/index.ts +++ b/src/lib/ai/coach/tools/index.ts @@ -6,28 +6,12 @@ * figures, and a bounded loop that executes them. The legacy snapshot path * stays alive as the no-tools fallback (per-provider `supportsTools`). */ -export { - COACH_TOOL_DEFS, - COACH_TOOL_NAMES, - isCoachToolName, - type CoachToolName, -} from "./definitions"; -export { - executeCoachTool, - type CoachToolResult, - type CoachToolTrace, -} from "./executor"; +export { COACH_TOOL_DEFS } from "./definitions"; +export type { CoachToolTrace } from "./executor"; export { buildCoachDataInventory, renderDataInventory, renderFocusHint, - type CoachDataInventory, - type InventoryEntry, } from "./inventory"; export { buildToolModeAddendum } from "./system-addendum"; -export { - runCoachToolLoop, - MAX_ROUNDS, - HARD_CAP, - type CoachToolLoopResult, -} from "./loop"; +export { runCoachToolLoop, MAX_ROUNDS } from "./loop"; diff --git a/src/lib/analytics/score/index.ts b/src/lib/analytics/score/index.ts index b06e0e637..a044997c5 100644 --- a/src/lib/analytics/score/index.ts +++ b/src/lib/analytics/score/index.ts @@ -11,7 +11,6 @@ import { SCORE_PILLAR_DOMAINS, SCORE_PILLAR_IDS, SCORE_VERSION, - type CompositeValue, type HealthScoreReport, type PillarInputs, type ScorePillarId, @@ -95,5 +94,5 @@ export function computeHealthScore( }; } -export type { CompositeValue, HealthScoreReport }; +export type { HealthScoreReport }; export * from "./types"; diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index d319a36de..3c0a6629a 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -1046,10 +1046,38 @@ export const MFA_STEP_UP_MAX_AGE_SECONDS = 5 * 60; export type FreshMfaContext = AuthContext & { mfaVerifiedAt: Date }; +/** + * Which credentials count when asking "can this account produce a fresh-factor + * proof at all". + * + * The gate is the same either way — a `Session.mfaVerifiedAt` inside the window + * on the cookie path, an elevation minted from a `FRESH_FACTOR_METHODS` proof on + * the Bearer path. What differs is the pre-check that decides whether refusing + * would be a gate or a dead end, and the honest answer to that depends on what + * the route is protecting. + * + * `second-factor` — a confirmed TOTP secret or a registered second-factor + * security key. The default, and the right question for the routes that MANAGE + * a second factor: there is nothing there to protect if nothing is enrolled. + * + * `any-possession` — the above, OR a registered PRIMARY passkey. The right + * question for a route protecting the passkey itself, because a passkey is a + * possession factor in its own right and this codebase already treats it as + * one in three places: `/api/auth/passkey/login-verify` stamps `mfaVerifiedAt` + * on a passkey login (v1.23, M-review M1), `passkey` is in + * `FRESH_FACTOR_METHODS` so a passkey-proved elevation reaches the fresh-factor + * routes, and `resolveMfaEnrollmentRequired` counts a primary passkey as + * satisfying the enforcement policy outright. A passkey-only account can + * therefore clear the gate on both transports, and asking the narrower + * question would have thrown `mfa_not_enrolled` at an account that was holding + * the proof all along. + */ +export type FreshFactorProofSource = "second-factor" | "any-possession"; + /** * v1.23 — step-up gate. Passes only for a COOKIE session whose - * `Session.mfaVerifiedAt` is within `maxAgeSeconds` AND whose user has an - * active second factor (`totpConfirmedAt`). Throws `StepUpRequiredError` + * `Session.mfaVerifiedAt` is within `maxAgeSeconds` AND whose user holds a + * credential that can produce such a stamp. Throws `StepUpRequiredError` * (401, `errorCode: "auth.stepup.required"`) otherwise. * * Bearer tokens can NEVER satisfy this — exactly like `requireAdmin`, the @@ -1059,9 +1087,14 @@ export type FreshMfaContext = AuthContext & { mfaVerifiedAt: Date }; * * Consumed in Phase M by MFA disable + recovery-code regeneration; later * waves gate account deletion, key rotation, and passphrase export on it. + * + * @param proofSource which credentials count as "can prove a factor". Defaults + * to the second-factor set every existing caller means; see + * {@link FreshFactorProofSource}. */ export async function requireFreshMfa( maxAgeSeconds: number, + proofSource: FreshFactorProofSource = "second-factor", ): Promise { const sessionData = await getSession(); if (!sessionData) throw new HttpError(401, "Not authenticated"); @@ -1075,18 +1108,12 @@ export async function requireFreshMfa( }); } - // The user must actually have a second factor active. A single-factor - // account cannot produce a fresh-MFA proof, so step-up-gated actions are - // unreachable for it by design (the management UI gates enrolment first). - // Either factor counts: a confirmed TOTP secret OR a registered WebAuthn - // security key — both stamp `Session.mfaVerifiedAt` on a completed login. - if (!sessionData.user.totpConfirmedAt) { - const webauthnKeyCount = await prisma.webauthnMfaCredential.count({ - where: { userId: sessionData.user.id }, - }); - if (webauthnKeyCount === 0) { - throw new StepUpRequiredError("auth.stepup.mfa_not_enrolled"); - } + // The account must hold a credential that can stamp `mfaVerifiedAt` in the + // first place. Without one a step-up-gated action is unreachable by design + // rather than merely refused, so the refusal is named differently + // (`mfa_not_enrolled`) and the management UI gates enrolment ahead of it. + if (!(await canProveFreshFactor(sessionData.user, proofSource))) { + throw new StepUpRequiredError("auth.stepup.mfa_not_enrolled"); } // Read the freshness stamp off the live session row — `getSession`'s @@ -1125,22 +1152,65 @@ export async function requireFreshMfaIfEnrolled( maxAgeSeconds: number, ): Promise { const auth = await requireAuth(); - // Either factor enrols the account: a confirmed TOTP secret OR a registered - // WebAuthn security key. A webauthn-only user must clear step-up too, so the - // destructive-action boundary tracks `requireFreshMfa`'s either-factor rule. - let enrolled = Boolean(auth.user.totpConfirmedAt); - if (!enrolled) { - const webauthnKeyCount = await prisma.webauthnMfaCredential.count({ - where: { userId: auth.user.id }, - }); - enrolled = webauthnKeyCount > 0; - } - if (enrolled) { + if (await hasSecondFactorEnrolled(auth.user)) { await requireFreshMfa(maxAgeSeconds); } return auth; } +/** + * Does this account have a SECOND FACTOR at all? + * + * Either factor enrols it: a confirmed TOTP secret OR a registered WebAuthn + * security key. A webauthn-only account must clear step-up too, so every + * boundary that asks this question tracks `requireFreshMfa`'s either-factor + * rule rather than reading `totpConfirmedAt` alone. It was written out three + * times before it was a function, which is two more places for the second arm + * to be forgotten. + * + * DELIBERATELY DOES NOT COUNT A PRIMARY PASSKEY, even though a passkey login + * stamps `mfaVerifiedAt` and could therefore satisfy the gate. This predicate + * answers "is there a second factor", and that is the question + * `requireFreshMfaIfEnrolled` asks before deciding whether account deletion, the + * data reset, the password change, the encrypted export and key rotation demand + * a step-up at all. Counting a passkey here would silently pull every + * passkey-holding account into a gate those routes have never applied to them — + * a behaviour change to five destructive actions, made in passing, for the + * benefit of a sixth. {@link canProveFreshFactor} is where the wider question + * lives. + */ +async function hasSecondFactorEnrolled(user: { + id: string; + totpConfirmedAt: Date | null; +}): Promise { + if (user.totpConfirmedAt) return true; + const webauthnKeyCount = await prisma.webauthnMfaCredential.count({ + where: { userId: user.id }, + }); + return webauthnKeyCount > 0; +} + +/** + * Can this account produce a fresh-factor proof at all? + * + * The wider question, asked by the routes that gate on possession rather than + * on second-factor management. See {@link FreshFactorProofSource} for why a + * primary passkey answers yes and where the codebase already says so; see + * {@link hasSecondFactorEnrolled} for why the narrower predicate must not be + * widened into this one. + */ +async function canProveFreshFactor( + user: { id: string; totpConfirmedAt: Date | null }, + proofSource: FreshFactorProofSource, +): Promise { + if (await hasSecondFactorEnrolled(user)) return true; + if (proofSource === "second-factor") return false; + const passkeyCount = await prisma.passkey.count({ + where: { userId: user.id }, + }); + return passkeyCount > 0; +} + /** * A Bearer-resolved caller. * @@ -1268,19 +1338,28 @@ export type MfaManagementContext = { * * @param options.freshFactor mirrors the cookie path's `requireFreshMfa`. Set by * the destructive routes (disable, recovery-code rotation, security-key - * removal). + * removal, passkey removal). + * @param options.proofSource which credentials count as "can prove a factor" + * when deciding whether the gate is reachable. Defaults to the second-factor + * set. Passkey removal passes `"any-possession"` because a primary passkey IS + * the proof there — see {@link FreshFactorProofSource}. It changes only the + * reachability pre-check; the gate itself is identical either way. */ export async function requireMfaManagementAuth( - options: { freshFactor?: boolean } = {}, + options: { + freshFactor?: boolean; + proofSource?: FreshFactorProofSource; + } = {}, ): Promise { const freshFactor = options.freshFactor === true; + const proofSource = options.proofSource ?? "second-factor"; // Cookie first, and via the original helpers — the web path runs the same // code it always did. const sessionData = await getSession(); if (sessionData) { const resolved = freshFactor - ? await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS) + ? await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS, proofSource) : await requireCookieAuth(); return { transport: "cookie", @@ -1338,20 +1417,13 @@ export async function requireMfaManagementAuth( }); if (!validated.ok) throw refusal(validated.reason); - // Parity with the cookie path: `requireFreshMfa` refuses an account with no - // second factor enrolled, because a step-up-gated action is meaningless there. - // The Bearer path holds the same line rather than becoming the softer route. - if (freshFactor) { - let enrolled = Boolean(auth.user.totpConfirmedAt); - if (!enrolled) { - const keys = await prisma.webauthnMfaCredential.count({ - where: { userId: auth.user.id }, - }); - enrolled = keys > 0; - } - if (!enrolled) { - throw new StepUpRequiredError("auth.stepup.mfa_not_enrolled"); - } + // Parity with the cookie path: `requireFreshMfa` refuses an account that holds + // no credential able to produce the proof, because a step-up-gated action is + // unreachable rather than merely refused there. The Bearer path asks the same + // question, with the same `proofSource`, rather than becoming the softer or + // the stricter route. + if (freshFactor && !(await canProveFreshFactor(auth.user, proofSource))) { + throw new StepUpRequiredError("auth.stepup.mfa_not_enrolled"); } return { diff --git a/src/lib/apple-health/ecg-csv.ts b/src/lib/apple-health/ecg-csv.ts index b32285671..d1b003b75 100644 --- a/src/lib/apple-health/ecg-csv.ts +++ b/src/lib/apple-health/ecg-csv.ts @@ -9,6 +9,7 @@ import { createInterface } from "node:readline"; import type { Readable } from "node:stream"; import type { RhythmClassification } from "@/generated/prisma/client"; +import { foldForMatch } from "@/lib/i18n/fold-for-match"; export interface NormalizedAppleHealthEcg { recordedAt: Date; @@ -37,38 +38,230 @@ function parseRecordedAt(value: string | undefined): Date { return parsed; } +/** + * Split a measured quantity into its magnitude and its unit word. + * + * The unit is matched loosely on purpose. A real export writes the sampling + * rate as "511,422 hertz" — the word spelled out, lower case, and separated by + * a NO-BREAK space in the German files — where the fixtures had always assumed + * "512 Hz". The magnitude is read through the shared decimal helper, so a + * comma-region watch's "511,562" is the same rate as "511.422". + * + * `\s` covers U+00A0, so the no-break space needs no special case. + */ +function splitQuantity( + raw: string, + unit: RegExp, +): { magnitude: number; ok: boolean } { + const match = /^([+-]?[\d.,]+)\s*([^\s\d]+)$/.exec(raw.trim()); + if (!match) return { magnitude: Number.NaN, ok: false }; + const magnitude = parseDecimal(match[1]); + if (magnitude === null) return { magnitude: Number.NaN, ok: false }; + return { magnitude, ok: unit.test(match[2]) }; +} + +/** "Hz", "hz", "hertz", "Hertz" — the same unit, spelled as the region does. */ +const HERTZ = /^(?:hz|hertz)$/i; + +/** "bpm" and the spelled-out forms a localised export may carry. */ +const BEATS_PER_MINUTE = /^(?:bpm|spm)$/i; + function parseRate(value: string | undefined): number { - const match = /^(\d+(?:\.\d+)?)\s*Hz$/i.exec(value?.trim() ?? ""); - const rate = match ? Number(match[1]) : Number.NaN; - if (!Number.isFinite(rate) || rate <= 0 || rate > 10_000) { + const { magnitude, ok } = splitQuantity(value ?? "", HERTZ); + if ( + !ok || + !Number.isFinite(magnitude) || + magnitude <= 0 || + magnitude > 10_000 + ) { throw parserError("sample rate is invalid"); } - return Math.round(rate); + return Math.round(magnitude); } function parseHeartRate(value: string | undefined): number | null { if (value === undefined || value.trim() === "") return null; - const match = /^(\d+(?:\.\d+)?)\s*bpm$/i.exec(value.trim()); - const rate = match ? Number(match[1]) : Number.NaN; - if (!Number.isFinite(rate) || rate <= 0 || rate > 300) { + const { magnitude, ok } = splitQuantity(value, BEATS_PER_MINUTE); + if (!ok || !Number.isFinite(magnitude) || magnitude <= 0 || magnitude > 300) { throw parserError("average heart rate is invalid"); } - return Math.round(rate); + return Math.round(magnitude); } -function mapClassification( +/* ────────────────────────────────────────────────────────────────────────── + * Localised exports. + * + * The CSV is written in the device's language, and not only in its values — + * the metadata KEYS are translated too. A German watch writes + * `Klassifizierung,Sinusrhythmus` and `Aufzeichnungsdatum,…`, so a parser + * indexing on the English keys found none of them, never crossed into the + * waveform, and rejected the whole recording. + * + * Both maps below are OBSERVED, from real exports: nine keys and two verdicts, + * consistent across every German file seen. The other four shipped languages + * are deliberately ABSENT rather than guessed. A wrong key silently mis-files a + * value and a wrong clinical verdict is worse than no verdict, so a localised + * file this parser cannot place is refused — which is visible — instead of + * being read into the wrong column, which is not. + * + * Adding a language is a data addition to these two maps and nothing else. + * + * Two gaps worth naming. No observed file carries an average-heart-rate row in + * any language, so there is no localised key for it here. And every observed + * localised file uses the single-column layout, so the paired layout's + * `Lead,Voltage` column header has only ever been seen in English; its + * translated form is unknown and is not invented here. + * ────────────────────────────────────────────────────────────────────────── */ + +/** Folded localised metadata key → the canonical English key we index on. */ +const METADATA_KEY_ALIASES: Readonly> = { + // de — observed across real exports + geburtstag: "Date of Birth", + aufzeichnungsdatum: "Recorded Date", + klassifizierung: "Classification", + symptome: "Symptoms", + softwareversion: "Software Version", + gerat: "Device", + messrate: "Sample Rate", + ableitung: "Lead", + einheit: "Unit", +}; + +/** + * Folded localised verdict → its English wording. + * + * Deliberately an indirection onto the English vocabulary rather than a second + * verdict-to-enum table: one place decides what a verdict MEANS, and + * translations only feed it. + */ +const CLASSIFICATION_ALIASES: Readonly> = { + // de — observed across real exports + sinusrhythmus: "Sinus Rhythm", + uneindeutig: "Inconclusive", +}; + +/** Resolve a metadata key to the English one this parser indexes on. */ +function canonicalMetadataKey(raw: string): string { + return METADATA_KEY_ALIASES[foldForMatch(raw)] ?? raw; +} + +/* ────────────────────────────────────────────────────────────────────────── + * Rhythm classification. + * + * The map used to hold three verdicts. Apple's documented set is larger, so a + * real verdict was being dropped on English devices: "Heart Rate Over 120" and + * "Heart Rate Under 50" both landed on `null`, which the column cannot tell + * apart from "this recording carries no classification at all". + * + * The English wordings below are OBSERVED, from a corpus of real Apple Watch + * exports rather than taken from the framework's case names — the CSV writes a + * display string, and it does not match the `HKElectrocardiogram.Classification` + * identifiers. `InconclusiveHighHeartRate` reaches the file as "Heart Rate Over + * 120", not as "High Heart Rate". The threshold is part of the wording and it + * tracks the watch generation, so the two heart-rate arms match the number + * rather than pin it. + * + * The remaining wordings are Apple's own result names from the ECG + * instructions-for-use document. They are verdicts the app can produce; a given + * file may never carry one, but mapping a documented verdict is not the same as + * leaving a branch in for a hypothetical caller. + * + * ## What is still unresolved, and why it is not guessed here + * + * The CSV is localised, and not only in this field — a German export writes + * `Klassifizierung,Sinusrhythmus`, with the KEY translated too. A non-English + * verdict therefore reaches the `unknown` arm below, and in practice it never + * gets this far, because the localised metadata keys fail the parse earlier. + * Translating a clinical verdict is not something to do from memory, so the + * language half is deliberately left open and named rather than filled in. + * ────────────────────────────────────────────────────────────────────────── */ + +/** What the parser was able to make of the `Classification` field. */ +export type EcgClassificationOutcome = + /** A verdict this repository's enum represents. */ + | { kind: "mapped"; value: RhythmClassification } + /** No classification field, or an empty one — the recording carries no verdict. */ + | { kind: "absent" } + /** A verdict Apple documents that this repository's enum cannot express. */ + | { kind: "unrepresentable"; verdict: string } + /** A verdict this parser has never seen — including every non-English one. */ + | { kind: "unknown" }; + +/** + * Apple's English verdicts, folded, to the enum member each one means. + * + * The inconclusive shapes collapse onto `INCONCLUSIVE` honestly: that member is + * documented as "device could not classify (poor signal / out-of-range HR)", + * which is exactly what Apple says each of them is. + */ +const RHYTHM_BY_VERDICT: ReadonlyMap = new Map< + string, + RhythmClassification +>([ + // Observed in real exports. + ["sinus rhythm", "NOT_DETECTED"], + ["inconclusive", "INCONCLUSIVE"], + // Documented in Apple's ECG instructions for use. + ["atrial fibrillation", "IRREGULAR"], + ["atrial fibrillation high heart rate", "IRREGULAR"], + ["high heart rate no atrial fibrillation detected", "NOT_DETECTED"], + ["poor recording", "INCONCLUSIVE"], +]); + +/** + * The heart-rate-bound verdicts, whose wording carries the threshold the watch + * generation decides ("Heart Rate Over 120", "Heart Rate Over 150"). Apple's + * framework documents both as inconclusive: the classifier could not check for + * atrial fibrillation at that rate. + */ +const HEART_RATE_BOUND_VERDICT = /^heart rate (?:over|under) \d{2,3}$/; + +/** + * Verdicts Apple documents that this repository's enum has no honest member + * for. `Unrecognized` is NOT `INCONCLUSIVE`: the device did not fail to + * classify the waveform, it received a verdict it does not know. Storing it as + * "could not classify" would assert something Apple did not say. + */ +const UNREPRESENTABLE_VERDICTS: ReadonlySet = new Set(["unrecognized"]); + +/** + * Classify the `Classification` field. + * + * Split out from the column write so the parser can tell a verdict it has never + * heard of from one it knows it cannot represent. Both still end as `null` in + * the nullable enum column — that is the column's whole vocabulary — but they + * are different facts, and the previous single `default: return null` could not + * distinguish them. No column is added for the distinction: nothing reads one + * yet, and a write-only column is the failure mode this repository keeps + * rediscovering. + */ +export function classifyEcgRhythm( value: string | undefined, -): RhythmClassification | null { - switch (value?.trim().toLowerCase()) { - case "sinus rhythm": - return "NOT_DETECTED"; - case "atrial fibrillation": - return "IRREGULAR"; - case "inconclusive": - return "INCONCLUSIVE"; - default: - return null; +): EcgClassificationOutcome { + const folded = foldForMatch(value ?? ""); + if (folded === "") return { kind: "absent" }; + const mapped = RHYTHM_BY_VERDICT.get(folded); + if (mapped) return { kind: "mapped", value: mapped }; + if (HEART_RATE_BOUND_VERDICT.test(folded)) { + return { kind: "mapped", value: "INCONCLUSIVE" }; + } + if (UNREPRESENTABLE_VERDICTS.has(folded)) { + return { kind: "unrepresentable", verdict: folded }; } + return { kind: "unknown" }; +} + +function mapClassification( + raw: string | undefined, +): RhythmClassification | null { + // Translate first, classify second. The alias table maps an observed + // localised verdict onto Apple's own English wording and stops there; + // `classifyEcgRhythm` remains the single place that decides what a verdict + // MEANS. A per-language verdict table would drift from this one the first + // time Apple adds a case, and only one of them would get updated. + const value = CLASSIFICATION_ALIASES[foldForMatch(raw ?? "")] ?? raw; + const outcome = classifyEcgRhythm(value); + return outcome.kind === "mapped" ? outcome.value : null; } function resolveUnitScale(value: string | undefined): number | null { @@ -94,6 +287,43 @@ function splitPair(line: string): [string, string] | null { return [line.slice(0, comma).trim(), line.slice(comma + 1).trim()]; } +/** + * Which section of the file the reader is in. + * + * The two waveform layouts disagree about what a comma MEANS, so the parser + * cannot decide that per line — it has to know which side of the boundary it is + * on. Naming the mode is what makes the two readings safe to hold at once. + */ +type Section = + /** Key/value metadata rows. A comma separates the key from the value. */ + | "header" + /** One bare number per row. A comma is a decimal mark. */ + | "single-column" + /** `lead,voltage` per row. A comma separates the two fields. */ + | "paired"; + +/** + * Read one scalar written in either decimal dialect: "-180.596" and "-180,596" + * are the same measurement, recorded by watches set to different regions. + * + * Accepts at most ONE separator. A number carrying thousands grouping + * ("1.234,5") is refused rather than guessed at, because resolving it needs to + * know the file's region and nothing in the file states it. + * + * The single ambiguous case is a lone separator followed by exactly three + * digits, where "-180,596" could in principle be grouping rather than a + * decimal. It is read as a decimal, for two reasons that agree. Observed + * waveforms carry two- and three-digit fractions in the same file + * ("-199,83" next to "-217,333"), and grouping is always three, so the + * two-digit rows settle what the separator is. And a machine writing one bare + * value per row has no reason to group thousands at all. + */ +function parseDecimal(raw: string): number | null { + if (!/^[+-]?\d+(?:[.,]\d+)?$/.test(raw)) return null; + const value = Number(raw.replace(",", ".")); + return Number.isFinite(value) ? value : null; +} + export async function parseAppleHealthEcgCsv(input: { memberName: string; stream: Readable; @@ -106,8 +336,7 @@ export async function parseAppleHealthEcgCsv(input: { const metadata = new Map(); const samples: number[] = []; - let inSamples = false; - let singleColumn = false; + let section: Section = "header"; let unitScale = 1; let lead: string | null = null; const lines = createInterface({ @@ -119,10 +348,23 @@ export async function parseAppleHealthEcgCsv(input: { for await (const rawLine of lines) { const line = rawLine.replace(/^\uFEFF/, "").trim(); if (line === "") { - // Apple's single-column layout declares the lead and unit as - // metadata rows and separates them from the waveform with a blank - // line; one bare number per row follows. - if (!inSamples && metadata.has("Lead") && metadata.has("Unit")) { + // THE CROSSING into the single-column waveform. + // + // Apple's single-column layout declares the lead and unit as metadata + // rows and separates them from the waveform with a blank line; one + // bare number per row follows. A blank line on its own does not mean + // anything — real exports carry two of them mid-header — so the + // boundary is "a blank line once Lead and Unit have both been + // declared", and it is taken exactly once, from the header. + // + // Everything after this point reads a comma as a decimal mark rather + // than as a field separator, which is why the parser tracks the + // section instead of deciding line by line. + if ( + section === "header" && + metadata.has("Lead") && + metadata.has("Unit") + ) { const scale = resolveUnitScale(metadata.get("Unit")); if (scale === null) { throw parserError("sample unit is unsupported"); @@ -132,20 +374,17 @@ export async function parseAppleHealthEcgCsv(input: { if (declaredLead.length > 0 && declaredLead.length <= 32) { lead = declaredLead; } - inSamples = true; - singleColumn = true; + section = "single-column"; } continue; } - if (singleColumn) { - if (line.includes(",")) { - throw parserError("row is malformed"); - } + if (section === "single-column") { if (samples.length >= input.maxSamples) { throw parserError("sample limit exceeded"); } - const value = Number(line); - if (!Number.isFinite(value)) { + // One value per row, in whichever decimal dialect the watch was set to. + const value = parseDecimal(line); + if (value === null) { throw parserError("sample value is invalid"); } const microvolts = Math.round(value * unitScale); @@ -160,23 +399,48 @@ export async function parseAppleHealthEcgCsv(input: { } const pair = splitPair(line); if (!pair) { - throw parserError("row is malformed"); + // A HEADER row whose value is empty is written without the trailing + // comma. Every real export opens with a bare `Name` when the name + // field is blank, which this parser used to reject on line 1 — before + // it had read anything at all. + // + // Only the header section may do this. Inside the single-column + // waveform a comma-less row is a sample and was handled above; inside + // the paired waveform every row is `lead,voltage`, so a missing comma + // there is still malformed. + // + // The row contributes NO metadata entry rather than an empty-string + // one: `Lead` and `Unit` presence is what switches the parser into the + // single-column mode below, and a valueless key must not be able to + // trip that switch. + if (section !== "header") { + throw parserError("row is malformed"); + } + continue; } const [key, value] = pair; - if (!inSamples) { - if (key.toLowerCase() === "lead" && value.toLowerCase() === "voltage") { - inSamples = true; + if (section === "header") { + // THE CROSSING into the paired waveform: the `Lead,Voltage` column + // header. Unlike the single-column boundary this one is explicit in + // the file, so there is nothing to infer. + if ( + canonicalMetadataKey(key) === "Lead" && + value.toLowerCase() === "voltage" + ) { + section = "paired"; continue; } - if (key !== "Name") metadata.set(key, value); + if (key !== "Name") metadata.set(canonicalMetadataKey(key), value); continue; } if (samples.length >= input.maxSamples) { throw parserError("sample limit exceeded"); } - const voltage = Number(value); - if (!Number.isFinite(voltage) || Math.abs(voltage) > 100) { + // `splitPair` cut on the FIRST comma, so a comma still inside the value + // is this row's decimal mark, exactly as in the single-column layout. + const voltage = parseDecimal(value); + if (voltage === null || Math.abs(voltage) > 100) { throw parserError("sample value is invalid"); } const microvolts = Math.round(voltage * 1_000); @@ -187,7 +451,7 @@ export async function parseAppleHealthEcgCsv(input: { samples.push(microvolts); } - if (!inSamples || samples.length === 0) { + if (section === "header" || samples.length === 0) { throw parserError("samples are missing"); } return { diff --git a/src/lib/auth/invite-token.ts b/src/lib/auth/invite-token.ts index ecc15be0b..2916cb20d 100644 --- a/src/lib/auth/invite-token.ts +++ b/src/lib/auth/invite-token.ts @@ -20,8 +20,6 @@ import { randomBytes } from "node:crypto"; import { prisma } from "@/lib/db"; import { hashToken } from "@/lib/auth/hmac"; -export { INVITE_MAX_TTL_DAYS } from "@/lib/validations/invite"; - /** Raw-token prefix — distinct from `hlk_` (API) and `hlr_` (refresh). */ export const INVITE_TOKEN_PREFIX = "hlv_"; diff --git a/src/lib/auth/oidc-native-handoff.ts b/src/lib/auth/oidc-native-handoff.ts index 35fec970c..c2d9076cc 100644 --- a/src/lib/auth/oidc-native-handoff.ts +++ b/src/lib/auth/oidc-native-handoff.ts @@ -44,13 +44,7 @@ export function buildNativeCallbackUrl(params: Record): string { // Re-export the shared mint/consume core so the OIDC callback + token route // keep importing from here unchanged. export { - HANDOFF_CODE_PREFIX, mintNativeHandoff, consumeNativeHandoff, stampIssuedRefreshToken, } from "@/lib/auth/native-handoff"; -export type { - HandoffFlow, - MintedHandoff, - ConsumeHandoffResult, -} from "@/lib/auth/native-handoff"; diff --git a/src/lib/crypto/encryption-corpus.ts b/src/lib/crypto/encryption-corpus.ts index 4230770eb..cadeee22d 100644 --- a/src/lib/crypto/encryption-corpus.ts +++ b/src/lib/crypto/encryption-corpus.ts @@ -40,7 +40,6 @@ import { } from "@/lib/crypto"; import { ENCRYPTED_COLUMNS, - encryptedColumnKey, type EncryptedColumn, } from "@/lib/crypto/encrypted-columns"; @@ -381,5 +380,3 @@ export async function rotateCorpus( totalErrors, }; } - -export { encryptedColumnKey }; diff --git a/src/lib/cycle/custom-symptoms.ts b/src/lib/cycle/custom-symptoms.ts index 7435d731a..fcfd4bd66 100644 --- a/src/lib/cycle/custom-symptoms.ts +++ b/src/lib/cycle/custom-symptoms.ts @@ -26,10 +26,8 @@ import { CUSTOM_SYMPTOM_KEY_PREFIX } from "@/lib/cycle/custom-symptoms-shared"; export { CUSTOM_SYMPTOM_KEY_PREFIX, - CUSTOM_SYMPTOM_CATEGORY_KEY, CUSTOM_SYMPTOM_CATEGORY_ID, MAX_CUSTOM_SYMPTOMS_PER_USER, - CUSTOM_SYMPTOM_ICON_ALLOWLIST, isCustomSymptomKey, createCustomSymptomSchema, updateCustomSymptomSchema, diff --git a/src/lib/cycle/index.ts b/src/lib/cycle/index.ts index 14fac3fa0..4dc97406a 100644 --- a/src/lib/cycle/index.ts +++ b/src/lib/cycle/index.ts @@ -10,25 +10,9 @@ export * from "./types"; export * from "./day-math"; export { predictCycle, - estimateCycleLength, - estimatePeriodLength, - observedPeriodLength, - detectTempShift, - detectMucusPeak, - confirmSymptothermal, - detectTemperatureTrend, - detectLhSurgeOvulation, median, mad, resolveLuteal, clampLuteal, } from "./prediction"; -export { phaseForDay, phaseSeries, type PhaseCycle } from "./phase"; -export { - resolveCycleVerdict, - type CycleVerdict, - type CycleVerdictState, - type CyclePhaseSpan, - type CycleProfileLengths, - type VerdictCalendarDay, -} from "./verdict"; +export { phaseForDay, type PhaseCycle } from "./phase"; diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index f5e79a422..d193958ad 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -18,7 +18,7 @@ import { isStructuredLeafId, REPORT_GROUPS, } from "./report-selection/catalogue"; -import { MEASUREMENT_TYPE_LABEL_KEYS } from "./measurements/type-label-keys"; + import { getUnitForType } from "./validations/measurement"; import { makeFormatters, @@ -166,12 +166,6 @@ export interface DoctorReportRenderOptions { includeCharts?: boolean; } -// Per-type label keys live in a lucide-free module so leaf consumers — the -// public clinician share view, this renderer — can import the map without -// dragging icon components into their graph. Re-exported here so the PDF core -// stays the single import surface its existing callers already use. -export { MEASUREMENT_TYPE_LABEL_KEYS }; - export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record = { WEIGHT: "kg", BLOOD_PRESSURE_SYS: "mmHg", diff --git a/src/lib/export/__tests__/missing-backup-sections.test.ts b/src/lib/export/__tests__/missing-backup-sections.test.ts new file mode 100644 index 000000000..8ac6fc0d0 --- /dev/null +++ b/src/lib/export/__tests__/missing-backup-sections.test.ts @@ -0,0 +1,125 @@ +/** + * A missing section and a declared omission are not the same thing. + * + * `findMissingBackupSections` is the rule that separates them, and the whole + * value of the refusal it feeds rests on it getting the separation right in + * both directions: + * + * - too lax and a portable file whose documents section is gone still runs, + * which is the defect (#237) — the restore deletes the vault it is about to + * rebuild and rebuilds nothing; + * - too strict and it refuses the two files that legitimately leave a section + * out. The portable export omits the mental-health screeners and the consent + * receipts ON PURPOSE, declares both in the manifest as `included: + * "omitted"`, and has to stay restorable. So does every backup written + * before the manifest existed, which declares nothing at all. + * + * The manifest is the only discriminator, and the cases below say so by proving + * the two things it is NOT: not the emptiness of the array (an account with no + * documents honestly writes `[]`), and not a guess from the export's purpose, + * which the payload never states. + * + * Mutation check: treat `[]` as missing and the empty-section case goes red; + * drop the `included === "omitted"` skip and the declared-omission case goes + * red; return early on a present-but-manifest-less file and the legacy case + * stays green while the first case goes red. + */ +import { describe, expect, it } from "vitest"; + +import { findMissingBackupSections } from "../restore-skips"; + +/** A portable file's manifest, as `buildFullBackupPayload` writes it. */ +const PORTABLE_MANIFEST = { + documents: { included: "metadata-only", note: "…" }, + workouts: { included: "summary-only", note: "…" }, + mentalHealth: { included: "omitted", note: "…" }, + consent: { included: "omitted", note: "…" }, +}; + +/** The disaster-recovery writer's manifest — every section carried. */ +const DR_MANIFEST = { + documents: { included: "encrypted-content", note: "…" }, + workouts: { included: "summary-only", note: "…" }, + mentalHealth: { included: "full", note: "…" }, + consent: { included: "full", note: "…" }, +}; + +function portable(overrides: Record = {}) { + return { + schemaVersion: "2", + exportedAt: "2026-08-01T00:00:00.000Z", + userId: "user-1", + manifest: PORTABLE_MANIFEST, + documents: [], + workouts: [], + mentalHealthAssessments: [], + consentReceipts: [], + ...overrides, + }; +} + +describe("findMissingBackupSections", () => { + it("names the documents section a portable file claims and does not carry", () => { + const file = portable(); + delete (file as Record).documents; + + expect(findMissingBackupSections(file)).toEqual(["documents"]); + }); + + it("finds nothing in a complete portable file", () => { + expect(findMissingBackupSections(portable())).toEqual([]); + }); + + it("does not read an empty section as a missing one", () => { + // An account with no documents writes `[]`, and that file is fine. Refusing + // it would make the check worse than the silence it replaces. + expect(findMissingBackupSections(portable({ documents: [] }))).toEqual([]); + }); + + it("leaves a DECLARED omission alone", () => { + // The portable export omits both of these on purpose and says so. This is + // the boundary: the keys are gone, and the file is still restorable. + const file = portable(); + delete (file as Record).mentalHealthAssessments; + delete (file as Record).consentReceipts; + + expect(findMissingBackupSections(file)).toEqual([]); + }); + + it("names the same two sections when the manifest says they ARE carried", () => { + // Identical payload to the case above, one word different in the manifest. + // That word is the whole rule. + const file = portable({ manifest: DR_MANIFEST }); + delete (file as Record).mentalHealthAssessments; + delete (file as Record).consentReceipts; + + expect(findMissingBackupSections(file)).toEqual([ + "mentalHealth", + "consent", + ]); + }); + + it("claims nothing about a file with no manifest", () => { + // Every backup written before the manifest existed. It declares nothing, so + // nothing is missing from it, and it stays restorable. + const legacy = portable({ manifest: null }); + delete (legacy as Record).documents; + delete (legacy as Record).workouts; + + expect(findMissingBackupSections(legacy)).toEqual([]); + }); + + it("reports every missing section rather than the first", () => { + const file = portable({ manifest: DR_MANIFEST }); + delete (file as Record).documents; + delete (file as Record).workouts; + + expect(findMissingBackupSections(file)).toEqual(["documents", "workouts"]); + }); + + it("survives a payload that is not an object at all", () => { + expect(findMissingBackupSections(null)).toEqual([]); + expect(findMissingBackupSections("nope")).toEqual([]); + expect(findMissingBackupSections([])).toEqual([]); + }); +}); diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts index ef8acb6c3..11ffe61d7 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -216,3 +216,91 @@ export function summarizeRestoreSkips(log: RestoreSkipLog): RestoreSkipSummary { links: catalogueKeys.reduce((total, entry) => total + entry.links, 0), }; } + +/** + * A section the file's own manifest says it carries, and does not. + * + * This is the other half of the honesty contract above, and it is deliberately + * the opposite answer. A skip drops one edge and names it, because the record + * it hangs off survives and giving the operator the rest is worth more than + * refusing them everything. A missing SECTION is not one edge — it is a whole + * class of the account, and the restore's first act is to delete the class it + * is about to rebuild. Restoring a file whose documents section is not there + * therefore does not lose a link. It empties the vault, tells the operator the + * restore succeeded, and leaves them to find out later. + * + * So this one refuses the file. Nothing is deleted, and the response names the + * section rather than saying "invalid backup", because an operator holding the + * only copy of an account needs to know whether the file is worthless or + * whether they picked the wrong one of two. + * + * ── The manifest is what tells a missing section from an omitted one ──────── + * + * A payload that omits a section on purpose is not broken, and refusing it + * would break the two exports that do it. `buildSensitiveBackupSection` leaves + * the screener administrations and the consent receipts out of a PORTABLE file + * — the first because the encrypted per-item answers include the PHQ-9 + * self-harm item, the second because a consent belongs to the operator it was + * given to — and says so in the manifest as `included: "omitted"`. That is a + * declared omission, and it stays restorable. + * + * The discriminator is therefore the manifest and nothing else. Not the emptiness + * of the array: an account with no documents honestly writes `[]`, and treating + * that as a loss would refuse a perfectly good file. Not the export's purpose + * either, which the payload does not state. Only "the file said it carries this, + * and the key is not there". + * + * A file with NO manifest at all declares nothing, so nothing is missing from + * it. Every backup written before the manifest existed is in that category and + * has to stay restorable. + */ +export type MissingBackupSection = + "documents" | "workouts" | "mentalHealth" | "consent"; + +/** + * Which payload key each manifest entry speaks for. + * + * `included: "omitted"` is the one value that means "not carried". Every other + * value the writers produce — `metadata-only`, `encrypted-content`, + * `summary-only`, `full` — describes HOW MUCH of the section travels, not + * whether the section is there, and all of them come with the key present. + */ +const MANIFEST_SECTIONS: ReadonlyArray<{ + manifestKey: MissingBackupSection; + payloadKey: string; +}> = [ + { manifestKey: "documents", payloadKey: "documents" }, + { manifestKey: "workouts", payloadKey: "workouts" }, + { manifestKey: "mentalHealth", payloadKey: "mentalHealthAssessments" }, + { manifestKey: "consent", payloadKey: "consentReceipts" }, +]; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Sections the raw file claims and does not carry, in manifest order. + * + * Takes the RAW parsed JSON, not a `BackupPayload`. Every section key in + * `backupPayloadSchema` carries `.default([])`, so by the time the payload has + * been through the schema an absent section and an empty one are the same + * value — which is exactly the distinction this function exists to make. Run it + * on the object that came out of `JSON.parse`. + */ +export function findMissingBackupSections( + raw: unknown, +): MissingBackupSection[] { + if (!isRecord(raw)) return []; + const manifest = raw.manifest; + if (!isRecord(manifest)) return []; + + const missing: MissingBackupSection[] = []; + for (const { manifestKey, payloadKey } of MANIFEST_SECTIONS) { + const entry = manifest[manifestKey]; + if (!isRecord(entry)) continue; + if (entry.included === "omitted") continue; + if (raw[payloadKey] === undefined) missing.push(manifestKey); + } + return missing; +} diff --git a/src/lib/fhir/lab-loinc.ts b/src/lib/fhir/lab-loinc.ts index 0e748d0f9..d9e883349 100644 --- a/src/lib/fhir/lab-loinc.ts +++ b/src/lib/fhir/lab-loinc.ts @@ -37,12 +37,9 @@ * to show a biomarker's name resolves it through the labs catalog instead. */ -import { LOINC_SYSTEM, UCUM_SYSTEM } from "@/lib/fhir/loinc-map"; import { localisedValues } from "@/lib/i18n/shared-resolve"; import { stripDiacritics } from "@/lib/i18n/fold-for-match"; -export { LOINC_SYSTEM, UCUM_SYSTEM }; - /** A curated biomarker → LOINC + canonical-UCUM entry. */ export interface LabLoincMapping { loinc: string; diff --git a/src/lib/insights/__tests__/metric-correlation-context.test.ts b/src/lib/insights/__tests__/metric-correlation-context.test.ts index 703af6931..5f4f411a5 100644 --- a/src/lib/insights/__tests__/metric-correlation-context.test.ts +++ b/src/lib/insights/__tests__/metric-correlation-context.test.ts @@ -48,22 +48,34 @@ describe("getRelevantCorrelationsForMetric", () => { }); it("surfaces only FDR-surviving pairs that involve the metric's channel", async () => { - // Build two strongly-correlated daily series over 60 days: - // TIME_IN_DAYLIGHT (behaviour) → next-day RESTING_HEART_RATE (outcome), - // anti-correlated, plus uncorrelated noise on other channels. + // Two strong, INDEPENDENT lag-1 relationships over 60 days: + // TIME_IN_DAYLIGHT → next-day RESTING_HEART_RATE (involves the metric) + // BLOOD_GLUCOSE → next-day WEIGHT (does not) + // + // The second pair is what makes this test say anything. With one + // correlated pair in the fixture, "every returned relation mentions the + // metric" is satisfied by a function that returns the whole matrix — which + // is what this one did for four releases while the assertion stayed green. const rows: Array<{ type: string; value: number; measuredAt: Date }> = []; const base = new Date("2026-01-01T12:00:00Z"); for (let d = 0; d < 60; d++) { const day = new Date(base.getTime() + d * 86_400_000); + const next = new Date(base.getTime() + (d + 1) * 86_400_000); const daylight = 30 + (d % 10) * 6; // varies 30..84 rows.push({ type: "TIME_IN_DAYLIGHT", value: daylight, measuredAt: day }); // next-day RHR moves opposite to today's daylight (lag-1 anti-corr). - const next = new Date(base.getTime() + (d + 1) * 86_400_000); rows.push({ type: "RESTING_HEART_RATE", value: 90 - daylight * 0.4, measuredAt: next, }); + const glucose = 80 + (d % 7) * 5; // varies 80..110 + rows.push({ type: "BLOOD_GLUCOSE", value: glucose, measuredAt: day }); + rows.push({ + type: "WEIGHT", + value: 60 + glucose * 0.1, + measuredAt: next, + }); } vi.mocked(prisma.measurement.findMany).mockResolvedValue(rows as never); @@ -79,6 +91,11 @@ describe("getRelevantCorrelationsForMetric", () => { expect(Number.isFinite(c.r)).toBe(true); expect(c.n).toBeGreaterThanOrEqual(20); } + // And the glucose → weight pair, which the same scan certainly found, is + // not in a resting-heart-rate card's grounded context. + for (const c of out) { + expect(c.interpretation.toLowerCase()).not.toContain("weight"); + } }); it("is best-effort: a DB failure resolves to [] rather than throwing", async () => { diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index ac0b7e46b..50ef35bd9 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -1216,9 +1216,13 @@ export async function generateComprehensiveInsight( if (insights && typeof insights === "object") { const payload = insights as Record; if (Array.isArray(payload.recommendations)) { - const coverage = computeCitationCoverage({ - recommendations: payload.recommendations, - }); + // The recommendations are written in `locale`, so the normative-claim + // bank has to be the reader's. Grading them against English alone + // reported "no normative claims" for four of the six shipped languages. + const coverage = computeCitationCoverage( + { recommendations: payload.recommendations }, + locale, + ); annotate({ action: { name: "insights.generate.citation_coverage" }, meta: { diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts index 57ae69b63..7e1be8512 100644 --- a/src/lib/insights/correlation-channel-series.ts +++ b/src/lib/insights/correlation-channel-series.ts @@ -1,14 +1,19 @@ /** - * v1.21.0 (INTEGFIX) — shared data-fetching helpers for the two non-measurement - * FDR correlation channels (medication compliance + symptom severity). + * v1.21.0 (INTEGFIX) — the DB reads behind the correlation-discovery channels. * * The pure series shapers live in `correlation-series-builders.ts`; the FDR - * engine itself is pure over `NamedSeries[]`. These two helpers own the DB reads - * that feed those shapers — the dose-history ledger (compliance) and the illness - * day-log (symptom severity) — so EVERY consumer of the discovery matrix (the - * `/api/insights/correlations` route AND the Coach `get_correlations` tool) - * builds the channels identically rather than re-implementing the queries, the - * tz keying, and the episode-span clamping per call site. + * engine itself is pure over `NamedSeries[]`. These helpers own the reads that + * feed those shapers — the dose-history ledger (compliance), the illness day-log + * (symptom severity), the daily weather rows, the opt-in custom metrics, the + * measurement window (raw and rollup-tiered), the mood window — so the queries, + * the tz keying and the episode-span clamping exist once. + * + * This module owns FETCHING one channel at a time. It does NOT decide which + * channels a surface gets: that is `discovery-matrix.ts`, the one assembler + * every consumer of the matrix calls. Fetch discipline was hoisted here in + * v1.30.3 and the assembly was left behind at four call sites, which is how the + * Coach tool spent v1.25 onward scanning a matrix with no weather in it. Add a + * channel here, admit it there, and every surface has it. * * Each helper degrades to an EMPTY series when the user has no data, so the * channel drops out of discovery (it cannot clear the n ≥ 20 floor) rather than @@ -22,6 +27,7 @@ import { SCHEDULE_COMPLIANCE_SELECT, } from "@/lib/analytics/compliance"; import { + FACTOR_CHANNEL_PREFIX, MEDICATION_COMPLIANCE_CHANNEL_KEY, SYMPTOM_SEVERITY_CHANNEL_KEY, type DailySeriesPoint, @@ -389,11 +395,10 @@ export interface MoodWindowFetch { /** * v1.30.3 (QA F1/F2/F3) — shared mood-window fetch, same desc+cap+resort - * discipline as {@link fetchMeasurementWindowSeries}. Callers that also need - * per-entry factor tag-links (the period narrative's RATED-factor channels) - * read `MoodEntry` themselves for the extra `tagLinks` select, but MUST - * apply the same desc+resort discipline — this helper only covers the - * plain-score case the route / coach tool / per-metric card share. + * discipline as {@link fetchMeasurementWindowSeries}. This helper covers the + * plain-score case; the RATED-factor variant that also reads each entry's + * tag-links is {@link fetchMoodFactorWindowSeries}, which carries the same + * discipline over a wider select. */ export async function fetchMoodWindowSeries( userId: string, @@ -417,6 +422,131 @@ export async function fetchMoodWindowSeries( return { moodDaily, moodCapped }; } +/** One mood-window fetch that also carries the RATED-factor channels. */ +export interface MoodFactorWindowFetch extends MoodWindowFetch { + /** One inverse-flipped daily-mean series per `FACTOR:` channel. */ + factorSeries: Map; +} + +/** + * A RATED mood factor link, carrying the scale + `inverse` flag so the + * documented sign-flip is applied once, at the boundary. + */ +interface FactorLink { + key: string; + rating: number; + scaleMin: number; + scaleMax: number; + inverse: boolean; + at: Date; +} + +/** + * v1.14.0 — collapse RATED-factor links to one inverse-flipped daily-mean + * series per factor, tz-day-keyed exactly like {@link toDailyMeans} so a factor + * channel joins the discovery matrix on the same day grid as every vital. + * An inverse factor's rating `r` maps to `(scaleMin + scaleMax) - r` BEFORE + * averaging so "up" always reads as a better day — the same flip the mood + * aggregates apply, kept in lock-step. Returns one `FACTOR:` series per + * factor the user actually rated. Pure. + */ +function factorDailyMeans( + links: FactorLink[], + tz: string, +): Map { + const byFactor = new Map< + string, + Map + >(); + for (const l of links) { + if (!Number.isFinite(l.rating)) continue; + const value = l.inverse ? l.scaleMin + l.scaleMax - l.rating : l.rating; + const day = tzDayKey(l.at, tz); + const days = + byFactor.get(l.key) ?? new Map(); + const acc = days.get(day) ?? { sum: 0, count: 0 }; + acc.sum += value; + acc.count += 1; + days.set(day, acc); + byFactor.set(l.key, days); + } + const out = new Map(); + for (const [key, days] of byFactor) { + out.set( + `${FACTOR_CHANNEL_PREFIX}${key}`, + [...days.entries()] + .map(([day, acc]) => ({ day, value: acc.sum / acc.count })) + .sort((a, b) => (a.day < b.day ? -1 : 1)), + ); + } + return out; +} + +/** + * Mood-window fetch WITH the per-entry RATED-factor tag-links, for the one + * surface that admits `FACTOR:*` channels into the discovery matrix (see the + * `includeMoodFactors` option on `assembleDiscoveryMatrix` for why that is one + * surface and not four). Same desc+cap+resort discipline as + * {@link fetchMoodWindowSeries} over a wider select — the tag-links ride the + * existing mood read, so this costs no extra round-trip over the plain variant. + * + * BINARY links carry a null `rating` and are excluded at the query. + */ +export async function fetchMoodFactorWindowSeries( + userId: string, + tz: string, + since: Date, +): Promise { + const rowsDesc = await prisma.moodEntry.findMany({ + where: { userId, deletedAt: null, moodLoggedAt: { gte: since } }, + orderBy: { moodLoggedAt: "desc" }, + take: MOOD_READ_CAP, + select: { + score: true, + moodLoggedAt: true, + tagLinks: { + where: { moodTag: { kind: "RATED" }, rating: { not: null } }, + select: { + rating: true, + moodTag: { + select: { + key: true, + scaleMin: true, + scaleMax: true, + inverse: true, + }, + }, + }, + }, + }, + }); + const moodCapped = rowsDesc.length >= MOOD_READ_CAP; + const rows = [...rowsDesc].sort( + (a, b) => a.moodLoggedAt.getTime() - b.moodLoggedAt.getTime(), + ); + const moodDaily = toDailyMeans( + rows.map((e) => ({ value: e.score, at: e.moodLoggedAt })), + tz, + ); + + const links: FactorLink[] = []; + for (const entry of rows) { + for (const link of entry.tagLinks) { + if (link.rating == null) continue; + links.push({ + key: link.moodTag.key, + rating: link.rating, + scaleMin: link.moodTag.scaleMin, + scaleMax: link.moodTag.scaleMax, + inverse: link.moodTag.inverse, + at: entry.moodLoggedAt, + }); + } + } + + return { moodDaily, moodCapped, factorSeries: factorDailyMeans(links, tz) }; +} + /** * v1.29.6 — collapse rows to per-day MEANS keyed in the user's tz. This is * the correct grain for spot metrics (BP, glucose, HRV, resting HR, weight, diff --git a/src/lib/insights/derived/__tests__/coach-read-locale.test.ts b/src/lib/insights/derived/__tests__/coach-read-locale.test.ts index 504d628af..c861ffce9 100644 --- a/src/lib/insights/derived/__tests__/coach-read-locale.test.ts +++ b/src/lib/insights/derived/__tests__/coach-read-locale.test.ts @@ -24,6 +24,8 @@ const intakeEventFindMany = vi.fn(); const illnessEpisodeFindMany = vi.fn(); const illnessDayLogFindMany = vi.fn(); const labResultFindMany = vi.fn(); +const environmentContextFindMany = vi.fn(); +const customMetricFindMany = vi.fn(); vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn(async () => true), @@ -42,6 +44,12 @@ vi.mock("@/lib/db", () => ({ illnessEpisode: { findMany: (a: unknown) => illnessEpisodeFindMany(a) }, illnessDayLog: { findMany: (a: unknown) => illnessDayLogFindMany(a) }, labResult: { findMany: (a: unknown) => labResultFindMany(a) }, + // The reader assembles the shared discovery matrix, which reads the + // environmental-exposure and custom-metric channels too. + environmentContext: { + findMany: (a: unknown) => environmentContextFindMany(a), + }, + customMetric: { findMany: (a: unknown) => customMetricFindMany(a) }, }, })); @@ -129,6 +137,8 @@ describe("buildCoachReadStrip — the pattern line speaks the reader's language" illnessEpisodeFindMany.mockReset().mockResolvedValue([]); illnessDayLogFindMany.mockReset().mockResolvedValue([]); labResultFindMany.mockReset().mockResolvedValue([]); + environmentContextFindMany.mockReset().mockResolvedValue([]); + customMetricFindMany.mockReset().mockResolvedValue([]); seedSymptomDrivesSleep(); }); diff --git a/src/lib/insights/derived/coach-read.ts b/src/lib/insights/derived/coach-read.ts index 346df7bae..7467e1f98 100644 --- a/src/lib/insights/derived/coach-read.ts +++ b/src/lib/insights/derived/coach-read.ts @@ -39,12 +39,7 @@ import { type CoachReadStripData, } from "@/lib/insights/derived/coach-read-shape"; -export type { - CoachReadBaseline, - CoachReadDriver, - CoachReadStripData, - CoachReadBaselinePlacement, -} from "@/lib/insights/derived/coach-read-shape"; +export type { CoachReadStripData } from "@/lib/insights/derived/coach-read-shape"; /** * Read the latest reading for `(userId, type)` — the value the strip places diff --git a/src/lib/insights/derived/index.ts b/src/lib/insights/derived/index.ts index 3197156a9..9d5026d18 100644 --- a/src/lib/insights/derived/index.ts +++ b/src/lib/insights/derived/index.ts @@ -1,185 +1,47 @@ /** * v1.10.0 — public barrel for the derived-metrics layer. * - * The one module every consumer imports from. Append-only per wave — - * Wave 1 exports the foundation (`Derived` + coverage builders + - * registry + norms + the flagship baseline engine + the route - * dispatcher); W2/W3 append their per-metric engines (`fitness-age`, - * `vascular-age`, `sleep-score`, `readiness`) here. + * Carries the cross-cutting surface: the registry (which metrics exist and + * how they are archetyped), the route dispatcher, the baseline profile + * loader, and the handful of per-metric compute engines and value shapes + * that consumers outside this directory actually reach for. * - * Re-exports the client-safe types/coverage builders and the - * server-only compute engines from one place; consumers import only what - * they need. (A `"use client"` component must value-import only from - * `./types` + `./coverage` + `./registry` — those are server-import-free. - * The route + server consumers may import the engines below.) + * It is deliberately NOT a mirror of every export under `./`. The per-metric + * scoring helpers, their `*Opts` / `*Band` types and the sub-score weights are + * imported from their concrete module (`./sleep-score`, `./trajectory`, …) by + * the code and tests that use them; re-exporting them here only produced lines + * nothing imported. Add a line below when an outside consumer appears, not in + * anticipation of one. + * + * A `"use client"` component must value-import only from `./types`, + * `./coverage` and `./registry` — those are server-import-free. The route and + * server consumers may import the engines below. */ -// ── client-safe contract (types + pure builders + metadata) ────────── -export type { - Derived, - DerivedOk, - DerivedInsufficient, - DerivedCoverage, - DerivedConfidence, - DerivedConfidenceBand, - DerivedProvenance, - DerivedProvenanceSource, -} from "./types"; +// ── client-safe contract ───────────────────────────────────────────── export { isDerivedOk } from "./types"; -export { - deriveCoverage, - buildOk, - buildInsufficient, - scoreToBand, - nowProvenanceTimestamp, -} from "./coverage"; -export type { DeriveCoverageArgs } from "./coverage"; - export { DERIVED_METRIC_IDS, - VITALS_BASELINE_TYPES, TRAJECTORY_TYPES, - SAME_TIME_BASELINE_TYPES, - SAME_TIME_BASELINE_WINDOW_DAYS, - SAME_TIME_BASELINE_MIN_HISTORY_DAYS, isDerivedMetricId, - getDerivedMetricMeta, - isVitalsBaselineType, - isTrajectoryType, - isSameTimeBaselineType, } from "./registry"; -export type { - DerivedMetricId, - DerivedMetricMeta, - DerivedArchetype, -} from "./registry"; - -export { - lookupNormalRange, - hasSharpenedNorm, - predictSixMinuteWalkDistance, -} from "./norms"; -export type { NormRange, NormSex } from "./norms"; +export type { DerivedMetricId } from "./registry"; // ── server-only compute engines (do NOT value-import from a client component) ── -export { - computeVitalsBaseline, - loadBaselineProfile, - buildBaselineBand, - median, - medianAbsoluteDeviation, -} from "./baseline"; -export type { - VitalsBaselineValue, - VitalsBaselineOpts, - BaselineProfile, -} from "./baseline"; +export { loadBaselineProfile } from "./baseline"; +export type { BaselineProfile } from "./baseline"; export { computeDerivedMetric } from "./dispatch"; -export type { DerivedComputeArgs } from "./dispatch"; -// ── W2b vitals tier: passthrough re-frames + derived bands ─────────── -export { - computeFitnessAge, - placeVo2Band, - fitnessAgeDeltaYears, -} from "./fitness-age"; -export type { FitnessAgeValue, FitnessBand } from "./fitness-age"; - -export { - computeSixMinuteWalkBand, - placeSixMinuteWalkBand, -} from "./six-minute-walk"; -export type { SixMinuteWalkValue, SixMinuteWalkBand } from "./six-minute-walk"; +export type { SleepScoreValue } from "./sleep-score"; -export { computeVascularAgeDelta, placeVascularBand } from "./vascular-age"; -export type { VascularAgeDeltaValue, VascularBand } from "./vascular-age"; +export { computeReadiness } from "./readiness"; +export type { ReadinessValue, ReadinessComponentKey } from "./readiness"; -export { computeHrvBalance, placeHrvBalance } from "./hrv-balance"; -export type { HrvBalanceValue, HrvBalanceBand } from "./hrv-balance"; +export { computeCoincidentDeviation } from "./coincident-deviation"; -export { computeBmi, classifyBmi } from "./bmi"; -export type { BmiValue, BmiBand, BmiCategory } from "./bmi"; -// ── W3 composites (server-only compute engines) ────────────────────── -export { - computeSleepScore, - blendSleepSubScores, - reconstructNights, - sleepNeedMinutes, - scoreSufficiency, - scoreEfficiency, - scoreComposition, - scoreConsistency, - scoreTiming, - SLEEP_SUBSCORE_WEIGHTS, -} from "./sleep-score"; -export type { - SleepScoreValue, - SleepScoreOpts, - SleepSubScore, - SleepSubScoreKey, - NightSummary, -} from "./sleep-score"; - -export { - computeReadiness, - blendReadinessComponents, - scoreDeviation, - READINESS_WEIGHTS, - READINESS_MIN_COMPONENTS, -} from "./readiness"; -export type { - ReadinessValue, - ReadinessOpts, - ReadinessComponent, - ReadinessComponentKey, -} from "./readiness"; - -export { - computeCoincidentDeviation, - classifyDeviation, - COINCIDENT_FIRE_THRESHOLD, - COINCIDENT_MIN_BANDS, -} from "./coincident-deviation"; -export type { - CoincidentDeviationValue, - CoincidentDeviationOpts, - VitalDeviation, -} from "./coincident-deviation"; - -// ── persisted nightly wellness scores (passthrough read) ───────────── -export { - computeWellnessScore, - bandWellnessScore, - WELLNESS_SCORE_TYPES, -} from "./wellness-scores"; -export type { - WellnessScoreValue, - WellnessScoreBand, - WellnessScoreType, - WellnessScoreOpts, -} from "./wellness-scores"; - -// ── v1.11.0 (Epic B, Pillar 3) forecasting engine (server-only) ────── -export { - computeTrajectory, - fitOls, - predictionIntervalHalfWidth, - TRAJECTORY_MIN_R2, - TRAJECTORY_MIN_HISTORY_DAYS, -} from "./trajectory"; -export type { - TrajectoryValue, - TrajectoryPoint, - TrajectoryOpts, - OlsFit, -} from "./trajectory"; +export type { WellnessScoreValue } from "./wellness-scores"; -// ── v1.34.0 same-time baseline for cumulative metrics (server-only) ── -export { computeSameTimeBaseline } from "./same-time-baseline"; -export type { - SameTimeBaselineValue, - SameTimeBaselineOpts, - SameTimeBand, -} from "./same-time-baseline"; +export { computeTrajectory } from "./trajectory"; +export type { TrajectoryValue } from "./trajectory"; diff --git a/src/lib/insights/discovery-matrix.ts b/src/lib/insights/discovery-matrix.ts new file mode 100644 index 000000000..d5dc07993 --- /dev/null +++ b/src/lib/insights/discovery-matrix.ts @@ -0,0 +1,326 @@ +/** + * The one assembler for the correlation-discovery matrix. + * + * `discoverCorrelations` is pure over `NamedSeries[]`. Deciding WHICH series go + * in is the part that used to be copied: the `/api/insights/correlations` route, + * the per-metric assessment card, the Coach `get_correlations` tool and the + * period narrative each folded their own channel list, and three of the four + * carried a different one. The Coach's was missing the environmental and + * custom-metric families entirely, so a question about air pressure and sleep + * met a matrix that had never contained the pair — while the tool's own file + * header promised "the SAME full-matrix scan the route runs". v1.30.3 hoisted + * the FETCH discipline into `correlation-channel-series.ts` for exactly this + * reason and left the assembly duplicated behind; this module finishes that. + * + * Every surface that scans the matrix calls {@link assembleDiscoveryMatrix} and + * gets the same channel set. The differences that remain are declared as + * options, each with its reason written at the option — a surface cannot drop a + * channel family by omission any more, only by naming what it is doing. + * + * What this does NOT own: + * + * - the scan. Each caller still runs `discoverCorrelations` (and, where it + * applies, `discoverEmergingCorrelations`) over the returned series, because + * what each does with the result differs — the route persists patterns, the + * card filters to one metric, the Coach shapes driver rows, the narrative + * cites them. Same input, same statistics, different presentation. + * - the labs ↔ outcome pass. `discoverLabOutcomeCorrelations` runs over + * `LabDrawPoint[]` (point-vs-window over sparse draws), not the + * behaviour × outcome day grid this assembles. Its input comes from + * `fetchLabDraws` at the two call sites that want it. + * - the cycle-phase matrix. `discoverPhaseCorrelations` builds a deliberately + * separate, gated matrix (CYCLE_PHASE × outcomes) that must never reach the + * non-gated surfaces; see `src/lib/cycle/phase-crosstab.ts`. + * + * The freeze on all of this is `src/__tests__/discovery-matrix-guard.test.ts`. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { + DISCOVERY_BEHAVIOURS, + DISCOVERY_OUTCOMES, + discoveryMeasurementTypes, + MEDICATION_COMPLIANCE_CHANNEL_KEY, + SYMPTOM_SEVERITY_CHANNEL_KEY, + type DailySeriesPoint, + type NamedSeries, +} from "@/lib/insights/correlation-discovery"; +import { + buildMeasurementDailySeries, + fetchComplianceSeries, + fetchCustomMetricBehaviourSeries, + fetchEnvironmentSeries, + fetchMeasurementDailySeriesTiered, + fetchMeasurementWindowSeries, + fetchMoodFactorWindowSeries, + fetchMoodWindowSeries, + fetchSymptomSeries, + type MoodFactorWindowFetch, +} from "@/lib/insights/correlation-channel-series"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; + +/** + * How the measurement channels are read. + * + * - `"tiered"` takes the DAY rollup read-swap where a channel is eligible and + * the user's profile timezone sits in the near-UTC band, falling back to the + * raw path per channel on any miss. Cheaper on a dense account; carries the + * documented ±3 h day-key tolerance and drops the partial oldest day. + * - `"raw"` always reads raw rows and reduces them in process. + * + * This is a read-cost decision, NOT a channel-set decision, so it stays a + * per-caller choice rather than something the assembler picks. The route takes + * the swap because it scans 180 days on every cache miss; the three library + * surfaces stayed raw when the swap landed and are left there, because moving + * them is a value change at the tolerance boundary and belongs in its own + * change with its own parity proof, not folded into a channel-set fix. + */ +export type DiscoveryMatrixFetchMode = "tiered" | "raw"; + +export interface DiscoveryMatrixOptions { + /** The user's display timezone — every day key is minted in it. */ + tz: string; + /** Inclusive start of the read window. */ + since: Date; + /** See {@link DiscoveryMatrixFetchMode}. */ + fetchMode: DiscoveryMatrixFetchMode; + /** + * Admit the user's RATED mood factors as `FACTOR:` channels, in BOTH + * roles. Off everywhere except the period narrative, deliberately: + * + * - `MoodTag.kind = "RATED"` is FROZEN as of v1.37. The capture sheet stopped + * offering factor ratings when the level-A columns landed on `MoodEntry`; + * nothing was deleted, so existing scores keep rendering and keep feeding + * the factor crosstab, but no new ones are written. Widening a frozen + * capture path's reach to three more surfaces is a decision about what to + * ask, not a parity fix — the schema comment on that column says so in as + * many words. + * - The route and the per-metric card PERSIST every surviving pair into + * `CorrelationPattern.factorKey` / `.outcomeKey`, plaintext columns. A + * custom RATED tag's key is `custom:` with its label held encrypted, + * so a factor channel on those two surfaces would write a channel key that + * only resolves through a decrypt — and would narrate as the raw key + * meanwhile. + * + * So this is a real distinction, not an accident of where someone stopped: + * the narrative reads a bounded period and cites factors in prose it does not + * store. Turning it on costs no extra round-trip — the tag-links ride the + * existing mood read (see `fetchMoodFactorWindowSeries`) — but it does widen + * the BH-FDR family, since each factor enters as both lag source and lag + * target. + */ + includeMoodFactors?: boolean; + /** + * Measurement types to read ALONGSIDE the discovery channels, surfaced in + * {@link DiscoveryMatrix.byMetric} but never folded into `series`. Lets a + * caller that needs more per-metric series than the matrix contains (the + * period narrative's delta metrics and banded vitals) get them from the one + * measurement read instead of a second one. + */ + extraMeasurementTypes?: readonly MeasurementType[]; +} + +/** Per-channel reach, for the callers' wide-event annotations. */ +export interface DiscoveryMatrixDiagnostics { + /** The raw measurement read hit its cap — the window may miss OLDER rows. */ + measurementsCapped: boolean; + /** The mood read hit its cap. */ + moodCapped: boolean; + /** Measurement channels served from the DAY rollup tier (tiered mode only). */ + rollupTypes: MeasurementType[]; + complianceDays: number; + symptomDays: number; + /** Stored daily points summed across the environmental-exposure channels. */ + environmentDays: number; + customMetricChannels: number; + customMetricDays: number; + /** `FACTOR:*` channels admitted (0 unless `includeMoodFactors`). */ + moodFactorChannels: number; +} + +export interface DiscoveryMatrix { + /** The behaviour × outcome channel set, ready for `discoverCorrelations`. */ + series: NamedSeries[]; + /** + * Day-keyed series per metric key — every measurement type read (discovery + * channels plus `extraMeasurementTypes`), `MOOD`, and each admitted + * `FACTOR:*` channel. A key with no data is absent; callers read it as + * `byMetric.get(key) ?? []`. + */ + byMetric: Map; + diagnostics: DiscoveryMatrixDiagnostics; +} + +/** + * The mood read, normalised to the factor-carrying shape. With factors off the + * factor map is empty, so the fold below has one code path rather than two. + */ +async function readMood( + userId: string, + tz: string, + since: Date, + withFactors: boolean, +): Promise { + if (withFactors) return fetchMoodFactorWindowSeries(userId, tz, since); + const plain = await fetchMoodWindowSeries(userId, tz, since); + return { ...plain, factorSeries: new Map() }; +} + +/** One measurement read, reduced to per-day series under the chosen mode. */ +async function readMeasurementDaily( + userId: string, + tz: string, + since: Date, + types: MeasurementType[], + mode: DiscoveryMatrixFetchMode, +): Promise<{ + byType: Map; + measurementsCapped: boolean; + rollupTypes: MeasurementType[]; +}> { + if (mode === "tiered") { + const tiered = await fetchMeasurementDailySeriesTiered( + userId, + tz, + since, + types, + ); + return { + byType: tiered.byType, + measurementsCapped: tiered.measurementsCapped, + rollupTypes: tiered.rollupTypes, + }; + } + + const [raw, priorityJson] = await Promise.all([ + fetchMeasurementWindowSeries(userId, since, types), + loadUserSourcePriority(userId), + ]); + const byType = new Map(); + for (const type of types) { + const rows = raw.byType.get(type); + if (!rows || rows.length === 0) continue; + byType.set(type, buildMeasurementDailySeries(type, rows, tz, priorityJson)); + } + return { + byType, + measurementsCapped: raw.measurementsCapped, + rollupTypes: [], + }; +} + +/** + * Build the full discovery matrix for a user over a window. + * + * Reads every channel family in parallel and folds them in one fixed order: + * the declared behaviours, the declared outcomes, the environmental-exposure + * channels, the opt-in custom-metric channels, then (only when asked) the + * RATED mood factors in both roles. A family the user has no data for yields + * an empty series and drops out of the scan at the n ≥ 20 floor — absence is + * never a fabricated constant. + * + * The order is fixed on purpose. `discoverCorrelations` ranks by shrunk effect + * then q, and `Array.prototype.sort` is stable, so two pairs equal on both + * come out in the order they were tested — which is the order the channels + * were folded. A stable fold keeps two runs over the same data ranked the same + * way, and keeps this route's ranking identical to what it was before the + * assembly moved here. + */ +export async function assembleDiscoveryMatrix( + userId: string, + opts: DiscoveryMatrixOptions, +): Promise { + const { tz, since, fetchMode } = opts; + + // The non-MeasurementType channels (MOOD, MEDICATION_COMPLIANCE, + // SYMPTOM_SEVERITY) are backed by other models, so `discoveryMeasurementTypes` + // drops them before the list reaches a Postgres `type IN (...)` enum cast — + // a non-enum string in that list errors the cast. Each is sourced below. + const measurementTypes = Array.from( + new Set([ + ...discoveryMeasurementTypes(DISCOVERY_BEHAVIOURS), + ...discoveryMeasurementTypes(DISCOVERY_OUTCOMES), + ...(opts.extraMeasurementTypes ?? []), + ]), + ) as MeasurementType[]; + + const [ + measurements, + mood, + complianceSeries, + symptomSeries, + environmentSeries, + customMetricSeries, + ] = await Promise.all([ + readMeasurementDaily(userId, tz, since, measurementTypes, fetchMode), + readMood(userId, tz, since, opts.includeMoodFactors === true), + fetchComplianceSeries(userId, tz, since), + fetchSymptomSeries(userId, tz, since), + fetchEnvironmentSeries(userId, since), + fetchCustomMetricBehaviourSeries(userId, tz, since), + ]); + + const { factorSeries } = mood; + + const points = (key: string): DailySeriesPoint[] => + key === "MOOD" ? mood.moodDaily : (measurements.byType.get(key) ?? []); + + const series: NamedSeries[] = []; + for (const key of DISCOVERY_BEHAVIOURS) { + if (key === MEDICATION_COMPLIANCE_CHANNEL_KEY) { + series.push(complianceSeries); + } else if (key === SYMPTOM_SEVERITY_CHANNEL_KEY) { + // The symptom builder returns the outcome role; the points are + // role-invariant, so the behaviour arm re-tags the same series. + series.push({ ...symptomSeries, role: "behaviour" }); + } else { + series.push({ key, role: "behaviour", points: points(key) }); + } + } + for (const key of DISCOVERY_OUTCOMES) { + if (key === SYMPTOM_SEVERITY_CHANNEL_KEY) { + series.push({ ...symptomSeries, role: "outcome" }); + } else { + series.push({ key, role: "outcome", points: points(key) }); + } + } + // Environmental exposure and custom metrics are lag SOURCES only: they pair + // (D → D+1) against every outcome above, under the unchanged n ≥ 20 / FDR / + // effect-size gates, so a thin weather or custom series degrades to absent. + series.push(...environmentSeries, ...customMetricSeries); + // A factor is plausibly both a lag source ("rated work today → next-day + // sleep") and a lag target ("steps today → next-day rated energy"), so it + // enters as both roles. The engine skips self-pairs — every factor shares + // MOOD's family — and BH-FDR controls the wider family this opens. + for (const [key, factorPoints] of factorSeries) { + series.push({ key, role: "behaviour", points: factorPoints }); + series.push({ key, role: "outcome", points: factorPoints }); + } + + const byMetric = new Map(measurements.byType); + if (mood.moodDaily.length > 0) byMetric.set("MOOD", mood.moodDaily); + for (const [key, factorPoints] of factorSeries) { + byMetric.set(key, factorPoints); + } + + return { + series, + byMetric, + diagnostics: { + measurementsCapped: measurements.measurementsCapped, + moodCapped: mood.moodCapped, + rollupTypes: measurements.rollupTypes, + complianceDays: complianceSeries.points.length, + symptomDays: symptomSeries.points.length, + environmentDays: environmentSeries.reduce( + (sum, s) => sum + s.points.length, + 0, + ), + customMetricChannels: customMetricSeries.length, + customMetricDays: customMetricSeries.reduce( + (sum, s) => sum + s.points.length, + 0, + ), + moodFactorChannels: factorSeries.size, + }, + }; +} diff --git a/src/lib/insights/metric-correlation-context.ts b/src/lib/insights/metric-correlation-context.ts index 10cea2f33..f626b5ef3 100644 --- a/src/lib/insights/metric-correlation-context.ts +++ b/src/lib/insights/metric-correlation-context.ts @@ -13,35 +13,27 @@ * This module runs the SAME full-matrix discovery the * `/api/insights/correlations` route runs (so a card never surfaces a pair * the correlations page wouldn't), then filters to the surviving pairs that - * INVOLVE the current metric's discovery channel. The result is the - * engine's own conservative, descriptive, never-causal `interpretation` - * strings — passed verbatim into the prompt as grounded context. + * INVOLVE the current metric's discovery channel. That parity is structural + * rather than asserted: both take their channel set from the one assembler in + * `src/lib/insights/discovery-matrix.ts`, and + * `src/__tests__/discovery-matrix-guard.test.ts` freezes the assembler as the + * only place a matrix is built. The result is the engine's own conservative, + * descriptive, never-causal `interpretation` strings — passed verbatim into + * the prompt as grounded context. * * Read-only consumption. No new statistics are computed here; the only - * change vs the route is the filter to one metric and a shorter window cap. + * changes vs the route are the filter to one metric and the raw (rather than + * rollup-tiered) measurement read. */ import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import type { Locale } from "@/lib/i18n/config"; import { discoverCorrelations, - discoveryMeasurementTypes, DISCOVERY_BEHAVIOURS, DISCOVERY_OUTCOMES, - type NamedSeries, - MEDICATION_COMPLIANCE_CHANNEL_KEY, - SYMPTOM_SEVERITY_CHANNEL_KEY, } from "@/lib/insights/correlation-discovery"; -import { - buildMeasurementDailySeries, - fetchComplianceSeries, - fetchCustomMetricBehaviourSeries, - fetchEnvironmentSeries, - fetchMeasurementWindowSeries, - fetchMoodWindowSeries, - fetchSymptomSeries, -} from "@/lib/insights/correlation-channel-series"; -import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { assembleDiscoveryMatrix } from "@/lib/insights/discovery-matrix"; import { decisionForEvidence, PATTERN_FAMILIES, @@ -102,73 +94,15 @@ export async function getRelevantCorrelationsForMetric( const tz = profile?.timezone ?? "Europe/Berlin"; const since = new Date(Date.now() - WINDOW_DAYS * MS_PER_DAY); - // Non-measurement channels (MOOD, and v1.21.0 MEDICATION_COMPLIANCE / - // SYMPTOM_SEVERITY) are backed by other models — `discoveryMeasurementTypes` - // strips them from the measurement `type IN (...)` query (a non-enum string - // would error the cast). This per-metric-card surface does not populate the - // compliance / symptom channels (they degrade to absent here); the - // canonical `/api/insights/correlations` route builds them. - const behaviourTypes = discoveryMeasurementTypes( - DISCOVERY_BEHAVIOURS, - ) as MeasurementType[]; - const outcomeTypes = discoveryMeasurementTypes( - DISCOVERY_OUTCOMES, - ) as MeasurementType[]; - - // v1.30.3 (QA F1) — the fetch + desc/cap/resort discipline (a dense - // account's cap must fall on the OLDEST rows, never the newest) now - // lives in `fetchMeasurementWindowSeries` / `fetchMoodWindowSeries` - // (`correlation-channel-series.ts`), shared with the route, the Coach - // tool, and the period narrative. - const [ - { byType }, - { moodDaily }, - priorityJson, - complianceSeries, - symptomSeries, - environmentSeries, - customMetricSeries, - ] = await Promise.all([ - fetchMeasurementWindowSeries(userId, since, [ - ...behaviourTypes, - ...outcomeTypes, - ]), - fetchMoodWindowSeries(userId, tz, since), - loadUserSourcePriority(userId), - fetchComplianceSeries(userId, tz, since), - fetchSymptomSeries(userId, tz, since), - fetchEnvironmentSeries(userId, since), - fetchCustomMetricBehaviourSeries(userId, tz, since), - ]); - - const points = (key: string) => - key === "MOOD" - ? moodDaily - : buildMeasurementDailySeries( - key as MeasurementType, - byType.get(key) ?? [], - tz, - priorityJson, - ); - - const series: NamedSeries[] = []; - for (const key of DISCOVERY_BEHAVIOURS) { - if (key === MEDICATION_COMPLIANCE_CHANNEL_KEY) { - series.push(complianceSeries); - } else if (key === SYMPTOM_SEVERITY_CHANNEL_KEY) { - series.push({ ...symptomSeries, role: "behaviour" }); - } else { - series.push({ key, role: "behaviour", points: points(key) }); - } - } - for (const key of DISCOVERY_OUTCOMES) { - if (key === SYMPTOM_SEVERITY_CHANNEL_KEY) { - series.push({ ...symptomSeries, role: "outcome" }); - } else { - series.push({ key, role: "outcome", points: points(key) }); - } - } - series.push(...environmentSeries, ...customMetricSeries); + // The channel set is the shared one — every family the correlations page + // scans, so a card can never be missing a pair the page would show. The + // measurement read stays `"raw"` (the route's rollup read-swap is a + // read-cost choice, not a channel-set one; see the option's doc comment). + const { series } = await assembleDiscoveryMatrix(userId, { + tz, + since, + fetchMode: "raw", + }); const result = discoverCorrelations(series, { locale }); const decisions = await syncAcceptedPatterns({ @@ -184,16 +118,26 @@ export async function getRelevantCorrelationsForMetric( qValue: pattern.qValue, })), }); - return result.discovered - .filter((d) => { - const decision = decisionForEvidence(decisions, { - factorKey: d.behaviour, - outcomeKey: d.outcome, - lagDays: d.lagDays, - }); - return decision?.dismissed !== true; - }) - .map((d) => ({ interpretation: d.interpretation, n: d.n, r: d.r })); + return ( + result.discovered + // The pair has to INVOLVE this metric. This filter is the difference + // between the function's name and a full dump of the matrix: without + // it, a WEIGHT card's prompt is handed "your daylight goes with your + // sleep" as grounded context for a paragraph about weight. It was here + // from the start and was lost when the pattern-dismissal filter was + // written in its place rather than after it; the docstring, the name + // and the `channelKeyForType` guard above all kept saying otherwise. + .filter((d) => d.behaviour === channel || d.outcome === channel) + .filter((d) => { + const decision = decisionForEvidence(decisions, { + factorKey: d.behaviour, + outcomeKey: d.outcome, + lagDays: d.lagDays, + }); + return decision?.dismissed !== true; + }) + .map((d) => ({ interpretation: d.interpretation, n: d.n, r: d.r })) + ); } catch { return []; } diff --git a/src/lib/insights/mood-aggregates.ts b/src/lib/insights/mood-aggregates.ts index 985b998ea..f829df5f6 100644 --- a/src/lib/insights/mood-aggregates.ts +++ b/src/lib/insights/mood-aggregates.ts @@ -39,11 +39,7 @@ import { computeMoodDimensionSeries, type MoodDimensionSummary, } from "@/lib/insights/mood-dimension-series"; -export { - computeMoodDimensionSeries, - type MoodDimensionPoint, - type MoodDimensionSummary, -} from "@/lib/insights/mood-dimension-series"; +export { type MoodDimensionSummary } from "@/lib/insights/mood-dimension-series"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import { annotate } from "@/lib/logging/context"; import { createCustomLabelResolver } from "@/lib/mood/custom-tags"; diff --git a/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts b/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts index 2415fde27..2b24fcada 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts @@ -18,11 +18,31 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const userFindUnique = vi.fn(); const measurementFindMany = vi.fn(); const moodFindMany = vi.fn(); +// The wrapper takes its channel set from the shared discovery +// assembler, so the four channel families that used to be missing here (the +// dose-history ledger, the illness day-log, the daily weather rows, the opt-in +// custom metrics) are read on this path too. Default every one empty: this +// file is about the measurement read's ORDER and GRAIN, and an empty channel +// degrades to absent without touching either. +const medicationFindMany = vi.fn(); +const intakeEventFindMany = vi.fn(); +const illnessEpisodeFindMany = vi.fn(); +const illnessDayLogFindMany = vi.fn(); +const environmentContextFindMany = vi.fn(); +const customMetricFindMany = vi.fn(); vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: (a: unknown) => userFindUnique(a) }, measurement: { findMany: (a: unknown) => measurementFindMany(a) }, moodEntry: { findMany: (a: unknown) => moodFindMany(a) }, + medication: { findMany: (a: unknown) => medicationFindMany(a) }, + medicationIntakeEvent: { findMany: (a: unknown) => intakeEventFindMany(a) }, + illnessEpisode: { findMany: (a: unknown) => illnessEpisodeFindMany(a) }, + illnessDayLog: { findMany: (a: unknown) => illnessDayLogFindMany(a) }, + environmentContext: { + findMany: (a: unknown) => environmentContextFindMany(a), + }, + customMetric: { findMany: (a: unknown) => customMetricFindMany(a) }, }, })); @@ -37,6 +57,12 @@ beforeEach(() => { }); measurementFindMany.mockReset(); moodFindMany.mockReset().mockResolvedValue([]); + medicationFindMany.mockReset().mockResolvedValue([]); + intakeEventFindMany.mockReset().mockResolvedValue([]); + illnessEpisodeFindMany.mockReset().mockResolvedValue([]); + illnessDayLogFindMany.mockReset().mockResolvedValue([]); + environmentContextFindMany.mockReset().mockResolvedValue([]); + customMetricFindMany.mockReset().mockResolvedValue([]); }); describe("buildPeriodNarrativeContext — grain (QA F3)", () => { @@ -135,5 +161,53 @@ describe("buildPeriodNarrativeContext — grain (QA F3)", () => { take: 20000, }), ); + // The mood read carries the same discipline, and it is the one that also + // pulls the RATED-factor tag-links this surface admits. + expect(moodFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: { moodLoggedAt: "desc" }, + take: 5000, + }), + ); + }); + + // The narrative used to fold in only the measurement channels, + // MOOD and the RATED mood factors: the compliance, symptom, environmental + // and custom-metric families had arrived at the correlations route and never + // reached this surface. Nothing explained the gap — the comment that looked + // like it did was the same sentence a sibling file carried while doing the + // opposite. It scans the shared matrix now. + it("reads every discovery channel family, not just the measurement ones", async () => { + const now = new Date("2026-06-30T12:00:00.000Z"); + measurementFindMany.mockResolvedValue([]); + + await buildPeriodNarrativeContext("u1", { + period: "month", + now, + locale: "en", + }); + + expect(medicationFindMany).toHaveBeenCalledTimes(1); + expect(illnessEpisodeFindMany).toHaveBeenCalledTimes(1); + expect(environmentContextFindMany).toHaveBeenCalledTimes(1); + expect(customMetricFindMany).toHaveBeenCalledTimes(1); + }); + + // The one declared divergence: this surface — and only this surface — admits + // the user's RATED mood factors. See `includeMoodFactors` for why. + it("pulls the RATED-factor tag-links the other three surfaces do not", async () => { + const now = new Date("2026-06-30T12:00:00.000Z"); + measurementFindMany.mockResolvedValue([]); + + await buildPeriodNarrativeContext("u1", { + period: "month", + now, + locale: "en", + }); + + const select = moodFindMany.mock.calls[0]?.[0]?.select as { + tagLinks?: { where?: { moodTag?: { kind?: string } } }; + }; + expect(select.tagLinks?.where?.moodTag?.kind).toBe("RATED"); }); }); diff --git a/src/lib/insights/narrative/period-narrative.ts b/src/lib/insights/narrative/period-narrative.ts index 2a160b63e..34a600fd1 100644 --- a/src/lib/insights/narrative/period-narrative.ts +++ b/src/lib/insights/narrative/period-narrative.ts @@ -12,7 +12,19 @@ * The honesty contract carries through verbatim from the layers it reuses: * - **Drivers** are ONLY the BH-FDR-surviving pairs from `discoverCorrelations` * (`benjaminiHochberg` already enforces descriptive-never-causal); each - * keeps its conservative `interpretation` string unchanged. + * keeps its conservative `interpretation` string unchanged. The channels + * scanned come from the one shared assembler in + * `src/lib/insights/discovery-matrix.ts`, so this surface sees the same + * families the correlations page and the Coach do. It differs in exactly + * one declared way — it admits the user's RATED mood factors as + * `FACTOR:` channels, which the three persisting surfaces do not; the + * reason is written at the `includeMoodFactors` option. + * + * It also silently lacked, until the assembler landed, the + * medication-compliance, symptom-severity, environmental-exposure and + * custom-metric families, which had arrived at the route and never + * here. Nothing explained that; the comment that appeared to was the + * same sentence a sibling file carried while doing the opposite. * - **Band transitions** are a personal-baseline (median ± k·MAD, Hampel/Leys) * comparison of the current period against the band established over the * PRIOR period — never an invented threshold. @@ -36,25 +48,14 @@ import { annotate } from "@/lib/logging/context"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { discoverCorrelations, - discoveryMeasurementTypes, - DISCOVERY_BEHAVIOURS, - DISCOVERY_OUTCOMES, - FACTOR_CHANNEL_PREFIX, type DailySeriesPoint, type NamedSeries, } from "@/lib/insights/correlation-discovery"; import { buildBaselineBand, median } from "@/lib/insights/derived/baseline"; import { VITALS_BASELINE_TYPES } from "@/lib/insights/derived/registry"; -import { - buildMeasurementDailySeries, - fetchMeasurementWindowSeries, - toDailyMeans, -} from "@/lib/insights/correlation-channel-series"; -import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { assembleDiscoveryMatrix } from "@/lib/insights/discovery-matrix"; const MS_PER_DAY = 24 * 60 * 60 * 1000; -/** Cap on the mood-entry window read, mirroring the route / Coach tool. */ -const MOOD_READ_CAP = 5000; /** The two supported narrative periods and their length in days. */ export const PERIOD_DAYS = { week: 7, month: 30 } as const; @@ -191,61 +192,6 @@ function tzDayKey(at: Date, tz: string): string { return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; } -/** - * A RATED mood factor link the period read pulls alongside the score. - * Carries the scale + `inverse` so the series build can apply the - * documented sign-flip once, at the boundary. - */ -interface FactorLink { - key: string; - rating: number; - scaleMin: number; - scaleMax: number; - inverse: boolean; - at: Date; -} - -/** - * v1.14.0 — collapse RATED-factor links to one inverse-flipped daily-mean - * series per factor, tz-day-keyed exactly like `toDailyMeans` so a factor - * channel joins the discovery matrix on the same day grid as every vital. - * An inverse factor's rating `r` maps to `(scaleMin + scaleMax) - r` BEFORE - * averaging so "up" always reads as a better day — the same flip the mood - * aggregates apply, kept in lock-step. Returns one `FACTOR:` series per - * factor the user actually rated. Pure. - */ -function factorDailyMeans( - links: FactorLink[], - tz: string, -): Map { - const byFactor = new Map< - string, - Map - >(); - for (const l of links) { - if (!Number.isFinite(l.rating)) continue; - const value = l.inverse ? l.scaleMin + l.scaleMax - l.rating : l.rating; - const day = tzDayKey(l.at, tz); - const days = - byFactor.get(l.key) ?? new Map(); - const acc = days.get(day) ?? { sum: 0, count: 0 }; - acc.sum += value; - acc.count += 1; - days.set(day, acc); - byFactor.set(l.key, days); - } - const out = new Map(); - for (const [key, days] of byFactor) { - out.set( - `${FACTOR_CHANNEL_PREFIX}${key}`, - [...days.entries()] - .map(([day, acc]) => ({ day, value: acc.sum / acc.count })) - .sort((a, b) => (a.day < b.day ? -1 : 1)), - ); - } - return out; -} - /** Round to 2 decimals. */ function round2(n: number): number { return Math.round(n * 100) / 100; @@ -508,8 +454,15 @@ export interface BuildPeriodNarrativeContextOpts { * Fetch + day-key + assemble. Reads a single bounded window covering the * current AND prior period (2× the period length plus one extra day so the * day-1 lag join in discovery has its source), day-keys in the user's tz, - * and delegates to the pure core. No LLM, no migration, no new heavy query — - * one measurement read + one mood read, both bounded. + * and delegates to the pure core. No LLM, no migration — every read is the + * shared assembler's, bounded, and the delta / band beats ride the same + * measurement query as the discovery channels. + * + * A note on what the `week` period can produce: its window is 15 days, and + * `discoverCorrelations` needs 20 lag-joined pairs before it will test one, so + * a weekly narrative's `drivers` list is empty by arithmetic, not by data. + * That predates this file's current shape and is left as it is; the deltas, + * band transitions and coincident flags are what a weekly narrative rests on. */ export async function buildPeriodNarrativeContext( userId: string, @@ -537,77 +490,40 @@ export async function buildPeriodNarrativeContext( tz, ); - // The full set of types any beat needs: delta metrics ∪ banded vitals ∪ - // discovery channels (minus the NON-measurement channels: MOOD is mood-entry - // backed; v1.21.0 MEDICATION_COMPLIANCE / SYMPTOM_SEVERITY are - // ledger / illness-day-log backed — none are MeasurementType enum values, so - // `discoveryMeasurementTypes` drops them from the `type IN (...)` query). - // Those two channels are not populated on the period-narrative surface (they - // degrade to absent here); the canonical `/api/insights/correlations` route - // builds them. - const measurementTypes = Array.from( - new Set([ - ...DELTA_METRICS.map((m) => m.type), - ...VITALS_BASELINE_TYPES, - ...discoveryMeasurementTypes(DISCOVERY_BEHAVIOURS), - ...discoveryMeasurementTypes(DISCOVERY_OUTCOMES), - ]), - ) as MeasurementType[]; - + // One read for both jobs. The discovery channels come from the shared + // assembler; `extraMeasurementTypes` widens the SAME measurement query with + // the types only this surface's other beats need (the period deltas and the + // banded vitals), so the delta beat costs no second round-trip. + // // v1.30.3 (QA F2/F3) — the fetch + desc/cap/resort discipline AND the // per-type grain resolution (source-collapse sum for cumulative types, - // per-night reconstruction for sleep — not a blind per-row MEAN) now - // route through the shared `fetchMeasurementWindowSeries` + - // `buildMeasurementDailySeries` (`correlation-channel-series.ts`), the - // same helpers the route / Coach tool / per-metric card share. Before - // this fix the local `toDailyMeans` twin folded EVERY type through a - // blind per-row mean: a month's current-vs-prior period comparison for - // `SLEEP_DURATION` averaged per-stage segment durations (~45 min) - // instead of summing a night's total time asleep, and `ACTIVITY_STEPS` - // blended per-sample chunk means with drained daily totals — and the - // `asc, take 20000` cap dropped the CURRENT period first on a dense - // account, the worst possible direction for a current-vs-prior surface. - const [{ byType, measurementsCapped }, moodEntriesDesc, priorityJson] = - await Promise.all([ - fetchMeasurementWindowSeries(userId, since, measurementTypes), - prisma.moodEntry.findMany({ - where: { userId, deletedAt: null, moodLoggedAt: { gte: since } }, - orderBy: { moodLoggedAt: "desc" }, - take: MOOD_READ_CAP, - select: { - score: true, - moodLoggedAt: true, - // v1.14.0 — pull RATED-factor links so each factor the user scores - // (work / sleep-quality / stress …) joins the discovery matrix as a - // `FACTOR:` channel. One extra select on the existing mood read - // — no new round-trip. BINARY links carry a null `rating` and are - // dropped below. - tagLinks: { - where: { moodTag: { kind: "RATED" }, rating: { not: null } }, - select: { - rating: true, - moodTag: { - select: { - key: true, - scaleMin: true, - scaleMax: true, - inverse: true, - }, - }, - }, - }, - }, - }), - loadUserSourcePriority(userId), - ]); - // This surface reads mood alongside its RATED-factor tag-links (a select - // the shared `fetchMoodWindowSeries` helper does not carry), so the - // desc+cap+resort discipline is applied in place here rather than - // through that helper — same discipline, bespoke select. - const moodCapped = moodEntriesDesc.length >= MOOD_READ_CAP; - const moodEntries = [...moodEntriesDesc].sort( - (a, b) => a.moodLoggedAt.getTime() - b.moodLoggedAt.getTime(), - ); + // per-night reconstruction for sleep — not a blind per-row MEAN) live in + // `correlation-channel-series.ts`, which the assembler reads through. Before + // that fix the local `toDailyMeans` twin folded EVERY type through a blind + // per-row mean: a month's current-vs-prior comparison for `SLEEP_DURATION` + // averaged per-stage segment durations (~45 min) instead of summing a + // night's total time asleep, and `ACTIVITY_STEPS` blended per-sample chunk + // means with drained daily totals — and the `asc, take 20000` cap dropped + // the CURRENT period first on a dense account, the worst possible direction + // for a current-vs-prior surface. + const { + series: discoverySeries, + byMetric: seriesByMetric, + diagnostics, + } = await assembleDiscoveryMatrix(userId, { + tz, + since, + fetchMode: "raw", + // The one surface that admits RATED mood factors — see the option's doc + // comment for why it is one and not four. + includeMoodFactors: true, + extraMeasurementTypes: Array.from( + new Set([ + ...DELTA_METRICS.map((m) => m.type), + ...VITALS_BASELINE_TYPES, + ]), + ), + }); // QA F2 — surfaces when a dense account's window exceeded the read cap, // mirroring the route's identical annotation. The cap now falls on the @@ -617,71 +533,12 @@ export async function buildPeriodNarrativeContext( action: { name: "insights.period-narrative.read" }, meta: { period, - measurements_capped: measurementsCapped, - mood_entries_capped: moodCapped, + measurements_capped: diagnostics.measurementsCapped, + mood_entries_capped: diagnostics.moodCapped, + mood_factor_channels: diagnostics.moodFactorChannels, }, }); - const seriesByMetric = new Map(); - for (const type of measurementTypes) { - const rows = byType.get(type); - if (!rows || rows.length === 0) continue; - seriesByMetric.set( - type, - buildMeasurementDailySeries(type, rows, tz, priorityJson), - ); - } - const moodPoints = toDailyMeans( - moodEntries.map((e) => ({ value: e.score, at: e.moodLoggedAt })), - tz, - ); - if (moodPoints.length > 0) seriesByMetric.set("MOOD", moodPoints); - - // v1.14.0 — RATED-factor channels. Flatten every entry's RATED links into - // one inverse-flipped daily-mean series per factor, keyed `FACTOR:`. - const factorLinks: FactorLink[] = []; - for (const e of moodEntries) { - for (const link of e.tagLinks) { - if (link.rating == null) continue; - factorLinks.push({ - key: link.moodTag.key, - rating: link.rating, - scaleMin: link.moodTag.scaleMin, - scaleMax: link.moodTag.scaleMax, - inverse: link.moodTag.inverse, - at: e.moodLoggedAt, - }); - } - } - const factorSeries = factorDailyMeans(factorLinks, tz); - for (const [key, points] of factorSeries) seriesByMetric.set(key, points); - - // Discovery matrix over the same window. - const discoverySeries: NamedSeries[] = []; - for (const key of DISCOVERY_BEHAVIOURS) { - discoverySeries.push({ - key, - role: "behaviour", - points: seriesByMetric.get(key) ?? [], - }); - } - for (const key of DISCOVERY_OUTCOMES) { - discoverySeries.push({ - key, - role: "outcome", - points: seriesByMetric.get(key) ?? [], - }); - } - // A factor is plausibly both a lag source ("rated work today → next-day - // sleep") and a lag target ("steps today → next-day rated energy"), so it - // enters as both roles. The engine skips self-pairs and BH-FDR-controls the - // wider family this opens. Factors the user rated on < the paired-day floor - // simply never get tested — no new statistical machinery, same honesty. - for (const [key, points] of factorSeries) { - discoverySeries.push({ key, role: "behaviour", points }); - discoverySeries.push({ key, role: "outcome", points }); - } - return assemblePeriodNarrativeContext({ period, currentFrom, diff --git a/src/lib/insights/status-provider.ts b/src/lib/insights/status-provider.ts index 8f36e8a1d..f30a7b36a 100644 --- a/src/lib/insights/status-provider.ts +++ b/src/lib/insights/status-provider.ts @@ -1,6 +1,5 @@ import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider"; import { - AllProvidersFailedError, runRawCompletionWithFallback, type ProviderChainResolved, } from "@/lib/ai/provider-runner"; @@ -299,5 +298,3 @@ export async function runStatusCompletion( tokensUsed: result.tokensUsed ?? null, }; } - -export { AllProvidersFailedError }; diff --git a/src/lib/links/index.ts b/src/lib/links/index.ts index 3de5c1e27..e3f340e04 100644 --- a/src/lib/links/index.ts +++ b/src/lib/links/index.ts @@ -41,9 +41,5 @@ export { listTargets, listTargetsBySource, listDistinctTargets, - type LinkSourceKind, type LinkTargetKind, - type LinkRequest, - type LinkResult, - type LinkedTarget, } from "@/lib/links/link-service"; diff --git a/src/lib/mcp/index.ts b/src/lib/mcp/index.ts index 1302cec27..7a734c5eb 100644 --- a/src/lib/mcp/index.ts +++ b/src/lib/mcp/index.ts @@ -1,47 +1,17 @@ /** * HealthLog MCP server — barrel. * - * A read-only-by-default Model Context Protocol surface that re-exports + * A read-only-by-default Model Context Protocol surface that exposes * HealthLog's existing server-authoritative read paths over the MCP wire, so a * user can query their own health data from any MCP-capable assistant. The tool - * / resource layer here is transport-agnostic; transports (stdio now, remote - * `/mcp` later) attach to `createMcpServer(ctx)`. + * / resource layer under this directory is transport-agnostic; transports + * attach to `createMcpServer(ctx)`. + * + * The barrel carries only what the remote `/mcp` transport route needs. The + * tool, resource, prompt and write registries are imported from their concrete + * module by `./server` and by their own tests — mirroring them here produced + * lines nothing imported. */ -export { createMcpServer, MCP_SERVER_NAME, MCP_SERVER_VERSION } from "./server"; -export { resolveMcpAuthContext, type McpAuthContext } from "./auth"; -export { MCP_TOOLS, MCP_TOOL_NAMES, type McpToolDefinition } from "./tools"; -export { MCP_WRITE_TOOLS, MCP_WRITE_TOOL_NAMES } from "./write-tools"; -export { - logMcpMeasurement, - logMcpMood, - type McpMeasurementResult, - type McpMoodResult, -} from "./writes"; -export { - MCP_RESOURCES, - MCP_RESOURCE_URIS, - type McpResourceDefinition, -} from "./resources"; -export { - MCP_PROMPTS, - MCP_PROMPT_NAMES, - type McpPromptDefinition, - type McpPromptResult, - type McpPromptMessage, -} from "./prompts"; -export { - getCorrelation, - compareMetric, - getMetricBaseline, - detectChangepoints, - resolveRichMetric, - type RichMetric, -} from "./rich-reads"; -export { - SCOPE_HEALTH_READ, - SCOPE_HEALTH_WRITE, - SCOPE_WILDCARD, - tokenAllowsRead, - tokenAllowsWrite, - sessionBinding, -} from "./scopes"; +export { createMcpServer } from "./server"; +export { resolveMcpAuthContext } from "./auth"; +export { SCOPE_HEALTH_READ } from "./scopes"; diff --git a/src/lib/measurements/__tests__/apple-health-ecg-import.test.ts b/src/lib/measurements/__tests__/apple-health-ecg-import.test.ts index d939c7638..d3a34f38c 100644 --- a/src/lib/measurements/__tests__/apple-health-ecg-import.test.ts +++ b/src/lib/measurements/__tests__/apple-health-ecg-import.test.ts @@ -6,6 +6,8 @@ import { Readable } from "node:stream"; import { strToU8, zipSync } from "fflate"; +import { classifyEcgRhythm } from "@/lib/apple-health/ecg-csv"; + type ArchiveLimits = { maxMembers: number; maxEcgMembers: number; @@ -349,13 +351,71 @@ describe("Apple Health HKElectrocardiogram CSV parser", () => { ["Sinus Rhythm", "NOT_DETECTED"], ["Atrial Fibrillation", "IRREGULAR"], ["Inconclusive", "INCONCLUSIVE"], - ["Localized or unknown verdict", null], + // Observed in real exports, and dropped to null until now. + ["Heart Rate Over 120", "INCONCLUSIVE"], + ["Heart Rate Under 50", "INCONCLUSIVE"], + // The same verdict from a watch with a different threshold. + ["Heart Rate Over 150", "INCONCLUSIVE"], + // Named in Apple's ECG instructions for use. + ["Poor Recording", "INCONCLUSIVE"], + ["Atrial Fibrillation - High Heart Rate", "IRREGULAR"], + ["High Heart Rate - No Atrial Fibrillation Detected", "NOT_DETECTED"], + // Apple's own "this device does not know this verdict". Deliberately NOT + // INCONCLUSIVE — the device did not fail to classify the waveform. + ["Unrecognized", null], + // A verdict from a German watch. This asserted `null` while the two + // halves of the fix lived on separate branches: the classifier gained + // Apple's full English set without knowing any German, so a German + // verdict was still unreachable. Together they meet — the alias table + // translates the wording, the classifier decides the meaning — and the + // old expectation was pinning the gap rather than a decision. + ["Sinusrhythmus", "NOT_DETECTED"], + // Four of the six languages are still unmapped, so this stays true. + ["Fibrillation auriculaire", null], + ["Some verdict this parser has never seen", null], ])("passes through device classification %s only", async (raw, expected) => { await expect( parse(validCsv({ Classification: raw })), ).resolves.toMatchObject({ rhythmClassification: expected }); }); + /** + * The nullable column has one spelling for four different facts. The + * classifier keeps them apart even though the column cannot, so a reader of + * this code can see which nulls are honest and which are the unresolved + * language half. + * + * The old table pinned `["Localized or unknown verdict", null]` as a single + * row. That assertion is still true, but it fused a localised verdict with an + * unknown one and with the two heart-rate verdicts that were being silently + * dropped beside them — it read as "everything else is unknown" when two of + * those were verdicts Apple documents. It is split apart here. + */ + it.each([ + ["Sinus Rhythm", "mapped"], + ["Heart Rate Under 50", "mapped"], + ["", "absent"], + [undefined, "absent"], + ["Unrecognized", "unrepresentable"], + ["Sinusrhythmus", "unknown"], + ["Fibrillation auriculaire", "unknown"], + ])("classifies %s as %s", (raw, kind) => { + expect(classifyEcgRhythm(raw).kind).toBe(kind); + }); + + it("treats every heart-rate threshold the same way", () => { + // The number is the watch generation's, not a value to enumerate. + for (const n of [50, 100, 120, 150]) { + expect(classifyEcgRhythm(`Heart Rate Over ${n}`)).toEqual({ + kind: "mapped", + value: "INCONCLUSIVE", + }); + } + // Not a threshold verdict — no number, so it stays unknown rather than + // being swept into INCONCLUSIVE by a loose pattern. + expect(classifyEcgRhythm("Heart Rate Over").kind).toBe("unknown"); + }); + it.each([ ["bad timestamp", validCsv({ "Recorded Date": "not-a-date" })], ["bad frequency", validCsv({ "Sample Rate": "not-a-rate" })], @@ -427,3 +487,209 @@ describe("Apple Health HKElectrocardiogram CSV parser", () => { expect(spies.every((spy) => spy.mock.calls.length === 0)).toBe(true); }); }); + +/** + * The shapes real exports actually use. + * + * The fixtures above were written from the format's documentation rather than + * from a file off a watch, and they drifted from it in four independent ways. + * Each block below pins one of them, and — because the differences are dialect + * rather than version — pins BOTH dialects side by side, so a later edit cannot + * quietly trade one for the other. + */ +describe("Apple Health ECG CSV — real-export shapes", () => { + async function parse(csv: string, maxSamples = 8) { + const subject = await loadParserSubject(); + return subject.parseAppleHealthEcgCsv({ + memberName: "apple_health_export/electrocardiograms/ecg_2026-07-18.csv", + stream: Readable.from([Buffer.from(csv)]), + maxSamples, + }); + } + + describe("a header row with no value", () => { + it("accepts the bare `Name` row every observed export opens with", async () => { + // The name field is blank and Apple omits the comma entirely, so the + // parser used to fail on line 1, before reading anything. + await expect( + parse(validCsv().replace("Name,Private Patient Name", "Name")), + ).resolves.toMatchObject({ samplingFrequency: 512 }); + }); + + it("keeps a valueless header row out of the metadata", async () => { + // `Lead` + `Unit` presence is what switches the parser into the + // single-column mode; a valueless key must not be able to trip it. + await expect( + parse(`${singleColumnCsv().replace("Unit,µV", "Unit")}`), + ).rejects.toThrow(/samples are missing|malformed/i); + }); + + it("still rejects a comma-less row inside the paired waveform", async () => { + await expect( + parse(validCsv().replace("I,-0.002", "-0.002")), + ).rejects.toThrow(/malformed/i); + }); + }); + + describe("both decimal dialects", () => { + /** The same three samples, written by a dot-region and a comma-region watch. */ + const DOT_SAMPLES = ["1.5", "-2.4", "3"]; + const COMMA_SAMPLES = ["1,5", "-2,4", "3"]; + + function withSamples(rows: readonly string[]): string { + return `${singleColumnCsv().split("\n").slice(0, -3).join("\n")}\n${rows.join("\n")}`; + } + + it.each([ + ["dot", DOT_SAMPLES], + ["comma", COMMA_SAMPLES], + ])( + "reads a %s-decimal waveform to the same microvolts", + async (_d, rows) => { + await expect(parse(withSamples(rows))).resolves.toMatchObject({ + samples: [2, -2, 3], + }); + }, + ); + + it("reads both dialects of the paired layout identically", async () => { + // splitPair cuts on the first comma, so the value keeps its own mark. + await expect(parse(validCsv())).resolves.toMatchObject({ + samples: [1, -2, 3], + }); + const commaPaired = validCsv() + .replace("I,0.001", "I,0,001") + .replace("I,-0.002", "I,-0,002") + .replace("I,0.003", "I,0,003"); + await expect(parse(commaPaired)).resolves.toMatchObject({ + samples: [1, -2, 3], + }); + }); + + it("refuses a grouped number rather than guessing its region", async () => { + // "1.234,5" needs the file's region to resolve and the file never says. + await expect( + parse(withSamples(["1.234,5", "-2,4", "3"])), + ).rejects.toThrow(/sample value is invalid/i); + }); + + it.each([ + ["512 Hz", 512], + ["512 hz", 512], + ["512 hertz", 512], + ["511,422 hertz", 511], + ["511.422 hertz", 511], + ["511,562\u00a0Hertz", 512], + ["512Hz", 512], + ])("reads the sample rate written as %s", async (raw, expected) => { + // Observed exports spell the unit out, lower-case it, and (in the German + // files) separate it with a no-break space. The fixtures had only ever + // used "512 Hz". + await expect( + parse(validCsv({ "Sample Rate": raw })), + ).resolves.toMatchObject({ samplingFrequency: expected }); + }); + + it.each([ + ["a missing unit", "512"], + ["the wrong unit", "512 bpm"], + ["a rate above the bound", "20000 hertz"], + ["a grouped rate", "1.234,5 hertz"], + ])("still rejects a sample rate with %s", async (_label, raw) => { + await expect(parse(validCsv({ "Sample Rate": raw }))).rejects.toThrow( + /sample rate is invalid/i, + ); + }); + + it("reads the average heart rate in both dialects", async () => { + await expect( + parse(validCsv({ "Average Heart Rate": "64 bpm" })), + ).resolves.toMatchObject({ averageHeartRate: 64 }); + await expect( + parse(validCsv({ "Average Heart Rate": "63,7 bpm" })), + ).resolves.toMatchObject({ averageHeartRate: 64 }); + }); + + it("still rejects a paired row that follows the single-column waveform", async () => { + await expect(parse(`${singleColumnCsv()}\nI,0.001`)).rejects.toThrow( + /sample value is invalid/i, + ); + }); + }); + + describe("a localised export", () => { + /** + * The header of a real German export, key for key. Every row here was + * observed; nothing is translated by hand. The blank rows mid-header and + * the no-break space before "Hertz" are the file's, not a typo. + */ + function germanCsv( + overrides: Partial> = {}, + ): string { + const metadata: Record = { + Geburtstag: '"27.03.1987"', + Aufzeichnungsdatum: "2020-07-31 22:48:19 +0200", + Klassifizierung: "Sinusrhythmus", + Symptome: "", + Softwareversion: "1.13", + Gerät: '"Watch4,2"', + Messrate: "511,562\u00a0Hertz", + ...overrides, + }; + return [ + "Name", + ...Object.entries(metadata).map(([k, v]) => `${k},${v}`), + "", + "", + "Ableitung,Ableitung I", + "Einheit,µV", + "", + "-180,596", + "-199,83", + "-217,333", + ].join("\n"); + } + + it("reads a German export end to end", async () => { + await expect(parse(germanCsv())).resolves.toMatchObject({ + recordedAt: new Date("2020-07-31T20:48:19.000Z"), + samplingFrequency: 512, + samples: [-181, -200, -217], + lead: "Ableitung I", + rhythmClassification: "NOT_DETECTED", + }); + }); + + it("reads the other observed German verdict", async () => { + await expect( + parse(germanCsv({ Klassifizierung: "Uneindeutig" })), + ).resolves.toMatchObject({ rhythmClassification: "INCONCLUSIVE" }); + }); + + it("returns no verdict for a language that is not mapped yet", async () => { + // French is not in the alias map and is not guessed at. The recording + // still imports; only the verdict is withheld. + await expect( + parse(germanCsv({ Klassifizierung: "Rythme sinusal" })), + ).resolves.toMatchObject({ rhythmClassification: null }); + }); + + it("refuses a file whose keys it cannot place, rather than mis-filing it", async () => { + // An unmapped language's KEYS mean the parser never finds the waveform. + // Refusing is visible; reading values into the wrong column is not. + const french = germanCsv() + .replace("Aufzeichnungsdatum,", "Date d'enregistrement,") + .replace("Messrate,", "Fréquence d'échantillonnage,") + .replace("Ableitung,Ableitung I", "Dérivation,Dérivation I") + .replace("Einheit,µV", "Unité,µV"); + await expect(parse(french)).rejects.toThrow(/samples are missing/i); + }); + + it("leaves the English header working unchanged", async () => { + await expect(parse(validCsv())).resolves.toMatchObject({ + samplingFrequency: 512, + rhythmClassification: "NOT_DETECTED", + }); + }); + }); +}); diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 3d2694ab7..0c2715658 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -1529,7 +1529,9 @@ export const authPaths: NonNullable = { summary: "Remove a registered passkey", description: "Deletes one primary sign-in credential, scoped to the authenticated user. Refuses to remove the LAST one when the account has no password — an account must keep at least one way in.\n\n" + - "No step-up: this endpoint takes a plain cookie session or a wildcard Bearer token, where removing a second-factor security key (DELETE /api/auth/me/mfa/webauthn/{id}) requires a fresh factor proof. The asymmetry is in the code as it stands, not a description of intent.", + "Step-up gated, on the same mechanism and at the same strength as removing a second-factor security key (DELETE /api/auth/me/mfa/webauthn/{id}). The caller must present a fresh possession proof: on the cookie path a session that completed one within the last five minutes, on Bearer an `X-Step-Up` elevation minted at POST /api/auth/step-up against a re-proved factor. A password-proved elevation is NOT enough.\n\n" + + 'A PASSKEY IS SUCH A PROOF here, unlike on the second-factor routes. An account whose only credential is a passkey can satisfy this gate: a passkey login stamps the session, and POST /api/auth/step-up accepts `method: "passkey"` against the account\'s primary passkeys. So its refusal is `meta.errorCode: auth.stepup.required` — re-prove and retry — and not the dead-end `auth.stepup.mfa_not_enrolled`, which is reserved for an account holding no credential this gate could ever accept.\n\n' + + "The elevation is spent only when the deletion is about to happen. A 404 for an unknown id, or the last-credential refusal, leaves it unconsumed and reusable.", requestParams: { path: z.object({ id: z.string() }) }, responses: { "200": { @@ -1553,6 +1555,13 @@ export const authPaths: NonNullable = { content: { "application/json": { schema: errorEnvelope } }, }, ...stdResponses, + // After `stdResponses`, or the generic 401 there overwrites this one + // and the step-up contract disappears from the published operation. + "401": { + description: + "Not authenticated, or the request carried no fresh possession proof (`meta.errorCode` = `auth.stepup.required`). Re-prove and retry — on a native client by minting an elevation at POST /api/auth/step-up, on the web by signing in again, which is what stamps the session. `auth.stepup.mfa_not_enrolled` is the other arm and means the account holds no credential this gate can accept, so retrying will not help. Nothing was removed either way.", + content: { "application/json": { schema: errorEnvelope } }, + }, }, }, }, diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index f3e50f971..f2d5b8eba 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -653,6 +653,27 @@ const thresholdsOverridesResponse = z .object({ overrides: thresholdOverrideMap }) .meta({ id: "ThresholdsOverridesResponse" }); +/** + * The typed confirmation the parameterless DELETE demands. + * + * Published because it is the difference between "reset one band" and "erase + * every band the account has tuned", and a client that learned the endpoint + * from the contract alone would otherwise discover the requirement as a 422. + */ +const thresholdsResetAllRequest = z + .object({ + confirm: z + .literal("RESET_THRESHOLDS") + .describe( + "Required when `metric` is omitted. Compared exactly; any other value is refused.", + ), + }) + .meta({ + id: "ThresholdsResetAllRequest", + description: + "Confirmation body for the reset-everything form. Send no body at all when resetting a single metric with `?metric=`.", + }); + const thresholdsUpdateRequest = thresholdsUpdateSchema.meta({ id: "ThresholdsUpdateRequest", description: @@ -1716,17 +1737,23 @@ export const profilePaths: NonNullable = { tags: ["Analytics"], summary: "Reset one threshold override, or all of them", description: - "With `?metric=` it drops that metric's override; WITHOUT the parameter it drops EVERY override on the account. There is no confirmation step and no dry run, so a client that means to reset one metric must send the parameter.\n\n" + - "Audit-logged with the before and after maps. Unlike the PUT this path runs no rate limit of its own.", + 'With `?metric=` it drops that metric\'s override; WITHOUT the parameter it drops EVERY override on the account. The wide form is not reachable by omitting the parameter alone — it requires `{ "confirm": "RESET_THRESHOLDS" }` in the body, the same typed-confirmation shape account deletion and the data reset use. The `?metric=` form takes no body and is unchanged.\n\n' + + "Audit-logged with the before and after maps. Rate-limited 30 / 5 min per user, matching the PUT.", requestParams: { query: z.object({ metric: thresholdMetricEnum .optional() .describe( - "The single metric to reset. Omit to reset every override on the account.", + "The single metric to reset. Omit to reset every override on the account — which then requires the confirmation body.", ), }), }, + requestBody: { + required: false, + content: { + "application/json": { schema: thresholdsResetAllRequest }, + }, + }, responses: { "200": { description: "The remaining override map.", @@ -1745,6 +1772,14 @@ export const profilePaths: NonNullable = { content: { "application/json": { schema: errorEnvelope } }, }, ...stdResponses, + // After `stdResponses` on purpose: the generic 422 there would + // otherwise overwrite this one and the confirmation contract would + // vanish from the published operation. + "422": { + description: + '`metric` was omitted and the body did not carry `{ "confirm": "RESET_THRESHOLDS" }`. Nothing was reset.', + content: { "application/json": { schema: errorEnvelope } }, + }, }, }, }, diff --git a/src/lib/record-settings/index.ts b/src/lib/record-settings/index.ts index f479498fb..d4032ea63 100644 --- a/src/lib/record-settings/index.ts +++ b/src/lib/record-settings/index.ts @@ -1,18 +1,7 @@ -export { - assertRecordSettingsAccess, - resolveGuardianRecordSettingsAccess, - type RecordSettingsAccess, -} from "./access"; -export { toRecordSettingsDto, type RecordSettingsDto } from "./dto"; +export { resolveGuardianRecordSettingsAccess } from "./access"; +export { toRecordSettingsDto } from "./dto"; export { resolveManagedIntegrationState } from "./integrations"; -export { - classifySettingsDestination, - isGuardianSettingsWriteAllowed, - isManageDelegateSettingsDestination, - SETTINGS_DESTINATION_INVENTORY, - type SettingsDestinationClassification, - type SettingsDestinationKind, -} from "./classification"; +export { classifySettingsDestination } from "./classification"; export { isManagedRecordSettingsFamily, managedModulePreferencesFrom, @@ -22,6 +11,5 @@ export { parseManagedRecordSettingsPatch, safeParseManagedRecordSettingsPatch, type ManagedRecordSettingsFamily, - type ManagedRecordSettingsPatch, } from "./configuration"; export { assertRecordSettingsResponseForRecord } from "./response"; diff --git a/src/lib/report-selection/saved-profile.ts b/src/lib/report-selection/saved-profile.ts index 987c2c02c..c23988809 100644 --- a/src/lib/report-selection/saved-profile.ts +++ b/src/lib/report-selection/saved-profile.ts @@ -26,13 +26,6 @@ import { type ReportSelection, } from "./selection"; -export { - savedReportProfileSchema, - parseSavedProfile, - SAVED_PROFILE_FALLBACK, - type SavedReportProfile, -} from "./profile-shape"; - /** * Resolve the owner's saved selection. * diff --git a/src/lib/report-selection/template.ts b/src/lib/report-selection/template.ts index 0a2cc953f..c5246cc08 100644 --- a/src/lib/report-selection/template.ts +++ b/src/lib/report-selection/template.ts @@ -11,7 +11,7 @@ * The template intersects the fenced tier at zero leaves, and * `catalogue-guard.test.ts` fails the build if that stops being true. */ -import { SENSITIVE_LEAF_IDS, type ReportLeafId } from "./catalogue"; +import { type ReportLeafId } from "./catalogue"; /** * Identity and insurer (the cover page a practice files the document under), @@ -59,6 +59,3 @@ export function standardTemplateFor( const hidden = new Set(hiddenLeaves); return STANDARD_TEMPLATE_LEAVES.filter((leaf) => !hidden.has(leaf)); } - -/** Exported so the purity guard reads the same set the panel does. */ -export const SENSITIVE_LEAVES = SENSITIVE_LEAF_IDS; diff --git a/src/lib/telegram-webhook-handlers.ts b/src/lib/telegram-webhook-handlers.ts index 5d05f0239..f458bf6cc 100644 --- a/src/lib/telegram-webhook-handlers.ts +++ b/src/lib/telegram-webhook-handlers.ts @@ -8,6 +8,7 @@ import { } from "@/lib/telegram"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { locales, type Locale } from "@/lib/i18n/config"; +import { foldForMatch } from "@/lib/i18n/fold-for-match"; import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; import { applyCanonicalSlotWrite, @@ -43,6 +44,60 @@ import { * from the linked chat via `findTelegramUser` — never from the payload. */ +/* ────────────────────────────────────────────────────────────────────────── + * Bare-word aliases. + * + * The bot's real commands are slash commands (`/help`, `/start`, `/add`) plus + * the `taken ` text command, and `telegram.helpBody` documents exactly + * those in all six bundles — none of that depends on the reader's language. + * What did depend on it were the two undocumented courtesies below, which knew + * English and German: "aide", "ayuda", "aiuto" and "pomoc" reached the intake + * keyword instead, failed it, and the handler returned without replying at all. + * Silence is the worst answer to someone asking for help. + * + * These are ordinary words, not app labels, so there is nothing in the bundles + * to derive them from. They are matched on the FOLDED text so "cześć" and + * "czesc" are one entry and a stray capital or accent cannot miss. + * ────────────────────────────────────────────────────────────────────────── */ + +/** "Help", in every language the app ships. */ +const HELP_WORDS: readonly string[] = [ + "help", + "hilfe", + "aide", + "ayuda", + "aiuto", + "pomoc", +]; + +/** Greetings the bot answers with a wave, in every language the app ships. */ +const GREETING_WORDS: readonly string[] = [ + "hi", + "hello", + "hey", + "hallo", + "moin", + "bonjour", + "salut", + "hola", + "buenas", + "ciao", + "salve", + "czesc", + "witaj", + "dzien dobry", +]; + +/** + * True when the whole message is one of `words`. Folded on both sides, so the + * comparison is case-, accent- and punctuation-insensitive; anchored to the + * whole message, so "help me log my weight" is not swallowed as a command. + */ +function isBareCommandWord(text: string, words: readonly string[]): boolean { + const folded = foldForMatch(text); + return folded !== "" && words.includes(folded); +} + export interface TelegramUpdate { update_id: number; message?: { @@ -1106,12 +1161,15 @@ export async function handleTextMessage(update: TelegramUpdate) { } } - // "Help" / start: accept English + German keyword aliases independent of locale + // Help / start. The slash commands are locale-independent by construction and + // are what `telegram.helpBody` documents in every bundle. The BARE word is an + // undocumented convenience that used to know English and German only, so a + // French user typing "aide" got silence — the handler falls through to the + // intake keyword and returns without a reply. if ( /^\/help\b/i.test(text) || /^\/start\b/i.test(text) || - /^hilfe$/i.test(text) || - /^help$/i.test(text) + isBareCommandWord(text, HELP_WORDS) ) { const userMsgId = message?.message_id; const resp = await sendTelegramMessage( @@ -1212,10 +1270,7 @@ export async function handleTextMessage(update: TelegramUpdate) { } // Greeting responses (kept locale-independent) - const greetings = ["hi", "hallo", "hey", "moin", "hello"]; - const lowerText = text.toLowerCase(); - const matchedGreeting = greetings.find((g) => lowerText === g); - if (matchedGreeting) { + if (isBareCommandWord(text, GREETING_WORDS)) { const userMsgId = message?.message_id; const reply = text.charAt(0).toUpperCase() + text.slice(1).toLowerCase(); const resp = await sendTelegramMessage(botToken, chatId, `${reply}! 👋`); diff --git a/src/lib/tz/resolver.ts b/src/lib/tz/resolver.ts index a51feb7a8..1835432d5 100644 --- a/src/lib/tz/resolver.ts +++ b/src/lib/tz/resolver.ts @@ -49,7 +49,6 @@ export { formatInUserTz, userDayKey, hourInTz, - type FormatInUserTzShape, } from "./format"; const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { diff --git a/src/lib/validations/coach-reminder.ts b/src/lib/validations/coach-reminder.ts index 9a1eccccb..1e0d4eb91 100644 --- a/src/lib/validations/coach-reminder.ts +++ b/src/lib/validations/coach-reminder.ts @@ -19,7 +19,6 @@ import { REMINDER_METRIC_MAX_CHARS, } from "@/lib/ai/coach/reminders"; import { - SUGGESTED_ACTION_TYPES, CHECKUP_LABEL_MAX_CHARS, ACTION_NOTE_MAX_CHARS, ACTION_METRIC_MAX_CHARS, @@ -133,6 +132,3 @@ export const coachSuggestedActionSchema = z export type CoachSuggestedActionInput = z.infer< typeof coachSuggestedActionSchema >; - -/** Stable list of confirmable action types (mirrors the lib allowlist). */ -export const COACH_SUGGESTED_ACTION_TYPES = SUGGESTED_ACTION_TYPES; diff --git a/src/lib/validations/labs.ts b/src/lib/validations/labs.ts index 8a1b7c6bc..d7c2ea3c3 100644 --- a/src/lib/validations/labs.ts +++ b/src/lib/validations/labs.ts @@ -307,10 +307,7 @@ export type ListLabResultsInput = z.infer; * implementation. Re-exported here for the existing import sites. */ export { - classifyAgainstEffectiveRange, classifyReferenceRange, - formatReferenceRange, - resolveEffectiveReferenceRange, type EffectiveReferenceRange, type ReferenceRangeOrigin, type ReferenceRangeStatus, diff --git a/tests/integration/backup-missing-section-restore.test.ts b/tests/integration/backup-missing-section-restore.test.ts new file mode 100644 index 000000000..89702af11 --- /dev/null +++ b/tests/integration/backup-missing-section-restore.test.ts @@ -0,0 +1,238 @@ +/** + * A file that claims a section it does not carry is refused whole (#237). + * + * The restore's first act is to delete the class of records it is about to + * rebuild. So a payload whose documents section is simply absent does not + * "restore everything else": it empties the vault, reports success, and leaves + * the operator to discover the hole later. The maintainer's decision is to + * refuse the file instead — above the transaction, like the metadata-only + * check beside it, so the account survives the refusal. + * + * The hard part is not the refusal. It is the boundary, and both halves are + * asserted here against the real route and real rows: + * + * MISSING — the manifest says the file carries documents, and the key is not + * there. Refused, named, nothing deleted. + * + * DECLARED OMITTED — the manifest says the file does NOT carry the + * mental-health screeners or the consent receipts, and the keys are not + * there. That is what every portable export writes, on purpose, and it has to + * restore. The two cases below are the same builder, the same account and the + * same deleted-key trick; the only difference is what the manifest said, which + * is exactly the claim being made. + * + * Mutation check: drop the `included === "omitted"` skip in + * `findMissingBackupSections` and the second case goes red. Move the refusal + * below the `prisma.$transaction(...)` call and the first case's surviving-rows + * assertions go red while its status assertion stays green — which is why the + * rows are asserted separately rather than trusted to follow from the status. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { encrypt } from "@/lib/crypto"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { POST } from "@/app/api/admin/backups/[id]/restore/route"; +import { invalidateUserData } from "@/lib/cache/invalidate"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserData: vi.fn(), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + vi.mocked(invalidateUserData).mockClear(); +}); + +function makeRequest(id: string) { + return new Request(`http://localhost/api/admin/backups/${id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }); +} + +/** + * An admin account with one measurement and nothing else, signed in. + * + * The measurement is the witness. It references nothing the refusal is about, + * so it can only be lost to the wipe — which is what tells a refused restore + * apart from a destroyed account. + */ +async function seedOwner(username: string) { + const prisma = getPrismaClient(); + const owner = await prisma.user.create({ + data: { username, email: `${username}@example.test`, role: "ADMIN" }, + }); + const session = await prisma.session.create({ + data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + await prisma.measurement.create({ + data: { + userId: owner.id, + type: "WEIGHT", + value: 79.2, + unit: "kg", + source: "MANUAL", + measuredAt: new Date("2026-07-11T06:15:00.000Z"), + }, + }); + return owner; +} + +/** + * A real portable export of that account, with the named keys cut out of the + * JSON before it is sealed. + * + * Cutting them from the RAW object rather than from a parsed payload is the + * whole point: every section in `backupPayloadSchema` carries `.default([])`, + * so a payload that has been through the schema no longer knows the difference + * between a section that was absent and one that was empty. + */ +async function sealPortableWithout( + ownerId: string, + keys: string[], +): Promise<{ backupId: string; manifest: Record }> { + const prisma = getPrismaClient(); + // No `purpose`, so the builder takes the portable branch — the file a person + // gets from "export my data". + const { payload } = await buildFullBackupPayload(prisma, ownerId); + const raw = JSON.parse(JSON.stringify(payload)) as Record; + for (const key of keys) delete raw[key]; + + const backup = await prisma.dataBackup.create({ + data: { + userId: ownerId, + type: "MISSING_SECTION_RESTORE", + data: encrypt(JSON.stringify(raw)), + }, + }); + return { + backupId: backup.id, + manifest: raw.manifest as Record, + }; +} + +async function restore(backupId: string) { + return await POST( + makeRequest(backupId) as unknown as Parameters[0], + { params: Promise.resolve({ id: backupId }) }, + ); +} + +describe("a backup whose manifest claims a section the file does not carry", () => { + it("refuses the whole file, names the section, and changes nothing", async () => { + const prisma = getPrismaClient(); + const owner = await seedOwner("missing-section-owner"); + const { backupId, manifest } = await sealPortableWithout(owner.id, [ + "documents", + ]); + + // Prove the premise rather than assume it: the file still SAYS it carries + // documents. Without this the test would keep passing if the builder ever + // stopped writing the manifest entry. + expect( + (manifest.documents as { included?: string } | undefined)?.included, + ).toBe("metadata-only"); + + const response = await restore(backupId); + + expect(response.status).toBe(422); + const body = (await response.json()) as { + error: string; + meta?: { errorCode?: string; sections?: string[] }; + }; + expect( + body.error, + "the message names the section, so the operator can act on it", + ).toContain("documents"); + expect( + body.error, + "and says the account is intact, because that is the decision they need", + ).toContain("Nothing was changed"); + expect(body.meta?.errorCode).toBe("backup.section.missing"); + expect(body.meta?.sections).toEqual(["documents"]); + + // The refusal is only worth anything above the wipe. + expect( + await prisma.measurement.count({ where: { userId: owner.id } }), + "a refused restore must not have run the wipe", + ).toBe(1); + // A second, independent witness: the invalidation only fires at the end of + // a restore that actually happened. + expect(vi.mocked(invalidateUserData)).not.toHaveBeenCalled(); + + const auditRow = await prisma.auditLog.findFirstOrThrow({ + where: { action: "admin.backups.restore.failed" }, + orderBy: { createdAt: "desc" }, + }); + const details = JSON.parse(auditRow.details ?? "{}") as { + reason?: string; + sections?: string[]; + }; + expect(details.reason).toBe("section_missing"); + expect(details.sections).toEqual(["documents"]); + }); + + it("restores a file whose manifest DECLARES the same keys omitted", async () => { + const prisma = getPrismaClient(); + const owner = await seedOwner("declared-omission-owner"); + // The two sections a portable export leaves out on purpose, cut from the + // file exactly the way `documents` was cut above. + const { backupId, manifest } = await sealPortableWithout(owner.id, [ + "mentalHealthAssessments", + "consentReceipts", + ]); + + expect( + (manifest.mentalHealth as { included?: string } | undefined)?.included, + ).toBe("omitted"); + expect( + (manifest.consent as { included?: string } | undefined)?.included, + ).toBe("omitted"); + + const response = await restore(backupId); + + expect( + response.status, + "a declared omission is not a missing section", + ).toBe(200); + const body = (await response.json()) as { data: { restored: boolean } }; + expect(body.data.restored).toBe(true); + // The restore genuinely ran: the measurement came back out of the file it + // was exported into, and the cache was evicted. + expect( + await prisma.measurement.count({ where: { userId: owner.id } }), + ).toBe(1); + expect(vi.mocked(invalidateUserData)).toHaveBeenCalledWith(owner.id); + }); +}); diff --git a/tests/integration/step-up-elevation.test.ts b/tests/integration/step-up-elevation.test.ts index e47f69546..e6a82cbd4 100644 --- a/tests/integration/step-up-elevation.test.ts +++ b/tests/integration/step-up-elevation.test.ts @@ -1156,3 +1156,177 @@ describe("E13 — the second-factor status read needs no elevation", () => { expect(text).not.toContain("credentialId"); }); }); + +// ── E14 — passkey removal is gated, and a passkey satisfies the gate ── + +/** + * `DELETE /api/auth/passkeys/{id}` used to take a plain cookie session or a + * wildcard Bearer, while the second-factor key beside it demanded a fresh + * proof. That was the softer gate on the PRIMARY sign-in credential, so it now + * goes through `requireMfaManagementAuth({ freshFactor: true, proofSource: + * "any-possession" })`. + * + * THE PROOF SOURCE IS THE PART THAT NEEDS PROVING, and the cases split on the + * difference between the two refusals rather than on the status, because both + * are 401 and only one of them is survivable: + * + * `auth.stepup.required` — re-prove and retry. A gate. + * `auth.stepup.mfa_not_enrolled` — the account holds nothing that could ever + * produce the proof. A dead end, and the reachability pre-check is what + * decides which one a caller gets. + * + * A passkey-only account holds no SECOND factor, so the default pre-check would + * hand it the dead end over a proof it was already carrying — its passkey stamps + * `mfaVerifiedAt` on login and is one of the mint's arms. `"any-possession"` is + * what makes its refusal the survivable one, and E14b then shows it actually + * surviving: a session stamped the way `/api/auth/passkey/login-verify` stamps + * it goes straight through. + * + * The Bearer happy path is driven with TOTP rather than a passkey assertion for + * the reason `enrolTotp` above already gives — it is the only fresh-factor arm + * reachable without a real authenticator. What it proves is the redemption and + * the spend; the passkey arm's admission is proved by E14a/E14b on the pre-check + * that governs both transports. + * + * Mutation check: drop `proofSource` from the route and E14a's errorCode + * assertion plus E14b go red; drop `freshFactor` and E14a, E14c and E14d's spend + * go red; move `commitElevation()` above the 404 branch and E14f goes red. + */ +describe("E14 — removing a passkey needs a fresh possession proof", () => { + async function seedPasskey(label: string): Promise { + const row = await getPrismaClient().passkey.create({ + data: { + userId: USER_ID, + name: label, + credentialId: `cred-${label}`, + credentialPublicKey: Buffer.from([1, 2, 3]), + credentialDeviceType: "multiDevice", + transports: ["internal"], + }, + select: { id: true }, + }); + return row.id; + } + + async function callDelete(id: string): Promise { + const { DELETE } = await import("@/app/api/auth/passkeys/[id]/route"); + return await DELETE(req(`/api/auth/passkeys/${id}`, "DELETE"), { + params: Promise.resolve({ id }), + }); + } + + async function useCookieSession(): Promise { + const session = await getPrismaClient().session.create({ + data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return session.id; + } + + async function errorCodeOf(res: Response): Promise { + const body = (await res.json()) as { meta?: { errorCode?: string } }; + return body.meta?.errorCode; + } + + it("refuses a passkey-only cookie session, and the refusal is re-provable", async () => { + const passkeyId = await seedPasskey("e14a"); + // No TOTP, no security key. Only the passkey it is trying to remove. + await useCookieSession(); + + const res = await callDelete(passkeyId); + + expect(res.status).toBe(401); + // The whole point: NOT `mfa_not_enrolled`. This account can re-prove. + expect(await errorCodeOf(res)).toBe("auth.stepup.required"); + expect(await getPrismaClient().passkey.count()).toBe(1); + }); + + it("admits that same account once its session carries a passkey login", async () => { + const passkeyId = await seedPasskey("e14b"); + const sessionId = await useCookieSession(); + // Exactly what `/api/auth/passkey/login-verify` writes on a passkey login + // (v1.23, M-review M1). Still no second factor anywhere on the account. + await getPrismaClient().session.update({ + where: { id: sessionId }, + data: { mfaVerifiedAt: new Date() }, + }); + + expect((await callDelete(passkeyId)).status).toBe(200); + expect(await getPrismaClient().passkey.count()).toBe(0); + }); + + it("refuses a stale passkey login, so the window is real", async () => { + const passkeyId = await seedPasskey("e14c"); + const sessionId = await useCookieSession(); + await getPrismaClient().session.update({ + where: { id: sessionId }, + data: { + mfaVerifiedAt: new Date(Date.now() - 6 * 60 * 1000), + }, + }); + + const res = await callDelete(passkeyId); + + expect(res.status).toBe(401); + expect(await errorCodeOf(res)).toBe("auth.stepup.required"); + expect(await getPrismaClient().passkey.count()).toBe(1); + }); + + it("refuses a Bearer client presenting no elevation", async () => { + const passkeyId = await seedPasskey("e14d"); + const { raw } = await mintToken("e14d"); + useToken(raw); + useElevation(null); + + const res = await callDelete(passkeyId); + + expect(res.status).toBe(401); + expect(await getPrismaClient().passkey.count()).toBe(1); + }); + + it("admits the native client on a factor-proved elevation, and spends it", async () => { + const passkeyId = await seedPasskey("e14e"); + const secret = await enrolTotp(); + const { raw, id: tokenId } = await mintToken("e14e"); + useToken(raw); + const elevation = elevationOf(await mintTotpElevation(secret)); + useElevation(elevation); + + expect((await callDelete(passkeyId)).status).toBe(200); + expect(await getPrismaClient().passkey.count()).toBe(0); + + const row = await getPrismaClient().stepUpElevation.findFirst({ + where: { apiTokenId: tokenId }, + }); + expect(row?.consumedAt).not.toBeNull(); + }); + + it("does not burn the elevation on a foreign passkey id", async () => { + await seedPasskey("e14f"); + const secret = await enrolTotp(); + const { raw, id: tokenId } = await mintToken("e14f"); + useToken(raw); + const elevation = elevationOf(await mintTotpElevation(secret)); + useElevation(elevation); + + expect((await callDelete("pk-does-not-exist")).status).toBe(404); + + const row = await getPrismaClient().stepUpElevation.findFirst({ + where: { apiTokenId: tokenId }, + }); + expect(row?.consumedAt).toBeNull(); + }); + + it("tells an account holding nothing that there is nothing to prove with", async () => { + // No passkey, no TOTP, no security key. There is no credential this gate + // could ever accept, and the refusal says so rather than inviting a + // re-verification that cannot happen. Reachable only with a stale id — an + // account with no passkey has nothing here to delete. + await useCookieSession(); + + const res = await callDelete("pk-does-not-exist"); + + expect(res.status).toBe(401); + expect(await errorCodeOf(res)).toBe("auth.stepup.mfa_not_enrolled"); + }); +});