Merge remote-tracking branch 'origin/main' into wip/v140-H

This commit is contained in:
Marc-André Bombeck committed 2026-10-04 00:43:10 +02:00
commit db9618ee7b
54 files changed
+2531 -531

No files matched your search

+7
View File
@@ -67,6 +67,13 @@ NODE_ENV="development"
# your encryption key" step shows first; unset means compose.
# HEALTHLOG_PLATFORM="compose"
# Start-up encryption key check: enforce (default) refuses to serve when the
# key does not open the stored data; warn only logs it and serves anyway. A
# last resort for a refusal you are certain is false (a first start, with no
# recorded key to reset). It disables a safety check: with a wrong key every
# encrypted value fails and new rows are written under that key.
# ENCRYPTION_KEY_CHECK="enforce"
# -----------------------------------------------------------------------------
# Required Secrets — generate with `openssl rand -hex 32`
+8
View File
@@ -266,6 +266,14 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# it for you.
# HEALTHLOG_PLATFORM="compose"
# Start-up encryption key check: enforce (default) refuses to serve when the
# key does not open the stored data; warn only logs it, reports it as a
# warning on /api/health, and serves anyway. A last resort for a refusal you
# are certain is false (a first start, with no recorded key to reset). It
# disables a safety check: with a wrong key every encrypted value fails and
# new rows are written under that key. Remove it once the key is confirmed.
# ENCRYPTION_KEY_CHECK="enforce"
# -----------------------------------------------------------------------------
# Deploy webhook -- Coolify -> HealthLog auto-deploy feedback
+5
View File
@@ -135,6 +135,11 @@ x-healthlog-env: &healthlog_env
# compose | truenas | coolify | unraid | portainer. Only chooses which
# instructions the admin's "Back up your encryption key" step shows first.
HEALTHLOG_PLATFORM: "${HEALTHLOG_PLATFORM:-}"
# enforce (default) | warn. The start-up encryption key check refuses to
# serve when the key does not open the stored data; warn only logs it and
# serves anyway. A last resort for a refusal you are certain is false: it
# disables a safety check. See docs/self-hosting/getting-started.md.
ENCRYPTION_KEY_CHECK: "${ENCRYPTION_KEY_CHECK:-}"
BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}"
BACKUP_ENCRYPTION_PREVIOUS_KEYS: "${BACKUP_ENCRYPTION_PREVIOUS_KEYS:-}"
BACKUP_S3_ENDPOINT: "${BACKUP_S3_ENDPOINT:-}"
+2 -4
View File
@@ -72655,10 +72655,8 @@ components:
anyOf:
- type: string
- type: "null"
description: "Explicit unit token. When set it wins over `unitKey`. Set on the `sleep` card (`h`, because its value is a
per-night total in hours rather than the canonical SLEEP_DURATION minutes) and, since v1.40.0, on the
`glucose` card: the record owner's glucose unit (`mg/dL` or `mmol/L`), with `latestValue`, `secondaryValue`
and `sparkline` converted to it (mmol/L to one decimal). Null on every other kind."
description: Explicit unit token, set only on the `sleep` card (`h`, because its value is a per-night total in hours
rather than the canonical SLEEP_DURATION minutes). Null on every other kind.
sleepStages:
anyOf:
- type: object
+7
View File
@@ -103,6 +103,13 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
a full `Columns walked` line. Both read the same registry the rotation
walks.
A run that walked every column without an error also removes the boot
key check's record of each previous key id that no longer holds a single
value (`encryption_key_canaries`); the script prints which ids it removed
and, for an id that still holds values, the columns that do. With the record
gone, the id can later carry a different key without the start-up check
refusing it.
A zero only means "nothing left" when the run also says it walked every
registered column. A zero from a run that skipped columns means "never
looked", and dropping the old key on it makes those rows permanently
+37
View File
@@ -267,6 +267,43 @@ every API request answers `503` with `meta.errorCode`
Restore the original key, point `DATABASE_URL` at the matching database, or
start with an empty database; then restart.
The check remembers the key it first saw for each key id in the table
`encryption_key_canaries`. Before it records a key, it reads the oldest
encrypted values in the database and records the key only if they open, or
if the database holds no encrypted value under that key id at all. When the
stored data proves neither (a single unreadable value, the oldest values
failing while newer ones open, or a probe that ran out of time), the app
keeps serving, records nothing, logs a warning that starts with
`[boot] Encryption key check inconclusive`, and checks again at the next
start.
If you are certain the configured key is the one your data was written
with, and the check still refuses because it recorded a different key
earlier, reset the check for that key id and restart. The refusal in the log
prints the exact statement; for the default key id it is:
```bash
docker compose exec -T db psql -U healthlog healthlog \
-c "DELETE FROM encryption_key_canaries WHERE key_id = 'v1';"
docker compose restart app
```
The next start probes the stored data again and records the key only if
that data opens with it. Do this only when you are sure: if the key is in
fact wrong, the app serves errors on every encrypted value instead of the
one clear refusal. After a key rotation the rotation script removes the
record of a retired key id itself, once it reports that no values remain
under that id.
At a first start there is no record yet, so there is nothing to reset. If
the check refuses there and you are certain the key is right, the last
resort is `ENCRYPTION_KEY_CHECK=warn` (default `enforce`): the check still
runs and logs its full result, `/api/health` names it as
`warning: "encryption_key_mismatch"` without failing, requests are served,
and no key is recorded while the check does not pass. This disables a
safety check. With a wrong key every encrypted value fails and new rows are
written under that key, so remove the setting once the key is confirmed.
## Backup and restore
Your database is the only stateful piece — back it up, and back up the
@@ -36,6 +36,12 @@
"description": "Opens the admin's encryption key backup step on the Portainer instructions.",
"default": "portainer"
},
{
"name": "ENCRYPTION_KEY_CHECK",
"label": "Encryption key check",
"description": "enforce refuses to serve when the key does not open the stored data. warn only logs it: a last resort for a refusal you are certain is false; it disables a safety check.",
"default": "enforce"
},
{
"name": "SESSION_COOKIE_SECURE",
"label": "Session cookie Secure flag",
+43 -4
View File
@@ -46,10 +46,12 @@ staging file. The import then fails with a staging-file-missing error
("the import staging file is not visible to the worker"). Two things to
know:
- A pure `web` container has no pg-boss handle, so an import kicked off
there currently returns `503 Background worker is not running`. Run
imports from a container that also runs the worker until send-only
boss support lands.
- A pure `web` container opens a send-only pg-boss connection at boot
(`src/instrumentation.ts`, `startGlobalBossProducer`), so an import
kicked off there is accepted and queued for the worker like any other
job. It answers `503 Background worker is not running` only when that
producer connection could not be opened (the boot log then shows
`Failed to start pg-boss producer`).
- If you run web and worker as separate containers, they **must share
the import staging directory** — mount the same named volume at the
temp path on both — or run imports in single-container (`all`) mode.
@@ -86,6 +88,43 @@ boot and every 15 minutes, flipping orphaned rows to `failed`
(`interrupted_by_restart`) so the UI offers a retry instead of an
endless spinner.
## Document and lab-scan reads run on the worker (v1.40)
Since v1.40, every AI read of a document or a lab report runs as a
background job on the worker, not inside the HTTP request:
- reading a document for search (the content index),
- a document summary, stored or shown once,
- filing suggestions (title, type, date),
- extracting facts from a stored document for review,
- a lab scan (photo or PDF of a lab report).
The request only queues the read and answers `202`; the client then
polls the run until it ends. That is what keeps a
slow model from being cut off by a proxy timeout in front of the
server. When the enqueue itself fails (no pg-boss connection at all),
the request answers `503` straight away, and nothing is charged.
**"Waiting for the background worker"** is the line the app shows when
a read has sat in the queue for more than 30 seconds without a worker
picking it up. On a single `all` container that is rare and short
(the worker is busy with another job). If it does not go away, no
worker process is consuming the queue: the worker container is
stopped, crashing on boot, or pointed at another database. A run that
no worker picks up within 15 minutes fails as
`aiRuns.workerUnavailable` and its AI budget reservation is handed
back. That sweep is itself a worker job, so with no worker running at
all the run simply stays queued until one starts.
**In a web/worker split, the worker must run the same version as the
web containers.** The web container queues the read; only a worker
that knows the `document-ai-run` queue can execute it. A worker left
on a pre-v1.40 image never takes these jobs, so every document read
and lab scan waits and then fails as above, while everything else
looks healthy. Deploy both from the same image tag
(`HEALTHLOG_IMAGE_REF` is shared by `app` and the `app-worker` block)
and restart the worker together with the web containers.
## Healthchecks
- `app` (web) healthcheck: `wget /api/health` every 30s.
+35
View File
@@ -80,6 +80,41 @@ Fix it one of three ways, then restart the app:
3. Start fresh with an empty database. The old data and its backups need the
old key.
Before it records a key as the right one, the check reads the oldest
encrypted values in the database and records the key only if they open (or if
the database holds no encrypted value under that key id yet). When the data
proves neither, the app keeps serving, records nothing, writes a warning
starting with `[boot] Encryption key check inconclusive` to the log, and
checks again at the next start.
### Resetting the check
If you are certain the configured key is the one the data was written with,
and the app still refuses because the check recorded a different key earlier,
remove that record and restart. Open a shell on the database container (Apps,
HealthLog, the database container's Shell) and run, with the key id the log
names (`v1` unless you use `ENCRYPTION_KEYS`):
```bash
psql -U healthlog healthlog \
-c "DELETE FROM encryption_key_canaries WHERE key_id = 'v1';"
```
Then restart the app. It probes the stored data again and records the key
only if that data opens with it. If the key is in fact wrong, the app then
fails on every encrypted value instead of refusing clearly, so only do this
when you are sure. After a key rotation you never need this for the old key
id: the rotation script removes its record once no values remain under it.
On a first start there is no record to remove. If the check refuses there and
you are certain the key is right, add `ENCRYPTION_KEY_CHECK` = `warn` under
Apps, HealthLog, Edit, Environment as a last resort. The check still runs and
logs, `/api/health` reports `warning: "encryption_key_mismatch"` without
failing, the app serves, and no key is recorded until the check passes. This
switches off a safety check: with a wrong key every encrypted value fails and
new data is written under that key. Remove the setting once the key is
confirmed.
## Updates
Pick the new tag, take a snapshot of the database dataset first, and update.
+1
View File
@@ -54,6 +54,7 @@
<Config Name="SESSION_COOKIE_SECURE" Target="SESSION_COOKIE_SECURE" Default="false" Mode="" Description="Set to false for LAN / plain-HTTP installs (http://tower:3000) or login fails. Set to true only behind an HTTPS reverse proxy." Type="Variable" Display="always" Required="true" Mask="false">false</Config>
<Config Name="HEALTHLOG_PLATFORM" Target="HEALTHLOG_PLATFORM" Default="unraid" Mode="" Description="Where this server is configured. Opens the admin's encryption key backup step on the Unraid instructions." Type="Variable" Display="advanced" Required="false" Mask="false">unraid</Config>
<Config Name="ENCRYPTION_KEY_CHECK" Target="ENCRYPTION_KEY_CHECK" Default="enforce" Mode="" Description="Start-up encryption key check. enforce refuses to serve when the key does not open the stored data. warn only logs it: a last resort for a refusal you are certain is false, and it disables a safety check." Type="Variable" Display="advanced" Required="false" Mask="false">enforce</Config>
<Config Name="NEXT_PUBLIC_APP_URL" Target="NEXT_PUBLIC_APP_URL" Default="http://tower:3000" Mode="" Description="Public URL users reach HealthLog at. Use the LAN URL (e.g. http://tower:3000) or your HTTPS domain behind a reverse proxy." Type="Variable" Display="always" Required="true" Mask="false"/>
+23
View File
@@ -7717,6 +7717,7 @@
"fixDatabase": "Oder lass DATABASE_URL auf die Datenbank zeigen, die zu diesem Schlüssel gehört.",
"fixFresh": "Oder starte neu mit einer leeren Datenbank. Die alten Daten und ihre Sicherungen lassen sich nur mit dem alten Schlüssel lesen.",
"restartNote": "Starte HealthLog nach der Änderung neu.",
"lastResort": "Letzter Ausweg, nur wenn du sicher bist, dass der Schlüssel stimmt, und keiner der drei Wege passt: setze {setting} und starte neu. HealthLog liefert dann trotzdem aus und schreibt den Befund nur ins Log. Damit ist eine Sicherheitsprüfung abgeschaltet: Mit einem falschen Schlüssel scheitert jeder verschlüsselte Wert, und neue Daten werden unter diesem Schlüssel geschrieben.",
"codes": "Für den Betrieb: API-Anfragen antworten mit 503 und dem Fehlercode {code}, /api/health meldet den Grund {reason}."
}
},
@@ -11593,6 +11594,28 @@
"nameLabel": "Name",
"namePlaceholder": "z. B. Aura",
"iconLabel": "Symbol",
"icons": {
"Tag": "Etikett",
"Activity": "Aktivität",
"Heart": "Herz",
"HeartPulse": "Herzschlag",
"Brain": "Gehirn",
"Zap": "Blitz",
"Flame": "Flamme",
"Snowflake": "Schneeflocke",
"Droplet": "Tropfen",
"CircleDot": "Punkt",
"BatteryLow": "Leerer Akku",
"MoonStar": "Mond und Stern",
"PersonStanding": "Person",
"Drama": "Masken",
"Frown": "Trauriges Gesicht",
"Cookie": "Keks",
"Soup": "Suppe",
"Pill": "Tablette",
"Thermometer": "Fieberthermometer",
"Stethoscope": "Stethoskop"
},
"limitReached": "Du hast 8 Symptome. Blende eins aus, um ein neues anzulegen.",
"saveError": "Das Symptom konnte nicht gespeichert werden. Bitte versuch es noch einmal.",
"nameRequired": "Gib dem Symptom einen Namen.",
+23
View File
@@ -7717,6 +7717,7 @@
"fixDatabase": "Or point DATABASE_URL at the database that belongs to this key.",
"fixFresh": "Or start fresh with an empty database. The old data and its backups can only be read with the old key.",
"restartNote": "Restart HealthLog after the change.",
"lastResort": "Last resort, only if you are certain the key is right and none of the three applies: set {setting} and restart. HealthLog then serves anyway and only logs the finding. This disables a safety check: with a wrong key every encrypted value fails and new data is written under that key.",
"codes": "For the operator: API requests answer 503 with error code {code}, and /api/health reports reason {reason}."
}
},
@@ -11593,6 +11594,28 @@
"nameLabel": "Name",
"namePlaceholder": "e.g. Aura",
"iconLabel": "Icon",
"icons": {
"Tag": "Tag",
"Activity": "Activity",
"Heart": "Heart",
"HeartPulse": "Heartbeat",
"Brain": "Brain",
"Zap": "Lightning",
"Flame": "Flame",
"Snowflake": "Snowflake",
"Droplet": "Drop",
"CircleDot": "Dot",
"BatteryLow": "Low battery",
"MoonStar": "Moon and star",
"PersonStanding": "Person",
"Drama": "Masks",
"Frown": "Frowning face",
"Cookie": "Cookie",
"Soup": "Soup",
"Pill": "Pill",
"Thermometer": "Thermometer",
"Stethoscope": "Stethoscope"
},
"limitReached": "You have 8 symptoms. Hide one to add another.",
"saveError": "Couldn't save the symptom. Please try again.",
"nameRequired": "Give the symptom a name.",
+23
View File
@@ -7717,6 +7717,7 @@
"fixDatabase": "O haz que DATABASE_URL apunte a la base de datos que corresponde a esta clave.",
"fixFresh": "O empieza de cero con una base de datos vacía. Los datos antiguos y sus copias solo se pueden leer con la clave antigua.",
"restartNote": "Reinicia HealthLog después del cambio.",
"lastResort": "Último recurso, solo si estás seguro de que la clave es correcta y ninguna de las tres opciones sirve: define {setting} y reinicia. HealthLog sirve igualmente y solo registra el hallazgo. Esto desactiva una comprobación de seguridad: con una clave incorrecta falla cada valor cifrado y los datos nuevos se escriben con esa clave.",
"codes": "Para el administrador: las peticiones a la API responden 503 con el código de error {code}, y /api/health indica el motivo {reason}."
}
},
@@ -11593,6 +11594,28 @@
"nameLabel": "Nombre",
"namePlaceholder": "p. ej. Aura",
"iconLabel": "Icono",
"icons": {
"Tag": "Etiqueta",
"Activity": "Actividad",
"Heart": "Corazón",
"HeartPulse": "Latido",
"Brain": "Cerebro",
"Zap": "Rayo",
"Flame": "Llama",
"Snowflake": "Copo de nieve",
"Droplet": "Gota",
"CircleDot": "Punto",
"BatteryLow": "Batería baja",
"MoonStar": "Luna y estrella",
"PersonStanding": "Persona",
"Drama": "Máscaras",
"Frown": "Cara triste",
"Cookie": "Galleta",
"Soup": "Sopa",
"Pill": "Pastilla",
"Thermometer": "Termómetro",
"Stethoscope": "Estetoscopio"
},
"limitReached": "Tienes 8 síntomas. Oculta uno para añadir otro.",
"saveError": "No se pudo guardar el síntoma. Inténtalo de nuevo.",
"nameRequired": "Ponle un nombre al síntoma.",
+23
View File
@@ -7717,6 +7717,7 @@
"fixDatabase": "Ou fais pointer DATABASE_URL vers la base de données qui correspond à cette clé.",
"fixFresh": "Ou repars de zéro avec une base vide. Les anciennes données et leurs sauvegardes ne se lisent qu'avec l'ancienne clé.",
"restartNote": "Redémarre HealthLog après la modification.",
"lastResort": "Dernier recours, seulement si tu es sûr que la clé est la bonne et qu'aucune des trois solutions ne convient : définis {setting} et redémarre. HealthLog sert alors quand même et se contente de journaliser le constat. Cela désactive une vérification de sécurité : avec une mauvaise clé, chaque valeur chiffrée échoue et les nouvelles données sont écrites sous cette clé.",
"codes": "Pour l'administrateur : les requêtes API répondent 503 avec le code d'erreur {code}, et /api/health indique la raison {reason}."
}
},
@@ -11593,6 +11594,28 @@
"nameLabel": "Nom",
"namePlaceholder": "p. ex. Aura",
"iconLabel": "Icône",
"icons": {
"Tag": "Étiquette",
"Activity": "Activité",
"Heart": "Cœur",
"HeartPulse": "Battement de cœur",
"Brain": "Cerveau",
"Zap": "Éclair",
"Flame": "Flamme",
"Snowflake": "Flocon de neige",
"Droplet": "Goutte",
"CircleDot": "Point",
"BatteryLow": "Batterie faible",
"MoonStar": "Lune et étoile",
"PersonStanding": "Personne",
"Drama": "Masques",
"Frown": "Visage triste",
"Cookie": "Biscuit",
"Soup": "Soupe",
"Pill": "Comprimé",
"Thermometer": "Thermomètre",
"Stethoscope": "Stéthoscope"
},
"limitReached": "Tu as 8 symptômes. Masques-en un pour en ajouter un autre.",
"saveError": "Impossible d'enregistrer le symptôme. Réessaie.",
"nameRequired": "Donne un nom au symptôme.",
+23
View File
@@ -7717,6 +7717,7 @@
"fixDatabase": "Oppure fai puntare DATABASE_URL al database che appartiene a questa chiave.",
"fixFresh": "Oppure ricomincia con un database vuoto. I vecchi dati e i loro backup si leggono solo con la vecchia chiave.",
"restartNote": "Riavvia HealthLog dopo la modifica.",
"lastResort": "Ultima risorsa, solo se sei certo che la chiave sia giusta e nessuna delle tre soluzioni vale: imposta {setting} e riavvia. HealthLog serve comunque e si limita a registrare il problema nel log. Così si disattiva un controllo di sicurezza: con una chiave sbagliata ogni valore cifrato fallisce e i nuovi dati vengono scritti con quella chiave.",
"codes": "Per chi gestisce il server: le richieste API rispondono 503 con il codice di errore {code}, e /api/health indica il motivo {reason}."
}
},
@@ -11593,6 +11594,28 @@
"nameLabel": "Nome",
"namePlaceholder": "es. Aura",
"iconLabel": "Icona",
"icons": {
"Tag": "Etichetta",
"Activity": "Attività",
"Heart": "Cuore",
"HeartPulse": "Battito",
"Brain": "Cervello",
"Zap": "Fulmine",
"Flame": "Fiamma",
"Snowflake": "Fiocco di neve",
"Droplet": "Goccia",
"CircleDot": "Punto",
"BatteryLow": "Batteria scarica",
"MoonStar": "Luna e stella",
"PersonStanding": "Persona",
"Drama": "Maschere",
"Frown": "Viso triste",
"Cookie": "Biscotto",
"Soup": "Zuppa",
"Pill": "Pillola",
"Thermometer": "Termometro",
"Stethoscope": "Stetoscopio"
},
"limitReached": "Hai 8 sintomi. Nascondine uno per aggiungerne un altro.",
"saveError": "Impossibile salvare il sintomo. Riprova.",
"nameRequired": "Dai un nome al sintomo.",
+23
View File
@@ -7717,6 +7717,7 @@
"fixDatabase": "또는 DATABASE_URL이 이 키에 맞는 데이터베이스를 가리키게 하세요.",
"fixFresh": "또는 빈 데이터베이스로 새로 시작하세요. 이전 데이터와 백업은 이전 키로만 읽을 수 있습니다.",
"restartNote": "변경 후 HealthLog를 다시 시작하세요.",
"lastResort": "최후의 수단: 키가 맞다고 확신하고 위 세 가지가 모두 해당하지 않을 때만 {setting}을 설정하고 다시 시작하세요. HealthLog는 그래도 서비스하며 결과를 로그에만 남깁니다. 이 설정은 안전 검사를 끕니다. 키가 틀리면 모든 암호화된 값이 실패하고 새 데이터가 그 키로 기록됩니다.",
"codes": "운영자용: API 요청은 오류 코드 {code}와 함께 503으로 응답하고, /api/health는 원인 {reason}을 알려 줍니다."
}
},
@@ -11593,6 +11594,28 @@
"nameLabel": "이름",
"namePlaceholder": "예: 전조",
"iconLabel": "아이콘",
"icons": {
"Tag": "태그",
"Activity": "활동",
"Heart": "하트",
"HeartPulse": "심장 박동",
"Brain": "뇌",
"Zap": "번개",
"Flame": "불꽃",
"Snowflake": "눈송이",
"Droplet": "물방울",
"CircleDot": "점",
"BatteryLow": "배터리 부족",
"MoonStar": "달과 별",
"PersonStanding": "사람",
"Drama": "가면",
"Frown": "찡그린 얼굴",
"Cookie": "쿠키",
"Soup": "수프",
"Pill": "알약",
"Thermometer": "체온계",
"Stethoscope": "청진기"
},
"limitReached": "증상이 8개입니다. 하나를 숨기면 새로 추가할 수 있습니다.",
"saveError": "증상을 저장하지 못했습니다. 다시 시도하세요.",
"nameRequired": "증상 이름을 입력하세요.",
+23
View File
@@ -7717,6 +7717,7 @@
"fixDatabase": "Albo ustaw DATABASE_URL na bazę danych, która należy do tego klucza.",
"fixFresh": "Albo zacznij od nowa z pustą bazą. Stare dane i ich kopie można odczytać tylko starym kluczem.",
"restartNote": "Po zmianie uruchom HealthLog ponownie.",
"lastResort": "Ostateczność, tylko jeśli masz pewność, że klucz jest właściwy, a żadna z trzech dróg nie pasuje: ustaw {setting} i uruchom ponownie. HealthLog będzie wtedy działać mimo to i tylko zapisze wynik w logu. To wyłącza kontrolę bezpieczeństwa: przy złym kluczu każda zaszyfrowana wartość zawiedzie, a nowe dane zostaną zapisane tym kluczem.",
"codes": "Dla administratora: żądania API odpowiadają 503 z kodem błędu {code}, a /api/health podaje powód {reason}."
}
},
@@ -11593,6 +11594,28 @@
"nameLabel": "Nazwa",
"namePlaceholder": "np. Aura",
"iconLabel": "Ikona",
"icons": {
"Tag": "Etykieta",
"Activity": "Aktywność",
"Heart": "Serce",
"HeartPulse": "Bicie serca",
"Brain": "Mózg",
"Zap": "Błyskawica",
"Flame": "Płomień",
"Snowflake": "Płatek śniegu",
"Droplet": "Kropla",
"CircleDot": "Kropka",
"BatteryLow": "Słaba bateria",
"MoonStar": "Księżyc i gwiazda",
"PersonStanding": "Osoba",
"Drama": "Maski",
"Frown": "Smutna twarz",
"Cookie": "Ciastko",
"Soup": "Zupa",
"Pill": "Tabletka",
"Thermometer": "Termometr",
"Stethoscope": "Stetoskop"
},
"limitReached": "Masz 8 objawów. Ukryj jeden, aby dodać kolejny.",
"saveError": "Nie udało się zapisać objawu. Spróbuj ponownie.",
"nameRequired": "Nadaj objawowi nazwę.",
+5
View File
@@ -327,6 +327,11 @@
"purpose": "Where this server's environment is configured (compose, truenas, coolify, unraid, portainer). Shown back to the admin: the \"Back up your encryption key\" step opens on that platform's instructions. Unset means compose.",
"example": "compose"
},
{
"name": "ENCRYPTION_KEY_CHECK",
"purpose": "The start-up encryption key check. enforce (default) refuses to serve when the configured key does not open the stored data; warn only logs the finding, names it as a warning on /api/health, serves anyway and records no key until the check passes. A last resort for a refusal the operator is certain is false; it disables a safety check.",
"example": "enforce"
},
{
"name": "TRUST_PROXY_HOPS",
"purpose": "Trusted reverse-proxy hop count; the client IP is read that many entries from the right of X-Forwarded-For.",
+29
View File
@@ -17,6 +17,10 @@
* non-zero, because an operator reads a zero here as permission to drop the
* previous key, and "nothing left" and "never looked" must not print alike.
*
* After a complete, error-free run it checks every other configured key id
* for remaining values and removes the boot key check's record (canary) of
* each id that holds none, naming the columns of each id that still does.
*
* Usage:
* ENCRYPTION_KEYS='{"v1":"<old>","v2":"<new>"}' \
* ENCRYPTION_ACTIVE_KEY_ID=v2 \
@@ -27,6 +31,7 @@ import { Buffer } from "node:buffer";
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import { decrypt, encrypt, extractKeyId, getActiveKeyId } from "@/lib/crypto";
import { retireCanariesWithoutData } from "@/lib/crypto/canary";
import {
rotateColumn,
type CorpusClient,
@@ -1051,6 +1056,30 @@ async function main() {
`drop the previous key:\n ${notWalked.join("\n ")}`,
);
}
// ───── Key-check records of retired key ids ─────
// The boot key check keeps one canary per configured key id. Once no
// registered column holds a single value under an id, that id's key can
// leave ENCRYPTION_KEYS, and its canary goes with it: left behind, it would
// refuse a later, different key configured under the same id. Only after a
// complete, error-free run; the existence check walks every column itself
// rather than trusting the counts above.
if (notWalked.length === 0 && totalErrors === 0) {
const { removed, remaining } = await retireCanariesWithoutData(prisma);
for (const [keyId, columns] of Object.entries(remaining)) {
console.log(
`\nKey id '${keyId}': values remain in ${columns.length} column(s); ` +
`keep its key:\n ${columns.join("\n ")}`,
);
}
for (const keyId of removed) {
console.log(
`\nKey id '${keyId}': no values remain. Its key-check record was ` +
`removed; its key can now leave ENCRYPTION_KEYS.`,
);
}
}
await prisma.$disconnect();
if (notWalked.length > 0) process.exit(4);
process.exit(totalErrors > 0 ? 3 : 0);
@@ -0,0 +1,127 @@
/**
* Every request surface outside `apiHandler` refuses while the boot key check
* has found a mismatch.
*
* `apiHandler` answers 503 `encryption.key_mismatch` before auth and before
* the handler, so a process holding the wrong key never writes through it.
* A route that does not wrap in `apiHandler` gets none of that: the remote
* MCP endpoint and its OAuth bridge went without it, and an MCP write tool
* would have sealed new rows under the wrong key, rows the next boot's probe
* could then read as evidence that the wrong key is right.
*
* So: a `route.ts` under `src/app` that does not wrap in `apiHandler` must
* call `refuseOnKeyMismatch` / `refuseOAuthOnKeyMismatch`, or sit in the
* allowlist below with the reason it cannot touch encrypted data. A server
* action (`"use server"`) is held to the same rule; there are none today.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
/**
* Routes outside `apiHandler` that need no refusal, each with why. A file
* belongs here only if it reads nothing from the database and writes nothing.
*/
const NO_DATA_ROUTES: Record<string, string> = {
"app/.well-known/apple-app-site-association/route.ts":
"static JSON built from environment variables; no database access",
"app/.well-known/oauth-authorization-server/route.ts":
"RFC 8414 metadata built from the configured origin; no database access",
"app/.well-known/oauth-protected-resource/route.ts":
"RFC 9728 metadata built from the configured origin; no database access",
};
const REFUSAL_CALL = /\brefuse(?:OAuth)?OnKeyMismatch\s*\(/;
function sourceFiles(): string[] {
return walkSourceFiles(SRC, { floor: 3000 })
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
.sort();
}
/** The text of a top-level function starting at `from`, up to its closing brace. */
function functionBody(src: string, from: number): string {
const end = src.indexOf("\n}\n", from);
return src.slice(from, end === -1 ? undefined : end);
}
function read(rel: string): string {
return readFileSync(join(SRC, rel), "utf8");
}
describe("key mismatch refusal outside apiHandler", () => {
const routes = sourceFiles().filter(
(p) => p.startsWith("app/") && /\/route\.tsx?$/.test(p),
);
const outside = routes.filter((p) => !/\bapiHandler\s*\(/.test(read(p)));
it("finds the route tree and the routes outside apiHandler", () => {
// A matcher that matches nothing would pass every check below.
expect(routes.length).toBeGreaterThan(300);
expect(outside).toEqual(
expect.arrayContaining([
"app/mcp/route.ts",
"app/api/mcp/oauth/authorize/route.ts",
"app/api/mcp/oauth/register/route.ts",
"app/api/mcp/oauth/token/route.ts",
]),
);
});
it("every route outside apiHandler refuses, or is allowlisted with a reason", () => {
const missing = outside.filter(
(p) => !(p in NO_DATA_ROUTES) && !REFUSAL_CALL.test(read(p)),
);
expect(missing).toEqual([]);
});
it("an allowlisted route really stays away from the database", () => {
for (const p of Object.keys(NO_DATA_ROUTES)) {
expect(outside, `${p} is no longer outside apiHandler`).toContain(p);
const src = read(p);
expect(src, p).not.toMatch(/@\/lib\/db\b|prisma|\$queryRaw/);
}
});
it("every exported handler of a refusing route calls the refusal itself", () => {
// `export async function POST(...) { ... }` must refuse in its own body;
// `export const POST = handleX` must refuse in `handleX`. A refusal in one
// handler of a file proves nothing about its siblings.
let checked = 0;
for (const p of outside.filter((f) => !(f in NO_DATA_ROUTES))) {
const src = read(p);
const bodies: Array<[string, string]> = [];
for (const m of src.matchAll(
/export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE)\b/g,
)) {
bodies.push([m[1], functionBody(src, m.index)]);
}
for (const m of src.matchAll(
/export\s+const\s+(GET|POST|PUT|PATCH|DELETE)\s*=\s*(\w+)\s*;/g,
)) {
const def = new RegExp(`function\\s+${m[2]}\\s*\\(`).exec(src);
expect(def, `${p}: ${m[1]} → ${m[2]} not found`).not.toBeNull();
bodies.push([m[1], functionBody(src, def!.index)]);
}
expect(bodies.length, p).toBeGreaterThan(0);
for (const [name, body] of bodies) {
expect(REFUSAL_CALL.test(body), `${p} ${name}`).toBe(true);
checked += 1;
}
}
expect(checked).toBeGreaterThanOrEqual(6);
});
it("server actions refuse too", () => {
const actions = sourceFiles().filter((p) =>
/^\s*["']use server["']/m.test(read(p)),
);
const missing = actions.filter((p) => !REFUSAL_CALL.test(read(p)));
expect(missing).toEqual([]);
});
});
@@ -76,6 +76,11 @@ const PUBLISHED_TO_USERS = [
shownBy:
"the admin's encryption key backup step, which opens on that platform",
},
{
name: "ENCRYPTION_KEY_CHECK",
shownBy:
"the key mismatch page and the start-up log, which offer it as the last resort",
},
{
name: "AUDIT_LOG_RETENTION_DAYS",
shownBy: "the shared-access activity window and the revoke dialog",
@@ -561,32 +561,6 @@ describe("GET /api/dashboard/summary", () => {
expect(glucose?.lastSeenAt).toBe(tenDaysAgo.toISOString());
});
it("names the owner's glucose unit on the glucose card", async () => {
// Applied after the summary cache: the unit is the record's own choice
// and the card's numbers are in it (the conversion is pinned in
// `src/lib/dashboard/__tests__/glucose-card.test.ts`).
vi.mocked(getSession).mockResolvedValue({
...SESSION_OK,
user: { ...SESSION_OK.user, glucoseUnit: "mmol/L" },
} as never);
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
{
type: "BLOOD_GLUCOSE",
_count: { _all: 4 },
_max: { measuredAt: new Date(Date.now() - 10 * 86_400_000) },
},
] as never);
const res = await callGet(makeReq());
const body = (await res.json()) as {
data: { metrics: Array<{ id: string; unit: string | null }> };
};
expect(body.data.metrics.find((m) => m.id === "glucose")?.unit).toBe(
"mmol/L",
);
// Every other card keeps its own unit contract.
expect(body.data.metrics.find((m) => m.id === "weight")?.unit).toBeNull();
});
it("emits the sleep tile as the night TIME-ASLEEP total in hours, not one stage (v1.11.4)", async () => {
// SLEEP_DURATION is stored one row per STAGE per night (minutes). The
// tile must SUM the asleep stages of the latest night and convert to
+1 -9
View File
@@ -75,8 +75,6 @@ import {
} from "@/lib/dashboard/summary-reads";
import { resolveModuleMap } from "@/lib/modules/gate";
import { gateMetricCardsByModules } from "@/lib/dashboard/widget-modules";
import { glucoseCardInDisplayUnit } from "@/lib/dashboard/glucose-card";
import { resolveGlucoseUnit } from "@/lib/glucose";
import {
summarizeSleepNights,
reconstructSleepNights,
@@ -146,9 +144,6 @@ interface MetricCard {
* `latestValue` is a per-NIGHT total expressed in HOURS (a float),
* not the canonical `SLEEP_DURATION` minutes — see the sleep block
* below for why the night total replaced the single-stage value.
* v1.40.0 — the `glucose` tile sets it to the owner's glucose unit
* (`"mg/dL"` or `"mmol/L"`), with its values converted to match
* (`glucoseCardInDisplayUnit`, applied after the cache read).
*/
unit: string | null;
/**
@@ -330,10 +325,7 @@ export const GET = apiHandler(async () => {
// it off. Filtering the cached body makes the toggle effective on the very
// next request.
const modules = await resolveModuleMap(user.id);
const glucoseUnit = resolveGlucoseUnit(user.glucoseUnit);
const metrics = gateMetricCardsByModules(body.metrics, modules).map((card) =>
glucoseCardInDisplayUnit(card, glucoseUnit),
);
const metrics = gateMetricCardsByModules(body.metrics, modules);
annotate({
meta: {
metrics_emitted: metrics.length,
@@ -140,3 +140,26 @@ describe("GET /api/health while the encryption key does not match", () => {
expect(body).toEqual({ status: "ok" });
});
});
describe("GET /api/health under ENCRYPTION_KEY_CHECK=warn", () => {
it("names the mismatch as a warning and stays ok", async () => {
const { setKeyMismatchWarning } =
await import("@/lib/boot/key-mismatch-state");
vi.stubEnv("HEALTHLOG_PROCESS_TYPE", "web");
getGlobalBoss.mockReturnValue({});
setKeyMismatchWarning({
keyIds: ["v1"],
detectedAt: "2026-10-03T00:00:00Z",
});
try {
const { response, body } = await health();
expect(response.status).toBe(200);
expect(body).toEqual({
status: "ok",
warning: "encryption_key_mismatch",
});
} finally {
setKeyMismatchWarning(null);
}
});
});
+9 -1
View File
@@ -7,6 +7,7 @@ import { NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
getKeyMismatchWarning,
isKeyMismatch,
KEY_MISMATCH_HEALTH_REASON,
} from "@/lib/boot/key-mismatch-state";
@@ -31,6 +32,12 @@ export const GET = apiHandler(async () => {
// it is the one place a catalog install's status page reads.
const keyMismatch = isKeyMismatch();
const reason = keyMismatch ? KEY_MISMATCH_HEALTH_REASON : undefined;
// Under ENCRYPTION_KEY_CHECK=warn the same finding does not refuse; it is
// named as a warning and leaves the status alone.
const warning =
!keyMismatch && getKeyMismatchWarning()
? KEY_MISMATCH_HEALTH_REASON
: undefined;
const status =
!keyMismatch &&
dbOk &&
@@ -51,6 +58,7 @@ export const GET = apiHandler(async () => {
{
status,
...(reason ? { reason } : {}),
...(warning ? { warning } : {}),
timestamp: new Date().toISOString(),
database: dbOk ? "connected" : "disconnected",
worker: worker.running ? "running" : "stopped",
@@ -63,7 +71,7 @@ export const GET = apiHandler(async () => {
}
return NextResponse.json(
{ status, ...(reason ? { reason } : {}) },
{ status, ...(reason ? { reason } : {}), ...(warning ? { warning } : {}) },
{ status: statusCode, headers: cacheHeaders },
);
});
+6
View File
@@ -29,6 +29,7 @@ import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { annotate } from "@/lib/logging/context";
import { refuseOAuthOnKeyMismatch } from "@/lib/boot/key-mismatch-refusal";
import { withBackgroundEvent } from "@/lib/logging/background";
import { auditLog } from "@/lib/auth/audit";
import { getSession } from "@/lib/auth/session";
@@ -344,10 +345,15 @@ function ensureAuthorizeCsp(response: Response): Response {
}
export async function GET(request: NextRequest): Promise<Response> {
// The boot key check refused this process: no grant is issued or stored.
const refusal = refuseOAuthOnKeyMismatch();
if (refusal) return ensureAuthorizeCsp(refusal);
return ensureAuthorizeCsp(await authorizeGet(request));
}
export async function POST(request: NextRequest): Promise<Response> {
const refusal = refuseOAuthOnKeyMismatch();
if (refusal) return ensureAuthorizeCsp(refusal);
return ensureAuthorizeCsp(await authorizePost(request));
}
+5
View File
@@ -16,6 +16,7 @@ import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { annotate } from "@/lib/logging/context";
import { refuseOAuthOnKeyMismatch } from "@/lib/boot/key-mismatch-refusal";
import { withBackgroundEvent } from "@/lib/logging/background";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { isApiGloballyEnabled } from "@/lib/app-settings";
@@ -68,6 +69,10 @@ function oauthError(
export async function POST(request: NextRequest): Promise<Response> {
return withBackgroundEvent("mcp.oauth.register", async () => {
// The boot key check refused this process: mint nothing, write nothing.
const refusal = refuseOAuthOnKeyMismatch();
if (refusal) return refusal;
const rl = await checkAuthSurfaceRateLimit(
request,
"mcp:oauth:register",
+5
View File
@@ -32,6 +32,7 @@ import { NextRequest } from "next/server";
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { refuseOAuthOnKeyMismatch } from "@/lib/boot/key-mismatch-refusal";
import { withBackgroundEvent } from "@/lib/logging/background";
import { auditLog } from "@/lib/auth/audit";
import { issueApiToken } from "@/lib/auth/issue-token";
@@ -201,6 +202,10 @@ async function finalizeTokenPair(
export async function POST(request: NextRequest): Promise<Response> {
return withBackgroundEvent("mcp.oauth.token", async () => {
// The boot key check refused this process: mint nothing, write nothing.
const refusal = refuseOAuthOnKeyMismatch();
if (refusal) return refusal;
// M1 — fail closed without a pinned origin; M4 — honour the operator's
// global API kill-switch. Either off and the bridge mints no tokens.
if (!isMcpOriginConfigured() || !(await isApiGloballyEnabled())) {
+29 -6
View File
@@ -194,7 +194,8 @@ interface EntryResult {
}
/**
* v1.31.1 — emit one data arrival per workout this batch actually inserted.
* v1.31.1 — emit one data arrival per workout this batch actually inserted,
* and per hand-entered workout whose edit it actually wrote.
*
* `createManyAndReturn` gives the transaction the exact inserted ids. The
* index-keyed map avoids a second lookup and keeps equal external ids from
@@ -541,6 +542,8 @@ async function postBatch(request: NextRequest): Promise<Response> {
let enrichedCount = 0;
let updatedCount = 0;
const insertedIdByIndex = new Map<number, string>();
/** Workout ids of the manual edits this request actually wrote. */
const overwrittenIdByIndex = new Map<number, string>();
if (survivors.length > 0) {
// v1.4.42 W5 — only the write-time dedup survivors are probed
@@ -709,6 +712,19 @@ async function postBatch(request: NextRequest): Promise<Response> {
externalId: o.externalId,
},
});
// The paragraph described the old values (a 12 km run corrected
// to 1.2 km must not keep its 12 km story). It goes with them,
// and the claim is let go of its workout the way a delete does
// (`ON DELETE SET NULL`): the row stays as the day's ledger, so
// the edit buys no slot back, and the arrival emitted below can
// claim a fresh one through the ordinary path, under the same cap.
await tx.workoutInsight.deleteMany({
where: { userId: user.id, workoutId: o.workoutId },
});
await tx.workoutInsightGenerationClaim.updateMany({
where: { userId: user.id, workoutId: o.workoutId },
data: { workoutId: null },
});
}
}
@@ -841,7 +857,10 @@ async function postBatch(request: NextRequest): Promise<Response> {
index: o.index,
status: landed ? "updated" : "duplicate",
};
if (landed) updatedCount += 1;
if (landed) {
updatedCount += 1;
overwrittenIdByIndex.set(o.index, o.workoutId);
}
duplicateCount += 1;
}
}
@@ -914,13 +933,17 @@ async function postBatch(request: NextRequest): Promise<Response> {
invalidateUserMeasurements(user.id);
}
if (insertedCount > 0) {
if (insertedCount > 0 || overwrittenIdByIndex.size > 0) {
// One arrival per exact INSERT ... RETURNING winner. Historical rows still
// stop at the shared salience classifier before any queue work.
// An enrichment raises none: the workout arrived when it was stored.
void emitWorkoutArrivals(user.id, prepared, insertedIdByIndex).catch(
() => {},
);
// A hand-entered workout whose edit landed raises one again, so its
// paragraph (dropped with the old values) is written from the new ones.
void emitWorkoutArrivals(
user.id,
prepared,
new Map([...insertedIdByIndex, ...overwrittenIdByIndex]),
).catch(() => {});
}
return apiSuccess({
+5
View File
@@ -31,6 +31,11 @@ export function KeyMismatchPage({ locale }: { locale: Locale }) {
<p className="text-muted-foreground text-sm">
{t("admin.keyMismatch.restartNote")}
</p>
<p className="text-muted-foreground text-sm">
{t("admin.keyMismatch.lastResort", {
setting: "ENCRYPTION_KEY_CHECK=warn",
})}
</p>
<p className="text-muted-foreground text-xs">
{t("admin.keyMismatch.codes", {
code: "encryption.key_mismatch",
+7
View File
@@ -50,6 +50,7 @@ import {
resolveMcpAuthContext,
SCOPE_HEALTH_READ,
} from "@/lib/mcp";
import { refuseOnKeyMismatch } from "@/lib/boot/key-mismatch-refusal";
import { withBackgroundEvent } from "@/lib/logging/background";
import { annotate } from "@/lib/logging/context";
import { isModuleEnabled } from "@/lib/modules/gate";
@@ -135,6 +136,12 @@ function readBearer(request: Request): string | null {
async function handleMcp(request: Request): Promise<Response> {
return withBackgroundEvent("mcp.request", async () => {
// 00. KEY CHECK — the boot key check found that the configured encryption
// key does not open this database. The write tools would seal new
// rows under it, so refuse before anything else, as apiHandler does.
const refusal = refuseOnKeyMismatch();
if (refusal) return refusal;
// 0. ORIGIN — refuse a cross-origin browser request before anything else.
if (!originAllowed(request)) {
annotate({ action: { name: "mcp.origin.rejected" } });
@@ -0,0 +1,56 @@
/**
* The cycle sheet's "add a custom symptom" popover offers the same icon
* picker as the symptoms feature, and must look and behave the same: 44 px
* targets on a phone, 36 px from `sm`, a translated name per icon.
*
* The popover portals and portals do not render server-side, so it is
* replaced by pass-through wrappers; what is measured is the picker inside.
*
* Mutation check (run, seen red): put the cycle sheet's own inline picker
* back (`size-7`, `aria-label={name}`) → this test goes red.
*/
import { describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/components/ui/popover", () => ({
Popover: ({ children }: { children: ReactNode }) => <>{children}</>,
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
PopoverContent: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
}));
vi.mock("../use-cycle", async (importOriginal) => ({
...(await importOriginal<typeof import("../use-cycle")>()),
useCreateCustomSymptom: () => ({
mutateAsync: vi.fn(),
reset: vi.fn(),
isPending: false,
isError: false,
error: null,
}),
}));
import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-shared";
import { I18nProvider } from "@/lib/i18n/context";
import { AddSymptomChip } from "../log-day-sheet";
describe("cycle custom-symptom icon picker", () => {
it("has 44 px targets on a phone and translated icon names", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="de">
<AddSymptomChip onCreated={() => {}} />
</I18nProvider>,
);
const buttons = html.match(/<button[^>]*role="radio"[^>]*>/g) ?? [];
expect(buttons).toHaveLength(CUSTOM_SYMPTOM_ICON_ALLOWLIST.length);
for (const button of buttons) {
expect(button).toContain("size-11");
expect(button).toContain("sm:size-9");
expect(button).not.toMatch(/\bsize-7\b/);
}
expect(html).toContain('aria-label="Herzschlag"');
expect(html).not.toContain('aria-label="HeartPulse"');
});
});
+11 -30
View File
@@ -48,7 +48,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { resolveIntlLocale } from "@/lib/format-locale";
import type { Locale } from "@/lib/i18n/config";
import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-shared";
import { SymptomIconPicker } from "@/components/symptoms/symptom-icon-picker";
import { FieldInfo } from "./field-info";
import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog";
import { FLOW_HUE, PHASE_HUE } from "./phase-tokens";
@@ -1519,7 +1519,11 @@ export function SymptomRow({
* label + icon popover to mint a new custom symptom. Same height and shape as
* a symptom row so it reads as part of the list, not a foreign button.
*/
function AddSymptomChip({ onCreated }: { onCreated: (key: string) => void }) {
export function AddSymptomChip({
onCreated,
}: {
onCreated: (key: string) => void;
}) {
const { t } = useTranslations();
const create = useCreateCustomSymptom();
const [openForm, setOpenForm] = useState(false);
@@ -1594,34 +1598,11 @@ function AddSymptomChip({ onCreated }: { onCreated: (key: string) => void }) {
<p className="text-xs font-medium">
{t("cycle.symptom.custom.icon")}
</p>
<div
className="flex flex-wrap gap-1.5"
role="radiogroup"
aria-label={t("cycle.symptom.custom.icon")}
>
{CUSTOM_SYMPTOM_ICON_ALLOWLIST.map((name) => {
const IconC = customIcon(name);
const selected = icon === name;
return (
<button
key={name}
type="button"
role="radio"
aria-checked={selected}
aria-label={name}
onClick={() => setIcon(name)}
className={cn(
"focus-visible:ring-ring/50 grid size-7 place-items-center rounded-md border transition-colors focus-visible:ring-2 focus-visible:outline-none",
selected
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:bg-accent",
)}
>
<IconC className="size-4" aria-hidden="true" />
</button>
);
})}
</div>
<SymptomIconPicker
value={icon}
onChange={setIcon}
label={t("cycle.symptom.custom.icon")}
/>
</div>
{limitReached ? (
<p className="text-destructive text-sm" role="alert">
@@ -15,7 +15,10 @@ import {
unitSkippedAnalytes,
} from "../ocr-review-dialog";
const hookState = vi.hoisted(() => ({ extractPending: false }));
const hookState = vi.hoisted(() => ({
extractPending: false,
runPhase: "idle" as "idle" | "running" | "waitingForWorker",
}));
vi.mock("@/components/ui/responsive-sheet", () => ({
ResponsiveSheet: ({
@@ -37,6 +40,7 @@ vi.mock("@/components/ui/responsive-sheet", () => ({
vi.mock("../use-ocr-extract", () => ({
useOcrExtract: () => ({
isPending: hookState.extractPending,
runPhase: hookState.runPhase,
mutate: vi.fn(),
reset: vi.fn(),
}),
@@ -174,6 +178,35 @@ describe("<OcrReviewDialog> document-reading consent", () => {
});
});
/**
* A lab scan's proposed rows live only in its run: nothing is stored until
* the person confirms them here. So the waiting line must not promise that
* the page can be left (the result would be lost, and a second scan costs
* another rate slot). Mutation check: swap `aiRuns.backgroundScan` for
* `aiRuns.backgroundDocument` in the dialog → both cases go red.
*/
describe("<OcrReviewDialog> background read", () => {
afterEach(() => {
hookState.extractPending = false;
hookState.runPhase = "idle";
});
it.each([
["en", "With a slow model", "leave this page"],
["de", "Mit einem langsamen Modell", "Seite verlassen"],
] as const)(
"(%s) says it reads in the background, never that the page can be left",
(locale, expected, forbidden) => {
hookState.extractPending = true;
hookState.runPhase = "running";
const html = render(locale);
expect(html).toContain('data-slot="ocr-run-phase"');
expect(html).toContain(expected);
expect(html).not.toContain(forbidden);
},
);
});
describe("extractErrorMessage", () => {
const t = (key: string) => key;
const refusal = (errorCode: string, status = 403) =>
@@ -7,13 +7,16 @@
* - drop the `resolvedAt === null` filter on the episodes → "offers no
* episode selector when every episode is resolved" goes red;
* - render the add chip unconditionally → "a delegate who may log but not
* manage gets no add chip" goes red.
* manage gets no add chip" goes red;
* - drop the `definitions.isError` branch → "a failed read shows the error
* row, not the empty hint" goes red (the empty hint renders instead).
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const state = vi.hoisted(() => ({
canManage: true,
definitionsFailed: false,
episodes: [] as Array<{
id: string;
label: string;
@@ -34,30 +37,42 @@ vi.mock("@/components/illness/use-illness", () => ({
}));
vi.mock("../use-symptoms", () => ({
useSymptomDefinitions: () => ({
isLoading: false,
data: {
limit: 8,
definitions: [
{
id: "d1",
label: "Aura",
icon: "Zap",
sortOrder: 0,
isActive: true,
recent: { count30d: 0, maxIntensity30d: null, lastOccurredAt: null },
useSymptomDefinitions: () =>
state.definitionsFailed
? { isLoading: false, isError: true, data: undefined, refetch: vi.fn() }
: {
isLoading: false,
isError: false,
data: {
limit: 8,
definitions: [
{
id: "d1",
label: "Aura",
icon: "Zap",
sortOrder: 0,
isActive: true,
recent: {
count30d: 0,
maxIntensity30d: null,
lastOccurredAt: null,
},
},
{
id: "d2",
label: "Headache",
icon: "Brain",
sortOrder: 1,
isActive: true,
recent: {
count30d: 0,
maxIntensity30d: null,
lastOccurredAt: null,
},
},
],
},
},
{
id: "d2",
label: "Headache",
icon: "Brain",
sortOrder: 1,
isActive: true,
recent: { count30d: 0, maxIntensity30d: null, lastOccurredAt: null },
},
],
},
}),
useLogSymptomEvent: () => ({ mutateAsync: vi.fn(), isPending: false }),
useCreateSymptomDefinition: () => ({
mutateAsync: vi.fn(),
@@ -111,4 +126,19 @@ describe("<SymptomEntryForm>", () => {
];
expect(render()).not.toContain("-episode");
});
it("a failed read shows the error row, not the empty hint", () => {
state.canManage = true;
state.episodes = [];
state.definitionsFailed = true;
try {
const html = render();
expect(html).toContain('data-slot="query-error-row"');
expect(html).toContain("load your symptoms");
expect(html).not.toContain("first symptom");
expect(html).not.toContain('data-testid="symptom-add-chip"');
} finally {
state.definitionsFailed = false;
}
});
});
@@ -0,0 +1,122 @@
/**
* Both symptom icon pickers (the add chip's popover and the edit sheet) share
* one shape: 44 px targets on a phone, 36 px from `sm`, and a translated
* accessible name per icon rather than the raw Lucide component name.
*
* The popover and the sheet portal, and portals do not render server-side,
* so both are replaced by pass-through wrappers that render their children
* inline; what is measured is the picker inside them.
*
* Mutation checks (each run, each seen red):
* - put `size-7` back on the add chip's icon buttons → "the add chip's
* icon targets" goes red;
* - put `aria-label={name}` back on either picker → the label assertions
* go red ("HeartPulse" is a raw name, "Heartbeat" its English label).
*/
import { describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/components/ui/popover", () => ({
Popover: ({ children }: { children: ReactNode }) => <>{children}</>,
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
PopoverContent: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
),
}));
vi.mock("@/components/ui/responsive-sheet", () => ({
ResponsiveSheet: ({
children,
footer,
}: {
children: ReactNode;
footer?: ReactNode;
}) => (
<div>
{children}
{footer}
</div>
),
}));
vi.mock("../use-symptoms", () => {
const mutation = {
mutateAsync: vi.fn(),
reset: vi.fn(),
isPending: false,
isError: false,
error: null,
};
return {
useCreateSymptomDefinition: () => mutation,
useUpdateSymptomDefinition: () => mutation,
useUpdateSymptomEvent: () => mutation,
};
});
import { I18nProvider } from "@/lib/i18n/context";
import { SYMPTOM_ICON_ALLOWLIST } from "@/lib/symptoms/shared";
import { AddSymptomChip } from "../add-symptom-chip";
import { EditSymptomDefinitionSheet } from "../symptom-edit-sheets";
function iconButtons(html: string): string[] {
return html.match(/<button[^>]*role="radio"[^>]*>/g) ?? [];
}
function assertPicker(html: string) {
const buttons = iconButtons(html);
expect(buttons).toHaveLength(SYMPTOM_ICON_ALLOWLIST.length);
for (const button of buttons) {
expect(button).toContain("size-11");
expect(button).toContain("sm:size-9");
expect(button).not.toMatch(/\bsize-7\b/);
}
expect(html).toContain('aria-label="Heartbeat"');
expect(html).toContain('aria-label="Low battery"');
expect(html).not.toContain('aria-label="HeartPulse"');
expect(html).not.toContain('aria-label="BatteryLow"');
}
describe("symptom icon pickers", () => {
it("the add chip's icon targets are 44 px on a phone, with translated names", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<AddSymptomChip onCreated={() => {}} />
</I18nProvider>,
);
assertPicker(html);
});
it("the edit sheet's picker matches, with translated names", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<EditSymptomDefinitionSheet
definition={{
id: "d1",
label: "Aura",
icon: "Zap",
sortOrder: 0,
isActive: true,
recent: {
count30d: 0,
maxIntensity30d: null,
lastOccurredAt: null,
},
}}
onClose={() => {}}
/>
</I18nProvider>,
);
assertPicker(html);
});
it("every allowlisted icon has a label in the English bundle", async () => {
const en = (await import("../../../../messages/en.json")).default as {
symptoms: { icons: Record<string, string> };
};
for (const name of SYMPTOM_ICON_ALLOWLIST) {
expect(en.symptoms.icons[name], name).toBeTruthy();
}
});
});
+6 -31
View File
@@ -19,10 +19,8 @@ import {
} from "@/components/ui/popover";
import { ApiError } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { SYMPTOM_ICON_ALLOWLIST } from "@/lib/symptoms/shared";
import { cn } from "@/lib/utils";
import { symptomIcon } from "./symptom-icons";
import { SymptomIconPicker } from "./symptom-icon-picker";
import { useCreateSymptomDefinition } from "./use-symptoms";
export function AddSymptomChip({
@@ -101,34 +99,11 @@ export function AddSymptomChip({
</div>
<div className="space-y-1.5">
<p className="text-xs font-medium">{t("symptoms.iconLabel")}</p>
<div
className="flex flex-wrap gap-1.5"
role="radiogroup"
aria-label={t("symptoms.iconLabel")}
>
{SYMPTOM_ICON_ALLOWLIST.map((name) => {
const IconC = symptomIcon(name);
const selected = icon === name;
return (
<button
key={name}
type="button"
role="radio"
aria-checked={selected}
aria-label={name}
onClick={() => setIcon(name)}
className={cn(
"focus-visible:ring-ring/50 grid size-7 place-items-center rounded-md border transition-colors focus-visible:ring-2 focus-visible:outline-none",
selected
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:bg-accent",
)}
>
<IconC className="size-4" aria-hidden="true" />
</button>
);
})}
</div>
<SymptomIconPicker
value={icon}
onChange={setIcon}
label={t("symptoms.iconLabel")}
/>
</div>
{create.isError ? (
<p className="text-destructive text-sm" role="alert">
@@ -18,15 +18,13 @@ import { SliderField } from "@/components/ui/slider";
import { localizedApiError } from "@/lib/api/localized-error";
import { useTranslations } from "@/lib/i18n/context";
import {
SYMPTOM_ICON_ALLOWLIST,
SYMPTOM_INTENSITY_MAX,
SYMPTOM_INTENSITY_MIN,
type SymptomDefinitionDTO,
type SymptomEventDTO,
} from "@/lib/symptoms/shared";
import { cn } from "@/lib/utils";
import { symptomIcon } from "./symptom-icons";
import { SymptomIconPicker } from "./symptom-icon-picker";
import {
useUpdateSymptomDefinition,
useUpdateSymptomEvent,
@@ -227,34 +225,11 @@ export function EditSymptomDefinitionSheet({
</FieldGroup>
<div className="space-y-2">
<Label>{t("symptoms.iconLabel")}</Label>
<div
className="flex flex-wrap gap-1.5"
role="radiogroup"
aria-label={t("symptoms.iconLabel")}
>
{SYMPTOM_ICON_ALLOWLIST.map((name) => {
const IconC = symptomIcon(name);
const selected = icon === name;
return (
<button
key={name}
type="button"
role="radio"
aria-checked={selected}
aria-label={name}
onClick={() => setIcon(name)}
className={cn(
"focus-visible:ring-ring/50 grid size-11 place-items-center rounded-md border transition-colors focus-visible:ring-2 focus-visible:outline-none sm:size-9",
selected
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:bg-accent",
)}
>
<IconC className="size-4" aria-hidden="true" />
</button>
);
})}
</div>
<SymptomIconPicker
value={icon}
onChange={setIcon}
label={t("symptoms.iconLabel")}
/>
</div>
{error ? (
<p role="alert" className="text-destructive text-sm">
+65 -53
View File
@@ -27,6 +27,7 @@ import {
import { FieldGroup } from "@/components/ui/field-group";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import {
Select,
SelectContent,
@@ -210,59 +211,70 @@ export function SymptomEntryForm({
>
<div className="space-y-2">
<Label id={symptomGroupId}>{t("symptoms.entry.symptom")}</Label>
<div
role="radiogroup"
aria-labelledby={symptomGroupId}
aria-describedby={
symptomError ? `${symptomGroupId}-error` : undefined
}
className="flex flex-wrap gap-2"
>
{list.map((definition) => {
const selected = definitionId === definition.id;
return (
<button
key={definition.id}
type="button"
role="radio"
aria-checked={selected}
data-testid="symptom-chip"
onClick={() => {
setDefinitionId(selected ? null : definition.id);
setSymptomError(null);
}}
className={cn(
"focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none sm:min-h-8",
selected
? "border-primary bg-primary/10 text-foreground"
: "border-border text-foreground hover:bg-accent",
)}
>
<SymptomIcon name={definition.icon} className="size-4" />
{definition.label ?? t("symptoms.unreadableLabel")}
</button>
);
})}
{canDefine && !atLimit ? (
<AddSymptomChip
onCreated={(id) => {
setDefinitionId(id);
setSymptomError(null);
}}
/>
) : null}
</div>
{list.length === 0 && !definitions.isLoading ? (
<p className="text-muted-foreground text-xs">
{canDefine
? t("symptoms.entry.emptyHint")
: t("symptoms.entry.emptyHintNoManage")}
</p>
) : atLimit && canDefine ? (
<p className="text-muted-foreground text-xs">
{t("symptoms.entry.limitHint", { limit })}
</p>
) : null}
{definitions.isError ? (
// A failed read is not an empty list: the empty hint would invite
// the person to define a symptom they may already have.
<QueryErrorRow
message={t("symptoms.section.loadError")}
onRetry={() => void definitions.refetch()}
/>
) : (
<>
<div
role="radiogroup"
aria-labelledby={symptomGroupId}
aria-describedby={
symptomError ? `${symptomGroupId}-error` : undefined
}
className="flex flex-wrap gap-2"
>
{list.map((definition) => {
const selected = definitionId === definition.id;
return (
<button
key={definition.id}
type="button"
role="radio"
aria-checked={selected}
data-testid="symptom-chip"
onClick={() => {
setDefinitionId(selected ? null : definition.id);
setSymptomError(null);
}}
className={cn(
"focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none sm:min-h-8",
selected
? "border-primary bg-primary/10 text-foreground"
: "border-border text-foreground hover:bg-accent",
)}
>
<SymptomIcon name={definition.icon} className="size-4" />
{definition.label ?? t("symptoms.unreadableLabel")}
</button>
);
})}
{canDefine && !atLimit ? (
<AddSymptomChip
onCreated={(id) => {
setDefinitionId(id);
setSymptomError(null);
}}
/>
) : null}
</div>
{list.length === 0 && !definitions.isLoading ? (
<p className="text-muted-foreground text-xs">
{canDefine
? t("symptoms.entry.emptyHint")
: t("symptoms.entry.emptyHintNoManage")}
</p>
) : atLimit && canDefine ? (
<p className="text-muted-foreground text-xs">
{t("symptoms.entry.limitHint", { limit })}
</p>
) : null}
</>
)}
{symptomError ? (
<p
id={`${symptomGroupId}-error`}
@@ -0,0 +1,57 @@
"use client";
/**
* The icon picker a person-named symptom is given an icon with. One source for
* the three places that offer it (the symptoms add chip, the symptom edit
* sheet, and the cycle sheet's custom-symptom popover), which all draw from
* the same allowlist: 44 px targets on a phone, 36 px from `sm`, and each icon
* named in the reader's language rather than by its Lucide component name.
*/
import { useTranslations } from "@/lib/i18n/context";
import { SYMPTOM_ICON_ALLOWLIST } from "@/lib/symptoms/shared";
import { cn } from "@/lib/utils";
import { symptomIcon } from "./symptom-icons";
export function SymptomIconPicker({
value,
onChange,
label,
}: {
value: string;
onChange: (name: string) => void;
/** The radiogroup's accessible name (the visible field label). */
label: string;
}) {
const { t } = useTranslations();
return (
<div
className="flex flex-wrap gap-1.5"
role="radiogroup"
aria-label={label}
>
{SYMPTOM_ICON_ALLOWLIST.map((name) => {
const IconC = symptomIcon(name);
const selected = value === name;
return (
<button
key={name}
type="button"
role="radio"
aria-checked={selected}
aria-label={t(`symptoms.icons.${name}`)}
onClick={() => onChange(name)}
className={cn(
"focus-visible:ring-ring/50 grid size-11 place-items-center rounded-md border transition-colors focus-visible:ring-2 focus-visible:outline-none sm:size-9",
selected
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:bg-accent",
)}
>
<IconC className="size-4" aria-hidden="true" />
</button>
);
})}
</div>
);
}
+4 -16
View File
@@ -53,11 +53,7 @@ import {
assertRecordSessionFence,
attachRecordContextEcho,
} from "./sharing/record-session-fence";
import {
isKeyMismatch,
KEY_MISMATCH_ERROR_CODE,
KEY_MISMATCH_EXEMPT_PATHS,
} from "./boot/key-mismatch-state";
import { refuseOnKeyMismatch } from "./boot/key-mismatch-refusal";
import {
capturedRateLimit,
rateLimitResponseHeaders,
@@ -266,17 +262,9 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
// The boot key check found that the configured encryption key
// cannot open this database. Refuse before auth, before the
// handler: nothing here may read or write encrypted data.
if (isKeyMismatch() && !KEY_MISMATCH_EXEMPT_PATHS.has(url.pathname)) {
annotate({ action: { name: "encryption.key_mismatch.refused" } });
response = NextResponse.json(
{
data: null,
error:
"The server's encryption key does not match its database. The operator has to restore the original key or point the server at the matching database.",
meta: { errorCode: KEY_MISMATCH_ERROR_CODE },
},
{ status: 503, headers: { "Retry-After": "300" } },
);
const refusal = refuseOnKeyMismatch(url.pathname);
if (refusal) {
response = refusal;
} else {
response = await handler(...args);
}
+47 -8
View File
@@ -13,17 +13,50 @@ import {
checkEncryptionKeyCanaries,
keyMismatchLogBlock,
type CanaryClient,
type InconclusiveReason,
} from "@/lib/crypto/canary";
import { setKeyMismatchState } from "./key-mismatch-state";
import {
getKeyCheckMode,
setKeyMismatchState,
setKeyMismatchWarning,
} from "./key-mismatch-state";
const INCONCLUSIVE_EXPLANATION: Record<InconclusiveReason, string> = {
"single-value": "the only stored value found under that id did not open.",
mixed:
"the oldest stored values under that id do not open, newer ones do. " +
"That is what a wrong key that served for a while leaves behind; check " +
"that this is the key the database was first written with.",
unsampled:
"the values stored under that id are too large to probe at start-up.",
incomplete:
"the probe of the stored data did not finish in time or a table could " +
"not be read.",
};
export async function runBootKeyCheck(client: CanaryClient): Promise<boolean> {
const outcome = await checkEncryptionKeyCanaries(client);
const mode = getKeyCheckMode();
const outcome = await checkEncryptionKeyCanaries(client, { mode });
setKeyMismatchWarning(null);
if (outcome.state === "mismatch") {
setKeyMismatchState({
const state = {
keyIds: outcome.keyIds,
detectedAt: new Date().toISOString(),
});
};
console.error(keyMismatchLogBlock(outcome.keyIds));
if (mode === "warn") {
// The operator turned the refusal off. Say so next to the block, keep
// the finding for /api/health, and serve.
setKeyMismatchState(null);
setKeyMismatchWarning(state);
console.error(
"[boot] ENCRYPTION_KEY_CHECK=warn: serving anyway. No key is recorded " +
"while the check does not pass. Remove the setting once the key is " +
"confirmed.",
);
return false;
}
setKeyMismatchState(state);
return true;
}
setKeyMismatchState(null);
@@ -34,11 +67,17 @@ export async function runBootKeyCheck(client: CanaryClient): Promise<boolean> {
`[boot] Encryption key check skipped: ${outcome.message.slice(0, 300)}`,
);
} else {
if (outcome.inconclusive.length > 0) {
for (const { keyId, reason } of outcome.inconclusive) {
console.warn(
`[boot] Encryption key check inconclusive for key id(s) ${outcome.inconclusive.join(", ")}: ` +
"the only stored value found under that id did not open. Serving; " +
"no canary written; the check runs again at the next start.",
`[boot] Encryption key check inconclusive for key id ${keyId}: ` +
`${INCONCLUSIVE_EXPLANATION[reason]} Serving; no key recorded; ` +
"the check runs again at the next start.",
);
}
if (mode === "warn" && outcome.inconclusive.length > 0) {
console.warn(
"[boot] ENCRYPTION_KEY_CHECK=warn: no key recorded on this start " +
"because the check did not pass for every key id.",
);
}
if (outcome.written.length > 0) {
+67
View File
@@ -0,0 +1,67 @@
/**
* The refusal a request surface answers while the boot key check has found
* that the configured encryption key does not open this database.
*
* `apiHandler` applies it to every API route. A route outside `apiHandler`
* (the remote MCP endpoint, its OAuth bridge) calls one of the two helpers
* below as the first thing it does, before auth and before any read or
* write: a process holding the wrong key must never seal new rows, because
* those rows would open under that key at the next boot and read as evidence
* that it is the right one. `key-mismatch-refusal-guard.test.ts` fails when a
* route outside `apiHandler` does neither.
*/
import { annotate } from "@/lib/logging/context";
import {
isKeyMismatch,
KEY_MISMATCH_ERROR_CODE,
KEY_MISMATCH_EXEMPT_PATHS,
} from "./key-mismatch-state";
export const KEY_MISMATCH_MESSAGE =
"The server's encryption key does not match its database. The operator has to restore the original key or point the server at the matching database.";
const RETRY_AFTER = { "Retry-After": "300" };
/** The 503 in the standard `{ data, error, meta }` envelope. */
export function keyMismatchResponse(): Response {
annotate({ action: { name: "encryption.key_mismatch.refused" } });
return Response.json(
{
data: null,
error: KEY_MISMATCH_MESSAGE,
meta: { errorCode: KEY_MISMATCH_ERROR_CODE },
},
{ status: 503, headers: RETRY_AFTER },
);
}
/**
* The envelope refusal when the process refuses and `pathname` is not one of
* the exempt paths; otherwise null and the caller carries on.
*/
export function refuseOnKeyMismatch(pathname?: string): Response | null {
if (!isKeyMismatch()) return null;
if (pathname !== undefined && KEY_MISMATCH_EXEMPT_PATHS.has(pathname)) {
return null;
}
return keyMismatchResponse();
}
/**
* The same refusal in the RFC 6749 error shape the OAuth endpoints speak:
* `temporarily_unavailable` with a description, plus the HealthLog error
* code as an extension member a client may ignore.
*/
export function refuseOAuthOnKeyMismatch(): Response | null {
if (!isKeyMismatch()) return null;
annotate({ action: { name: "encryption.key_mismatch.refused" } });
return Response.json(
{
error: "temporarily_unavailable",
error_description:
"The server's encryption key does not match its database.",
error_code: KEY_MISMATCH_ERROR_CODE,
},
{ status: 503, headers: { ...RETRY_AFTER, "Cache-Control": "no-store" } },
);
}
+31
View File
@@ -22,11 +22,30 @@ export interface KeyMismatchState {
}
const STATE_SYMBOL = Symbol.for("healthlog.encryptionKeyMismatch");
const WARNING_SYMBOL = Symbol.for("healthlog.encryptionKeyMismatchWarning");
type GlobalWithState = typeof globalThis & {
[STATE_SYMBOL]?: KeyMismatchState | null;
[WARNING_SYMBOL]?: KeyMismatchState | null;
};
/**
* `ENCRYPTION_KEY_CHECK`: `enforce` (the default, and what any other value
* means) refuses on a mismatch; `warn` runs the check, logs it, reports it as
* a warning on `/api/health`, and serves anyway. `warn` is the operator's way
* out of a refusal they are certain is false at a first boot, where there is
* no recorded key yet whose row they could delete. It disables a safety
* check: a process with the wrong key then serves errors on every encrypted
* value and writes new rows under that key.
*/
export type KeyCheckMode = "enforce" | "warn";
export function getKeyCheckMode(): KeyCheckMode {
return process.env.ENCRYPTION_KEY_CHECK?.trim().toLowerCase() === "warn"
? "warn"
: "enforce";
}
/** The paths that keep answering while the process refuses everything else. */
export const KEY_MISMATCH_EXEMPT_PATHS: ReadonlySet<string> = new Set([
"/api/health",
@@ -50,3 +69,15 @@ export function isKeyMismatch(): boolean {
export function setKeyMismatchState(state: KeyMismatchState | null): void {
(globalThis as GlobalWithState)[STATE_SYMBOL] = state;
}
/**
* A mismatch the check found while `ENCRYPTION_KEY_CHECK=warn`: recorded so
* `/api/health` can name it as a warning, never read by the refusal.
*/
export function getKeyMismatchWarning(): KeyMismatchState | null {
return (globalThis as GlobalWithState)[WARNING_SYMBOL] ?? null;
}
export function setKeyMismatchWarning(state: KeyMismatchState | null): void {
(globalThis as GlobalWithState)[WARNING_SYMBOL] = state;
}
+391 -87
View File
@@ -1,17 +1,30 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import {
_resetCryptoCacheForTests,
candidateMatchesKey,
encrypt,
encryptBytes,
encryptUnderKeyId,
extractKeyIdFromBytes,
fingerprintKeyBytes,
getKeyFingerprint,
} from "@/lib/crypto";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
ENCRYPTED_COLUMNS,
WORKOUT_ROUTE_GEOMETRY_AAD,
encryptedColumnKey,
} from "@/lib/crypto/encrypted-columns";
import {
canaryPlaintext,
checkEncryptionKeyCanaries,
keyMismatchLogBlock,
probeValueOpens,
resolveColumnLocation,
type CanaryClient,
type ColumnSampler,
} from "../canary";
const KEY_A =
@@ -19,23 +32,49 @@ const KEY_A =
const KEY_B =
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
function useKey(hex: string) {
function configureKeys(keys: Record<string, string>, active: string) {
vi.stubEnv("ENCRYPTION_KEYS", JSON.stringify(keys));
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", active);
vi.stubEnv("ENCRYPTION_KEY", "");
_resetCryptoCacheForTests();
}
function configureKey(hex: string) {
vi.stubEnv("ENCRYPTION_KEYS", "");
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
vi.stubEnv("ENCRYPTION_KEY", hex);
_resetCryptoCacheForTests();
}
/** An in-memory stand-in for the two raw statements and the probe delegates. */
type Seeded =
| string
| Uint8Array
| { value: string | Uint8Array | null; ts?: string; codec?: string };
/**
* An in-memory stand-in for the raw statements, plus a sampler over seeded
* values keyed by `Model.field`. A seeded value's `ts` orders it; values
* without one keep their seeded order after every dated value, as the SQL
* sampler orders them.
*/
function fakeClient(opts: {
canaries?: Record<string, string>;
/** delegate → field → stored values (in row order). */
probe?: Record<string, Record<string, string[]>>;
probe?: Record<string, Seeded[]>;
/** Make the sampler throw for these columns. */
unreadable?: string[];
/** Another process seals this id (with this ciphertext) just before us. */
race?: { keyId: string; ciphertext: string };
}) {
const canaries = new Map(Object.entries(opts.canaries ?? {}));
const inserts: string[] = [];
let raced = false;
const client = {
async $queryRaw() {
async $queryRaw(_q: TemplateStringsArray, ...values: unknown[]) {
const [arg] = values;
if (typeof arg === "string") {
const c = canaries.get(arg);
return c ? [{ ciphertext: c }] : [];
}
return [...canaries.entries()].map(([key_id, ciphertext]) => ({
key_id,
ciphertext,
@@ -43,104 +82,118 @@ function fakeClient(opts: {
},
async $executeRaw(_q: TemplateStringsArray, ...values: unknown[]) {
const [keyId, ciphertext] = values as [string, string];
if (opts.race && !raced && opts.race.keyId === keyId) {
raced = true;
canaries.set(keyId, opts.race.ciphertext);
}
inserts.push(keyId);
if (!canaries.has(keyId)) canaries.set(keyId, ciphertext);
if (canaries.has(keyId)) return 0;
canaries.set(keyId, ciphertext);
return 1;
},
} as unknown as CanaryClient & Record<string, unknown>;
for (const [delegate, fields] of Object.entries(opts.probe ?? {})) {
client[delegate] = {
async findMany(args: {
select: Record<string, boolean>;
take: number;
skip?: number;
}) {
const field = Object.keys(args.select)[0];
const skip = args.skip ?? 0;
return (fields[field] ?? [])
.slice(skip, skip + args.take)
.map((value) => ({ [field]: value }));
},
};
}
return { client, canaries, inserts };
} as unknown as CanaryClient;
const sampler: ColumnSampler = async (column, keyId, limit) => {
const key = `${column.model}.${column.field}`;
if (opts.unreadable?.includes(key)) throw new Error("relation missing");
const rows = (opts.probe?.[key] ?? []).map((v) =>
typeof v === "string" || v instanceof Uint8Array
? { value: v, ts: null as Date | null, codec: null }
: {
value: v.value,
ts: v.ts ? new Date(v.ts) : null,
codec: v.codec ?? null,
},
);
const under = rows.filter((r) => {
if (r.value === null) return true;
if (typeof r.value === "string") return r.value.startsWith(`${keyId}.`);
const b = Buffer.from(r.value);
return (
b.toString("latin1").startsWith(`${keyId}.`) ||
extractKeyIdFromBytes(b) === keyId
);
});
return under
.map((r, i) => ({ r, i }))
.sort((a, b) => {
const ta = a.r.ts?.getTime() ?? Infinity;
const tb = b.r.ts?.getTime() ?? Infinity;
return ta === tb ? a.i - b.i : ta - tb;
})
.slice(0, limit)
.map(({ r }) => r);
};
const check = (budget?: number) =>
checkEncryptionKeyCanaries(client, { sampler, probeBudgetMs: budget });
return { client, sampler, canaries, inserts, check };
}
const ok = (fields: {
written?: string[];
verified?: string[];
inconclusive?: Array<{ keyId: string; reason: string }>;
}) => ({
state: "ok",
written: fields.written ?? [],
verified: fields.verified ?? [],
inconclusive: fields.inconclusive ?? [],
});
describe("encryption key canary", () => {
beforeEach(() => {
vi.unstubAllEnvs();
useKey(KEY_A);
configureKey(KEY_A);
});
it("writes a canary for a configured key with no row and no data", async () => {
const { client, canaries, inserts } = fakeClient({});
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome).toEqual({
state: "ok",
written: ["v1"],
verified: [],
inconclusive: [],
});
it("writes a canary for a configured key when no column holds a value under its id", async () => {
const { canaries, inserts, check } = fakeClient({});
expect(await check()).toEqual(ok({ written: ["v1"] }));
expect(inserts).toEqual(["v1"]);
expect(canaries.get("v1")?.startsWith("v1.")).toBe(true);
});
it("verifies a canary written under the same key", async () => {
const { client, inserts } = fakeClient({
const { inserts, check } = fakeClient({
canaries: { v1: encryptUnderKeyId(canaryPlaintext("v1"), "v1") },
});
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome).toEqual({
state: "ok",
written: [],
verified: ["v1"],
inconclusive: [],
});
expect(await check()).toEqual(ok({ verified: ["v1"] }));
expect(inserts).toEqual([]);
});
it("reports a mismatch when the key changed under the same id", async () => {
const sealedUnderA = encryptUnderKeyId(canaryPlaintext("v1"), "v1");
useKey(KEY_B);
const { client, inserts } = fakeClient({ canaries: { v1: sealedUnderA } });
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome).toEqual({ state: "mismatch", keyIds: ["v1"] });
configureKey(KEY_B);
const { inserts, check } = fakeClient({ canaries: { v1: sealedUnderA } });
expect(await check()).toEqual({ state: "mismatch", keyIds: ["v1"] });
expect(inserts).toEqual([]);
});
it("reports a mismatch for a canary that opens to the wrong value", async () => {
// A row from another key id copied over: opens, but says the wrong thing.
const { client } = fakeClient({
const { check } = fakeClient({
canaries: { v1: encryptUnderKeyId(canaryPlaintext("v2"), "v1") },
});
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome.state).toBe("mismatch");
expect((await check()).state).toBe("mismatch");
});
it("refuses to seal a wrong key when existing data predates the canary", async () => {
const existing = [encrypt("a stored token"), encrypt("another token")];
useKey(KEY_B);
const { client, inserts } = fakeClient({
configureKey(KEY_B);
const { inserts, check } = fakeClient({
probe: {
user: {
codexAccessTokenEncrypted: [existing[0]],
codexRefreshTokenEncrypted: [existing[1]],
},
"User.codexAccessTokenEncrypted": [existing[0]],
"User.codexRefreshTokenEncrypted": [existing[1]],
},
});
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome).toEqual({ state: "mismatch", keyIds: ["v1"] });
expect(await check()).toEqual({ state: "mismatch", keyIds: ["v1"] });
expect(inserts).toEqual([]);
});
it("writes the canary when existing data opens under the key", async () => {
const { client, inserts } = fakeClient({
probe: {
user: { codexAccessTokenEncrypted: [encrypt("a stored token")] },
},
const { inserts, check } = fakeClient({
probe: { "User.codexAccessTokenEncrypted": [encrypt("a stored token")] },
});
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome.state).toBe("ok");
expect((await check()).state).toBe("ok");
expect(inserts).toEqual(["v1"]);
});
@@ -148,46 +201,270 @@ describe("encryption key canary", () => {
// A row that claims key id v1 but does not open (damaged, or sealed by
// something else), found FIRST, then values that open.
const damaged = "v1." + Buffer.alloc(40, 7).toString("base64");
const { client, inserts } = fakeClient({
const { inserts, check } = fakeClient({
probe: {
user: {
codexAccessTokenEncrypted: [damaged],
codexRefreshTokenEncrypted: [encrypt("a stored token")],
},
"User.codexAccessTokenEncrypted": [damaged],
"User.codexRefreshTokenEncrypted": [encrypt("a stored token")],
},
});
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome.state).toBe("ok");
expect((await check()).state).toBe("ok");
expect(inserts).toEqual(["v1"]);
});
it("several values in one column count, so a wrong key with one busy column still refuses", async () => {
const values = [encrypt("one"), encrypt("two"), encrypt("three")];
useKey(KEY_B);
const { client, inserts } = fakeClient({
probe: { user: { codexAccessTokenEncrypted: values } },
configureKey(KEY_B);
const { inserts, check } = fakeClient({
probe: { "User.codexAccessTokenEncrypted": values },
});
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome).toEqual({ state: "mismatch", keyIds: ["v1"] });
expect(await check()).toEqual({ state: "mismatch", keyIds: ["v1"] });
expect(inserts).toEqual([]);
});
it("a single value that does not open is inconclusive: no mismatch, no canary", async () => {
const existing = encrypt("the only stored token");
useKey(KEY_B);
const { client, inserts } = fakeClient({
probe: { user: { codexAccessTokenEncrypted: [existing] } },
});
const outcome = await checkEncryptionKeyCanaries(client);
expect(outcome).toEqual({
state: "ok",
written: [],
verified: [],
inconclusive: ["v1"],
configureKey(KEY_B);
const { inserts, check } = fakeClient({
probe: { "User.codexAccessTokenEncrypted": [existing] },
});
expect(await check()).toEqual(
ok({ inconclusive: [{ keyId: "v1", reason: "single-value" }] }),
);
expect(inserts).toEqual([]);
});
describe("Bytes columns", () => {
it("a wrong key over Bytes-only ciphertext refuses instead of sealing", async () => {
const notes = [encryptToBytes("note one"), encryptToBytes("note two")];
configureKey(KEY_B);
const { inserts, check } = fakeClient({
probe: {
"MoodEntry.noteEncrypted": [notes[0]],
"Measurement.notesEncrypted": [notes[1]],
},
});
expect(await check()).toEqual({ state: "mismatch", keyIds: ["v1"] });
expect(inserts).toEqual([]);
});
it("opens the binary layout with its label, and the UTF-8 layout", async () => {
const route = encryptBytes(Buffer.from("{}"), WORKOUT_ROUTE_GEOMETRY_AAD);
const { inserts, check } = fakeClient({
probe: {
"WorkoutRoute.geometryEncrypted": [
{ value: route, ts: "2024-01-01" },
],
"MoodEntry.noteEncrypted": [
{ value: encryptToBytes("a note"), ts: "2024-01-02" },
],
},
});
expect(await check()).toEqual(ok({ written: ["v1"] }));
expect(inserts).toEqual(["v1"]);
});
it("a binary value under the wrong label does not count as opening", () => {
const sealed = encryptBytes(Buffer.from("x"), "some/other/label");
const column = ENCRYPTED_COLUMNS.find((c) => c.model === "WorkoutRoute")!;
expect(probeValueOpens(column, { value: sealed, ts: null }, "v1")).toBe(
false,
);
});
it("dispatches a codec column by the row's codec", () => {
const column = ENCRYPTED_COLUMNS.find(
(c) => c.model === "InboundDocument" && c.field === "contentEncrypted",
)!;
const binary = encryptBytes(Buffer.from("%PDF"));
const text = encryptToBytes("JVBERg==");
expect(
probeValueOpens(
column,
{ value: binary, codec: "binary2", ts: null },
"v1",
),
).toBe(true);
expect(
probeValueOpens(
column,
{ value: text, codec: "base64v1", ts: null },
"v1",
),
).toBe(true);
});
});
describe("oldest values decide", () => {
function seedOldAThenNewB() {
const old = [
encrypt("old 1"),
encrypt("old 2"),
encryptToBytes("old note 1"),
encryptToBytes("old note 2"),
];
configureKey(KEY_B);
const recent = [
encrypt("new 1"),
encrypt("new 2"),
encryptToBytes("new note 1"),
encryptToBytes("new note 2"),
];
configureKey(KEY_A);
return fakeClient({
probe: {
"User.codexAccessTokenEncrypted": [
{ value: recent[0], ts: "2026-09-01" },
{ value: old[0], ts: "2024-01-01" },
],
"User.codexRefreshTokenEncrypted": [
{ value: old[1], ts: "2024-01-02" },
{ value: recent[1], ts: "2026-09-02" },
],
"MoodEntry.noteEncrypted": [
{ value: recent[2], ts: "2026-09-03" },
{ value: old[2], ts: "2024-01-03" },
],
"Measurement.notesEncrypted": [
{ value: old[3], ts: "2024-01-04" },
{ value: recent[3], ts: "2026-09-04" },
],
},
});
}
it("the key that wrote only the newer rows is never sealed", async () => {
const fake = seedOldAThenNewB();
configureKey(KEY_B);
const outcome = await fake.check();
expect(outcome.state).not.toBe("error");
if (outcome.state === "ok") expect(outcome.written).toEqual([]);
expect(fake.canaries.size).toBe(0);
});
it("the key that wrote the oldest rows is sealed, newer unreadable rows notwithstanding", async () => {
const fake = seedOldAThenNewB();
expect(await fake.check()).toEqual(ok({ written: ["v1"] }));
});
it("two oldest values that fail before one that opens are mixed: inconclusive", async () => {
const opens = encrypt("opens");
configureKey(KEY_B);
const foreign = [encrypt("x"), encrypt("y")];
configureKey(KEY_A);
const { inserts, check } = fakeClient({
probe: {
"User.codexAccessTokenEncrypted": [
{ value: foreign[0], ts: "2024-01-01" },
],
"User.codexRefreshTokenEncrypted": [
{ value: foreign[1], ts: "2024-01-02" },
],
"MoodEntry.noteEncrypted": [{ value: opens, ts: "2025-01-01" }],
},
});
expect(await check()).toEqual(
ok({ inconclusive: [{ keyId: "v1", reason: "mixed" }] }),
);
expect(inserts).toEqual([]);
});
});
describe("nothing unproven seals", () => {
it("a probe out of time is inconclusive, even over data that opens", async () => {
const { inserts, check } = fakeClient({
probe: { "User.codexAccessTokenEncrypted": [encrypt("opens")] },
});
expect(await check(-1)).toEqual(
ok({ inconclusive: [{ keyId: "v1", reason: "incomplete" }] }),
);
expect(inserts).toEqual([]);
});
it("a column that cannot be read is inconclusive, never a fresh install", async () => {
const { inserts, check } = fakeClient({
unreadable: ["MoodEntry.noteEncrypted"],
});
expect(await check()).toEqual(
ok({ inconclusive: [{ keyId: "v1", reason: "incomplete" }] }),
);
expect(inserts).toEqual([]);
});
it("values too large to fetch are present: inconclusive, not fresh", async () => {
const { inserts, check } = fakeClient({
probe: { "InboundDocument.contentEncrypted": [{ value: null }] },
});
expect(await check()).toEqual(
ok({ inconclusive: [{ keyId: "v1", reason: "unsampled" }] }),
);
expect(inserts).toEqual([]);
});
});
describe("ENCRYPTION_KEY_CHECK=warn", () => {
it("records nothing for any key id while one is inconclusive", async () => {
configureKeys({ v1: KEY_A, v2: KEY_B }, "v2");
const lone = encryptUnderKeyId("only value", "v1");
configureKeys({ v1: KEY_B, v2: KEY_A }, "v2");
const fake = fakeClient({
probe: { "User.codexAccessTokenEncrypted": [lone] },
});
// Enforce records the proven id beside the inconclusive one.
expect(await fake.check()).toEqual(
ok({
written: ["v2"],
inconclusive: [{ keyId: "v1", reason: "single-value" }],
}),
);
const warn = fakeClient({
probe: { "User.codexAccessTokenEncrypted": [lone] },
});
expect(
await checkEncryptionKeyCanaries(warn.client, {
sampler: warn.sampler,
mode: "warn",
}),
).toEqual(
ok({ inconclusive: [{ keyId: "v1", reason: "single-value" }] }),
);
expect(warn.inserts).toEqual([]);
});
it("records a key that passes for every id, as enforce does", async () => {
const warn = fakeClient({
probe: { "User.codexAccessTokenEncrypted": [encrypt("opens")] },
});
expect(
await checkEncryptionKeyCanaries(warn.client, {
sampler: warn.sampler,
mode: "warn",
}),
).toEqual(ok({ written: ["v1"] }));
});
});
describe("the first-seal race", () => {
it("losing it to a process with another key is a mismatch", async () => {
configureKey(KEY_B);
const otherCanary = encryptUnderKeyId(canaryPlaintext("v1"), "v1");
configureKey(KEY_A);
const { check } = fakeClient({
race: { keyId: "v1", ciphertext: otherCanary },
});
expect(await check()).toEqual({ state: "mismatch", keyIds: ["v1"] });
});
it("losing it to a process with the same key verifies", async () => {
const { check } = fakeClient({
race: {
keyId: "v1",
ciphertext: encryptUnderKeyId(canaryPlaintext("v1"), "v1"),
},
});
expect(await check()).toEqual(ok({ verified: ["v1"] }));
});
});
it("reports an error, not a mismatch, when the table cannot be read", async () => {
const client = {
$queryRaw: async () => {
@@ -202,22 +479,49 @@ describe("encryption key canary", () => {
it("reports an error when no key is configured", async () => {
vi.stubEnv("ENCRYPTION_KEY", "");
_resetCryptoCacheForTests();
const outcome = await checkEncryptionKeyCanaries(fakeClient({}).client);
const outcome = await fakeClient({}).check();
expect(outcome.state).toBe("error");
});
it("names the key ids and never a key in the log block", () => {
it("names the key ids, the reset statement, and never a key in the log block", () => {
const block = keyMismatchLogBlock(["v1"]);
expect(block).toContain("'v1'");
expect(block).toContain("encryption.key_mismatch");
expect(block).toContain(
"DELETE FROM encryption_key_canaries WHERE key_id = 'v1';",
);
expect(block).not.toContain(KEY_A);
});
});
describe("the SQL sampler's column map", () => {
it("resolves every registered column to a table, a column and an order", () => {
// A throwaway client: never connects, only exposes the data model the
// probe reads table and column names from.
const client = new PrismaClient({
adapter: new PrismaPg({
connectionString: "postgres://x:y@127.0.0.1:1/x",
}),
});
for (const column of ENCRYPTED_COLUMNS) {
const loc = resolveColumnLocation(client, column);
expect(loc.table, encryptedColumnKey(column)).toMatch(/^[a-z_0-9]+$/);
expect(loc.column, encryptedColumnKey(column)).toMatch(/^[a-z_0-9]+$/);
if (column.codecField) expect(loc.codec).not.toBeNull();
}
const backupPieces = ENCRYPTED_COLUMNS.find(
(c) => c.model === "DataBackupChunk",
)!;
expect(resolveColumnLocation(client, backupPieces).ts).toBeNull();
const user = ENCRYPTED_COLUMNS.find((c) => c.model === "User")!;
expect(resolveColumnLocation(client, user).ts).toBe("updated_at");
});
});
describe("key fingerprint and copy check", () => {
beforeEach(() => {
vi.unstubAllEnvs();
useKey(KEY_A);
configureKey(KEY_A);
});
it("is the first 12 hex of SHA-256 over the raw key bytes", () => {
+476 -109
View File
@@ -12,44 +12,85 @@
*
* - row present: it must decrypt to the expected value, else the key is not
* the one the row was written with;
* - row absent: before writing one, sample existing ciphertext under that
* key id from the registered string columns and try it. Without this
* probe, the first boot after an upgrade (or a re-keyed boot over a
* database that predates the canary) would seal the wrong key as the
* right one.
* - row absent: before writing one, probe the ciphertext already stored
* under that key id. Without the probe, the first boot after an upgrade
* (or a re-keyed boot over a database that predates the canary) would
* seal the wrong key as the right one, and from then on refuse the
* right key.
*
* The probe samples up to `PROBE_SAMPLE_LIMIT` values, one per column first
* (different columns are different writers, so one bad writer cannot speak
* for the whole database), then further values from columns that had any.
* Its verdict, and what each one does:
* ## The probe
*
* - `opens`: at least one value opened. A wrong key opens nothing, so the
* key is right and any value that failed is a damaged or foreign row, not
* a key problem. The canary is written.
* Every registered column is asked, string and Bytes alike (`ENCRYPTED_COLUMNS`),
* for its OLDEST values under the key id: the `encrypt()` string form
* (`<keyId>.<base64>`, or a bare legacy base64 value for the legacy id `v1`),
* the streamed form (`~hlgcm1.<keyId>.`), the same string stored as UTF-8 in a
* Bytes column, and the binary layout (`0x02 <len> <keyId>`). The answer for a
* column also tells whether it holds ANY value under the id, which is the
* existence check below.
*
* Oldest means the row's `updatedAt`, else its `createdAt`, else (no timestamp
* on the model, e.g. backup pieces) the primary key, ordered after every
* timestamped row. `updatedAt` rather than `createdAt` because a value is
* sealed when its row is written, and a row updated yesterday may carry a
* value sealed yesterday on a row created years ago; `updatedAt` is a ceiling
* on when the value was sealed. The point of the order: a process that served
* under a wrong key without a canary (an inconclusive boot, a version that
* predates this check) wrote rows that open under that wrong key. Those rows
* are by construction newer than the data the database was built with, so
* reading the oldest values first means they are never what decides.
*
* Up to `PROBE_ROWS_PER_COLUMN` values per column are fetched, the merged set
* is ordered by that timestamp, and the oldest `PROBE_SAMPLE_LIMIT` are tried.
* Values larger than `PROBE_VALUE_MAX_BYTES` (stored documents, large backups)
* count as present but are not fetched. The verdict:
*
* - `opens`: the oldest value that opens is preceded by at most ONE older
* value that does not. That one is a damaged or foreign row, not a key
* problem: a wrong key opens nothing. Values newer than the first opening
* one do not count against it; they are what a wrong-key process would
* have left behind. The canary is written.
* - `fails`: two or more values were tried and none opened. That is what a
* wrong key looks like; the process refuses (mismatch).
* - `inconclusive`: exactly one value was found and it did not open. One
* row cannot tell a wrong key from one damaged row, and the two possible
* mistakes are not equal: declaring a mismatch takes a correctly keyed
* server down with 503 on every request, while serving keeps today's
* behaviour. So the process serves, but writes NO canary either (that
* would seal a key that may be wrong), logs a warning, and probes again
* at the next boot, by which time more data may decide it.
* - `none`: nothing found inside the time budget. The canary is written,
* which is exactly the behaviour before this check existed.
* - `none`: every column answered, and none holds a single value under the
* key id. A genuinely fresh install (or a new id added for rotation); the
* canary is written.
* - `inconclusive`, with a reason: anything not proven. Only one value and
* it did not open (`single-value`); two or more of the oldest values do
* not open but a newer one does (`mixed`); values exist but none could be
* fetched (`unsampled`); the time budget ran out or a column could not be
* read (`incomplete`). The process serves, as before this check existed,
* writes NO canary, logs a warning, and probes again at the next boot.
* Serving rather than refusing because a refusal takes a correctly keyed
* server down with 503 on every request; not sealing because that would
* lock the right key out later.
*
* Writes are `ON CONFLICT DO NOTHING`, so two processes racing is fine.
* The first seal can race another process. `ON CONFLICT DO NOTHING` keeps the
* row the other process wrote; when this process's insert affected no row,
* the stored canary is read back and must open under this process's key, or
* the outcome is a mismatch.
*
* Never logs or returns key material. The canary is written by raw SQL and is
* deliberately not in the rotation registry: it belongs to its key id.
* deliberately not in the rotation registry: it belongs to its key id, and the
* rotation script removes it once no ciphertext remains under that id
* (`retireCanariesWithoutData`).
*/
import {
decrypt,
decryptBytes,
decryptStream,
encryptUnderKeyId,
extractKeyId,
extractKeyIdFromBytes,
extractStreamKeyId,
getActiveKeyId,
getConfiguredKeyIds,
isStreamCiphertext,
} from "@/lib/crypto";
import { ENCRYPTED_COLUMNS } from "@/lib/crypto/encrypted-columns";
import {
ENCRYPTED_COLUMNS,
encryptedColumnKey,
type EncryptedColumn,
} from "@/lib/crypto/encrypted-columns";
export const CANARY_PREFIX = "healthlog-canary:";
@@ -57,12 +98,21 @@ export function canaryPlaintext(keyId: string): string {
return `${CANARY_PREFIX}${keyId}`;
}
/** How many existing values the probe tries at most per key id. */
export const PROBE_SAMPLE_LIMIT = 5;
/** How many of the oldest values the probe tries at most per key id. */
export const PROBE_SAMPLE_LIMIT = 8;
/** How long the existing-data probe may take before the canary is written anyway. */
/** How many of its oldest values each column contributes to the pool. */
export const PROBE_ROWS_PER_COLUMN = 2;
/** Values larger than this count as present but are not fetched. */
export const PROBE_VALUE_MAX_BYTES = 2 * 1024 * 1024;
/** How long the probe may take before it gives up as `inconclusive`. */
export const PROBE_BUDGET_MS = 10_000;
/** The key id the legacy bare-base64 string format belongs to. */
const LEGACY_KEY_ID = "v1";
/** The slice of a Prisma client this module needs. */
export interface CanaryClient {
$queryRaw<T = unknown>(
@@ -73,31 +123,53 @@ export interface CanaryClient {
query: TemplateStringsArray,
...values: unknown[]
): Promise<number>;
$queryRawUnsafe?<T = unknown>(
query: string,
...values: unknown[]
): Promise<T>;
$executeRawUnsafe?(query: string, ...values: unknown[]): Promise<number>;
}
type ProbeDelegate = {
findMany: (args: {
where: Record<string, unknown>;
select: Record<string, boolean>;
take: number;
skip?: number;
}) => Promise<Array<Record<string, unknown>>>;
};
/** One stored value under the key id, as the probe sees it. */
export interface ProbeRow {
/** The stored value; null when it exists but is too large to fetch. */
value: string | Uint8Array | null;
/** The row's codec column, for a codec-dispatched column. */
codec?: string | null;
/** The row's write time (see the module comment); null when it has none. */
ts: Date | null;
}
export type ProbeVerdict = "opens" | "fails" | "inconclusive" | "none";
/**
* Reads up to `limit` of the oldest values under `keyId` in one column, oldest
* first. Throws when the column cannot be read; an empty array means the
* column holds no value under that id.
*/
export type ColumnSampler = (
column: EncryptedColumn,
keyId: string,
limit: number,
) => Promise<ProbeRow[]>;
export type InconclusiveReason =
"single-value" | "mixed" | "unsampled" | "incomplete";
export type ProbeResult =
| { verdict: "opens" | "fails" | "none" }
| { verdict: "inconclusive"; reason: InconclusiveReason };
export type KeyCheckOutcome =
| {
state: "ok";
/** Key ids whose canary this run wrote. */
written: string[];
/** Key ids whose canary existed and opened. */
/** Key ids whose canary existed (or another process wrote) and opened. */
verified: string[];
/**
* Key ids with no canary whose only existing value did not open: not a
* mismatch, no canary written, probed again at the next boot.
* Key ids with no canary whose stored data proved nothing either way:
* not a mismatch, no canary written, probed again at the next boot.
*/
inconclusive: string[];
inconclusive: Array<{ keyId: string; reason: InconclusiveReason }>;
}
| { state: "mismatch"; keyIds: string[] }
| { state: "error"; message: string };
@@ -112,77 +184,304 @@ function opensAs(ciphertext: string, keyId: string, expected?: string) {
}
}
/**
* Sample existing values sealed under `keyId` and try them; see the module
* comment for what each verdict means and why.
*/
export async function probeExistingData(
client: unknown,
function isBinaryLayout(column: EncryptedColumn, row: ProbeRow): boolean {
if (column.codec === "binary2") return true;
return column.codecField !== undefined && row.codec === "binary2";
}
/** Does one stored value open under `keyId`? Never throws. */
export function probeValueOpens(
column: EncryptedColumn,
row: ProbeRow,
keyId: string,
deadline: number,
): Promise<ProbeVerdict> {
const delegates = client as Record<string, ProbeDelegate | undefined>;
const columns = ENCRYPTED_COLUMNS.filter(
(c) => c.kind === "string" && !c.codec && !c.codecField,
);
let tried = 0;
const columnsWithData: typeof columns = [];
const sample = async (
column: (typeof columns)[number],
skip: number,
take: number,
): Promise<string[]> => {
const delegate =
delegates[column.model.charAt(0).toLowerCase() + column.model.slice(1)];
if (!delegate || typeof delegate.findMany !== "function") return [];
try {
const rows = await delegate.findMany({
where: { [column.field]: { startsWith: `${keyId}.` } },
select: { [column.field]: true },
take,
...(skip > 0 ? { skip } : {}),
});
return rows
.map((row) => row[column.field])
.filter((v): v is string => typeof v === "string");
} catch {
return [];
): boolean {
const value = row.value;
if (value === null) return false;
try {
if (typeof value === "string") {
if (isStreamCiphertext(value)) {
if (extractStreamKeyId(value) !== keyId) return false;
decryptStream(value);
return true;
}
const id = extractKeyId(value);
if (id === null) {
// Legacy bare base64: only ever the legacy id's.
if (keyId !== LEGACY_KEY_ID) return false;
decrypt(value);
return true;
}
return opensAs(value, keyId);
}
};
// Pass 1: one value per column, so different writers are heard first.
for (const column of columns) {
if (tried >= PROBE_SAMPLE_LIMIT || Date.now() > deadline) break;
const values = await sample(column, 0, 1);
if (values.length === 0) continue;
columnsWithData.push(column);
tried += 1;
if (opensAs(values[0], keyId)) return "opens";
}
// Pass 2: more values from the columns that had any, up to the limit.
for (const column of columnsWithData) {
if (tried >= PROBE_SAMPLE_LIMIT || Date.now() > deadline) break;
const values = await sample(column, 1, PROBE_SAMPLE_LIMIT - tried);
for (const value of values) {
tried += 1;
if (opensAs(value, keyId)) return "opens";
const buf = Buffer.from(value.buffer, value.byteOffset, value.byteLength);
if (isBinaryLayout(column, row)) {
if (extractKeyIdFromBytes(buf) !== keyId) return false;
decryptBytes(buf, column.aad);
return true;
}
return opensAs(buf.toString("utf8"), keyId);
} catch {
return false;
}
if (tried >= 2) return "fails";
if (tried === 1) return "inconclusive";
return "none";
}
/**
* Probe the stored values under `keyId`; see the module comment for what each
* verdict means and why.
*/
export async function probeExistingData(
sampler: ColumnSampler,
keyId: string,
deadline: number,
columns: readonly EncryptedColumn[] = ENCRYPTED_COLUMNS,
): Promise<ProbeResult> {
const pool: Array<{ column: EncryptedColumn; row: ProbeRow; order: number }> =
[];
let present = 0;
for (const column of columns) {
if (Date.now() > deadline) {
return { verdict: "inconclusive", reason: "incomplete" };
}
let rows: ProbeRow[];
try {
rows = await sampler(column, keyId, PROBE_ROWS_PER_COLUMN);
} catch {
return { verdict: "inconclusive", reason: "incomplete" };
}
present += rows.length;
for (const row of rows) {
if (row.value !== null) pool.push({ column, row, order: pool.length });
}
}
if (Date.now() > deadline) {
return { verdict: "inconclusive", reason: "incomplete" };
}
if (present === 0) return { verdict: "none" };
if (pool.length === 0) {
return { verdict: "inconclusive", reason: "unsampled" };
}
// Oldest first; rows without a timestamp after every row with one.
pool.sort((a, b) => {
const ta = a.row.ts?.getTime() ?? Number.POSITIVE_INFINITY;
const tb = b.row.ts?.getTime() ?? Number.POSITIVE_INFINITY;
return ta === tb ? a.order - b.order : ta - tb;
});
const tried = pool.slice(0, PROBE_SAMPLE_LIMIT);
let failedBeforeFirstOpen = 0;
for (const { column, row } of tried) {
if (probeValueOpens(column, row, keyId)) {
return failedBeforeFirstOpen <= 1
? { verdict: "opens" }
: { verdict: "inconclusive", reason: "mixed" };
}
failedBeforeFirstOpen += 1;
}
return tried.length >= 2
? { verdict: "fails" }
: { verdict: "inconclusive", reason: "single-value" };
}
// ─── The SQL sampler ────────────────────────────────────────────────────────
interface ColumnLocation {
table: string;
column: string;
pk: string;
ts: string | null;
codec: string | null;
}
type RuntimeDataModel = {
models: Record<
string,
{
dbName: string | null;
fields: Array<{ name: string; dbName?: string | null }>;
}
>;
};
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;
const KEY_ID = /^[A-Za-z0-9_-]{1,32}$/;
function quoteIdentifier(name: string): string {
if (!IDENTIFIER.test(name)) {
throw new Error(`Unexpected identifier in the column registry: ${name}`);
}
return `"${name}"`;
}
/**
* Table and column names for a registry entry, read from the Prisma client's
* runtime data model (the same `@@map` / `@map` names the client queries
* with). `canary.test.ts` pins that every registry column resolves, so a
* Prisma upgrade that moves this fails a test rather than the probe.
*/
export function resolveColumnLocation(
client: unknown,
column: EncryptedColumn,
): ColumnLocation {
const dm = (client as { _runtimeDataModel?: RuntimeDataModel })
._runtimeDataModel;
const model = dm?.models?.[column.model];
if (!model) {
throw new Error(`No data model for ${column.model}`);
}
const dbField = (name: string): string | null => {
const f = model.fields.find((x) => x.name === name);
return f ? (f.dbName ?? f.name) : null;
};
const columnName = dbField(column.field);
const pk = dbField(column.pkField ?? "id");
if (!columnName || !pk) {
throw new Error(`No column for ${encryptedColumnKey(column)}`);
}
return {
table: model.dbName ?? column.model,
column: columnName,
pk,
ts: dbField("updatedAt") ?? dbField("createdAt"),
codec: column.codecField ? dbField(column.codecField) : null,
};
}
/**
* The SQL that finds values under `keyId` in one column. Identifiers come from
* the Prisma data model and are checked against `IDENTIFIER` before they are
* spliced; the key id is checked against the key-id grammar and travels as a
* bound parameter. The prefix tests use `substr` / `substring`, which read
* only the head of a large out-of-line value instead of the whole of it.
*/
function columnQuery(
loc: ColumnLocation,
column: EncryptedColumn,
keyId: string,
mode: "sample" | "exists",
): { sql: string; params: unknown[] } {
if (!KEY_ID.test(keyId)) throw new Error("Malformed key id");
const c = quoteIdentifier(loc.column);
const params: unknown[] = [];
const bind = (v: unknown) => {
params.push(v);
return `$${params.length}`;
};
const tests: string[] = [];
if (column.kind === "string") {
const versioned = `${keyId}.`;
const streamed = `~hlgcm1.${keyId}.`;
tests.push(
`substr(${c}, 1, ${versioned.length}) = ${bind(versioned)}`,
`substr(${c}, 1, ${streamed.length}) = ${bind(streamed)}`,
);
if (keyId === LEGACY_KEY_ID) {
// A legacy value is bare base64 of at least IV + tag + one block.
tests.push(`substr(${c}, 1, 40) ~ '^[A-Za-z0-9+/]{40}$'`);
}
} else {
const utf8 = Buffer.from(`${keyId}.`, "ascii");
const id = Buffer.from(keyId, "ascii");
const binary = Buffer.concat([Buffer.from([0x02, id.length]), id]);
if (column.codec !== "binary2") {
tests.push(
`substring(${c} from 1 for ${utf8.length}) = ${bind(utf8)}::bytea`,
);
}
if (column.codec === "binary2" || column.codecField) {
tests.push(
`substring(${c} from 1 for ${binary.length}) = ${bind(binary)}::bytea`,
);
}
}
const table = quoteIdentifier(loc.table);
const where = `${c} IS NOT NULL AND (${tests.join(" OR ")})`;
if (mode === "exists") {
return {
sql: `SELECT 1 AS one FROM ${table} WHERE ${where} LIMIT 1`,
params,
};
}
const ts = loc.ts ? quoteIdentifier(loc.ts) : null;
const codec = loc.codec ? quoteIdentifier(loc.codec) : "NULL";
const max = Math.floor(PROBE_VALUE_MAX_BYTES);
const sql =
`SELECT CASE WHEN octet_length(${c}) <= ${max} THEN ${c} END AS value, ` +
`${codec} AS codec, ${ts ?? "NULL::timestamp"} AS ts ` +
`FROM ${table} WHERE ${where} ` +
`ORDER BY ${ts ? `${ts} ASC NULLS LAST, ` : ""}${quoteIdentifier(loc.pk)} ASC ` +
`LIMIT ${bind(0)}`;
return { sql, params };
}
function requireUnsafe(client: CanaryClient) {
const query = client.$queryRawUnsafe;
if (typeof query !== "function") {
throw new Error("The database client cannot run the probe queries");
}
return query.bind(client);
}
/** The production sampler: one bounded query per column. */
export function sqlColumnSampler(client: CanaryClient): ColumnSampler {
const query = requireUnsafe(client);
return async (column, keyId, limit) => {
const loc = resolveColumnLocation(client, column);
const { sql, params } = columnQuery(loc, column, keyId, "sample");
params[params.length - 1] = limit;
const rows = await query<
Array<{
value: string | Uint8Array | null;
codec: string | null;
ts: Date | null;
}>
>(sql, ...params);
return rows.map((r) => ({
value: r.value,
codec: r.codec,
ts: r.ts instanceof Date ? r.ts : r.ts ? new Date(r.ts) : null,
}));
};
}
/**
* Every registered column that still holds a value under `keyId`, by
* `Model.field`. Exhaustive: one existence query per column, no budget.
*/
export async function columnsHoldingKeyId(
client: CanaryClient,
keyId: string,
): Promise<string[]> {
const query = requireUnsafe(client);
const holding: string[] = [];
for (const column of ENCRYPTED_COLUMNS) {
const loc = resolveColumnLocation(client, column);
const { sql, params } = columnQuery(loc, column, keyId, "exists");
const rows = await query<unknown[]>(sql, ...params);
if (rows.length > 0) holding.push(encryptedColumnKey(column));
}
return holding;
}
// ─── The check ──────────────────────────────────────────────────────────────
/**
* Run the check for every configured key id. Never throws: a failure to run
* the check at all is reported as `error`, and only a value that provably does
* not open is reported as `mismatch`.
* the check at all is reported as `error`, and only values that provably do
* not open are reported as `mismatch`.
*/
export async function checkEncryptionKeyCanaries(
client: CanaryClient,
options: { probeBudgetMs?: number } = {},
options: {
probeBudgetMs?: number;
sampler?: ColumnSampler;
/**
* `warn` (`ENCRYPTION_KEY_CHECK=warn`): record nothing unless every key
* id passed. The process serves whatever the outcome, so a key that is
* not proven for every id must not be recorded beside it.
*/
mode?: "enforce" | "warn";
} = {},
): Promise<KeyCheckOutcome> {
let keyIds: string[];
try {
@@ -217,12 +516,21 @@ export async function checkEncryptionKeyCanaries(
const deadline = Date.now() + (options.probeBudgetMs ?? PROBE_BUDGET_MS);
const toWrite: string[] = [];
const inconclusive: string[] = [];
for (const keyId of missing) {
const probe = await probeExistingData(client, keyId, deadline);
if (probe === "fails") mismatched.push(keyId);
else if (probe === "inconclusive") inconclusive.push(keyId);
else toWrite.push(keyId);
const inconclusive: Array<{ keyId: string; reason: InconclusiveReason }> = [];
if (missing.length > 0) {
let sampler: ColumnSampler;
try {
sampler = options.sampler ?? sqlColumnSampler(client);
} catch (err) {
return { state: "error", message: (err as Error).message };
}
for (const keyId of missing) {
const probe = await probeExistingData(sampler, keyId, deadline);
if (probe.verdict === "fails") mismatched.push(keyId);
else if (probe.verdict === "inconclusive") {
inconclusive.push({ keyId, reason: probe.reason });
} else toWrite.push(keyId);
}
}
if (mismatched.length > 0) {
@@ -231,16 +539,63 @@ export async function checkEncryptionKeyCanaries(
return { state: "mismatch", keyIds: mismatched.sort() };
}
const written: string[] = [];
const sealable =
options.mode === "warn" && inconclusive.length > 0 ? [] : toWrite;
try {
for (const keyId of toWrite) {
for (const keyId of sealable) {
const ciphertext = encryptUnderKeyId(canaryPlaintext(keyId), keyId);
await client.$executeRaw`INSERT INTO encryption_key_canaries (key_id, ciphertext) VALUES (${keyId}, ${ciphertext}) ON CONFLICT (key_id) DO NOTHING`;
const inserted =
await client.$executeRaw`INSERT INTO encryption_key_canaries (key_id, ciphertext) VALUES (${keyId}, ${ciphertext}) ON CONFLICT (key_id) DO NOTHING`;
if (inserted > 0) {
written.push(keyId);
continue;
}
// Another process sealed this id first. Its canary has to open under
// this process's key, or one of the two holds the wrong key.
const [winner] = await client.$queryRaw<Array<{ ciphertext: string }>>`
SELECT ciphertext FROM encryption_key_canaries WHERE key_id = ${keyId}`;
if (winner && opensAs(winner.ciphertext, keyId, canaryPlaintext(keyId))) {
verified.push(keyId);
} else {
mismatched.push(keyId);
}
}
} catch (err) {
return { state: "error", message: (err as Error).message };
}
if (mismatched.length > 0) {
return { state: "mismatch", keyIds: mismatched.sort() };
}
return { state: "ok", written: toWrite, verified, inconclusive };
return { state: "ok", written, verified, inconclusive };
}
/**
* After a rotation: remove the canary of every configured key id other than
* the active one that no longer holds a single value in any registered
* column. The id's key can then leave `ENCRYPTION_KEYS`; and if the id is
* ever reused for a different key, no stale canary refuses it.
*/
export async function retireCanariesWithoutData(client: CanaryClient): Promise<{
removed: string[];
remaining: Record<string, string[]>;
}> {
const active = getActiveKeyId();
const removed: string[] = [];
const remaining: Record<string, string[]> = {};
for (const keyId of getConfiguredKeyIds()) {
if (keyId === active) continue;
const holding = await columnsHoldingKeyId(client, keyId);
if (holding.length > 0) {
remaining[keyId] = holding;
continue;
}
const deleted =
await client.$executeRaw`DELETE FROM encryption_key_canaries WHERE key_id = ${keyId}`;
if (deleted > 0) removed.push(keyId);
}
return { removed, remaining };
}
/**
@@ -266,6 +621,18 @@ export function keyMismatchLogBlock(keyIds: string[]): string {
" 3. Start fresh: an empty database with this key. Stored data and",
" in-database backups of the old database cannot be read without",
" the old key.",
"",
" Only if you are CERTAIN the configured key is the one this data was",
" written with (the check recorded a different key earlier), reset the",
" check and restart; it then probes the stored data again:",
...keyIds.map(
(id) => ` DELETE FROM encryption_key_canaries WHERE key_id = '${id}';`,
),
"",
" Last resort, when no record exists yet to reset (a first start) and you",
" are certain the key is right: ENCRYPTION_KEY_CHECK=warn serves anyway",
" and only logs. It disables this safety check; with a wrong key every",
" encrypted value then fails and new rows are written under that key.",
"============================================================",
].join("\n");
}
@@ -1,47 +0,0 @@
import { describe, expect, it } from "vitest";
import { glucoseCardInDisplayUnit } from "../glucose-card";
const card = (over: Record<string, unknown> = {}) => ({
kind: "glucose",
latestValue: 101 as number | null,
secondaryValue: null as number | null,
sparkline: [90, 101, 126],
unit: null as string | null,
unitKey: "dashboard.metric.unit.glucose",
...over,
});
describe("glucoseCardInDisplayUnit", () => {
it("converts the card to mmol/L and names the unit", () => {
expect(glucoseCardInDisplayUnit(card(), "mmol/L")).toMatchObject({
latestValue: 5.6,
secondaryValue: null,
sparkline: [5, 5.6, 7],
unit: "mmol/L",
unitKey: "dashboard.metric.unit.glucose",
});
});
it("keeps mg/dL values and names that unit too", () => {
expect(glucoseCardInDisplayUnit(card(), "mg/dL")).toMatchObject({
latestValue: 101,
sparkline: [90, 101, 126],
unit: "mg/dL",
});
});
it("leaves an empty card empty", () => {
expect(
glucoseCardInDisplayUnit(
card({ latestValue: null, sparkline: [] }),
"mmol/L",
),
).toMatchObject({ latestValue: null, sparkline: [], unit: "mmol/L" });
});
it("passes every other card through untouched", () => {
const weight = card({ kind: "weight", latestValue: 80 });
expect(glucoseCardInDisplayUnit(weight, "mmol/L")).toBe(weight);
});
});
-39
View File
@@ -1,39 +0,0 @@
import { convertGlucose, type GlucoseUnit } from "@/lib/glucose";
/** The fields of a dashboard summary card the glucose unit touches. */
interface GlucoseConvertibleCard {
kind: string;
latestValue: number | null;
secondaryValue: number | null;
sparkline: number[];
unit: string | null;
}
/**
* Put the glucose card in the record owner's display unit.
*
* Glucose is stored in mg/dL. The summary card used to send that number with
* `unit: null` and a `unitKey` that reads "mg/dL", so a reader who chose
* mmol/L was shown 101 "mg/dL" on a client that honours the card. The card now
* carries its value, its secondary value and its sparkline in the chosen unit
* and names that unit in `unit`, the field that wins over `unitKey` (as the
* sleep card's `"h"` already does). Every other card passes through untouched.
*
* Applied after the summary cache is read, so changing the unit takes effect
* on the next request rather than after the cache window.
*/
export function glucoseCardInDisplayUnit<T extends GlucoseConvertibleCard>(
card: T,
unit: GlucoseUnit,
): T {
if (card.kind !== "glucose") return card;
const show = (value: number | null) =>
value === null ? null : convertGlucose(value, unit);
return {
...card,
latestValue: show(card.latestValue),
secondaryValue: show(card.secondaryValue),
sparkline: card.sparkline.map((value) => convertGlucose(value, unit)),
unit,
};
}
+42 -1
View File
@@ -71,7 +71,7 @@ describe("commitApprovedFact — OBSERVATION unit integrity", () => {
expect(prisma.labResult.create).not.toHaveBeenCalled();
});
it("commits when the stated unit matches the marker's unit (case/space-insensitive)", async () => {
it("commits when the stated unit is the marker's unit spelled another way", async () => {
vi.mocked(decryptFactData).mockReturnValue({
label: "Glucose",
value: 95,
@@ -103,4 +103,45 @@ describe("commitApprovedFact — OBSERVATION unit integrity", () => {
expect(takenAt.toISOString()).toBe("2025-01-01T12:00:00.000Z");
expect(userDayKey(takenAt, "America/Los_Angeles")).toBe("2025-01-01");
});
// The comparison is the shared spelling normaliser every other lab write
// path uses, not a case fold. Mutation check: put the lower-cased equality
// back → the first case commits (milli read as mega) and the second refuses
// a µg/L reading the other paths accept.
function observation(unit: string, markerUnit: string) {
vi.mocked(decryptFactData).mockReturnValue({
label: "TSH",
value: 2.1,
unit,
referenceLow: null,
referenceHigh: null,
effectiveDate: "2025-01-01",
} as never);
vi.mocked(resolveOrMintBiomarker).mockResolvedValue({
id: "bm-2",
name: "TSH",
unit: markerUnit,
lowerBound: null,
upperBound: null,
panel: null,
});
vi.mocked(prisma.labResult.create).mockResolvedValue({
id: "lr-2",
} as never);
}
it("refuses MIU/L against a marker in mIU/L: the capital is mega, not milli", async () => {
observation("MIU/L", "mIU/L");
await expect(
commitApprovedFact("user-1", observationFact()),
).rejects.toMatchObject({ code: "observation.unitMismatch" });
expect(prisma.labResult.create).not.toHaveBeenCalled();
});
it("accepts ug/L against a marker in µg/L, as the other lab write paths do", async () => {
observation("ug/L", "µg/L");
await expect(
commitApprovedFact("user-1", observationFact()),
).resolves.toEqual({ recordType: "labResult", recordId: "lr-2" });
});
});
+4 -3
View File
@@ -28,6 +28,7 @@ import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
import { decryptFactData } from "@/lib/documents/store";
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
import { sameLabUnit } from "@/lib/labs/unit-normalise";
import {
isUnreadableRange,
parseReferenceRange,
@@ -134,10 +135,10 @@ async function commitObservation(
// the stated number under the catalog's unit would corrupt the reading AND
// break the "transcribe verbatim" contract — so reject the fact and let the
// user reconcile on the review screen, exactly like the `unitRequired` gate.
// Spelling is compared through the shared normaliser, the same one the
// manual and scan paths use: `ug/L` is `µg/L`, but `MIU/L` is not `mIU/L`.
if (!isQualitative) {
const stated = (data.unit as string).trim().toLowerCase();
const target = biomarker.unit.trim().toLowerCase();
if (stated !== target) {
if (!sameLabUnit(data.unit as string, biomarker.unit)) {
throw new FactCommitError(
"observation.unitMismatch",
`The stated unit (${(data.unit as string).trim()}) does not match the saved unit for ${biomarker.name} (${biomarker.unit || "none"}). Reconcile the unit before saving.`,
+17
View File
@@ -32,6 +32,10 @@ import {
type CorpusClient,
} from "@/lib/crypto/encryption-corpus";
import { getWorkerPrisma } from "@/lib/jobs/reminder/shared";
import {
retireCanariesWithoutData,
type CanaryClient,
} from "@/lib/crypto/canary";
export const ENCRYPTION_KEY_ROTATE_QUEUE = "encryption-key-rotate";
export const ENCRYPTION_KEY_ROTATE_CONCURRENCY = 1;
@@ -62,9 +66,20 @@ export async function runEncryptionKeyRotation(
totalErrors: number;
totalDropped: number;
stoppedEarly: boolean;
/** Key ids whose boot key-check record this run removed. */
retiredKeyIds: string[];
}> {
const prisma = getWorkerPrisma();
const out = await rotateCorpus(prisma as unknown as CorpusClient, shouldStop);
// A complete, error-free pass: remove the boot key check's record of every
// other key id that no longer holds a value in any registered column, so
// its key can leave ENCRYPTION_KEYS and the id can never refuse a later,
// different key. The existence check walks every column itself.
const retiredKeyIds =
!out.stoppedEarly && out.totalErrors === 0
? (await retireCanariesWithoutData(prisma as unknown as CanaryClient))
.removed
: [];
return {
activeKeyId: out.activeKeyId,
totalScanned: out.totalScanned,
@@ -72,6 +87,7 @@ export async function runEncryptionKeyRotation(
totalErrors: out.totalErrors,
totalDropped: out.totalDropped,
stoppedEarly: out.stoppedEarly,
retiredKeyIds,
};
}
@@ -98,6 +114,7 @@ export async function handleEncryptionKeyRotate(
rotated: result.totalRotated,
errors: result.totalErrors,
dropped: result.totalDropped,
retiredKeyIds: result.retiredKeyIds,
},
});
// Per-row errors are the fail-closed skip of a row under a key the
+1 -1
View File
@@ -186,7 +186,7 @@ const dashboardMetricCard = z
.string()
.nullable()
.describe(
"Explicit unit token. When set it wins over `unitKey`. Set on the `sleep` card (`h`, because its value is a per-night total in hours rather than the canonical SLEEP_DURATION minutes) and, since v1.40.0, on the `glucose` card: the record owner's glucose unit (`mg/dL` or `mmol/L`), with `latestValue`, `secondaryValue` and `sparkline` converted to it (mmol/L to one decimal). Null on every other kind.",
"Explicit unit token, set only on the `sleep` card (`h`, because its value is a per-night total in hours rather than the canonical SLEEP_DURATION minutes). Null on every other kind.",
),
sleepStages: z
.record(z.string(), z.number())
@@ -47,12 +47,22 @@ vi.mock("@/lib/db-compat", () => ({
import {
_resetCryptoCacheForTests,
encrypt,
encryptUnderKeyId,
getKeyFingerprint,
} from "@/lib/crypto";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { runBootKeyCheck } from "@/lib/boot/key-check";
import {
canaryPlaintext,
checkEncryptionKeyCanaries,
retireCanariesWithoutData,
type CanaryClient,
} from "@/lib/crypto/canary";
import {
getKeyMismatchWarning,
isKeyMismatch,
setKeyMismatchState,
setKeyMismatchWarning,
} from "@/lib/boot/key-mismatch-state";
const KEY_A =
@@ -223,6 +233,369 @@ describe("boot key check probe over existing data (real Postgres)", () => {
});
});
/** Two users, one oldest, with ciphertext in string AND Bytes columns. */
async function seedUser(
name: string,
writtenAt: Date,
seal: () => { text: string; bytes: Uint8Array<ArrayBuffer> },
) {
const prisma = getPrismaClient();
const a = seal();
const b = seal();
const user = await prisma.user.create({
data: {
username: name,
email: `${name}@example.test`,
codexAccessTokenEncrypted: a.text,
codexRefreshTokenEncrypted: b.text,
createdAt: writtenAt,
updatedAt: writtenAt,
},
});
await prisma.userHealthProfile.create({
data: {
userId: user.id,
aboutMeEncrypted: a.bytes,
conditionsEncrypted: b.bytes,
createdAt: writtenAt,
updatedAt: writtenAt,
},
});
return user.id;
}
const sealNow = () => ({
text: encrypt("a stored value"),
bytes: encryptToBytes("a stored note"),
});
describe("boot key check: what decides, and what may seal (real Postgres)", () => {
it("probes Bytes columns: an install whose ciphertext is only Bytes does not seal a wrong key", async () => {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: { username: "bytes-only", email: "bytes-only@example.test" },
});
await prisma.userHealthProfile.create({
data: {
userId: user.id,
aboutMeEncrypted: encryptToBytes("about me"),
conditionsEncrypted: encryptToBytes("conditions"),
allergiesEncrypted: encryptToBytes("allergies"),
},
});
useKey(KEY_B);
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
expect(await runBootKeyCheck(prisma)).toBe(true);
errors.mockRestore();
expect(await prisma.encryptionKeyCanary.count()).toBe(0);
useKey(KEY_A);
expect(await runBootKeyCheck(prisma)).toBe(false);
expect(await prisma.encryptionKeyCanary.count()).toBe(1);
});
it("a probe that runs out of time seals nothing and keeps serving", async () => {
const prisma = getPrismaClient();
await seedUser("slow", new Date("2024-01-01T00:00:00Z"), sealNow);
const outcome = await checkEncryptionKeyCanaries(prisma, {
probeBudgetMs: -1,
});
expect(outcome.state).toBe("ok");
if (outcome.state !== "ok") return;
expect(outcome.written).toEqual([]);
expect(outcome.inconclusive.map((i) => i.keyId)).toEqual(["v1"]);
expect(await prisma.encryptionKeyCanary.count()).toBe(0);
});
it("a fresh database with no ciphertext under the key id seals it", async () => {
const prisma = getPrismaClient();
// Ciphertext exists, but under another key id: not evidence about v1.
vi.stubEnv("ENCRYPTION_KEYS", JSON.stringify({ v1: KEY_A, v2: KEY_B }));
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "v2");
vi.stubEnv("ENCRYPTION_KEY", "");
_resetCryptoCacheForTests();
await seedUser("v2-only", new Date("2024-01-01T00:00:00Z"), sealNow);
expect(await runBootKeyCheck(prisma)).toBe(false);
const ids = (await prisma.encryptionKeyCanary.findMany())
.map((r) => r.keyId)
.sort();
expect(ids).toEqual(["v1", "v2"]);
});
it("rows a wrong key wrote recently never decide: old rows under A, newer under B", async () => {
const prisma = getPrismaClient();
// The original data, under A.
await seedUser("original", new Date("2024-01-01T00:00:00Z"), sealNow);
await seedUser("original-2", new Date("2024-02-01T00:00:00Z"), sealNow);
// A process holding B served for a while (no canary yet) and wrote more.
useKey(KEY_B);
for (let i = 0; i < 6; i++) {
await seedUser(
`recent-${i}`,
new Date(Date.UTC(2026, 8, 1 + i)),
sealNow,
);
}
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
await runBootKeyCheck(prisma);
warn.mockRestore();
errors.mockRestore();
// B is not sealed as the right key, whatever else the verdict is.
expect(await prisma.encryptionKeyCanary.count()).toBe(0);
const outcome = await checkEncryptionKeyCanaries(prisma);
expect(
outcome.state === "mismatch" ||
(outcome.state === "ok" && outcome.written.length === 0),
).toBe(true);
// The key the oldest data was written with is the right one.
useKey(KEY_A);
setKeyMismatchState(null);
expect(await runBootKeyCheck(prisma)).toBe(false);
expect(isKeyMismatch()).toBe(false);
expect(await prisma.encryptionKeyCanary.count()).toBe(1);
});
it("losing the first-seal race to another key is a mismatch, not ok", async () => {
const prisma = getPrismaClient();
// The other process, holding A, wins the insert between our probe and
// our write.
const sealedUnderA = encryptUnderKeyId(canaryPlaintext("v1"), "v1");
useKey(KEY_B);
let raced = false;
const racing = new Proxy(prisma, {
get(target, prop, receiver) {
if (prop === "$executeRaw") {
return async (q: TemplateStringsArray, ...values: unknown[]) => {
if (!raced) {
raced = true;
await target.$executeRaw`INSERT INTO encryption_key_canaries (key_id, ciphertext) VALUES ('v1', ${sealedUnderA})`;
}
return target.$executeRaw(q, ...values);
};
}
const value = Reflect.get(target, prop, receiver);
return typeof value === "function" ? value.bind(target) : value;
},
}) as unknown as CanaryClient;
const outcome = await checkEncryptionKeyCanaries(racing);
expect(raced).toBe(true);
expect(outcome).toEqual({ state: "mismatch", keyIds: ["v1"] });
// The same race lost to a process holding the SAME key is fine.
await prisma.$executeRaw`DELETE FROM encryption_key_canaries`;
raced = false;
useKey(KEY_A);
const same = await checkEncryptionKeyCanaries(racing);
expect(same.state).toBe("ok");
});
});
describe("ENCRYPTION_KEY_CHECK (real Postgres)", () => {
afterEach(() => setKeyMismatchWarning(null));
it("warn: a mismatch is logged and reported, never refused, never recorded", async () => {
const prisma = getPrismaClient();
await seedUser("warned", new Date("2024-01-01T00:00:00Z"), sealNow);
useKey(KEY_B);
vi.stubEnv("ENCRYPTION_KEY_CHECK", "warn");
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
expect(await runBootKeyCheck(prisma)).toBe(false);
const logged = errors.mock.calls.map((c) => String(c[0])).join("\n");
errors.mockRestore();
expect(logged).toContain("encryption.key_mismatch");
expect(logged).toContain("ENCRYPTION_KEY_CHECK=warn");
expect(isKeyMismatch()).toBe(false);
expect(getKeyMismatchWarning()?.keyIds).toEqual(["v1"]);
expect(await prisma.encryptionKeyCanary.count()).toBe(0);
// Requests are served, through apiHandler and outside it.
await seedAdmin();
const { GET: keyBackupGet } =
await import("@/app/api/admin/encryption/key-backup/route");
const served = await asRoute(keyBackupGet)(
new NextRequest("http://localhost/api/admin/encryption/key-backup"),
);
expect(served.status).toBe(200);
const { POST: mcp } = await import("@/app/mcp/route");
const mcpRes = await mcp(
new Request("http://localhost/mcp", { method: "POST", body: "{}" }),
);
expect(mcpRes.status).not.toBe(503);
const { GET: healthGet } = await import("@/app/api/health/route");
const healthBody = await (
await asRoute(healthGet)(new NextRequest("http://localhost/api/health"))
).json();
expect(healthBody.warning).toBe("encryption_key_mismatch");
expect(healthBody.reason).toBeUndefined();
});
it("warn: nothing is recorded while any key id is inconclusive", async () => {
const prisma = getPrismaClient();
vi.stubEnv("ENCRYPTION_KEYS", JSON.stringify({ v1: KEY_A, v2: KEY_B }));
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "v1");
vi.stubEnv("ENCRYPTION_KEY", "");
_resetCryptoCacheForTests();
await prisma.user.create({
data: {
username: "lone",
email: "lone@example.test",
codexAccessTokenEncrypted: encrypt("the only value"),
},
});
// v1 now holds a different key: its one value does not open. v2 has no
// data, which on its own would be recorded.
vi.stubEnv("ENCRYPTION_KEYS", JSON.stringify({ v1: KEY_B, v2: KEY_A }));
_resetCryptoCacheForTests();
vi.stubEnv("ENCRYPTION_KEY_CHECK", "warn");
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(await runBootKeyCheck(prisma)).toBe(false);
warn.mockRestore();
expect(await prisma.encryptionKeyCanary.count()).toBe(0);
// Enforce records the proven id, as before.
vi.stubEnv("ENCRYPTION_KEY_CHECK", "enforce");
const warn2 = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(await runBootKeyCheck(prisma)).toBe(false);
warn2.mockRestore();
const ids = (await prisma.encryptionKeyCanary.findMany()).map(
(r) => r.keyId,
);
expect(ids).toEqual(["v2"]);
});
it("enforce, set explicitly or by any other value, refuses as before", async () => {
const prisma = getPrismaClient();
await seedUser("enforced", new Date("2024-01-01T00:00:00Z"), sealNow);
useKey(KEY_B);
for (const value of ["enforce", "", "off"]) {
vi.stubEnv("ENCRYPTION_KEY_CHECK", value);
setKeyMismatchState(null);
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
expect(await runBootKeyCheck(prisma)).toBe(true);
errors.mockRestore();
expect(isKeyMismatch()).toBe(true);
expect(getKeyMismatchWarning()).toBeNull();
}
expect(await prisma.encryptionKeyCanary.count()).toBe(0);
});
});
describe("retiring a key id after rotation (real Postgres)", () => {
it("removes the canary of a key id only once no ciphertext remains under it", async () => {
const prisma = getPrismaClient();
vi.stubEnv("ENCRYPTION_KEYS", JSON.stringify({ v1: KEY_A, v2: KEY_B }));
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "v1");
vi.stubEnv("ENCRYPTION_KEY", "");
_resetCryptoCacheForTests();
const userId = await seedUser(
"rotating",
new Date("2024-01-01T00:00:00Z"),
sealNow,
);
expect(await runBootKeyCheck(prisma)).toBe(false);
expect(await prisma.encryptionKeyCanary.count()).toBe(2);
// Rotation to v2 has not finished: one Bytes value still under v1.
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "v2");
_resetCryptoCacheForTests();
await prisma.user.update({
where: { id: userId },
data: {
codexAccessTokenEncrypted: encrypt("rotated"),
codexRefreshTokenEncrypted: encrypt("rotated"),
},
});
await prisma.userHealthProfile.update({
where: { userId },
data: { aboutMeEncrypted: encryptToBytes("rotated") },
});
const partial = await retireCanariesWithoutData(prisma);
expect(partial.removed).toEqual([]);
expect(partial.remaining).toEqual({
v1: ["UserHealthProfile.conditionsEncrypted"],
});
expect(await prisma.encryptionKeyCanary.count()).toBe(2);
await prisma.userHealthProfile.update({
where: { userId },
data: { conditionsEncrypted: encryptToBytes("rotated") },
});
const done = await retireCanariesWithoutData(prisma);
expect(done.removed).toEqual(["v1"]);
const left = await prisma.encryptionKeyCanary.findMany();
expect(left.map((r) => r.keyId)).toEqual(["v2"]);
});
});
describe("routes outside apiHandler refuse while the key does not match", () => {
beforeEach(() => {
setKeyMismatchState({ keyIds: ["v1"], detectedAt: "2026-10-03T00:00:00Z" });
});
it("/mcp answers 503 encryption.key_mismatch before auth", async () => {
const { POST } = await import("@/app/mcp/route");
const res = await POST(
new Request("http://localhost/mcp", {
method: "POST",
headers: {
"content-type": "application/json",
authorization: "Bearer hlk_whatever",
},
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
}),
);
expect(res.status).toBe(503);
expect((await res.json()).meta).toEqual({
errorCode: "encryption.key_mismatch",
});
});
it("the MCP OAuth endpoints refuse in their RFC shape", async () => {
const { POST: token } = await import("@/app/api/mcp/oauth/token/route");
const { POST: register } =
await import("@/app/api/mcp/oauth/register/route");
const { GET: authorizeGet, POST: authorizePost } =
await import("@/app/api/mcp/oauth/authorize/route");
const responses = [
await token(
new NextRequest("http://localhost/api/mcp/oauth/token", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: "grant_type=authorization_code&code=x",
}),
),
await register(
post("/api/mcp/oauth/register", {
redirect_uris: ["https://client.example/cb"],
}),
),
await authorizeGet(
new NextRequest(
"http://localhost/api/mcp/oauth/authorize?response_type=code",
),
),
await authorizePost(
new NextRequest("http://localhost/api/mcp/oauth/authorize", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: "decision=approve",
}),
),
];
for (const res of responses) {
expect(res.status).toBe(503);
const body = await res.json();
expect(body.error).toBe("temporarily_unavailable");
expect(body.error_description).toContain("encryption key");
}
});
});
describe("encryption key backup step (real Postgres)", () => {
it("is due, refuses a stale confirmation, records the right one, and is due again after a re-key", async () => {
const prisma = getPrismaClient();
@@ -11,6 +11,9 @@
* - a second submit of the same form is a `duplicate`, not a second row;
* - a submit after an edit is `updated` and the one row carries the edit,
* while a synced id with changed values stays first-write-wins;
* - an edit drops the paragraph written about the old values, lets go of
* its generation claim without freeing the day's slot, and raises the
* workout's arrival again so the paragraph is written from the new ones;
* - delete removes a hand-entered workout and refuses a synced one.
*/
import { NextRequest } from "next/server";
@@ -45,6 +48,11 @@ vi.mock("next/headers", async () => {
};
});
// The arrival seam is observed, not run: the edit test asserts the route
// raises one for the edited workout; what the spine then does is its own suite.
const emitDataArrival = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("@/lib/arrivals/emit-shared", () => ({ emitDataArrival }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
@@ -216,6 +224,97 @@ describe("a workout entered by hand", () => {
]);
});
it("drops the paragraph about the old values when an edit lands", async () => {
const prisma = getPrismaClient();
const entry = formEntry(newManualWorkoutExternalId());
await post({ workouts: [entry] });
const stored = await prisma.workout.findFirstOrThrow({
where: { userId: TEST_USER_ID },
});
// What the worker leaves behind once it wrote the paragraph.
const claim = await prisma.workoutInsightGenerationClaim.create({
data: {
userId: TEST_USER_ID,
workoutId: stored.id,
localDate: "2026-10-03",
providerInvokedAt: new Date(),
completedAt: new Date(),
},
});
await prisma.workoutInsight.create({
data: {
userId: TEST_USER_ID,
workoutId: stored.id,
paragraphEncrypted: Buffer.from("ciphertext-stand-in", "utf8"),
inputHash: "hash-of-the-old-values",
promptVersion: "1.0.0",
providerType: "local",
locale: "en",
generatedAt: new Date(),
},
});
emitDataArrival.mockClear();
// A run corrected from 8 km to 1.2 km.
const res = await post({
workouts: [{ ...entry, totalDistanceM: 1200 }],
});
expect((await res.json()).data.entries).toEqual([
{ index: 0, status: "updated" },
]);
expect(
await prisma.workoutInsight.count({ where: { workoutId: stored.id } }),
).toBe(0);
// The claim stays as the day's ledger, detached from the workout, so the
// edit frees no slot and a fresh claim for the workout is possible.
const ledger = await prisma.workoutInsightGenerationClaim.findUniqueOrThrow(
{ where: { id: claim.id } },
);
expect(ledger.workoutId).toBeNull();
expect(ledger.providerInvokedAt).not.toBeNull();
await vi.waitFor(() =>
expect(emitDataArrival).toHaveBeenCalledWith(
expect.objectContaining({
userId: TEST_USER_ID,
kind: "workout",
refId: stored.id,
}),
),
);
});
it("keeps the paragraph when the same form is sent again unchanged", async () => {
const prisma = getPrismaClient();
const entry = formEntry(newManualWorkoutExternalId());
await post({ workouts: [entry] });
const stored = await prisma.workout.findFirstOrThrow({
where: { userId: TEST_USER_ID },
});
await prisma.workoutInsight.create({
data: {
userId: TEST_USER_ID,
workoutId: stored.id,
paragraphEncrypted: Buffer.from("ciphertext-stand-in", "utf8"),
inputHash: "hash-of-the-values",
promptVersion: "1.0.0",
providerType: "local",
locale: "en",
generatedAt: new Date(),
},
});
emitDataArrival.mockClear();
const res = await post({ workouts: [entry] });
expect((await res.json()).data.entries).toEqual([
{ index: 0, status: "duplicate" },
]);
expect(
await prisma.workoutInsight.count({ where: { workoutId: stored.id } }),
).toBe(1);
expect(emitDataArrival).not.toHaveBeenCalled();
});
it("keeps a synced workout first-write-wins under the same resend", async () => {
const prisma = getPrismaClient();
const entry = {