mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Merge branch 'fix/encrypt-free-text-and-local-ai' into release/v1.39.3
# Conflicts: # src/app/api/import/csv/route.ts
This commit is contained in:
commit
66b1aef40c
69 files changed
+3625
-367
No files matched your search
+9
-4
@@ -125,10 +125,15 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
|
||||
# Users bring their own API keys via Settings. No server-side key needed by
|
||||
# default. The admin can configure a fallback key in the Admin Panel.
|
||||
|
||||
# Local-AI SSRF opt-in (optional). The local AI provider rejects private/
|
||||
# internal hosts by default. Set to an exact host allowlist (comma-separated,
|
||||
# e.g. "ollama.lan,10.0.0.5") or "true" for any private host — intended for a
|
||||
# self-hosted Ollama / LM Studio endpoint. See docs/integrations/ai-providers.md.
|
||||
# AI endpoints on your own network (optional). An AI base URL on a private
|
||||
# address is refused unless its exact origin is listed here, comma-separated
|
||||
# (e.g. "http://ollama.lan:11434,http://10.0.0.5:4000") — for a self-hosted
|
||||
# Ollama, LM Studio or LiteLLM gateway. Link-local, metadata and unspecified
|
||||
# addresses stay refused even when listed. See docs/integrations/ai-providers.md.
|
||||
# AI_PRIVATE_ORIGINS=""
|
||||
# Older form, kept for existing installs: an exact host list still works
|
||||
# (e.g. "ollama.lan,10.0.0.5"). "true" is deprecated: it now covers only the
|
||||
# admin AI key and admin accounts' own AI settings, and will be removed.
|
||||
# ALLOW_LOCAL_AI_PRIVATE_HOSTS=""
|
||||
|
||||
# Daily-briefing output-token ceiling (optional). Default 2500, clamped to
|
||||
|
||||
@@ -281,10 +281,15 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# docs/ops/tls-cert-pin.md for extraction + the re-pin runbook.
|
||||
# TLS_LEAF_SPKI_PINS=""
|
||||
|
||||
# Local-AI SSRF opt-in (optional). The local AI provider rejects private/
|
||||
# internal hosts by default. Set to an exact host allowlist (comma-separated,
|
||||
# e.g. "ollama.lan,10.0.0.5") or "true" for any private host — intended for a
|
||||
# self-hosted Ollama / LM Studio endpoint. See docs/integrations/ai-providers.md.
|
||||
# AI endpoints on your own network (optional). An AI base URL on a private
|
||||
# address is refused unless its exact origin is listed here, comma-separated
|
||||
# (e.g. "http://ollama.lan:11434,http://10.0.0.5:4000") — for a self-hosted
|
||||
# Ollama, LM Studio or LiteLLM gateway. Link-local, metadata and unspecified
|
||||
# addresses stay refused even when listed. See docs/integrations/ai-providers.md.
|
||||
# AI_PRIVATE_ORIGINS=""
|
||||
# Older form, kept for existing installs: an exact host list still works
|
||||
# (e.g. "ollama.lan,10.0.0.5"). "true" is deprecated: it now covers only the
|
||||
# admin AI key and admin accounts' own AI settings, and will be removed.
|
||||
# ALLOW_LOCAL_AI_PRIVATE_HOSTS=""
|
||||
|
||||
# Daily-briefing output-token ceiling (optional). Default 2500, clamped to
|
||||
|
||||
+11
-6
@@ -269,12 +269,17 @@ x-healthlog-env: &healthlog_env
|
||||
# container (the `environment:` block is a whitelist). See
|
||||
# docs/ops/tls-cert-pin.md.
|
||||
TLS_LEAF_SPKI_PINS: "${TLS_LEAF_SPKI_PINS:-}"
|
||||
# Local-AI SSRF opt-in (optional). The local AI provider rejects
|
||||
# private/internal hosts by default; set this to an exact host allowlist
|
||||
# (e.g. "ollama.lan,10.0.0.5") or "true" for any private host, intended
|
||||
# for a self-hosted Ollama / LM Studio. Listed here so an operator value
|
||||
# in .env reaches the container (the `environment:` block is a whitelist).
|
||||
# See docs/integrations/ai-providers.md.
|
||||
# AI endpoints on your own network (optional): a self-hosted Ollama,
|
||||
# LM Studio or LiteLLM gateway, comma-separated exact origins (e.g.
|
||||
# "http://ollama.lan:11434,http://10.0.0.5:4000"). Without it an AI base
|
||||
# URL on a private address is refused. Link-local, metadata and
|
||||
# unspecified addresses stay refused even when listed. See
|
||||
# docs/integrations/ai-providers.md.
|
||||
AI_PRIVATE_ORIGINS: "${AI_PRIVATE_ORIGINS:-}"
|
||||
# Older form of the above, kept for existing installs: an exact host list
|
||||
# (e.g. "ollama.lan,10.0.0.5") still works. "true" is deprecated: it now
|
||||
# covers only the admin AI key and admin accounts' own AI settings, and
|
||||
# will be removed in a later release; move to AI_PRIVATE_ORIGINS.
|
||||
ALLOW_LOCAL_AI_PRIVATE_HOSTS: "${ALLOW_LOCAL_AI_PRIVATE_HOSTS:-}"
|
||||
# Extra hosts the admin-configured AI base URL may point at, on top of the
|
||||
# built-in public providers. Comma-separated bare hostnames. Set it when
|
||||
|
||||
+21
-5
@@ -3429,7 +3429,13 @@ paths:
|
||||
"302":
|
||||
description: Always a redirect. To the provider on success. On failure to `/auth/login?error=<reason>` (browser) or
|
||||
`healthlog://oidc-callback?error=<reason>` (native), where reason is one of `oidc_disabled`,
|
||||
`oidc_rate_limited`, `oidc_invalid_request`, `oidc_failed`.
|
||||
`oidc_rate_limited`, `oidc_invalid_request`, `oidc_failed`. The provider then returns to the callback (not
|
||||
listed here, because the identity provider drives it rather than a client), which ends the native leg at
|
||||
`healthlog://oidc-callback` with `code=<hlh_…>`, with `mfa_ticket=<ticket>&methods=<list>`, or with
|
||||
`error=<reason>`, where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_denied`,
|
||||
`oidc_no_email`, `oidc_email_unverified`, `oidc_identity_conflict`, `oidc_registration_disabled`,
|
||||
`oidc_failed`. The reasons are this exact snake_case vocabulary on both legs; a client maps a value it does
|
||||
not know to a generic sign-in failure.
|
||||
/api/auth/passkey/register-options:
|
||||
post:
|
||||
tags:
|
||||
@@ -13215,9 +13221,12 @@ paths:
|
||||
before 1900). Glucose accepts `mmol/L` (converted to canonical `mg/dL`); weight accepts `lb` (converted to
|
||||
`kg`). `glucoseContext` is optional on a `BLOOD_GLUCOSE` row — a blank cell stores no context, which is the
|
||||
normal shape of a continuous-sensor export; a non-empty value is still checked against the enum, and the column
|
||||
is refused on any other type. An `externalId` column makes re-upload idempotent (upsert on `(userId, type,
|
||||
source=IMPORT, externalId)`); without it a re-upload duplicates. `?dryRun=1` validates + previews without
|
||||
writing. Shares the 5/hour `import:` rate bucket."
|
||||
is refused on any other type. Re-uploading a file is idempotent. With an `externalId` column a row upserts on
|
||||
`(userId, type, source=IMPORT, externalId)`: a changed row is `updated`, an unchanged one is `skipped` with
|
||||
reason `duplicate` and not written. Without it a row is deduplicated on `(type, measuredAt)` and a repeat is
|
||||
`skipped` / `duplicate`. Writes commit in chunks rather than as one transaction; a failure part-way leaves
|
||||
earlier chunks written, and re-uploading the same file completes the rest. `?dryRun=1` validates + previews
|
||||
without writing. Shares the 5/hour `import:` rate bucket."
|
||||
parameters:
|
||||
- name: dryRun
|
||||
in: query
|
||||
@@ -36407,6 +36416,11 @@ components:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
noteEncrypted:
|
||||
anyOf:
|
||||
- type: string
|
||||
minLength: 1
|
||||
- type: "null"
|
||||
createdAt:
|
||||
type: string
|
||||
minLength: 1
|
||||
@@ -37084,6 +37098,9 @@ components:
|
||||
minLength: 1
|
||||
title:
|
||||
type: string
|
||||
titleEncrypted:
|
||||
type: string
|
||||
minLength: 1
|
||||
documentScoped:
|
||||
type: boolean
|
||||
summaryEncrypted:
|
||||
@@ -37175,7 +37192,6 @@ components:
|
||||
additionalProperties: {}
|
||||
required:
|
||||
- id
|
||||
- title
|
||||
- documentScoped
|
||||
- createdAt
|
||||
- updatedAt
|
||||
|
||||
@@ -140,16 +140,35 @@ Base URL `http://<vllm-host>:8000/v1`, API key `your-shared-secret`,
|
||||
model `Qwen/Qwen2.5-14B-Instruct`. The local-client encrypts the
|
||||
API key at rest like every other provider credential.
|
||||
|
||||
**SSRF guard.** Local endpoints route through the same validation
|
||||
as every other outbound URL HealthLog hits. The guard rejects
|
||||
private-IP-range targets by default. `ALLOW_LOCAL_AI_PRIVATE_HOSTS`
|
||||
opens the escape hatch and accepts two forms: a comma-separated host
|
||||
allowlist (e.g. `ALLOW_LOCAL_AI_PRIVATE_HOSTS=ollama.lan,10.0.0.5`),
|
||||
which permits only those exact hostnames, or the legacy
|
||||
`ALLOW_LOCAL_AI_PRIVATE_HOSTS=true`, which permits any private host —
|
||||
including the cloud-metadata endpoint, so prefer the host list. Leave
|
||||
it unset on public-facing instances unless you specifically run an
|
||||
internal endpoint.
|
||||
**Private endpoints.** Any user of the instance can type a base URL,
|
||||
so HealthLog refuses one on a private address unless the operator has
|
||||
listed it. List the endpoint's exact origin in `AI_PRIVATE_ORIGINS`,
|
||||
comma-separated, the same way `NOTIFICATION_PRIVATE_ORIGINS` works for
|
||||
notification relays:
|
||||
|
||||
```bash
|
||||
AI_PRIVATE_ORIGINS="http://ollama.lan:11434,http://10.0.0.5:4000"
|
||||
```
|
||||
|
||||
An origin is `scheme://host[:port]` and nothing else: no path, no
|
||||
wildcard, no range. A listed endpoint is called through a pinned
|
||||
connection that refuses to follow redirects, and the cloud-metadata
|
||||
address, link-local addresses and the unspecified address stay
|
||||
unreachable even when listed or when a listed name resolves to one.
|
||||
|
||||
`ALLOW_LOCAL_AI_PRIVATE_HOSTS`, the older setting, still works as a
|
||||
host list (`ollama.lan,10.0.0.5`, any port), with the same pinning.
|
||||
|
||||
Its `true` form used to open every private host on the network to
|
||||
every user. It is deprecated since v1.39.3 and will be removed in a
|
||||
later release. Until then it still covers the operator's own AI
|
||||
configurations, the server key in the admin panel and AI settings
|
||||
saved on an admin account, over the same pinned connection, so the
|
||||
metadata address and link-local addresses stay unreachable. Any other
|
||||
account's base URL needs its origin in `AI_PRIVATE_ORIGINS`. The boot
|
||||
summary names the setting while it is set, and the admin AI settings
|
||||
page shows the exact `AI_PRIVATE_ORIGINS` line for the endpoints saved
|
||||
on the instance.
|
||||
|
||||
**Model sizing.** Roughly: 7-8B for 8 GB GPU / Apple Silicon base
|
||||
(briefings fine, deeper Coach reasoning gets terse), 14-24B for
|
||||
@@ -186,9 +205,9 @@ therefore never be sent to a host you typed into a different form,
|
||||
and that property is structural rather than a comment — the OPENAI
|
||||
arm has no code path that reads the gateway's base URL.
|
||||
|
||||
**SSRF guard.** The gateway's base URL passes the same floor as the
|
||||
Local provider's: a public host always, a private one only when the
|
||||
operator allowlisted it via `ALLOW_LOCAL_AI_PRIVATE_HOSTS` (documented
|
||||
**Private endpoints.** The gateway's base URL passes the same floor
|
||||
as the Local provider's: a public host always, a private one only when
|
||||
the operator listed its origin in `AI_PRIVATE_ORIGINS` (documented
|
||||
under Local endpoints above). LAN gateways are the normal case for LiteLLM and vLLM, so
|
||||
the escape hatch matters here as much as it does for Ollama.
|
||||
|
||||
@@ -439,12 +458,12 @@ upstream error.
|
||||
- **"No AI provider configured."** Either set up at least one BYOK
|
||||
provider in `/settings/ai`, or configure an admin-shared OpenAI
|
||||
key in the admin panel.
|
||||
- **Local endpoint rejected as "internal/private host".** Set
|
||||
`ALLOW_LOCAL_AI_PRIVATE_HOSTS` to the endpoint's exact hostname
|
||||
(e.g. `ollama.lan`; comma-separate several) in the container
|
||||
environment, or to `true` to allow any private host. Prefer the host
|
||||
list; leave it unset on internet-facing instances unless you
|
||||
specifically want to allow private-range targets.
|
||||
- **Local endpoint rejected as "internal/private host".** Add the
|
||||
endpoint's exact origin to `AI_PRIVATE_ORIGINS` in the container
|
||||
environment (e.g. `http://ollama.lan:11434`; comma-separate several)
|
||||
and restart. With `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` an admin
|
||||
account's endpoint still works, but no other account's does; the
|
||||
admin AI settings page lists the origins to add.
|
||||
- **Codex disconnects after a few weeks of inactivity.** ChatGPT
|
||||
refresh tokens lapse after extended idle periods. Reconnect via
|
||||
the **Connect ChatGPT** button — HealthLog re-runs the device-code
|
||||
|
||||
+5
-1
@@ -6792,7 +6792,11 @@
|
||||
"removeBody": "Konten ohne eigenen Schlüssel verlieren die KI-Funktionen sofort. Der Schlüssel wird nach dem Speichern nirgends mehr angezeigt — zum Wiederherstellen brauchst du das Original.",
|
||||
"removeConfirm": "Schlüssel entfernen",
|
||||
"saved": "KI-Server-Schlüssel gespeichert",
|
||||
"error": "Speichern des KI-Server-Schlüssels fehlgeschlagen"
|
||||
"error": "Speichern des KI-Server-Schlüssels fehlgeschlagen",
|
||||
"legacyGrantTitle": "ALLOW_LOCAL_AI_PRIVATE_HOSTS=true ist veraltet",
|
||||
"legacyGrantBody": "Die Einstellung erlaubt dem Server-Schlüssel oben und KI-Einstellungen, die in einem Admin-Konto gespeichert sind, weiterhin Zugriff auf dein Netzwerk und entfällt in einer späteren Version. Andere Konten brauchen ihren KI-Endpunkt schon jetzt in AI_PRIVATE_ORIGINS. Setze AI_PRIVATE_ORIGINS in der Server-Umgebung, starte neu und entferne dann ALLOW_LOCAL_AI_PRIVATE_HOSTS.",
|
||||
"legacyGrantOrigins": "Diese auf dieser Instanz gespeicherten KI-Adressen zeigen in dein Netzwerk:",
|
||||
"legacyGrantNoOrigins": "Keine auf dieser Instanz gespeicherte KI-Adresse zeigt in dein Netzwerk, du kannst ALLOW_LOCAL_AI_PRIVATE_HOSTS also jetzt entfernen."
|
||||
},
|
||||
"providerHealth": {
|
||||
"title": "Zustand der KI-Provider",
|
||||
|
||||
+5
-1
@@ -6792,7 +6792,11 @@
|
||||
"removeBody": "Accounts without their own key lose AI features straight away. The key is not shown anywhere after it is stored, so you will need the original to put it back.",
|
||||
"removeConfirm": "Remove key",
|
||||
"saved": "AI server key settings saved",
|
||||
"error": "Saving the AI server key failed"
|
||||
"error": "Saving the AI server key failed",
|
||||
"legacyGrantTitle": "ALLOW_LOCAL_AI_PRIVATE_HOSTS=true is deprecated",
|
||||
"legacyGrantBody": "It still lets the server key above and AI settings saved on an admin account reach your network, and will be removed in a later release. Other accounts need their AI endpoint listed in AI_PRIVATE_ORIGINS already. Set AI_PRIVATE_ORIGINS in the server environment, restart, then remove ALLOW_LOCAL_AI_PRIVATE_HOSTS.",
|
||||
"legacyGrantOrigins": "The AI base URLs saved on this instance that point at your network:",
|
||||
"legacyGrantNoOrigins": "No AI base URL saved on this instance points at your network, so you can remove ALLOW_LOCAL_AI_PRIVATE_HOSTS now."
|
||||
},
|
||||
"providerHealth": {
|
||||
"title": "AI provider health",
|
||||
|
||||
+5
-1
@@ -6792,7 +6792,11 @@
|
||||
"removeBody": "Las cuentas sin clave propia pierden las funciones de IA de inmediato. La clave no se muestra en ningún sitio una vez guardada, así que necesitarás el original para volver a ponerla.",
|
||||
"removeConfirm": "Eliminar clave",
|
||||
"saved": "Ajustes de la clave de IA del servidor guardados",
|
||||
"error": "No se pudo guardar la clave de IA del servidor"
|
||||
"error": "No se pudo guardar la clave de IA del servidor",
|
||||
"legacyGrantTitle": "ALLOW_LOCAL_AI_PRIVATE_HOSTS=true está obsoleto",
|
||||
"legacyGrantBody": "Sigue permitiendo que la clave del servidor de arriba y la configuración de IA guardada en una cuenta de administrador lleguen a tu red, y se eliminará en una versión posterior. Las demás cuentas ya necesitan su endpoint de IA en AI_PRIVATE_ORIGINS. Define AI_PRIVATE_ORIGINS en el entorno del servidor, reinicia y después elimina ALLOW_LOCAL_AI_PRIVATE_HOSTS.",
|
||||
"legacyGrantOrigins": "Las URL de IA guardadas en esta instancia que apuntan a tu red:",
|
||||
"legacyGrantNoOrigins": "Ninguna URL de IA guardada en esta instancia apunta a tu red, así que ya puedes eliminar ALLOW_LOCAL_AI_PRIVATE_HOSTS."
|
||||
},
|
||||
"providerHealth": {
|
||||
"title": "Estado de los proveedores de IA",
|
||||
|
||||
+5
-1
@@ -6792,7 +6792,11 @@
|
||||
"removeBody": "Les comptes sans clé personnelle perdent aussitôt les fonctions IA. La clé n’est affichée nulle part une fois enregistrée : il vous faudra l’original pour la remettre.",
|
||||
"removeConfirm": "Supprimer la clé",
|
||||
"saved": "Paramètres de la clé IA du serveur enregistrés",
|
||||
"error": "Échec de l'enregistrement de la clé IA du serveur"
|
||||
"error": "Échec de l'enregistrement de la clé IA du serveur",
|
||||
"legacyGrantTitle": "ALLOW_LOCAL_AI_PRIVATE_HOSTS=true est obsolète",
|
||||
"legacyGrantBody": "Ce réglage permet encore à la clé serveur ci-dessus et aux réglages d'IA enregistrés sur un compte administrateur d'atteindre votre réseau, et sera supprimé dans une version ultérieure. Les autres comptes ont déjà besoin que leur point d'accès IA figure dans AI_PRIVATE_ORIGINS. Définissez AI_PRIVATE_ORIGINS dans l'environnement du serveur, redémarrez, puis supprimez ALLOW_LOCAL_AI_PRIVATE_HOSTS.",
|
||||
"legacyGrantOrigins": "Les URL d'IA enregistrées sur cette instance qui pointent vers votre réseau :",
|
||||
"legacyGrantNoOrigins": "Aucune URL d'IA enregistrée sur cette instance ne pointe vers votre réseau, vous pouvez donc supprimer ALLOW_LOCAL_AI_PRIVATE_HOSTS dès maintenant."
|
||||
},
|
||||
"providerHealth": {
|
||||
"title": "État des fournisseurs d'IA",
|
||||
|
||||
+5
-1
@@ -6792,7 +6792,11 @@
|
||||
"removeBody": "Gli account senza una chiave propria perdono subito le funzioni IA. Dopo il salvataggio la chiave non è più visibile da nessuna parte: per rimetterla ti servirà l’originale.",
|
||||
"removeConfirm": "Rimuovi chiave",
|
||||
"saved": "Impostazioni della chiave IA del server salvate",
|
||||
"error": "Salvataggio della chiave IA del server non riuscito"
|
||||
"error": "Salvataggio della chiave IA del server non riuscito",
|
||||
"legacyGrantTitle": "ALLOW_LOCAL_AI_PRIVATE_HOSTS=true è deprecato",
|
||||
"legacyGrantBody": "Consente ancora alla chiave del server qui sopra e alle impostazioni IA salvate su un account amministratore di raggiungere la tua rete, e verrà rimosso in una versione successiva. Gli altri account hanno già bisogno del loro endpoint IA in AI_PRIVATE_ORIGINS. Imposta AI_PRIVATE_ORIGINS nell'ambiente del server, riavvia e poi rimuovi ALLOW_LOCAL_AI_PRIVATE_HOSTS.",
|
||||
"legacyGrantOrigins": "Gli URL IA salvati su questa istanza che puntano alla tua rete:",
|
||||
"legacyGrantNoOrigins": "Nessun URL IA salvato su questa istanza punta alla tua rete, quindi puoi rimuovere ALLOW_LOCAL_AI_PRIVATE_HOSTS già ora."
|
||||
},
|
||||
"providerHealth": {
|
||||
"title": "Stato dei provider IA",
|
||||
|
||||
+5
-1
@@ -6792,7 +6792,11 @@
|
||||
"removeBody": "자기 키가 없는 계정은 바로 AI 기능을 쓸 수 없게 돼요. 저장한 뒤에는 키를 어디에도 다시 보여주지 않으니, 되돌리려면 원본 키가 필요해요.",
|
||||
"removeConfirm": "키 삭제",
|
||||
"saved": "AI 서버 키 설정을 저장했어요",
|
||||
"error": "AI 서버 키를 저장하지 못했어요"
|
||||
"error": "AI 서버 키를 저장하지 못했어요",
|
||||
"legacyGrantTitle": "ALLOW_LOCAL_AI_PRIVATE_HOSTS=true는 더 이상 권장되지 않습니다",
|
||||
"legacyGrantBody": "위의 서버 키와 관리자 계정에 저장된 AI 설정은 아직 이 설정으로 네트워크에 접근할 수 있지만, 이후 버전에서 제거됩니다. 다른 계정은 이미 AI 엔드포인트를 AI_PRIVATE_ORIGINS에 등록해야 합니다. 서버 환경에 AI_PRIVATE_ORIGINS를 설정하고 재시작한 뒤 ALLOW_LOCAL_AI_PRIVATE_HOSTS를 제거하세요.",
|
||||
"legacyGrantOrigins": "이 인스턴스에 저장된 AI 주소 중 내부 네트워크를 가리키는 주소:",
|
||||
"legacyGrantNoOrigins": "이 인스턴스에 저장된 AI 주소 중 내부 네트워크를 가리키는 주소가 없으므로 지금 ALLOW_LOCAL_AI_PRIVATE_HOSTS를 제거해도 됩니다."
|
||||
},
|
||||
"aiQuality": {
|
||||
"title": "인사이트 품질",
|
||||
|
||||
+5
-1
@@ -6792,7 +6792,11 @@
|
||||
"removeBody": "Konta bez własnego klucza natychmiast tracą funkcje AI. Po zapisaniu klucz nie jest nigdzie pokazywany, więc do przywrócenia potrzebny będzie oryginał.",
|
||||
"removeConfirm": "Usuń klucz",
|
||||
"saved": "Zapisano ustawienia serwerowego klucza AI",
|
||||
"error": "Nie udało się zapisać serwerowego klucza AI"
|
||||
"error": "Nie udało się zapisać serwerowego klucza AI",
|
||||
"legacyGrantTitle": "ALLOW_LOCAL_AI_PRIVATE_HOSTS=true jest przestarzałe",
|
||||
"legacyGrantBody": "Nadal pozwala kluczowi serwera powyżej i ustawieniom AI zapisanym na koncie administratora sięgać do Twojej sieci i zostanie usunięte w jednej z kolejnych wersji. Pozostałe konta już teraz potrzebują swojego punktu końcowego AI w AI_PRIVATE_ORIGINS. Ustaw AI_PRIVATE_ORIGINS w środowisku serwera, uruchom go ponownie, a potem usuń ALLOW_LOCAL_AI_PRIVATE_HOSTS.",
|
||||
"legacyGrantOrigins": "Adresy AI zapisane na tej instancji, które wskazują na Twoją sieć:",
|
||||
"legacyGrantNoOrigins": "Żaden adres AI zapisany na tej instancji nie wskazuje na Twoją sieć, więc możesz już usunąć ALLOW_LOCAL_AI_PRIVATE_HOSTS."
|
||||
},
|
||||
"providerHealth": {
|
||||
"title": "Stan dostawców AI",
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Two free-text columns were still stored readable: the title of a Coach
|
||||
-- conversation (the first 80 characters of the opening message, or the name a
|
||||
-- person gave the thread) and the note on a custom-metric reading. Both move
|
||||
-- to AES-256-GCM at rest, in the same Bytes codec every other note uses.
|
||||
--
|
||||
-- SQL cannot encrypt, so this migration only adds the ciphertext columns and
|
||||
-- the discovery indexes. The boot-time `free-text-encryption-backfill` job
|
||||
-- encrypts the existing rows one at a time and nulls the readable value in the
|
||||
-- same transaction. Readers prefer the ciphertext and fall back to the old
|
||||
-- column until the backfill has reached a row. The old columns are dropped in
|
||||
-- a later release, once the backfill reports nothing left on every instance.
|
||||
--
|
||||
-- Additive only: no table rewrite, no data change. The title loses its NOT
|
||||
-- NULL because every new title is written to the ciphertext column and the
|
||||
-- readable one stays empty.
|
||||
|
||||
ALTER TABLE "coach_conversations" ADD COLUMN "title_encrypted" BYTEA;
|
||||
ALTER TABLE "coach_conversations" ALTER COLUMN "title" DROP NOT NULL;
|
||||
|
||||
ALTER TABLE "custom_metric_entries" ADD COLUMN "note_encrypted" BYTEA;
|
||||
|
||||
-- Partial indexes on exactly the backfill's discovery predicate, so the boot
|
||||
-- scan is index-only and shrinks to nothing as the backfill converges (the
|
||||
-- 0243 precedent). Non-concurrent: Prisma Migrate runs the file in a
|
||||
-- transaction, and the build lands in the pre-traffic window at boot.
|
||||
CREATE INDEX IF NOT EXISTS "coach_conversations_title_backfill_idx"
|
||||
ON "coach_conversations" ("user_id")
|
||||
WHERE "title" IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "custom_metric_entries_note_backfill_idx"
|
||||
ON "custom_metric_entries" ("user_id")
|
||||
WHERE "note" IS NOT NULL;
|
||||
+25
-1
@@ -5846,7 +5846,15 @@ model CustomMetricEntry {
|
||||
/// Unit snapshot from the metric at write time.
|
||||
unit String
|
||||
measuredAt DateTime @map("measured_at")
|
||||
/// @internal: legacy plaintext note, read only as a fallback for rows the
|
||||
/// free-text encryption backfill has not reached yet (v1.39.3). Every write
|
||||
/// since then goes to `noteEncrypted` and leaves this NULL; the backfill
|
||||
/// nulls it on the old rows. DROP this column in a later release, once the
|
||||
/// backfill reports zero remaining rows on every instance.
|
||||
note String?
|
||||
/// v1.39.3 — AES-256-GCM ciphertext of the note. Same Bytes codec as the
|
||||
/// measurement and mood notes.
|
||||
noteEncrypted Bytes? @map("note_encrypted")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
/// v1.37.20 — soft-delete tombstone, peer parity with Measurement /
|
||||
/// MoodEntry (every other entry surface deletes recoverably with an undo
|
||||
@@ -5857,6 +5865,10 @@ model CustomMetricEntry {
|
||||
metric CustomMetric @relation(fields: [customMetricId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, customMetricId, measuredAt])
|
||||
// `custom_metric_entries_note_backfill_idx` is a partial index over
|
||||
// `(user_id) WHERE note IS NOT NULL` — see
|
||||
// migration 0357 for the free-text encryption boot-discovery scan. Raw SQL
|
||||
// only (partial predicate not expressible in Prisma).
|
||||
@@map("custom_metric_entries")
|
||||
}
|
||||
|
||||
@@ -6107,7 +6119,15 @@ model RecommendationFeedback {
|
||||
model CoachConversation {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
title String
|
||||
/// @internal: legacy plaintext title, read only as a fallback for rows the
|
||||
/// free-text encryption backfill has not reached yet (v1.39.3). Every write
|
||||
/// since then goes to `titleEncrypted` and leaves this NULL; the backfill
|
||||
/// nulls it on the old rows. DROP this column in a later release, once the
|
||||
/// backfill reports zero remaining rows on every instance.
|
||||
title String?
|
||||
/// v1.39.3 — AES-256-GCM ciphertext of the title (the first 80 characters of
|
||||
/// the opening Coach message, or the rename). Same Bytes codec as the notes.
|
||||
titleEncrypted Bytes? @map("title_encrypted")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@ -6141,6 +6161,10 @@ model CoachConversation {
|
||||
// and the tool-route send path filters on the flag. Replaces the retired
|
||||
// [userId, documentId, updatedAt] composite.
|
||||
@@index([userId, documentScoped, updatedAt])
|
||||
// `coach_conversations_title_backfill_idx` is a partial index over
|
||||
// `(user_id) WHERE title IS NOT NULL` — see
|
||||
// migration 0357 for the free-text encryption boot-discovery scan. Raw SQL
|
||||
// only (partial predicate not expressible in Prisma).
|
||||
@@map("coach_conversations")
|
||||
}
|
||||
|
||||
|
||||
@@ -122,9 +122,13 @@
|
||||
"description": "Optional runtime policy and output tuning for user-configured AI providers. Each variable stands alone and has a safe default when unset.",
|
||||
"required": false,
|
||||
"variables": [
|
||||
{
|
||||
"name": "AI_PRIVATE_ORIGINS",
|
||||
"purpose": "AI endpoints on the operator's own network (Ollama, LM Studio, LiteLLM). Comma-separated exact origins (scheme://host[:port]), e.g. \"http://ollama.lan:11434\". Unset, a private AI base URL is refused (apart from what the deprecated ALLOW_LOCAL_AI_PRIVATE_HOSTS=true still covers). Link-local, metadata and unspecified addresses stay refused even when listed; a malformed entry is logged once and grants nothing."
|
||||
},
|
||||
{
|
||||
"name": "ALLOW_LOCAL_AI_PRIVATE_HOSTS",
|
||||
"purpose": "Local-AI SSRF opt-in. Unset rejects private/internal hosts; set an exact comma-separated host allowlist or \"true\" to allow any private host."
|
||||
"purpose": "Older form of AI_PRIVATE_ORIGINS, kept for existing installs: an exact comma-separated host list still grants those hosts. \"true\" is deprecated since v1.39.3: it covers only the admin AI key and AI settings saved on an admin account (pinned; metadata and link-local refused), is reported at boot and on the admin AI settings page, and will be removed in a later release."
|
||||
},
|
||||
{
|
||||
"name": "INSIGHTS_MAX_TOKENS",
|
||||
|
||||
@@ -418,7 +418,7 @@ async function main() {
|
||||
}
|
||||
|
||||
// ───── Coach (Bytes columns) ─────
|
||||
// "encryptedContent" "summaryEncrypted" "factEncrypted"
|
||||
// "encryptedContent" "summaryEncrypted" "titleEncrypted" "factEncrypted"
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"CoachMessage",
|
||||
@@ -433,6 +433,16 @@ async function main() {
|
||||
prisma.coachConversation,
|
||||
),
|
||||
);
|
||||
// v1.39.3 — the conversation title. NULL on a row the free-text backfill
|
||||
// has not reached yet (its title is still in the readable column), which
|
||||
// `rotateBytesColumn` skips.
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"CoachConversation",
|
||||
"titleEncrypted",
|
||||
prisma.coachConversation,
|
||||
),
|
||||
);
|
||||
results.push(
|
||||
await rotateBytesColumn("CoachFact", "factEncrypted", prisma.coachFact),
|
||||
);
|
||||
@@ -691,6 +701,17 @@ async function main() {
|
||||
),
|
||||
);
|
||||
|
||||
// ───── v1.39.3 custom-metric reading note (Bytes column) ─────
|
||||
// NULL on a reading without a note, and on a row the free-text backfill has
|
||||
// not reached yet; `rotateBytesColumn` skips both.
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"CustomMetricEntry",
|
||||
"noteEncrypted",
|
||||
prisma.customMetricEntry,
|
||||
),
|
||||
);
|
||||
|
||||
// ───── v1.38.0 dose free text (Bytes column) ─────
|
||||
// The note on an administered dose — a reaction, a sore arm. Always
|
||||
// encrypted on write, so every non-null row rotates.
|
||||
|
||||
@@ -23,10 +23,11 @@
|
||||
*
|
||||
* ## Why the matchers are what they are
|
||||
*
|
||||
* A grep for `process.env.X` alone is not the read set. Four modules resolve a
|
||||
* name through a helper and index `process.env` dynamically, so their reads
|
||||
* are invisible to the direct matcher — `ALLOW_LOCAL_AI_PRIVATE_HOSTS` and
|
||||
* every `OIDC_*` among them. Each is registered below with its own call-shape
|
||||
* A grep for `process.env.X` alone is not the read set. Three modules resolve
|
||||
* a name through a helper and index `process.env` dynamically, so their reads
|
||||
* are invisible to the direct matcher — every `OIDC_*` among them. (The AI
|
||||
* private-host grants were a fourth until v1.39.3, when they became direct
|
||||
* `process.env` reads.) Each is registered below with its own call-shape
|
||||
* matcher AND its own non-zero assertion, so a refactor that renames the
|
||||
* helper fails this file loudly instead of quietly shrinking the read set.
|
||||
*
|
||||
@@ -79,12 +80,6 @@ const INDIRECT_READERS: ReadonlyArray<{
|
||||
minimum: 1,
|
||||
why: "the retention windows are parsed through a local intEnv() helper",
|
||||
},
|
||||
{
|
||||
file: "lib/ai/local-host-allowlist.ts",
|
||||
pattern: /ENV_VAR\s*=\s*["']([A-Z][A-Z0-9_]*)["']/g,
|
||||
minimum: 1,
|
||||
why: "the name is hoisted into an ENV_VAR constant, then indexed",
|
||||
},
|
||||
{
|
||||
file: "lib/boot/readiness-summary.ts",
|
||||
pattern: /\benv\.([A-Z][A-Z0-9_]*)/g,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* The native OIDC leg ends at `healthlog://oidc-callback?error=<reason>`, and
|
||||
* the app maps each reason to its own message. The only written contract for
|
||||
* that vocabulary is the login route's 302 description in the OpenAPI
|
||||
* registry (the callback itself is provider-driven and has no entry).
|
||||
*
|
||||
* An app build once matched against hyphenated shorthand from a coordination
|
||||
* note (`identity-conflict`) while the server sent `oidc_identity_conflict`,
|
||||
* so every reason fell through to the generic message. This binds the two
|
||||
* ends that can be read exactly: every reason either route can send on a
|
||||
* redirect a native client may receive is named in that description, and
|
||||
* nothing is named there that no route sends.
|
||||
*
|
||||
* Its limit: it reads string literals passed to the redirect helpers, so a
|
||||
* reason assembled at runtime would slip past it. None is today.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ROOT = join(__dirname, "..");
|
||||
const read = (rel: string) => readFileSync(join(ROOT, rel), "utf8");
|
||||
|
||||
/** Literal reasons passed to one of the named redirect helpers. */
|
||||
function reasons(source: string, helpers: string[]): Set<string> {
|
||||
const found = new Set<string>();
|
||||
const pattern = new RegExp(
|
||||
`\\b(?:${helpers.join("|")})\\(\\s*"(oidc_[a-z_]+)"\\s*\\)`,
|
||||
"g",
|
||||
);
|
||||
for (const m of source.matchAll(pattern)) found.add(m[1]);
|
||||
return found;
|
||||
}
|
||||
|
||||
describe("native OIDC error reasons are documented", () => {
|
||||
// The login route answers both legs through `loginError`; the callback's
|
||||
// native-aware helper is `failRedirect`. `errorRedirect` in the callback is
|
||||
// the browser-only branch taken before the state (and so the native flag)
|
||||
// could be read, and never reaches the app.
|
||||
const sent = new Set([
|
||||
...reasons(read("app/api/auth/oidc/login/route.ts"), ["loginError"]),
|
||||
...reasons(read("app/api/auth/oidc/callback/route.ts"), ["failRedirect"]),
|
||||
]);
|
||||
|
||||
// The login route's 302 description is one string literal on one line.
|
||||
const description =
|
||||
read("lib/openapi/routes/auth.ts")
|
||||
.split("\n")
|
||||
.find(
|
||||
(line) =>
|
||||
line.includes("healthlog://oidc-callback?error=<reason>") &&
|
||||
line.includes("oidc_invalid_request"),
|
||||
) ?? "";
|
||||
const documented = new Set(
|
||||
[...description.matchAll(/`(oidc_[a-z_]+)`/g)].map((m) => m[1]),
|
||||
);
|
||||
|
||||
it("finds the reasons on both ends", () => {
|
||||
expect(sent.size).toBeGreaterThanOrEqual(8);
|
||||
expect(documented.size).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
|
||||
it("documents every reason a route sends", () => {
|
||||
expect([...sent].filter((r) => !documented.has(r))).toEqual([]);
|
||||
});
|
||||
|
||||
it("documents no reason that no route sends", () => {
|
||||
expect([...documented].filter((r) => !sent.has(r))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
* HTTPS + hostname allowlist on the base URL, and the masked
|
||||
* `keyPreview` in every response.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
@@ -15,6 +15,7 @@ vi.mock("@/lib/db", () => ({
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
user: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -100,6 +101,7 @@ describe("GET /api/admin/ai-settings", () => {
|
||||
keyPreview: null,
|
||||
model: "gpt-4o",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
legacyPrivateHostGrant: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -216,3 +218,52 @@ describe("PUT /api/admin/ai-settings", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.39.3 — while the deprecated `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` is set,
|
||||
* the page names it and lists the origins it is standing in for, so the
|
||||
* operator can copy them into `AI_PRIVATE_ORIGINS`.
|
||||
*/
|
||||
describe("GET /api/admin/ai-settings — deprecated private-host grant", () => {
|
||||
beforeEach(async () => {
|
||||
const { _resetAiGrantsForTests } =
|
||||
await import("@/lib/ai/local-host-allowlist");
|
||||
_resetAiGrantsForTests();
|
||||
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: null,
|
||||
adminAiModel: null,
|
||||
adminAiBaseUrl: "http://gateway.lan:4000/v1",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findMany).mockResolvedValue([
|
||||
{ aiBaseUrl: "http://10.0.0.5:11434/v1", aiCompatBaseUrl: null },
|
||||
{
|
||||
aiBaseUrl: "http://10.0.0.5:11434/v1/",
|
||||
aiCompatBaseUrl: "https://openrouter.ai/api/v1",
|
||||
},
|
||||
{ aiBaseUrl: "http://169.254.169.254/latest", aiCompatBaseUrl: null },
|
||||
] as never);
|
||||
vi.stubEnv("AI_PRIVATE_ORIGINS", "");
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("lists the private origins in use, once each, when `true` is set", async () => {
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
|
||||
const res = await GET();
|
||||
const body = await res.json();
|
||||
expect(body.data.legacyPrivateHostGrant).toEqual({
|
||||
originsInUse: ["http://10.0.0.5:11434", "http://gateway.lan:4000"],
|
||||
});
|
||||
});
|
||||
|
||||
it("is null when `true` is not set", async () => {
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "ollama.lan");
|
||||
const res = await GET();
|
||||
expect((await res.json()).data.legacyPrivateHostGrant).toBeNull();
|
||||
expect(prisma.user.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { apiSuccess, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { legacyPrivateHostReport } from "@/lib/ai/legacy-private-host-report";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -29,6 +30,11 @@ export const GET = apiHandler(async () => {
|
||||
: null,
|
||||
model,
|
||||
baseUrl,
|
||||
// v1.39.3 — present while the deprecated ALLOW_LOCAL_AI_PRIVATE_HOSTS=true
|
||||
// is set, naming the origins to move into AI_PRIVATE_ORIGINS.
|
||||
legacyPrivateHostGrant: await legacyPrivateHostReport(
|
||||
settings?.adminAiBaseUrl,
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
|
||||
import { serialiseCustomMetricEntry } from "@/lib/custom-metrics/custom-metric-store";
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { updateCustomMetricEntrySchema } from "@/lib/validations/custom-metrics";
|
||||
@@ -83,7 +84,13 @@ export const PATCH = apiHandler(
|
||||
const data: Record<string, unknown> = {};
|
||||
if (d.value !== undefined) data.value = d.value;
|
||||
if (d.measuredAt !== undefined) data.measuredAt = d.measuredAt;
|
||||
if (d.note !== undefined) data.note = d.note;
|
||||
// v1.39.3 — the note is stored encrypted. Any note write (a new value or
|
||||
// an explicit clear) also nulls the legacy readable column, so an edited
|
||||
// row never keeps its old text beside the new ciphertext.
|
||||
if (d.note !== undefined) {
|
||||
data.noteEncrypted = encryptNote(d.note);
|
||||
data.note = null;
|
||||
}
|
||||
|
||||
const updated = await prisma.customMetricEntry.update({
|
||||
where: { id: entryId },
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/rate-limit", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/rate-limit")>()),
|
||||
// The shared single-record write ceiling. Allowed here: this file is about
|
||||
// what the handler does with a body it accepted, not about the bucket.
|
||||
checkRecordWriteRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
limit: 300,
|
||||
remaining: 299,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserCorrelationPatterns: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
customMetric: { findFirst: vi.fn() },
|
||||
customMetricEntry: {
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/idempotency", () => ({
|
||||
withIdempotency: (fn: unknown) => fn,
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, POST } from "../route";
|
||||
import { PATCH } from "../[entryId]/route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
|
||||
/**
|
||||
* v1.39.3 — the note on a custom-metric reading is encrypted at rest. A write
|
||||
* stores ciphertext and leaves the readable column empty; a read opens the
|
||||
* ciphertext and falls back to the readable column only for a row the backfill
|
||||
* has not reached.
|
||||
*/
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "u", role: "USER" as const, locale: "en" },
|
||||
};
|
||||
|
||||
const ENTRY = {
|
||||
id: "e-1",
|
||||
userId: "user-1",
|
||||
customMetricId: "cm-1",
|
||||
value: 42,
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2026-06-10T00:00:00.000Z"),
|
||||
note: null as string | null,
|
||||
noteEncrypted: null as Uint8Array | null,
|
||||
createdAt: new Date("2026-06-10T00:00:00.000Z"),
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
function jsonRequest(url: string, method: string, body: unknown) {
|
||||
return new NextRequest(url, {
|
||||
method,
|
||||
body: JSON.stringify(body),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
});
|
||||
|
||||
describe("custom-metric reading note at rest", () => {
|
||||
it("POST stores the note as ciphertext and answers with the readable note", async () => {
|
||||
vi.mocked(prisma.customMetric.findFirst).mockResolvedValue({
|
||||
id: "cm-1",
|
||||
unit: "kg",
|
||||
} as never);
|
||||
vi.mocked(prisma.customMetricEntry.create).mockImplementation(
|
||||
(async (args: { data: Record<string, unknown> }) => ({
|
||||
...ENTRY,
|
||||
...args.data,
|
||||
})) as never,
|
||||
);
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest("http://localhost/api/custom-metrics/cm-1/entries", "POST", {
|
||||
value: 42,
|
||||
measuredAt: "2026-06-10T00:00:00.000Z",
|
||||
note: "after the long ride",
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "cm-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
const data = vi.mocked(prisma.customMetricEntry.create).mock.calls[0][0]
|
||||
.data as Record<string, unknown>;
|
||||
expect(data).not.toHaveProperty("note");
|
||||
expect(decryptFromBytes(data.noteEncrypted as Uint8Array)).toBe(
|
||||
"after the long ride",
|
||||
);
|
||||
const body = await res.json();
|
||||
expect(body.data.note).toBe("after the long ride");
|
||||
expect(body.data).not.toHaveProperty("noteEncrypted");
|
||||
});
|
||||
|
||||
it("GET opens sealed notes and still shows a legacy readable one", async () => {
|
||||
vi.mocked(prisma.customMetric.findFirst).mockResolvedValue({
|
||||
id: "cm-1",
|
||||
} as never);
|
||||
vi.mocked(prisma.customMetricEntry.findMany).mockResolvedValue([
|
||||
{ ...ENTRY, id: "e-1", noteEncrypted: encryptToBytes("sealed note") },
|
||||
{ ...ENTRY, id: "e-2", note: "legacy note" },
|
||||
{ ...ENTRY, id: "e-3" },
|
||||
] as never);
|
||||
vi.mocked(prisma.customMetricEntry.count).mockResolvedValue(3 as never);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/custom-metrics/cm-1/entries"),
|
||||
{ params: Promise.resolve({ id: "cm-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(
|
||||
body.data.entries.map((e: { note: string | null }) => e.note),
|
||||
).toEqual(["sealed note", "legacy note", null]);
|
||||
for (const entry of body.data.entries) {
|
||||
expect(entry).not.toHaveProperty("noteEncrypted");
|
||||
}
|
||||
});
|
||||
|
||||
it("PATCH seals a new note and clears the legacy readable column", async () => {
|
||||
vi.mocked(prisma.customMetricEntry.findFirst).mockResolvedValue({
|
||||
...ENTRY,
|
||||
note: "old readable note",
|
||||
} as never);
|
||||
vi.mocked(prisma.customMetricEntry.update).mockImplementation(
|
||||
(async (args: { data: Record<string, unknown> }) => ({
|
||||
...ENTRY,
|
||||
...args.data,
|
||||
})) as never,
|
||||
);
|
||||
|
||||
const res = await PATCH(
|
||||
jsonRequest(
|
||||
"http://localhost/api/custom-metrics/cm-1/entries/e-1",
|
||||
"PATCH",
|
||||
{ note: "edited" },
|
||||
),
|
||||
{ params: Promise.resolve({ id: "cm-1", entryId: "e-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const data = vi.mocked(prisma.customMetricEntry.update).mock.calls[0][0]
|
||||
.data as Record<string, unknown>;
|
||||
expect(data.note).toBeNull();
|
||||
expect(decryptFromBytes(data.noteEncrypted as Uint8Array)).toBe("edited");
|
||||
expect((await res.json()).data.note).toBe("edited");
|
||||
});
|
||||
|
||||
it("PATCH with an explicit null clears both columns", async () => {
|
||||
vi.mocked(prisma.customMetricEntry.findFirst).mockResolvedValue({
|
||||
...ENTRY,
|
||||
noteEncrypted: encryptToBytes("to be cleared"),
|
||||
} as never);
|
||||
vi.mocked(prisma.customMetricEntry.update).mockImplementation(
|
||||
(async (args: { data: Record<string, unknown> }) => ({
|
||||
...ENTRY,
|
||||
...args.data,
|
||||
})) as never,
|
||||
);
|
||||
|
||||
const res = await PATCH(
|
||||
jsonRequest(
|
||||
"http://localhost/api/custom-metrics/cm-1/entries/e-1",
|
||||
"PATCH",
|
||||
{ note: null },
|
||||
),
|
||||
{ params: Promise.resolve({ id: "cm-1", entryId: "e-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const data = vi.mocked(prisma.customMetricEntry.update).mock.calls[0][0]
|
||||
.data as Record<string, unknown>;
|
||||
expect(data).toMatchObject({ note: null, noteEncrypted: null });
|
||||
expect((await res.json()).data.note).toBeNull();
|
||||
});
|
||||
|
||||
it("PATCH without a note leaves both note columns untouched", async () => {
|
||||
vi.mocked(prisma.customMetricEntry.findFirst).mockResolvedValue(
|
||||
ENTRY as never,
|
||||
);
|
||||
vi.mocked(prisma.customMetricEntry.update).mockResolvedValue(
|
||||
ENTRY as never,
|
||||
);
|
||||
await PATCH(
|
||||
jsonRequest(
|
||||
"http://localhost/api/custom-metrics/cm-1/entries/e-1",
|
||||
"PATCH",
|
||||
{ value: 43 },
|
||||
),
|
||||
{ params: Promise.resolve({ id: "cm-1", entryId: "e-1" }) },
|
||||
);
|
||||
const data = vi.mocked(prisma.customMetricEntry.update).mock.calls[0][0]
|
||||
.data as Record<string, unknown>;
|
||||
expect(data).not.toHaveProperty("note");
|
||||
expect(data).not.toHaveProperty("noteEncrypted");
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
|
||||
import { serialiseCustomMetricEntry } from "@/lib/custom-metrics/custom-metric-store";
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -161,7 +162,8 @@ async function postCustomMetricEntry(
|
||||
value,
|
||||
unit: metric.unit,
|
||||
measuredAt,
|
||||
note: note ?? null,
|
||||
// v1.39.3 — the note is stored encrypted; the readable column stays NULL.
|
||||
noteEncrypted: encryptNote(note),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -17,10 +17,13 @@ vi.mock("@/lib/db", () => {
|
||||
return {
|
||||
prisma: {
|
||||
measurement,
|
||||
// Run the batched write callback against the same measurement mock so
|
||||
// createManyAndReturn / updateMany calls are observable.
|
||||
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) =>
|
||||
fn({ measurement }),
|
||||
// Both transaction forms against the same measurement mock so
|
||||
// createManyAndReturn / updateMany calls are observable: a callback
|
||||
// gets the mock as `tx`, a batch array is simply awaited.
|
||||
$transaction: vi.fn(async (arg: unknown) =>
|
||||
typeof arg === "function"
|
||||
? (arg as (tx: unknown) => unknown)({ measurement })
|
||||
: Promise.all(arg as Promise<unknown>[]),
|
||||
),
|
||||
},
|
||||
};
|
||||
@@ -37,6 +40,10 @@ vi.mock("@/lib/crypto/note-cipher", () => ({
|
||||
s === null || s === undefined || s.length === 0
|
||||
? null
|
||||
: new Uint8Array(Buffer.from(`enc:${s}`, "utf8")),
|
||||
readNote: (c: Uint8Array | null | undefined, p: string | null | undefined) =>
|
||||
c && c.byteLength > 0
|
||||
? Buffer.from(c).toString("utf8").slice(4)
|
||||
: (p ?? null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
@@ -115,8 +122,10 @@ beforeEach(() => {
|
||||
prisma.$transaction as unknown as {
|
||||
mockImplementation: (f: unknown) => void;
|
||||
}
|
||||
).mockImplementation(async (fn: (tx: unknown) => unknown) =>
|
||||
fn({ measurement }),
|
||||
).mockImplementation(async (arg: unknown) =>
|
||||
typeof arg === "function"
|
||||
? (arg as (tx: unknown) => unknown)({ measurement })
|
||||
: Promise.all(arg as Promise<unknown>[]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -239,7 +248,19 @@ describe("POST /api/import/csv — batched write + per-row envelope", () => {
|
||||
// row matches like a live one; the update must carry the resurrection
|
||||
// (IMPORT rows are re-importable by design).
|
||||
mMeasurement().findMany.mockResolvedValue([
|
||||
{ type: "WEIGHT", externalId: "ext-tomb" },
|
||||
{
|
||||
type: "WEIGHT",
|
||||
externalId: "ext-tomb",
|
||||
// Identical values: only the tombstone differs, and that alone
|
||||
// makes the line an update rather than a duplicate.
|
||||
value: 81.0,
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2026-05-01T08:00:00Z"),
|
||||
glucoseContext: null,
|
||||
notes: null,
|
||||
notesEncrypted: null,
|
||||
deletedAt: new Date("2026-05-02T08:00:00Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await POST(
|
||||
@@ -424,3 +445,105 @@ describe("POST /api/import/csv — contextless blood glucose", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.39.3 — re-importing the same file. A 9 000-row re-upload with an
|
||||
* externalId column used to run one update per row inside a single
|
||||
* interactive transaction and ended in a 500 when that outlived Prisma's 5 s
|
||||
* timeout. An unchanged row is now a duplicate with no write, a changed one
|
||||
* still updates, and lookups and writes run in bounded chunks.
|
||||
*/
|
||||
describe("POST /api/import/csv — re-import", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 4,
|
||||
resetAt: Date.now() + 3_600_000,
|
||||
} as never);
|
||||
mMeasurement().findMany.mockReset();
|
||||
mMeasurement().createManyAndReturn.mockReset();
|
||||
mMeasurement().createManyAndReturn.mockResolvedValue([]);
|
||||
mMeasurement().updateMany.mockReset();
|
||||
mMeasurement().updateMany.mockResolvedValue({ count: 1 });
|
||||
vi.mocked(prisma.$transaction).mockClear();
|
||||
});
|
||||
|
||||
function stored(externalId: string, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
type: "WEIGHT",
|
||||
externalId,
|
||||
value: 80.5,
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2026-05-01T08:00:00Z"),
|
||||
glucoseContext: null,
|
||||
notes: null,
|
||||
notesEncrypted: new Uint8Array(Buffer.from("enc:after run", "utf8")),
|
||||
deletedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("writes nothing for a row that is already stored exactly so", async () => {
|
||||
mMeasurement().findMany.mockResolvedValue([stored("ext-1")]);
|
||||
const res = await POST(
|
||||
csvRequest(
|
||||
[HEADER, "WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,after run,ext-1"].join(
|
||||
"\n",
|
||||
),
|
||||
),
|
||||
);
|
||||
const body = (await res.json()) as CsvEnvelope;
|
||||
expect(body.data).toMatchObject({ inserted: 0, updated: 0, skipped: 1 });
|
||||
expect(body.data?.rows).toEqual([
|
||||
{ line: 2, status: "skipped", reason: "duplicate" },
|
||||
]);
|
||||
expect(mMeasurement().updateMany).not.toHaveBeenCalled();
|
||||
expect(mMeasurement().createManyAndReturn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["value", "WEIGHT,81,kg,2026-05-01T08:00:00Z,,after run,ext-1"],
|
||||
["note", "WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,before run,ext-1"],
|
||||
["instant", "WEIGHT,80.5,kg,2026-05-01T09:00:00Z,,after run,ext-1"],
|
||||
])("still updates a row whose %s changed", async (_what, line) => {
|
||||
mMeasurement().findMany.mockResolvedValue([stored("ext-1")]);
|
||||
const res = await POST(csvRequest([HEADER, line].join("\n")));
|
||||
const body = (await res.json()) as CsvEnvelope;
|
||||
expect(body.data).toMatchObject({ updated: 1, skipped: 0 });
|
||||
expect(mMeasurement().updateMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("probes per type in bounded chunks and writes updates in bounded batches", async () => {
|
||||
const lines = Array.from({ length: 2500 }, (_, i) => {
|
||||
const at = new Date(Date.UTC(2026, 0, 1) + i * 60_000).toISOString();
|
||||
return `WEIGHT,${70 + (i % 10)},kg,${at},,,ext-${i}`;
|
||||
});
|
||||
// Every key exists, with a different value, so every line updates.
|
||||
mMeasurement().findMany.mockImplementation((async (args: {
|
||||
where: { externalId: { in: string[] } };
|
||||
}) =>
|
||||
args.where.externalId.in.map((externalId) =>
|
||||
stored(externalId, { value: 1 }),
|
||||
)) as never);
|
||||
|
||||
const res = await POST(csvRequest([HEADER, ...lines].join("\n")));
|
||||
const body = (await res.json()) as CsvEnvelope;
|
||||
expect(body.data?.updated).toBe(2500);
|
||||
|
||||
const probes = mMeasurement().findMany.mock.calls.map(
|
||||
(c) => (c[0] as { where: { externalId: { in: string[] } } }).where,
|
||||
);
|
||||
expect(probes).toHaveLength(3);
|
||||
expect(probes.every((w) => w.externalId.in.length <= 1000)).toBe(true);
|
||||
// 2 500 updates in batches of at most 200, each its own transaction:
|
||||
// no single transaction carries the whole file.
|
||||
const batches = vi
|
||||
.mocked(prisma.$transaction)
|
||||
.mock.calls.map((c) => c[0] as unknown as unknown[]);
|
||||
expect(batches).toHaveLength(13);
|
||||
expect(batches.every((b) => Array.isArray(b) && b.length <= 200)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(mMeasurement().updateMany).toHaveBeenCalledTimes(2500);
|
||||
});
|
||||
});
|
||||
+183
-78
@@ -8,7 +8,7 @@ import {
|
||||
getClientIp,
|
||||
readBodyText,
|
||||
} from "@/lib/api-response";
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import { encryptNote, readNote } from "@/lib/crypto/note-cipher";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
@@ -26,6 +26,63 @@ import type { NormalisedMeasurementRow } from "@/lib/import/csv-measurements";
|
||||
import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit";
|
||||
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
|
||||
|
||||
/** Keys per dedup probe: well under Postgres's bind-parameter cap. */
|
||||
const LOOKUP_CHUNK = 1000;
|
||||
/** Rows per create call / per update batch transaction. */
|
||||
const WRITE_CHUNK = 200;
|
||||
|
||||
function chunked<T>(items: readonly T[], size: number): T[][] {
|
||||
const out: T[][] = [];
|
||||
for (let i = 0; i < items.length; i += size) {
|
||||
out.push(items.slice(i, i + size));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Group rows' probe keys by measurement type, de-duplicated. */
|
||||
function groupByType<K>(
|
||||
rows: ReadonlyArray<{ row: NormalisedMeasurementRow }>,
|
||||
keyOf: (r: { row: NormalisedMeasurementRow }) => K,
|
||||
): Map<MeasurementType, K[]> {
|
||||
const byType = new Map<MeasurementType, Map<string, K>>();
|
||||
for (const r of rows) {
|
||||
const type = r.row.type as MeasurementType;
|
||||
const key = keyOf(r);
|
||||
const bucket = byType.get(type) ?? new Map<string, K>();
|
||||
bucket.set(key instanceof Date ? String(key.getTime()) : String(key), key);
|
||||
byType.set(type, bucket);
|
||||
}
|
||||
return new Map([...byType].map(([type, keys]) => [type, [...keys.values()]]));
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a stored IMPORT row already holds exactly what this line would
|
||||
* write: same value, unit, instant, glucose context and note, and live. Such
|
||||
* a line is a duplicate, not an update.
|
||||
*/
|
||||
function sameStoredRow(
|
||||
stored: {
|
||||
value: number;
|
||||
unit: string;
|
||||
measuredAt: Date;
|
||||
glucoseContext: GlucoseContext | null;
|
||||
notes: string | null;
|
||||
notesEncrypted: Uint8Array | null;
|
||||
deletedAt: Date | null;
|
||||
},
|
||||
m: NormalisedMeasurementRow,
|
||||
): boolean {
|
||||
return (
|
||||
stored.deletedAt === null &&
|
||||
stored.value === m.value &&
|
||||
stored.unit === m.unit &&
|
||||
stored.measuredAt.getTime() === m.measuredAt.getTime() &&
|
||||
stored.glucoseContext ===
|
||||
((m.glucoseContext as GlucoseContext | undefined) ?? null) &&
|
||||
readNote(stored.notesEncrypted, stored.notes) === (m.notes || null)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV measurement import (v1.17.1).
|
||||
*
|
||||
@@ -141,38 +198,72 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const plainRows = okRows.filter((r) => !r.row.externalId);
|
||||
|
||||
// --- externalId rows: idempotent upsert on
|
||||
// (userId, type, source=IMPORT, externalId). One probe fetches every
|
||||
// pre-existing key; a hit means the row updates in place.
|
||||
const extExisting =
|
||||
extRows.length > 0
|
||||
? await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
source: "IMPORT",
|
||||
OR: extRows.map((r) => ({
|
||||
type: r.row.type as MeasurementType,
|
||||
externalId: r.row.externalId as string,
|
||||
})),
|
||||
},
|
||||
select: { type: true, externalId: true },
|
||||
})
|
||||
: [];
|
||||
const extExistingSet = new Set(
|
||||
extExisting.map((e) => `${e.type}::${e.externalId}`),
|
||||
);
|
||||
// (userId, type, source=IMPORT, externalId). One probe per type and chunk
|
||||
// fetches every pre-existing key with the columns an update would write,
|
||||
// so a row whose stored values already match is left alone.
|
||||
const extExisting = new Map<
|
||||
string,
|
||||
{
|
||||
value: number;
|
||||
unit: string;
|
||||
measuredAt: Date;
|
||||
glucoseContext: GlucoseContext | null;
|
||||
notes: string | null;
|
||||
notesEncrypted: Uint8Array | null;
|
||||
deletedAt: Date | null;
|
||||
}
|
||||
>();
|
||||
for (const [type, ids] of groupByType(
|
||||
extRows,
|
||||
(r) => r.row.externalId as string,
|
||||
)) {
|
||||
for (const chunk of chunked(ids, LOOKUP_CHUNK)) {
|
||||
const found = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
source: "IMPORT",
|
||||
type,
|
||||
externalId: { in: chunk },
|
||||
},
|
||||
select: {
|
||||
externalId: true,
|
||||
value: true,
|
||||
unit: true,
|
||||
measuredAt: true,
|
||||
glucoseContext: true,
|
||||
notes: true,
|
||||
notesEncrypted: true,
|
||||
deletedAt: true,
|
||||
},
|
||||
});
|
||||
for (const row of found) {
|
||||
extExisting.set(`${type}::${row.externalId}`, row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Partition in file order, honouring in-file repeats: the first sighting
|
||||
// of a brand-new key inserts, every later sighting (or any sighting of a
|
||||
// key already in the DB) updates in place. The last value for a key wins,
|
||||
// matching the old sequential-upsert overwrite. Insert/update maps are
|
||||
// keyed by (type, externalId) so a repeated key collapses to one write.
|
||||
// A key already in the DB whose stored row equals this line is a
|
||||
// duplicate: no write, reported like the natural-key duplicates below.
|
||||
// That is what makes re-importing the same file cheap — a second upload
|
||||
// of an unchanged 9 000-row export writes nothing.
|
||||
const seenExt = new Set<string>();
|
||||
const extInserts = new Map<string, NormalisedMeasurementRow>();
|
||||
const extUpdates = new Map<string, NormalisedMeasurementRow>();
|
||||
for (const result of extRows) {
|
||||
const m = result.row;
|
||||
const key = `${m.type}::${m.externalId}`;
|
||||
if (extExistingSet.has(key)) {
|
||||
const stored = extExisting.get(key);
|
||||
if (stored && !extUpdates.has(key) && sameStoredRow(stored, m)) {
|
||||
skipped++;
|
||||
writeOutcome.set(result.line, "duplicate");
|
||||
continue;
|
||||
}
|
||||
if (stored) {
|
||||
extUpdates.set(key, m);
|
||||
updated++;
|
||||
writeOutcome.set(result.line, "updated");
|
||||
@@ -198,24 +289,28 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// --- externalId-less rows: create-only, deduped on the natural unique key
|
||||
// (userId, type, measuredAt, source=IMPORT, sleepStage=null). A collision
|
||||
// — against the DB or an earlier row in this same file — is a duplicate,
|
||||
// never an overwrite (each sample is a canonical reading).
|
||||
const plainExisting =
|
||||
plainRows.length > 0
|
||||
? await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
source: "IMPORT",
|
||||
OR: plainRows.map((r) => ({
|
||||
type: r.row.type as MeasurementType,
|
||||
measuredAt: r.row.measuredAt,
|
||||
})),
|
||||
},
|
||||
select: { type: true, measuredAt: true },
|
||||
})
|
||||
: [];
|
||||
const plainExistingSet = new Set(
|
||||
plainExisting.map((e) => `${e.type}::${e.measuredAt.getTime()}`),
|
||||
);
|
||||
// never an overwrite (each sample is a canonical reading). Probed per type
|
||||
// and chunk, like the externalId rows.
|
||||
const plainExistingSet = new Set<string>();
|
||||
for (const [type, instants] of groupByType(
|
||||
plainRows,
|
||||
(r) => r.row.measuredAt,
|
||||
)) {
|
||||
for (const chunk of chunked(instants, LOOKUP_CHUNK)) {
|
||||
const found = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
source: "IMPORT",
|
||||
type,
|
||||
measuredAt: { in: chunk },
|
||||
},
|
||||
select: { measuredAt: true },
|
||||
});
|
||||
for (const row of found) {
|
||||
plainExistingSet.add(`${type}::${row.measuredAt.getTime()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const seenPlain = new Set<string>();
|
||||
const plainInserts: NormalisedMeasurementRow[] = [];
|
||||
for (const result of plainRows) {
|
||||
@@ -236,55 +331,65 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk write: createManyAndReturn the survivors (chunked under the PG
|
||||
// parameter cap, skipDuplicates to absorb a concurrent double-submit
|
||||
// race), then grouped updateMany for pre-existing externalId rows.
|
||||
// Bulk write in bounded chunks, each its own short transaction.
|
||||
//
|
||||
// This used to be ONE interactive transaction around every create chunk
|
||||
// AND one `updateMany` per re-imported row. Re-importing a 9 000-row file
|
||||
// with an externalId column meant 9 000 serial updates inside it, which
|
||||
// outlived Prisma's 5 s interactive-transaction timeout and ended in a
|
||||
// 500. A chunk now commits on its own: creates go through
|
||||
// `createManyAndReturn` (skipDuplicates absorbs a concurrent
|
||||
// double-submit), updates through one batch transaction per chunk. A
|
||||
// failure part-way leaves the earlier chunks written, and that is safe to
|
||||
// retry: a re-upload of the same file skips what landed (unchanged
|
||||
// externalId rows and natural-key duplicates are not written again).
|
||||
const toCreate: Prisma.MeasurementCreateManyInput[] = [
|
||||
...[...extInserts.values()].map(buildCreateData),
|
||||
...plainInserts.map(buildCreateData),
|
||||
];
|
||||
const CREATE_CHUNK = 200;
|
||||
let createdCount = 0;
|
||||
const createdRows: Array<{
|
||||
id: string;
|
||||
type: MeasurementType;
|
||||
measuredAt: Date;
|
||||
}> = [];
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (let i = 0; i < toCreate.length; i += CREATE_CHUNK) {
|
||||
const rows = await tx.measurement.createManyAndReturn({
|
||||
data: toCreate.slice(i, i + CREATE_CHUNK),
|
||||
skipDuplicates: true,
|
||||
select: { id: true, type: true, measuredAt: true },
|
||||
});
|
||||
createdRows.push(...rows);
|
||||
createdCount += rows.length;
|
||||
}
|
||||
for (const m of extUpdates.values()) {
|
||||
await tx.measurement.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
source: "IMPORT",
|
||||
type: m.type as MeasurementType,
|
||||
externalId: m.externalId as string,
|
||||
},
|
||||
data: {
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
measuredAt: m.measuredAt,
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(m.notes ?? null),
|
||||
glucoseContext:
|
||||
(m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
// No-op on a live row, a deliberate RESURRECTION on a
|
||||
// tombstoned one — IMPORT rows are re-importable by design, so
|
||||
// a re-imported externalId brings the row back (mirrors the
|
||||
// source-owned sync resurrect rule).
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
for (const chunk of chunked(toCreate, WRITE_CHUNK)) {
|
||||
const rows = await prisma.measurement.createManyAndReturn({
|
||||
data: chunk,
|
||||
skipDuplicates: true,
|
||||
select: { id: true, type: true, measuredAt: true },
|
||||
});
|
||||
createdRows.push(...rows);
|
||||
createdCount += rows.length;
|
||||
}
|
||||
for (const chunk of chunked([...extUpdates.values()], WRITE_CHUNK)) {
|
||||
await prisma.$transaction(
|
||||
chunk.map((m) =>
|
||||
prisma.measurement.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
source: "IMPORT",
|
||||
type: m.type as MeasurementType,
|
||||
externalId: m.externalId as string,
|
||||
},
|
||||
data: {
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
measuredAt: m.measuredAt,
|
||||
notes: null,
|
||||
notesEncrypted: encryptNote(m.notes ?? null),
|
||||
glucoseContext:
|
||||
(m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
// No-op on a live row, a deliberate RESURRECTION on a
|
||||
// tombstoned one — IMPORT rows are re-importable by design, so
|
||||
// a re-imported externalId brings the row back (mirrors the
|
||||
// source-owned sync resurrect rule).
|
||||
deletedAt: null,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void emitInsertedMeasurementArrivals(
|
||||
userId,
|
||||
|
||||
@@ -66,6 +66,7 @@ import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
|
||||
import { reserveBudget, reconcileSpend } from "@/lib/ai/coach/budget";
|
||||
import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
|
||||
import { OcrExtractError, runOcrExtraction } from "@/lib/labs/ocr-extract";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -164,6 +165,29 @@ describe("POST /api/labs/ocr/extract — text mode budget", () => {
|
||||
expect(res.status).toBe(502);
|
||||
expect(body.error).toContain("configured AI provider");
|
||||
});
|
||||
|
||||
it("never passes the upstream body on, to the caller or to the event", async () => {
|
||||
const upstreamBody = "ami-id instance-id iam/security-credentials/admin";
|
||||
vi.mocked(runOcrExtraction).mockRejectedValue(
|
||||
Object.assign(new Error("Local AI request failed (403)"), {
|
||||
httpStatus: 403,
|
||||
model: "llama3",
|
||||
bodyExcerpt: upstreamBody,
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await POST(textReq());
|
||||
expect(res.status).toBe(502);
|
||||
expect(await res.text()).not.toContain("iam/security-credentials");
|
||||
const events = JSON.stringify(vi.mocked(annotate).mock.calls);
|
||||
expect(events).not.toContain("iam/security-credentials");
|
||||
// The status and the model stay, so an operator can still tell why.
|
||||
expect(annotate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
meta: expect.objectContaining({ upstreamStatus: 403, model: "llama3" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/labs/ocr/extract — vision PDF rasterization", () => {
|
||||
|
||||
@@ -72,11 +72,16 @@ export const dynamic = "force-dynamic";
|
||||
/** OCR'd text is bounded in the schema; cap the JSON body proportionally. */
|
||||
const TEXT_BODY_MAX_BYTES = 512 * 1024;
|
||||
|
||||
/**
|
||||
* The wide-event facts of a failed provider call: the status and the model,
|
||||
* never the upstream body. The base URL can be one a user typed, and what an
|
||||
* arbitrary endpoint answers has no business in the event stream (v1.39.3);
|
||||
* the response to the caller carries neither.
|
||||
*/
|
||||
function providerFailureMeta(error: unknown, mode?: "text") {
|
||||
const err = error as {
|
||||
httpStatus?: unknown;
|
||||
model?: unknown;
|
||||
bodyExcerpt?: unknown;
|
||||
};
|
||||
return {
|
||||
reason: "provider_error",
|
||||
@@ -85,9 +90,6 @@ function providerFailureMeta(error: unknown, mode?: "text") {
|
||||
? { upstreamStatus: err.httpStatus }
|
||||
: {}),
|
||||
...(typeof err.model === "string" ? { model: err.model } : {}),
|
||||
...(typeof err.bodyExcerpt === "string"
|
||||
? { upstreamError: err.bodyExcerpt }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* v1.39.3 — `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` is deprecated and narrowed,
|
||||
* not removed. Saving a private base URL under it is still accepted for the
|
||||
* operator's own configuration (an admin account) and refused for any other
|
||||
* account, which needs an exact origin in `AI_PRIVATE_ORIGINS`. Metadata and
|
||||
* link-local are refused for everyone. The real policy and the real public-host
|
||||
* check run here; only persistence is stubbed.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({
|
||||
user: { id: "u-1" },
|
||||
session: { id: "s-1" },
|
||||
})),
|
||||
HttpError: class HttpError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: vi.fn(), update: vi.fn() } },
|
||||
}));
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((v: string) => `dec:${v}`),
|
||||
encrypt: vi.fn((v: string) => `enc:${v}`),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
resolveProviderAvailability: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/ai/server-provider-health", () => ({
|
||||
readServerProviderHealth: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/feature-flags", async () =>
|
||||
(
|
||||
await import("@/__tests__/helpers/assistant-switches-mock")
|
||||
).mockAssistantSwitches(vi.fn()),
|
||||
);
|
||||
vi.mock("@/lib/sharing/provider-work-authority", () => ({
|
||||
providerWorkAuthorityForRecord: vi.fn(() => ({ origin: "owner" })),
|
||||
providerCredentialPolicy: vi.fn(() => "personal"),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
hasActiveConsentForSurface: vi.fn(),
|
||||
}));
|
||||
|
||||
import { PATCH } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { _resetAiGrantsForTests } from "@/lib/ai/local-host-allowlist";
|
||||
|
||||
const patch = PATCH as (req: Request) => Promise<Response>;
|
||||
|
||||
function save(body: Record<string, string>) {
|
||||
return patch(
|
||||
new Request("http://localhost/api/user/ai-provider", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function asRole(role: "ADMIN" | "USER") {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({ role } as never);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.user.findUnique).mockReset();
|
||||
vi.mocked(prisma.user.update).mockReset();
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
_resetAiGrantsForTests();
|
||||
vi.stubEnv("AI_PRIVATE_ORIGINS", "");
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("PATCH /api/user/ai-provider under ALLOW_LOCAL_AI_PRIVATE_HOSTS=true", () => {
|
||||
it.each([
|
||||
["baseUrl", "http://10.0.0.5:11434/v1"],
|
||||
["compatBaseUrl", "http://192.168.1.20:4000/v1"],
|
||||
])("refuses a non-admin account's private %s", async (field, url) => {
|
||||
asRole("USER");
|
||||
const res = await save({ [field]: url });
|
||||
expect(res.status).toBe(422);
|
||||
expect((await res.json()).error).toContain("AI_PRIVATE_ORIGINS");
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["baseUrl", "http://10.0.0.5:11434/v1"],
|
||||
["compatBaseUrl", "http://192.168.1.20:4000/v1"],
|
||||
])("accepts an admin account's private %s", async (field, url) => {
|
||||
asRole("ADMIN");
|
||||
const res = await save({ [field]: url });
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://[fe80::1]:11434/v1",
|
||||
"http://0.0.0.0:11434/v1",
|
||||
])("refuses %s even for an admin account", async (url) => {
|
||||
asRole("ADMIN");
|
||||
const res = await save({ baseUrl: url });
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts a non-admin account's private URL once its exact origin is granted", async () => {
|
||||
vi.stubEnv("AI_PRIVATE_ORIGINS", "http://10.0.0.5:11434");
|
||||
asRole("USER");
|
||||
const res = await save({ baseUrl: "http://10.0.0.5:11434/v1" });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -179,6 +179,26 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
const body = parsed.data;
|
||||
|
||||
// v1.39.3 — whose configuration this is. Settings saved on an admin account
|
||||
// are the operator's own and are still covered by the deprecated
|
||||
// `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true`; any other account needs an exact
|
||||
// origin in `AI_PRIVATE_ORIGINS`. Read from the saved row, the same rule
|
||||
// the provider resolution applies when it later dials the URL.
|
||||
const needsHostPolicy =
|
||||
(typeof body.baseUrl === "string" && body.baseUrl !== "") ||
|
||||
(typeof body.compatBaseUrl === "string" && body.compatBaseUrl !== "");
|
||||
const owner = needsHostPolicy
|
||||
? {
|
||||
operatorTrusted:
|
||||
(
|
||||
await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { role: true },
|
||||
})
|
||||
)?.role === "ADMIN",
|
||||
}
|
||||
: {};
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
|
||||
if (body.provider !== undefined) {
|
||||
@@ -197,14 +217,15 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
const trimmed = body.baseUrl.trim();
|
||||
// SSRF guard: by default reject private/internal hostnames so a
|
||||
// compromised user account cannot point the server at the cloud
|
||||
// metadata endpoint or internal admin panels. v1.18.7 (SECURITY LOW) —
|
||||
// ops opt in via `ALLOW_LOCAL_AI_PRIVATE_HOSTS`, now a host allowlist:
|
||||
// `true` permits any private host (legacy), a comma-separated host list
|
||||
// permits only those exact hostnames.
|
||||
const allowPrivate = isLocalAiHostAllowed(trimmed);
|
||||
// metadata endpoint or internal admin panels. The operator grants a
|
||||
// private endpoint by exact origin (`AI_PRIVATE_ORIGINS`, or the legacy
|
||||
// host list); the deprecated `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` covers
|
||||
// an admin account's own settings only (`owner` above), and no grant
|
||||
// can open metadata or link-local.
|
||||
const allowPrivate = isLocalAiHostAllowed(trimmed, owner);
|
||||
if (!allowPrivate && !isPublicUrl(trimmed)) {
|
||||
return apiError(
|
||||
"Base URL points to an internal/private host. Ops must allow it on this instance via ALLOW_LOCAL_AI_PRIVATE_HOSTS — set it to the exact host (e.g. ollama.lan) or to true for any private host (intended for self-hosted Ollama / LM Studio).",
|
||||
"Base URL points to an internal/private host. The operator must allow its exact origin on this instance via AI_PRIVATE_ORIGINS (e.g. http://ollama.lan:11434) for a self-hosted Ollama / LM Studio.",
|
||||
422,
|
||||
);
|
||||
}
|
||||
@@ -226,9 +247,9 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
// private one only when the operator allowlisted it. Gateways on a LAN
|
||||
// are the normal case for LiteLLM / vLLM, so the escape hatch matters
|
||||
// here as much as it does for Ollama.
|
||||
if (!isLocalAiHostAllowed(trimmed) && !isPublicUrl(trimmed)) {
|
||||
if (!isLocalAiHostAllowed(trimmed, owner) && !isPublicUrl(trimmed)) {
|
||||
return apiError(
|
||||
"Base URL points to an internal/private host. Ops must allow it on this instance via ALLOW_LOCAL_AI_PRIVATE_HOSTS — set it to the exact host (e.g. litellm.lan) or to true for any private host (intended for a self-hosted gateway).",
|
||||
"Base URL points to an internal/private host. The operator must allow its exact origin on this instance via AI_PRIVATE_ORIGINS (e.g. http://litellm.lan:4000) for a self-hosted gateway.",
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.39.3 — the admin AI card names the deprecated
|
||||
* `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` and gives the exact
|
||||
* `AI_PRIVATE_ORIGINS` line to set instead, built from the origins the server
|
||||
* reports in use. Absent when the server reports no such grant.
|
||||
*/
|
||||
|
||||
const state = vi.hoisted(() => ({ data: null as null | object }));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: state.data }),
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { AiServerKeySection } from "../ai-server-key-section";
|
||||
|
||||
function render(): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<AiServerKeySection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const base = {
|
||||
hasKey: false,
|
||||
keyPreview: null,
|
||||
model: "gpt-4o",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
};
|
||||
|
||||
describe("<AiServerKeySection> deprecated private-host notice", () => {
|
||||
it("shows the exact line to set, with every origin in use", () => {
|
||||
state.data = {
|
||||
...base,
|
||||
legacyPrivateHostGrant: {
|
||||
originsInUse: ["http://10.0.0.5:11434", "http://gateway.lan:4000"],
|
||||
},
|
||||
};
|
||||
const html = render();
|
||||
expect(html).toContain('data-slot="admin-ai-legacy-private-hosts"');
|
||||
expect(html).toContain("is deprecated");
|
||||
expect(html).toContain(
|
||||
"AI_PRIVATE_ORIGINS="http://10.0.0.5:11434,http://gateway.lan:4000"",
|
||||
);
|
||||
});
|
||||
|
||||
it("says the setting can go when no saved URL needs it", () => {
|
||||
state.data = { ...base, legacyPrivateHostGrant: { originsInUse: [] } };
|
||||
const html = render();
|
||||
expect(html).toContain("you can remove ALLOW_LOCAL_AI_PRIVATE_HOSTS now");
|
||||
expect(html).not.toContain("admin-ai-legacy-private-hosts-line");
|
||||
});
|
||||
|
||||
it("is absent when the server reports no deprecated grant", () => {
|
||||
state.data = { ...base, legacyPrivateHostGrant: null };
|
||||
expect(render()).not.toContain("admin-ai-legacy-private-hosts");
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { KeyRound, Loader2 } from "lucide-react";
|
||||
import { AlertTriangle, KeyRound, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -49,6 +49,12 @@ interface AiServerKeyResponse {
|
||||
keyPreview: string | null;
|
||||
model: string;
|
||||
baseUrl: string;
|
||||
/**
|
||||
* v1.39.3 — present while the deprecated
|
||||
* `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` is set: the origins it is standing in
|
||||
* for, to copy into `AI_PRIVATE_ORIGINS`.
|
||||
*/
|
||||
legacyPrivateHostGrant?: { originsInUse: string[] } | null;
|
||||
}
|
||||
|
||||
export function AiServerKeySection() {
|
||||
@@ -120,6 +126,11 @@ export function AiServerKeySection() {
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
{settings?.legacyPrivateHostGrant ? (
|
||||
<LegacyPrivateHostNotice
|
||||
originsInUse={settings.legacyPrivateHostGrant.originsInUse}
|
||||
/>
|
||||
) : null}
|
||||
<div className="space-y-2 text-sm">
|
||||
<p>{t("admin.aiServerKey.byokHint")}</p>
|
||||
<p>{t("admin.aiServerKey.consentHint")}</p>
|
||||
@@ -201,3 +212,43 @@ export function AiServerKeySection() {
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.39.3 — the deprecated `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true`, with the exact
|
||||
* line to set instead. The origins come from the saved base URLs on this
|
||||
* instance; the operator copies the line into the server environment.
|
||||
*/
|
||||
function LegacyPrivateHostNotice({ originsInUse }: { originsInUse: string[] }) {
|
||||
const { t } = useTranslations();
|
||||
const line = `AI_PRIVATE_ORIGINS="${originsInUse.join(",")}"`;
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
data-slot="admin-ai-legacy-private-hosts"
|
||||
className="border-warning/40 bg-warning/10 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="font-medium">
|
||||
{t("admin.aiServerKey.legacyGrantTitle")}
|
||||
</p>
|
||||
<p>{t("admin.aiServerKey.legacyGrantBody")}</p>
|
||||
{originsInUse.length > 0 ? (
|
||||
<>
|
||||
<p>{t("admin.aiServerKey.legacyGrantOrigins")}</p>
|
||||
<code
|
||||
data-slot="admin-ai-legacy-private-hosts-line"
|
||||
className="bg-muted block overflow-x-auto rounded px-2 py-1 font-mono text-xs break-all select-all"
|
||||
>
|
||||
{line}
|
||||
</code>
|
||||
</>
|
||||
) : (
|
||||
<p>{t("admin.aiServerKey.legacyGrantNoOrigins")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,14 +9,13 @@
|
||||
* instead of shipping silently.
|
||||
*
|
||||
* The LOCAL AI client and (since v1.37.30) the openai-client's gateway and
|
||||
* admin-key tags are the deliberate exceptions: they stay CONDITIONAL
|
||||
* (`requirePublicHost: !allowPrivate`) so an operator can opt into LAN hosts
|
||||
* via `ALLOW_LOCAL_AI_PRIVATE_HOSTS`. v1.18.7 (SECURITY LOW) — that flag is
|
||||
* now a host ALLOWLIST (`true` = any private host; a comma-separated list =
|
||||
* only those), resolved by `isLocalAiHostAllowed`. We assert the client keeps
|
||||
* the conditional shape AND derives `allowPrivate` from the allowlist helper
|
||||
* (not a raw `=== "true"` binary), never an unconditional `true` (which would
|
||||
* break LAN local models) and never absent (which would re-open the default).
|
||||
* admin-key tags are the deliberate exceptions: an operator can grant a LAN
|
||||
* endpoint. Since v1.39.3 the grant is an exact origin (`AI_PRIVATE_ORIGINS`,
|
||||
* or the legacy host list) and the call is dialled through the pinned
|
||||
* operator-approved dispatcher, never unpinned; the whole policy comes from
|
||||
* `aiEgressPolicyFor`. We assert every AI dial site spreads that policy, and
|
||||
* that neither an unconditional `false` (no pin) nor a local
|
||||
* `isLocalAiHostAllowed` decision (a grant with no pin) reappears there.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
@@ -35,8 +34,9 @@ describe("SSRF requirePublicHost pin inventory", () => {
|
||||
it("openai-client routes person-typed base URLs through the allowlist with a pinned floor", () => {
|
||||
const src = read("ai/openai-client.ts");
|
||||
expect(src).toMatch(
|
||||
/requirePublicHost:\s*this\.isGateway \|\| this\.type === "admin-key"\s*\? requirePublicHostFor\(this\.config\.baseUrl\)\s*:\s*true/,
|
||||
/this\.isGateway \|\| this\.type === "admin-key"\s*\?\s*aiEgressPolicyFor\(url,\s*\{\s*operatorTrusted:\s*this\.config\.operatorTrusted,?\s*\}\)\s*:\s*\{\s*requirePublicHost:\s*true as const\s*\}/,
|
||||
);
|
||||
expect(src).not.toMatch(/requirePublicHost:\s*false/);
|
||||
});
|
||||
|
||||
it("anthropic-client pins the BYO base-URL outbound unconditionally", () => {
|
||||
@@ -83,16 +83,20 @@ describe("SSRF requirePublicHost pin inventory", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("local AI client keeps the pin CONDITIONAL (LAN escape hatch)", () => {
|
||||
it("local AI client dials every call through the shared egress policy", () => {
|
||||
const src = read("ai/local-client.ts");
|
||||
// Must carry the conditional form …
|
||||
expect(src).toMatch(/requirePublicHost:\s*!?\s*allowPrivate/);
|
||||
// … and must NOT be hardened to an unconditional true (that would break
|
||||
// a deliberately-private LAN local model).
|
||||
expect(src).not.toMatch(/requirePublicHost:\s*true/);
|
||||
// … and `allowPrivate` must come from the host-allowlist helper, not a
|
||||
// raw binary `=== "true"` (v1.18.7 — the flag became an allowlist).
|
||||
expect(src).toMatch(/allowPrivate\s*=\s*isLocalAiHostAllowed\(/);
|
||||
expect(src).not.toMatch(/ALLOW_LOCAL_AI_PRIVATE_HOSTS\s*===\s*"true"/);
|
||||
// Both the buffered and the streaming call derive the policy …
|
||||
expect([
|
||||
...src.matchAll(
|
||||
/const egress = aiEgressPolicyFor\(url,\s*\{\s*operatorTrusted:\s*this\.config\.operatorTrusted,?\s*\}\)/g,
|
||||
),
|
||||
]).toHaveLength(2);
|
||||
// … and spread it into safeFetch …
|
||||
expect([...src.matchAll(/\.\.\.egress,/g)]).toHaveLength(2);
|
||||
// … with no local decision beside it: no hard-coded pin value (true
|
||||
// would break a granted LAN model, false would drop the pin) and no
|
||||
// grant check that could skip the operator-approved dispatcher.
|
||||
expect(src).not.toMatch(/requirePublicHost:\s*(?:true|false|!)/);
|
||||
expect(src).not.toMatch(/isLocalAiHostAllowed\(/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* v1.39.3 — the destination policy an AI call is dialled with.
|
||||
*
|
||||
* A granted private origin goes to `safeFetch` as an exact
|
||||
* `operatorApprovedPrivateOrigin`, which routes the connect through the pinned
|
||||
* operator-approved dispatcher: redirects forbidden, metadata, link-local and
|
||||
* the unspecified address dropped at dial time even for a granted name. Before
|
||||
* this release a granted host was dialled with no pin at all. Everything else
|
||||
* is pinned to public hosts.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { safeFetch } = vi.hoisted(() => ({ safeFetch: vi.fn() }));
|
||||
vi.mock("@/lib/safe-fetch", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/safe-fetch")>()),
|
||||
safeFetch,
|
||||
}));
|
||||
|
||||
import { LocalOpenAICompatibleClient } from "../local-client";
|
||||
import { _resetAiGrantsForTests } from "../local-host-allowlist";
|
||||
import { singleUserTurn } from "../types";
|
||||
|
||||
function client(baseUrl: string) {
|
||||
return new LocalOpenAICompatibleClient({
|
||||
apiKey: null,
|
||||
model: "llama3:8b",
|
||||
baseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
_resetAiGrantsForTests();
|
||||
safeFetch.mockReset();
|
||||
safeFetch.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({ choices: [{ message: { content: "ok" } }] }),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
vi.stubEnv("AI_PRIVATE_ORIGINS", "");
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "");
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const call = singleUserTurn({ system: "sys", user: "hi" });
|
||||
|
||||
describe("Local AI egress policy", () => {
|
||||
it("dials a granted origin through the operator-approved pin", async () => {
|
||||
vi.stubEnv("AI_PRIVATE_ORIGINS", "http://ollama.lan:11434");
|
||||
await client("http://ollama.lan:11434/v1").generateCompletion(call);
|
||||
const opts = safeFetch.mock.calls[0][2];
|
||||
expect(opts).toMatchObject({
|
||||
requirePublicHost: false,
|
||||
operatorApprovedPrivateOrigin: "http://ollama.lan:11434",
|
||||
});
|
||||
});
|
||||
|
||||
it("pins an ungranted private URL to public hosts, so safeFetch refuses it", async () => {
|
||||
await client("http://10.0.0.5:11434/v1").generateCompletion(call);
|
||||
const opts = safeFetch.mock.calls[0][2];
|
||||
expect(opts.requirePublicHost).toBe(true);
|
||||
expect(opts).not.toHaveProperty("operatorApprovedPrivateOrigin");
|
||||
});
|
||||
|
||||
it("the deprecated `=true` no longer covers a non-admin account's endpoint", async () => {
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
|
||||
await client("http://10.0.0.5:11434/v1").generateCompletion(call);
|
||||
expect(safeFetch.mock.calls[0][2].requirePublicHost).toBe(true);
|
||||
});
|
||||
|
||||
it("the deprecated `=true` still covers the operator's own endpoint, pinned", async () => {
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
|
||||
await new LocalOpenAICompatibleClient({
|
||||
apiKey: null,
|
||||
model: "llama3:8b",
|
||||
baseUrl: "http://10.0.0.5:11434/v1",
|
||||
operatorTrusted: true,
|
||||
}).generateCompletion(call);
|
||||
expect(safeFetch.mock.calls[0][2]).toMatchObject({
|
||||
requirePublicHost: false,
|
||||
operatorApprovedPrivateOrigin: "http://10.0.0.5:11434",
|
||||
});
|
||||
});
|
||||
|
||||
it("streams through the same policy", async () => {
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "ollama.lan");
|
||||
await client("http://ollama.lan:11434/v1")
|
||||
.generateCompletionStream(call, () => {})
|
||||
.catch(() => undefined);
|
||||
expect(safeFetch.mock.calls[0][2]).toMatchObject({
|
||||
requirePublicHost: false,
|
||||
operatorApprovedPrivateOrigin: "http://ollama.lan:11434",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,11 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// The client's destination policy has its own suite
|
||||
// (`local-host-allowlist.test.ts`, `local-client-egress.test.ts`). Here the
|
||||
// grant is stubbed to "no policy" so the request shape can be asserted
|
||||
// against the stubbed global fetch; the pinned dispatchers drive undici's own
|
||||
// fetch, which a global stub cannot see.
|
||||
vi.mock("../local-host-allowlist", () => ({ aiEgressPolicyFor: () => ({}) }));
|
||||
import {
|
||||
LocalOpenAICompatibleClient,
|
||||
resetLocalJsonDialectCache,
|
||||
@@ -8,10 +15,6 @@ import { singleUserTurn } from "../types";
|
||||
describe("LocalOpenAICompatibleClient", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
// These tests cover the operator-opt-in path (LM Studio / Ollama on
|
||||
// an RFC1918 host). The safeFetch wrapper enforces the SSRF guard
|
||||
// unless the operator explicitly accepts a private host.
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
|
||||
// v1.28.28 (#470) — the JSON dialect is cached per baseUrl for the
|
||||
// process; clear it so each test starts from the default dialect.
|
||||
resetLocalJsonDialectCache();
|
||||
|
||||
@@ -1,57 +1,183 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
_resetAiGrantsForTests,
|
||||
aiEgressPolicyFor,
|
||||
grantedAiOrigin,
|
||||
isLocalAiHostAllowed,
|
||||
requirePublicHostFor,
|
||||
legacyAnyHostConfigured,
|
||||
LEGACY_ANY_HOST_WARNING,
|
||||
originNeedingAiGrant,
|
||||
} from "../local-host-allowlist";
|
||||
|
||||
/**
|
||||
* v1.39.3 — AI base URLs on a private network are granted by exact origin
|
||||
* (`AI_PRIVATE_ORIGINS`), the grammar the notification and Nightscout grants
|
||||
* use, and dialled through the pinned operator-approved dispatcher. The legacy
|
||||
* host list keeps working; its deprecated `=true` form covers only the
|
||||
* operator's own configurations.
|
||||
*/
|
||||
|
||||
let warn: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
_resetAiGrantsForTests();
|
||||
vi.stubEnv("AI_PRIVATE_ORIGINS", "");
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "");
|
||||
warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
describe("isLocalAiHostAllowed", () => {
|
||||
it("denies every private host when unset (secure default)", () => {
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "");
|
||||
describe("AI_PRIVATE_ORIGINS", () => {
|
||||
it("grants nothing when unset", () => {
|
||||
expect(isLocalAiHostAllowed("http://10.0.0.5:11434/v1")).toBe(false);
|
||||
expect(isLocalAiHostAllowed("http://ollama.lan/v1")).toBe(false);
|
||||
});
|
||||
|
||||
it('treats "false" as denying every private host', () => {
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "false");
|
||||
expect(isLocalAiHostAllowed("http://10.0.0.5/v1")).toBe(false);
|
||||
it("grants exactly the listed origin, not its siblings", () => {
|
||||
vi.stubEnv(
|
||||
"AI_PRIVATE_ORIGINS",
|
||||
"http://ollama.lan:11434, http://10.0.0.5:4000",
|
||||
);
|
||||
expect(grantedAiOrigin("http://ollama.lan:11434/v1/chat")).toBe(
|
||||
"http://ollama.lan:11434",
|
||||
);
|
||||
expect(isLocalAiHostAllowed("http://10.0.0.5:4000/v1")).toBe(true);
|
||||
// Another port, another scheme, a subdomain: all separate origins.
|
||||
expect(isLocalAiHostAllowed("http://ollama.lan:8080/v1")).toBe(false);
|
||||
expect(isLocalAiHostAllowed("https://ollama.lan:11434/v1")).toBe(false);
|
||||
expect(isLocalAiHostAllowed("http://evil.ollama.lan:11434/v1")).toBe(false);
|
||||
expect(isLocalAiHostAllowed("http://10.0.0.6:4000/v1")).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the legacy "true" = any private host', () => {
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
|
||||
expect(isLocalAiHostAllowed("http://10.0.0.5:11434/v1")).toBe(true);
|
||||
expect(isLocalAiHostAllowed("http://anything.lan/v1")).toBe(true);
|
||||
});
|
||||
|
||||
it("permits ONLY the listed hosts when given a comma-separated allowlist", () => {
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "ollama.lan, 10.0.0.5");
|
||||
expect(isLocalAiHostAllowed("http://ollama.lan:11434/v1")).toBe(true);
|
||||
expect(isLocalAiHostAllowed("http://10.0.0.5:11434/v1")).toBe(true);
|
||||
// A different private host is still rejected — the metadata endpoint and
|
||||
// internal panels no longer ride the binary flag.
|
||||
it("never grants metadata, link-local or the unspecified address, even when listed", () => {
|
||||
vi.stubEnv(
|
||||
"AI_PRIVATE_ORIGINS",
|
||||
"http://169.254.169.254,http://[fe80::1]:80,http://0.0.0.0:11434",
|
||||
);
|
||||
expect(isLocalAiHostAllowed("http://169.254.169.254/latest/")).toBe(false);
|
||||
expect(isLocalAiHostAllowed("http://[fe80::1]/v1")).toBe(false);
|
||||
expect(isLocalAiHostAllowed("http://0.0.0.0:11434/v1")).toBe(false);
|
||||
expect(warn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ALLOW_LOCAL_AI_PRIVATE_HOSTS (legacy)", () => {
|
||||
it("still grants a listed host on any port", () => {
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", " Ollama.LAN , 10.0.0.5 ");
|
||||
expect(isLocalAiHostAllowed("http://ollama.lan:11434/v1")).toBe(true);
|
||||
expect(isLocalAiHostAllowed("http://10.0.0.5:8080/v1")).toBe(true);
|
||||
expect(isLocalAiHostAllowed("http://10.0.0.6/v1")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches hostnames case-insensitively and ignores blank entries", () => {
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", " , Ollama.LAN , ");
|
||||
expect(isLocalAiHostAllowed("http://ollama.lan/v1")).toBe(true);
|
||||
it("`true` still covers an operator-owned configuration, pinned, and says it is deprecated", () => {
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
|
||||
expect(
|
||||
aiEgressPolicyFor("http://10.0.0.5:11434/v1", { operatorTrusted: true }),
|
||||
).toEqual({
|
||||
requirePublicHost: false,
|
||||
operatorApprovedPrivateOrigin: "http://10.0.0.5:11434",
|
||||
});
|
||||
expect(warn).toHaveBeenCalledWith(LEGACY_ANY_HOST_WARNING);
|
||||
expect(LEGACY_ANY_HOST_WARNING).toContain("deprecated");
|
||||
expect(LEGACY_ANY_HOST_WARNING).toContain("AI_PRIVATE_ORIGINS=");
|
||||
expect(legacyAnyHostConfigured()).toBe(true);
|
||||
});
|
||||
|
||||
it("never permits an unparseable URL", () => {
|
||||
it("`true` no longer covers a non-admin account's own base URL", () => {
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
|
||||
expect(isLocalAiHostAllowed("not a url")).toBe(true); // host-agnostic "any"
|
||||
expect(isLocalAiHostAllowed("http://10.0.0.5:11434/v1")).toBe(false);
|
||||
expect(
|
||||
isLocalAiHostAllowed("http://10.0.0.5:11434/v1", {
|
||||
operatorTrusted: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(aiEgressPolicyFor("http://10.0.0.5:11434/v1")).toEqual({
|
||||
requirePublicHost: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("`true` never opens metadata, link-local or the unspecified address, even for the operator", () => {
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
|
||||
for (const url of [
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://[fe80::1]/v1",
|
||||
"http://0.0.0.0:11434/v1",
|
||||
"http://[::ffff:169.254.169.254]/v1",
|
||||
]) {
|
||||
expect(isLocalAiHostAllowed(url, { operatorTrusted: true }), url).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("an exact origin grant covers every account, with or without `true`", () => {
|
||||
vi.stubEnv("AI_PRIVATE_ORIGINS", "http://ollama.lan:11434");
|
||||
expect(isLocalAiHostAllowed("http://ollama.lan:11434/v1")).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a metadata literal and a host:port entry instead of granting a guess", () => {
|
||||
vi.stubEnv(
|
||||
"ALLOW_LOCAL_AI_PRIVATE_HOSTS",
|
||||
"169.254.169.254,ollama.lan:11434",
|
||||
);
|
||||
expect(isLocalAiHostAllowed("http://169.254.169.254/latest/")).toBe(false);
|
||||
expect(isLocalAiHostAllowed("http://ollama.lan:11434/v1")).toBe(false);
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("an unparseable URL is never granted", () => {
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "ollama.lan");
|
||||
expect(isLocalAiHostAllowed("not a url")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("requirePublicHostFor is the inverse of the allowlist", () => {
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "ollama.lan");
|
||||
expect(requirePublicHostFor("http://ollama.lan/v1")).toBe(false);
|
||||
expect(requirePublicHostFor("http://10.0.0.6/v1")).toBe(true);
|
||||
describe("aiEgressPolicyFor", () => {
|
||||
it("pins an ungranted URL to the public dispatcher", () => {
|
||||
expect(aiEgressPolicyFor("http://10.0.0.5/v1")).toEqual({
|
||||
requirePublicHost: true,
|
||||
});
|
||||
expect(aiEgressPolicyFor("https://api.example.com/v1")).toEqual({
|
||||
requirePublicHost: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("hands a granted URL to the operator-approved dispatcher for exactly its origin", () => {
|
||||
vi.stubEnv("AI_PRIVATE_ORIGINS", "http://ollama.lan:11434");
|
||||
expect(aiEgressPolicyFor("http://ollama.lan:11434/v1/chat")).toEqual({
|
||||
requirePublicHost: false,
|
||||
operatorApprovedPrivateOrigin: "http://ollama.lan:11434",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("originNeedingAiGrant", () => {
|
||||
it("names the origin of a LAN endpoint no grant covers yet", () => {
|
||||
for (const [url, origin] of [
|
||||
["http://10.0.0.5:11434/v1", "http://10.0.0.5:11434"],
|
||||
["http://ollama.lan:11434/v1", "http://ollama.lan:11434"],
|
||||
["http://ollama:11434/v1", "http://ollama:11434"],
|
||||
["http://nas.local:1234/v1", "http://nas.local:1234"],
|
||||
["http://localhost:11434/v1", "http://localhost:11434"],
|
||||
]) {
|
||||
expect(originNeedingAiGrant(url), url).toBe(origin);
|
||||
}
|
||||
});
|
||||
|
||||
it("skips hosted endpoints, never-grantable addresses and origins already granted", () => {
|
||||
vi.stubEnv("AI_PRIVATE_ORIGINS", "http://10.0.0.5:11434");
|
||||
for (const url of [
|
||||
"https://openrouter.ai/api/v1",
|
||||
"https://api.openai.com/v1",
|
||||
"http://169.254.169.254/latest",
|
||||
"http://10.0.0.5:11434/v1",
|
||||
"not a url",
|
||||
]) {
|
||||
expect(originNeedingAiGrant(url), url).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -53,8 +53,8 @@ describe("AIProvider contract", () => {
|
||||
vi.restoreAllMocks();
|
||||
// The local-provider contract test points at a localhost OpenAI-
|
||||
// compatible server (Ollama / LM Studio). safeFetch enforces the
|
||||
// SSRF guard unless the operator explicitly opts in.
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
|
||||
// SSRF guard unless the operator grants that exact origin.
|
||||
vi.stubEnv("AI_PRIVATE_ORIGINS", "http://localhost:11434");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* v1.39.3 — the deprecated `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` at dial time.
|
||||
*
|
||||
* Provider resolution decides whose configuration a base URL is, and the
|
||||
* client dials accordingly. Under `=true` a private endpoint is still reached
|
||||
* for the operator's own configurations (the instance-wide admin provider and
|
||||
* settings saved on an admin account), always through the pinned
|
||||
* operator-approved dispatcher; any other account's saved URL is pinned to
|
||||
* public hosts, so `safeFetch` refuses it. Checked at the wire: the options
|
||||
* each resolved provider hands to `safeFetch`.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { safeFetch } = vi.hoisted(() => ({ safeFetch: vi.fn() }));
|
||||
vi.mock("@/lib/safe-fetch", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/safe-fetch")>()),
|
||||
safeFetch,
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((v: string) => `decrypted:${v}`),
|
||||
encrypt: vi.fn((v: string) => `encrypted:${v}`),
|
||||
}));
|
||||
vi.mock("@/lib/ai/codex-oauth", () => ({
|
||||
refreshDeviceTokens: vi.fn(),
|
||||
encryptCodexCreds: vi.fn(),
|
||||
decryptCodexCreds: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
import {
|
||||
AITestConfigError,
|
||||
resolveProvider,
|
||||
resolveProviderForTest,
|
||||
} from "../provider";
|
||||
import { _resetAiGrantsForTests } from "../local-host-allowlist";
|
||||
import { singleUserTurn } from "../types";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
function row(role: "ADMIN" | "USER", overrides: Record<string, unknown>) {
|
||||
return {
|
||||
aiProvider: null,
|
||||
aiModel: "m",
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: null,
|
||||
aiCompatBaseUrl: null,
|
||||
aiCompatKeyEncrypted: null,
|
||||
aiCompatModel: null,
|
||||
managedProfileAt: null,
|
||||
role,
|
||||
...overrides,
|
||||
} as never;
|
||||
}
|
||||
|
||||
async function dialOptions(): Promise<Record<string, unknown>> {
|
||||
const provider = await resolveProvider("u-1");
|
||||
await provider
|
||||
.generateCompletion(singleUserTurn({ system: "s", user: "u" }))
|
||||
.catch(() => undefined);
|
||||
return safeFetch.mock.calls[0][2] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
_resetAiGrantsForTests();
|
||||
safeFetch.mockReset();
|
||||
safeFetch.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({ choices: [{ message: { content: "ok" } }] }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
),
|
||||
);
|
||||
vi.mocked(prisma.user.findUnique).mockReset();
|
||||
vi.mocked(prisma.appSettings.findUnique).mockReset();
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
|
||||
vi.stubEnv("AI_PRIVATE_ORIGINS", "");
|
||||
vi.stubEnv("ALLOW_LOCAL_AI_PRIVATE_HOSTS", "true");
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("ALLOW_LOCAL_AI_PRIVATE_HOSTS=true at dial time", () => {
|
||||
it.each([
|
||||
["LOCAL", { aiProvider: "LOCAL", aiBaseUrl: "http://10.0.0.5:11434/v1" }],
|
||||
[
|
||||
"OPENAI_COMPATIBLE",
|
||||
{
|
||||
aiProvider: "OPENAI_COMPATIBLE",
|
||||
aiCompatBaseUrl: "http://10.0.0.5:4000/v1",
|
||||
aiCompatModel: "m",
|
||||
},
|
||||
],
|
||||
])(
|
||||
"an admin account's %s endpoint goes through the operator-approved pin",
|
||||
async (_label, overrides) => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
||||
row("ADMIN", overrides),
|
||||
);
|
||||
const opts = await dialOptions();
|
||||
expect(opts.requirePublicHost).toBe(false);
|
||||
expect(opts.operatorApprovedPrivateOrigin).toMatch(
|
||||
/^http:\/\/10\.0\.0\.5:/,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["LOCAL", { aiProvider: "LOCAL", aiBaseUrl: "http://10.0.0.5:11434/v1" }],
|
||||
[
|
||||
"OPENAI_COMPATIBLE",
|
||||
{
|
||||
aiProvider: "OPENAI_COMPATIBLE",
|
||||
aiCompatBaseUrl: "http://10.0.0.5:4000/v1",
|
||||
aiCompatModel: "m",
|
||||
},
|
||||
],
|
||||
])(
|
||||
"a non-admin account's %s endpoint stays on the public pin",
|
||||
async (_label, overrides) => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
||||
row("USER", overrides),
|
||||
);
|
||||
const opts = await dialOptions();
|
||||
expect(opts.requirePublicHost).toBe(true);
|
||||
expect(opts).not.toHaveProperty("operatorApprovedPrivateOrigin");
|
||||
},
|
||||
);
|
||||
|
||||
it("the metadata address stays on the public pin even for an admin account", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
||||
row("ADMIN", {
|
||||
aiProvider: "LOCAL",
|
||||
aiBaseUrl: "http://169.254.169.254/latest/v1",
|
||||
}),
|
||||
);
|
||||
const opts = await dialOptions();
|
||||
expect(opts.requirePublicHost).toBe(true);
|
||||
});
|
||||
|
||||
it("the instance-wide admin provider is the operator's own", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
||||
row("USER", { aiProvider: null }),
|
||||
);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: "k",
|
||||
adminAiModel: "m",
|
||||
adminAiBaseUrl: "http://10.0.0.7:8080/v1",
|
||||
} as never);
|
||||
const opts = await dialOptions();
|
||||
expect(opts).toMatchObject({
|
||||
requirePublicHost: false,
|
||||
operatorApprovedPrivateOrigin: "http://10.0.0.7:8080",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("the connection test under ALLOW_LOCAL_AI_PRIVATE_HOSTS=true", () => {
|
||||
it("refuses a non-admin account's private endpoint", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
||||
row("USER", {
|
||||
aiProvider: "LOCAL",
|
||||
aiBaseUrl: "http://10.0.0.5:11434/v1",
|
||||
}),
|
||||
);
|
||||
await expect(resolveProviderForTest("u-1")).rejects.toBeInstanceOf(
|
||||
AITestConfigError,
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves an admin account's private endpoint", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(
|
||||
row("ADMIN", {
|
||||
aiProvider: "LOCAL",
|
||||
aiBaseUrl: "http://10.0.0.5:11434/v1",
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
resolveProviderForTest("u-1", { provider: "LOCAL" }),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -104,15 +104,12 @@ describe("listConversations — filter + DTO", () => {
|
||||
});
|
||||
|
||||
// v1.30.2 (QoL H1) — server-side title search for the full-history rail.
|
||||
it("applies a case-insensitive title-contains filter when q is given", async () => {
|
||||
it("keeps the title out of SQL when q is given (it is encrypted) and stays owner-scoped", async () => {
|
||||
findMany.mockResolvedValue([]);
|
||||
await listConversations({ userId: "user-1", q: "blood pressure" });
|
||||
const where = findMany.mock.calls[0][0].where;
|
||||
expect(where.userId).toBe("user-1");
|
||||
expect(where.title).toEqual({
|
||||
contains: "blood pressure",
|
||||
mode: "insensitive",
|
||||
});
|
||||
expect("title" in where).toBe(false);
|
||||
});
|
||||
|
||||
it("trims q and omits the title filter for an empty/whitespace-only query", async () => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
const { updateMany } = vi.hoisted(() => ({ updateMany: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
@@ -9,6 +12,7 @@ vi.mock("@/lib/db", () => ({
|
||||
}));
|
||||
|
||||
import { renameConversation } from "../persistence";
|
||||
import { decryptFromBytes } from "../bytes-codec";
|
||||
|
||||
beforeEach(() => {
|
||||
updateMany.mockReset();
|
||||
@@ -21,10 +25,12 @@ describe("renameConversation", () => {
|
||||
await expect(
|
||||
renameConversation("owner-1", "conversation-1", "Renamed"),
|
||||
).resolves.toEqual({ id: "conversation-1", title: "Renamed" });
|
||||
expect(updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "conversation-1", userId: "owner-1" },
|
||||
data: { title: "Renamed" },
|
||||
});
|
||||
const arg = updateMany.mock.calls[0][0];
|
||||
expect(arg.where).toEqual({ id: "conversation-1", userId: "owner-1" });
|
||||
// v1.39.3 — the new title is stored as ciphertext and the readable column
|
||||
// is cleared, so a legacy row never keeps its old title beside the new one.
|
||||
expect(arg.data.title).toBeNull();
|
||||
expect(decryptFromBytes(arg.data.titleEncrypted)).toBe("Renamed");
|
||||
});
|
||||
|
||||
it("returns null when the owned update matched no row", async () => {
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* v1.39.3 — the Coach conversation title is encrypted at rest. These pin the
|
||||
* three things that change with it:
|
||||
* 1. every write stores the title as ciphertext and never in the readable
|
||||
* column;
|
||||
* 2. every read prefers the ciphertext, falls back to the readable column
|
||||
* only for a row the backfill has not reached, and never falls back past
|
||||
* a ciphertext that does not open;
|
||||
* 3. the history search, which used to be an SQL `ILIKE`, still finds titles
|
||||
* (sealed and legacy alike), case-insensitively, and pages with the same
|
||||
* cursor contract.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
const { findMany, create } = vi.hoisted(() => ({
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
coachConversation: { findMany },
|
||||
$transaction: async (
|
||||
fn: (tx: { coachConversation: { create: typeof create } }) => unknown,
|
||||
) => fn({ coachConversation: { create } }),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
createConversation,
|
||||
listConversations,
|
||||
readConversationTitle,
|
||||
} from "../persistence";
|
||||
import { decryptFromBytes, encryptToBytes } from "../bytes-codec";
|
||||
|
||||
const now = new Date("2026-09-20T10:00:00.000Z");
|
||||
|
||||
interface StoredRow {
|
||||
id: string;
|
||||
title: string | null;
|
||||
titleEncrypted: Uint8Array | null;
|
||||
}
|
||||
|
||||
function fullRow(row: StoredRow) {
|
||||
return {
|
||||
...row,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
documentScoped: false,
|
||||
_count: { messages: 2 },
|
||||
attachments: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A findMany that answers both reads of the search path: the id + title scan
|
||||
* (select) and the page fetch (`id: { in }`).
|
||||
*/
|
||||
function serve(rows: StoredRow[]) {
|
||||
findMany.mockImplementation(
|
||||
async (args: {
|
||||
where: { id?: { in: string[] } };
|
||||
select?: unknown;
|
||||
take?: number;
|
||||
}) => {
|
||||
if (args.where.id) {
|
||||
// Returned out of order on purpose: the page must follow the scan order.
|
||||
return rows
|
||||
.filter((r) => args.where.id!.in.includes(r.id))
|
||||
.reverse()
|
||||
.map(fullRow);
|
||||
}
|
||||
if (args.select) return rows.slice(0, args.take);
|
||||
return rows.slice(0, args.take).map(fullRow);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
findMany.mockReset();
|
||||
create.mockReset();
|
||||
});
|
||||
|
||||
describe("createConversation", () => {
|
||||
it("stores the summarised title as ciphertext only", async () => {
|
||||
create.mockResolvedValue({
|
||||
id: "c1",
|
||||
title: null,
|
||||
titleEncrypted: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
documentScoped: false,
|
||||
});
|
||||
const dto = await createConversation({
|
||||
userId: "u1",
|
||||
title: "Is my resting pulse\nhigher since the new job?",
|
||||
});
|
||||
const data = create.mock.calls[0][0].data;
|
||||
expect(data).not.toHaveProperty("title");
|
||||
expect(decryptFromBytes(data.titleEncrypted)).toBe(
|
||||
"Is my resting pulse higher since the new job?",
|
||||
);
|
||||
expect(dto.title).toBe("Is my resting pulse higher since the new job?");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readConversationTitle", () => {
|
||||
it("prefers the ciphertext over a readable value", () => {
|
||||
expect(
|
||||
readConversationTitle({
|
||||
title: "stale",
|
||||
titleEncrypted: encryptToBytes("sealed"),
|
||||
}),
|
||||
).toBe("sealed");
|
||||
});
|
||||
|
||||
it("falls back to the readable column only when there is no ciphertext", () => {
|
||||
expect(
|
||||
readConversationTitle({ title: "legacy", titleEncrypted: null }),
|
||||
).toBe("legacy");
|
||||
expect(readConversationTitle({ title: null, titleEncrypted: null })).toBe(
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on a ciphertext that does not open instead of showing the readable value", () => {
|
||||
expect(() =>
|
||||
readConversationTitle({
|
||||
title: "must not leak",
|
||||
titleEncrypted: new TextEncoder().encode("not-a-ciphertext"),
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("listConversations title search", () => {
|
||||
const rows: StoredRow[] = [
|
||||
{ id: "c5", title: null, titleEncrypted: encryptToBytes("Blood pressure") },
|
||||
{ id: "c4", title: null, titleEncrypted: encryptToBytes("Sleep debt") },
|
||||
// A row the backfill has not reached yet is still searchable.
|
||||
{ id: "c3", title: "Morning BLOOD pressure spikes", titleEncrypted: null },
|
||||
{ id: "c2", title: null, titleEncrypted: encryptToBytes("Knee pain") },
|
||||
{
|
||||
id: "c1",
|
||||
title: null,
|
||||
titleEncrypted: encryptToBytes("blood pressure and salt"),
|
||||
},
|
||||
];
|
||||
|
||||
it("matches decrypted and legacy titles case-insensitively, in rail order", async () => {
|
||||
serve(rows);
|
||||
const page = await listConversations({ userId: "u1", q: "Blood Pressure" });
|
||||
expect(page.conversations.map((c) => c.id)).toEqual(["c5", "c3", "c1"]);
|
||||
expect(page.conversations.map((c) => c.title)).toEqual([
|
||||
"Blood pressure",
|
||||
"Morning BLOOD pressure spikes",
|
||||
"blood pressure and salt",
|
||||
]);
|
||||
expect(page.nextCursor).toBeNull();
|
||||
// The scan stays owner-scoped and never matches in SQL.
|
||||
const scan = findMany.mock.calls[0][0];
|
||||
expect(scan.where.userId).toBe("u1");
|
||||
expect("title" in scan.where).toBe(false);
|
||||
});
|
||||
|
||||
it("pages the matches with the cursor contract of the unfiltered rail", async () => {
|
||||
serve(rows);
|
||||
const first = await listConversations({
|
||||
userId: "u1",
|
||||
q: "blood",
|
||||
limit: 2,
|
||||
});
|
||||
expect(first.conversations.map((c) => c.id)).toEqual(["c5", "c3"]);
|
||||
expect(first.nextCursor).toBe("c3");
|
||||
|
||||
const second = await listConversations({
|
||||
userId: "u1",
|
||||
q: "blood",
|
||||
limit: 2,
|
||||
cursor: first.nextCursor,
|
||||
});
|
||||
expect(second.conversations.map((c) => c.id)).toEqual(["c1"]);
|
||||
expect(second.nextCursor).toBeNull();
|
||||
});
|
||||
|
||||
it("answers an unknown cursor with an empty page", async () => {
|
||||
serve(rows);
|
||||
const page = await listConversations({
|
||||
userId: "u1",
|
||||
q: "blood",
|
||||
cursor: "gone",
|
||||
});
|
||||
expect(page.conversations).toEqual([]);
|
||||
expect(page.nextCursor).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -96,10 +96,14 @@ describe("recordProactiveNudge", () => {
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
// Conversation owned by the user, title summarised.
|
||||
expect(txCreate.coachConversation.create).toHaveBeenCalledWith({
|
||||
data: { userId: "user_1", title: "Time to weigh in" },
|
||||
});
|
||||
// Conversation owned by the user, title summarised and stored as
|
||||
// ciphertext only (v1.39.3); the readable column is never written.
|
||||
const convArg = txCreate.coachConversation.create.mock.calls[0][0];
|
||||
expect(convArg.data.userId).toBe("user_1");
|
||||
expect(convArg.data).not.toHaveProperty("title");
|
||||
expect(new TextDecoder().decode(convArg.data.titleEncrypted)).toBe(
|
||||
"enc:Time to weigh in",
|
||||
);
|
||||
|
||||
// The body is encrypted at rest (Bytes), role is assistant, and the
|
||||
// message hangs off the new conversation. No raw plaintext column.
|
||||
|
||||
+126
-41
@@ -31,9 +31,9 @@ import type {
|
||||
|
||||
/**
|
||||
* Title-from-message — first 80 chars trimmed, ellipsis on overflow.
|
||||
* Stays plain text (the history rail needs to render without paying
|
||||
* the per-message decrypt cost), so callers should pass already-
|
||||
* sanitised input.
|
||||
* Callers should pass already-sanitised input. The result is stored
|
||||
* encrypted (`titleEncrypted`, v1.39.3): it is the opening words of the
|
||||
* person's first message, which is as sensitive as the message itself.
|
||||
*/
|
||||
export function summariseTitle(input: string): string {
|
||||
const collapsed = input.replace(/\s+/g, " ").trim();
|
||||
@@ -301,15 +301,44 @@ export interface AppendMessageParams {
|
||||
* Create a brand-new conversation row owned by `userId`. Caller is
|
||||
* expected to immediately append the first user message.
|
||||
*/
|
||||
/**
|
||||
* v1.39.3 — the title's at-rest form. Always a ciphertext: `summariseTitle`
|
||||
* never returns an empty string and a rename is validated non-empty, so there
|
||||
* is no "no title" case to store as NULL.
|
||||
*/
|
||||
export function encryptConversationTitle(
|
||||
title: string,
|
||||
): Uint8Array<ArrayBuffer> {
|
||||
return encryptToBytes(title);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.39.3 — read a conversation title, ciphertext first. The readable column
|
||||
* is consulted only when there is no ciphertext, which is a row the free-text
|
||||
* encryption backfill has not reached yet. A present ciphertext that does not
|
||||
* decrypt throws, the same fail-closed posture as every note reader
|
||||
* (`readNote`); it never falls back to the readable column.
|
||||
*/
|
||||
export function readConversationTitle(row: {
|
||||
title: string | null;
|
||||
titleEncrypted: Uint8Array | null;
|
||||
}): string {
|
||||
if (row.titleEncrypted && row.titleEncrypted.byteLength > 0) {
|
||||
return decryptFromBytes(row.titleEncrypted);
|
||||
}
|
||||
return row.title ?? "";
|
||||
}
|
||||
|
||||
export async function createConversation(
|
||||
params: CreateConversationParams,
|
||||
): Promise<CoachConversationDTO> {
|
||||
const attachmentIds = params.attachmentIds ?? [];
|
||||
const title = summariseTitle(params.title);
|
||||
const row = await prisma.$transaction(async (tx) => {
|
||||
const conversation = await tx.coachConversation.create({
|
||||
data: {
|
||||
userId: params.userId,
|
||||
title: summariseTitle(params.title),
|
||||
titleEncrypted: encryptConversationTitle(title),
|
||||
documentScoped: params.documentScoped ?? false,
|
||||
},
|
||||
});
|
||||
@@ -327,7 +356,7 @@ export async function createConversation(
|
||||
});
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
title,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
messageCount: 0,
|
||||
@@ -439,7 +468,7 @@ export async function recordProactiveNudge(
|
||||
const conversation = await tx.coachConversation.create({
|
||||
data: {
|
||||
userId: params.userId,
|
||||
title: summariseTitle(params.title),
|
||||
titleEncrypted: encryptConversationTitle(summariseTitle(params.title)),
|
||||
},
|
||||
});
|
||||
const message = await tx.coachMessage.create({
|
||||
@@ -559,7 +588,7 @@ export async function fetchConversationWithMessages(
|
||||
const attachments = mapAttachments(row.attachments);
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
title: readConversationTitle(row),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
messageCount: messages.length,
|
||||
@@ -586,15 +615,31 @@ export interface ListConversationsParams {
|
||||
/**
|
||||
* v1.30.2 (QoL H1) — optional server-side title search for the history
|
||||
* rail + the standalone conversations page. Case-insensitive substring
|
||||
* match against `title` only — the only plaintext column on the row;
|
||||
* `CoachMessage.encryptedContent` cannot be searched without decrypting
|
||||
* every message, so message BODIES are out of scope for this pass (see
|
||||
* the route's doc comment). Trimmed empty string is treated as "no
|
||||
* filter", matching the pre-existing cursor/limit handling style.
|
||||
* match against the title only; message BODIES are out of scope (see the
|
||||
* route's doc comment). Since v1.39.3 the title is encrypted at rest, so
|
||||
* the match runs on the decrypted titles in memory rather than in SQL (see
|
||||
* `TITLE_SEARCH_SCAN_CAP`). Trimmed empty string is treated as "no filter",
|
||||
* matching the pre-existing cursor/limit handling style.
|
||||
*/
|
||||
q?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.39.3 — how many of an account's conversations a title search decrypts,
|
||||
* newest first. The title is encrypted at rest, so SQL cannot match it; the
|
||||
* search reads id + title for the account, decrypts, and filters in memory.
|
||||
* A title is at most 80 characters, so a scan of the whole cap is a few
|
||||
* milliseconds of AES. An account past the cap has its oldest threads left out
|
||||
* of search results (never out of the unfiltered rail), which no real account
|
||||
* comes near: the rail pages 20 at a time and a person opens a few a day.
|
||||
*/
|
||||
export const TITLE_SEARCH_SCAN_CAP = 5000;
|
||||
|
||||
const CONVERSATION_LIST_ORDER = [
|
||||
{ updatedAt: "desc" as const },
|
||||
{ id: "desc" as const },
|
||||
];
|
||||
|
||||
/**
|
||||
* Cursor-paginated list of conversations for the rail. Default limit
|
||||
* 20, cap 50. Cursor is the id of the last item on the previous page;
|
||||
@@ -608,43 +653,81 @@ export async function listConversations(
|
||||
}> {
|
||||
const limit = Math.min(Math.max(params.limit ?? 20, 1), 50);
|
||||
const q = params.q?.trim();
|
||||
const rows = await prisma.coachConversation.findMany({
|
||||
where: {
|
||||
userId: params.userId,
|
||||
...(params.attachedDocumentId
|
||||
? { attachments: { some: { documentId: params.attachedDocumentId } } }
|
||||
: {}),
|
||||
...(q ? { title: { contains: q, mode: "insensitive" } } : {}),
|
||||
},
|
||||
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
|
||||
take: limit + 1,
|
||||
...(params.cursor
|
||||
? {
|
||||
cursor: { id: params.cursor },
|
||||
skip: 1,
|
||||
}
|
||||
const scope = {
|
||||
userId: params.userId,
|
||||
...(params.attachedDocumentId
|
||||
? { attachments: { some: { documentId: params.attachedDocumentId } } }
|
||||
: {}),
|
||||
include: {
|
||||
_count: { select: { messages: true } },
|
||||
// v1.29.x (S7) — the live attachment set (label columns only) so the rail
|
||||
// can badge a fenced thread with a paperclip + the first document's title.
|
||||
// Empty on a health thread.
|
||||
attachments: {
|
||||
orderBy: { addedAt: "asc" },
|
||||
include: { document: { select: { title: true, filename: true } } },
|
||||
},
|
||||
};
|
||||
const include = {
|
||||
_count: { select: { messages: true } },
|
||||
// v1.29.x (S7) — the live attachment set (label columns only) so the rail
|
||||
// can badge a fenced thread with a paperclip + the first document's title.
|
||||
// Empty on a health thread.
|
||||
attachments: {
|
||||
orderBy: { addedAt: "asc" as const },
|
||||
include: { document: { select: { title: true, filename: true } } },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const page = rows.slice(0, limit);
|
||||
const nextCursor = rows.length > limit ? page[page.length - 1].id : null;
|
||||
let page;
|
||||
let nextCursor: string | null;
|
||||
if (q) {
|
||||
// Match on the decrypted titles, then page the matching ids with the same
|
||||
// cursor contract the SQL path has: the cursor is the last id of the
|
||||
// previous page, and an unknown cursor yields an empty page.
|
||||
const needle = q.toLocaleLowerCase();
|
||||
const candidates = await prisma.coachConversation.findMany({
|
||||
where: scope,
|
||||
orderBy: CONVERSATION_LIST_ORDER,
|
||||
take: TITLE_SEARCH_SCAN_CAP,
|
||||
select: { id: true, title: true, titleEncrypted: true },
|
||||
});
|
||||
const matching = candidates
|
||||
.filter((c) =>
|
||||
readConversationTitle(c).toLocaleLowerCase().includes(needle),
|
||||
)
|
||||
.map((c) => c.id);
|
||||
let start = 0;
|
||||
if (params.cursor) {
|
||||
const at = matching.indexOf(params.cursor);
|
||||
start = at === -1 ? matching.length : at + 1;
|
||||
}
|
||||
const pageIds = matching.slice(start, start + limit);
|
||||
nextCursor =
|
||||
matching.length > start + limit ? pageIds[pageIds.length - 1] : null;
|
||||
const rows = await prisma.coachConversation.findMany({
|
||||
where: { ...scope, id: { in: pageIds } },
|
||||
include,
|
||||
});
|
||||
const byId = new Map(rows.map((r) => [r.id, r]));
|
||||
page = pageIds.flatMap((id) => {
|
||||
const row = byId.get(id);
|
||||
return row ? [row] : [];
|
||||
});
|
||||
} else {
|
||||
const rows = await prisma.coachConversation.findMany({
|
||||
where: scope,
|
||||
orderBy: CONVERSATION_LIST_ORDER,
|
||||
take: limit + 1,
|
||||
...(params.cursor
|
||||
? {
|
||||
cursor: { id: params.cursor },
|
||||
skip: 1,
|
||||
}
|
||||
: {}),
|
||||
include,
|
||||
});
|
||||
page = rows.slice(0, limit);
|
||||
nextCursor = rows.length > limit ? page[page.length - 1].id : null;
|
||||
}
|
||||
|
||||
return {
|
||||
conversations: page.map((r) => {
|
||||
const attachments = mapAttachments(r.attachments);
|
||||
return {
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
title: readConversationTitle(r),
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
updatedAt: r.updatedAt.toISOString(),
|
||||
messageCount: r._count.messages,
|
||||
@@ -669,7 +752,9 @@ export async function renameConversation(
|
||||
): Promise<{ id: string; title: string } | null> {
|
||||
const { count } = await prisma.coachConversation.updateMany({
|
||||
where: { id: conversationId, userId },
|
||||
data: { title },
|
||||
// The readable column is cleared on a rename so a legacy row never keeps
|
||||
// its old title beside the new ciphertext.
|
||||
data: { title: null, titleEncrypted: encryptConversationTitle(title) },
|
||||
});
|
||||
return count === 1 ? { id: conversationId, title } : null;
|
||||
}
|
||||
|
||||
@@ -119,7 +119,10 @@ export async function surfaceCoachReminders(
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const conversation = await tx.coachConversation.create({
|
||||
data: { userId, title: title.slice(0, 80) },
|
||||
data: {
|
||||
userId,
|
||||
titleEncrypted: encryptToBytes(title.slice(0, 80)),
|
||||
},
|
||||
});
|
||||
await tx.coachMessage.create({
|
||||
data: {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* v1.39.3 — what the deprecated `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` is
|
||||
* currently standing in for, for the admin AI settings page.
|
||||
*
|
||||
* Null when `true` is not set. Otherwise the distinct origins of every saved
|
||||
* AI base URL on the instance (each account's Local and gateway URL, and the
|
||||
* instance-wide admin base URL) that point at the operator's network and that
|
||||
* no `AI_PRIVATE_ORIGINS` or legacy host-list entry covers yet. Listing them is
|
||||
* the migration path: the operator copies them into `AI_PRIVATE_ORIGINS`, and
|
||||
* `=true` can go. A non-admin account's URL is included on purpose; `=true` no
|
||||
* longer covers it, so it is exactly the one that needs an entry.
|
||||
*
|
||||
* An origin is scheme, host and port, nothing more, so no path or credential
|
||||
* a person typed into a base URL reaches the page.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
legacyAnyHostConfigured,
|
||||
originNeedingAiGrant,
|
||||
} from "@/lib/ai/local-host-allowlist";
|
||||
|
||||
export interface LegacyPrivateHostReport {
|
||||
originsInUse: string[];
|
||||
}
|
||||
|
||||
export async function legacyPrivateHostReport(
|
||||
adminBaseUrl: string | null | undefined,
|
||||
): Promise<LegacyPrivateHostReport | null> {
|
||||
if (!legacyAnyHostConfigured()) return null;
|
||||
const rows = await prisma.user.findMany({
|
||||
where: {
|
||||
OR: [{ aiBaseUrl: { not: null } }, { aiCompatBaseUrl: { not: null } }],
|
||||
},
|
||||
select: { aiBaseUrl: true, aiCompatBaseUrl: true },
|
||||
});
|
||||
const origins = new Set<string>();
|
||||
for (const url of [
|
||||
adminBaseUrl,
|
||||
...rows.flatMap((r) => [r.aiBaseUrl, r.aiCompatBaseUrl]),
|
||||
]) {
|
||||
if (!url) continue;
|
||||
const origin = originNeedingAiGrant(url);
|
||||
if (origin) origins.add(origin);
|
||||
}
|
||||
return { originsInUse: [...origins].sort() };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { isLocalAiHostAllowed } from "./local-host-allowlist";
|
||||
import { aiEgressPolicyFor } from "./local-host-allowlist";
|
||||
import type {
|
||||
AIProvider,
|
||||
AiMessage,
|
||||
@@ -27,6 +27,13 @@ interface LocalClientConfig {
|
||||
apiKey?: string | null;
|
||||
model: string;
|
||||
baseUrl: string;
|
||||
/**
|
||||
* v1.39.3 — true when the base URL belongs to the operator: the
|
||||
* instance-wide admin provider, or provider settings saved on an admin
|
||||
* account. Only such a URL is still covered by the deprecated
|
||||
* `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` (see `local-host-allowlist.ts`).
|
||||
*/
|
||||
operatorTrusted?: boolean;
|
||||
}
|
||||
|
||||
const STRICT_JSON_PREFIX =
|
||||
@@ -193,11 +200,14 @@ export class LocalOpenAICompatibleClient implements AIProvider {
|
||||
// pin (issue #217) extends the same flag with a connect-time
|
||||
// resolved-IP check to also defeat DNS rebinding. Operators who
|
||||
// legitimately point at a self-hosted Ollama / LM Studio on an
|
||||
// RFC1918 address opt in via `ALLOW_LOCAL_AI_PRIVATE_HOSTS` — either the
|
||||
// legacy `=true` (any private host) or a comma-separated host allowlist
|
||||
// (only those hostnames). Enforced at write-time in /api/user/ai-provider
|
||||
// too. v1.18.7 (SECURITY LOW) — the binary flag became a host allowlist.
|
||||
const allowPrivate = isLocalAiHostAllowed(url);
|
||||
// RFC1918 address opt in via `AI_PRIVATE_ORIGINS` (exact origins) or the
|
||||
// legacy host list. A granted origin is dialled through the pinned
|
||||
// operator-approved dispatcher, never unpinned, so the metadata range and
|
||||
// link-local stay unreachable even for a granted name (v1.39.3). Enforced
|
||||
// at write-time in /api/user/ai-provider too.
|
||||
const egress = aiEgressPolicyFor(url, {
|
||||
operatorTrusted: this.config.operatorTrusted,
|
||||
});
|
||||
const res = await safeFetch(
|
||||
url,
|
||||
{
|
||||
@@ -210,7 +220,7 @@ export class LocalOpenAICompatibleClient implements AIProvider {
|
||||
{
|
||||
// v1.22 (#89) — honour the caller's per-request timeout override; default 60 s.
|
||||
timeoutMs: params.timeoutMs ?? 60_000,
|
||||
requirePublicHost: !allowPrivate,
|
||||
...egress,
|
||||
signal: params.signal,
|
||||
},
|
||||
);
|
||||
@@ -335,7 +345,9 @@ export class LocalOpenAICompatibleClient implements AIProvider {
|
||||
onDelta: (delta: string) => void,
|
||||
): Promise<CompletionResult> {
|
||||
const { url, headers, body } = this.buildRequest(params, true);
|
||||
const allowPrivate = isLocalAiHostAllowed(url);
|
||||
const egress = aiEgressPolicyFor(url, {
|
||||
operatorTrusted: this.config.operatorTrusted,
|
||||
});
|
||||
|
||||
// Idle controller: aborts the upstream when no chunk arrives within the
|
||||
// per-idle window. Composed with the caller's cancel signal so a client
|
||||
@@ -355,7 +367,7 @@ export class LocalOpenAICompatibleClient implements AIProvider {
|
||||
// Absolute backstop only — the per-idle timer below is the real
|
||||
// ceiling. A server that streams forever still cannot pin a worker.
|
||||
timeoutMs: STREAM_ABSOLUTE_CEILING_MS,
|
||||
requirePublicHost: !allowPrivate,
|
||||
...egress,
|
||||
signal,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,76 +1,240 @@
|
||||
/**
|
||||
* v1.18.7 (SECURITY LOW) — host allowlist for local/private AI endpoints.
|
||||
* Which private hosts an AI base URL may reach, and how the call is dialled.
|
||||
*
|
||||
* The operator escape-hatch for pointing the server at a self-hosted
|
||||
* Ollama / LM Studio on an RFC1918 address used to be a single binary flag,
|
||||
* `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true`, which opened EVERY private/internal
|
||||
* host at once — including the cloud-metadata endpoint and any internal admin
|
||||
* panel. This narrows it to an explicit allowlist while keeping the binary
|
||||
* `true` for backward compatibility:
|
||||
* A base URL for the Local provider, the OpenAI-compatible gateway and the
|
||||
* operator's admin key is typed by a person, and for the first two that
|
||||
* person is any user of the instance. By default only public hosts are
|
||||
* reachable. An operator who runs Ollama, LM Studio or a LiteLLM gateway on
|
||||
* their own network grants it through the environment, the same exact-origin
|
||||
* grammar `NOTIFICATION_PRIVATE_ORIGINS` and `NIGHTSCOUT_PRIVATE_ORIGINS` use
|
||||
* (`src/lib/private-origin-policy.ts`):
|
||||
*
|
||||
* - unset / "" / "false" → no private host allowed (the secure default).
|
||||
* - "true" → ANY private host allowed (legacy behaviour).
|
||||
* - "ollama.lan, 10.0.0.5" → ONLY those exact hostnames allowed; every
|
||||
* other private host is still rejected.
|
||||
* AI_PRIVATE_ORIGINS="http://ollama.lan:11434,http://10.0.0.5:4000"
|
||||
*
|
||||
* This is layered ON TOP of the `safeFetch` / `isPublicUrl` floor: a public
|
||||
* URL never needs the allowlist, and a private host not on the list is
|
||||
* rejected exactly as before. The allowlist only ever WIDENS what the
|
||||
* operator deliberately permits, never narrows the public-host floor.
|
||||
* A grant is one `scheme://host[:port]`. It is never a wildcard, suffix or
|
||||
* range. A granted call is dialled through the pinned operator-approved
|
||||
* dispatcher with redirects forbidden, so a listed name that resolves to the
|
||||
* metadata range, link-local or the unspecified address is dropped at connect
|
||||
* time, and a literal in those ranges cannot be granted at all. Nothing in a
|
||||
* request or a settings field can confer a grant.
|
||||
*
|
||||
* `ALLOW_LOCAL_AI_PRIVATE_HOSTS` (v1.18.7) stays readable. A comma-separated
|
||||
* host list (`ollama.lan,10.0.0.5`) still grants those exact hosts, on any
|
||||
* port, dialled the same pinned way.
|
||||
*
|
||||
* Its `=true` form used to open every private host to every user of the
|
||||
* instance. From v1.39.3 it is deprecated and narrowed for one release cycle
|
||||
* rather than removed, so a single-user self-host with Ollama on the LAN keeps
|
||||
* working after a patch update: it still grants a private host, but only to
|
||||
* the configurations the operator owns (the instance-wide admin provider, and
|
||||
* provider settings saved on an admin account), and only through the same
|
||||
* pinned dispatcher, so the metadata range, link-local and the unspecified
|
||||
* address stay unreachable. A non-admin account's own base URL needs an exact
|
||||
* origin in `AI_PRIVATE_ORIGINS`. The boot summary and the admin AI settings
|
||||
* page say so and name the origins in use.
|
||||
*/
|
||||
import { isIP } from "node:net";
|
||||
|
||||
const ENV_VAR = "ALLOW_LOCAL_AI_PRIVATE_HOSTS";
|
||||
import {
|
||||
isNeverGrantableHost,
|
||||
originOfUrl,
|
||||
parsePrivateOrigins,
|
||||
} from "@/lib/private-origin-policy";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
|
||||
/** Parsed allowlist policy from the env var. */
|
||||
type LocalHostPolicy =
|
||||
| { kind: "none" }
|
||||
| { kind: "any" }
|
||||
| { kind: "hosts"; hosts: ReadonlySet<string> };
|
||||
const ORIGINS_ENV = "AI_PRIVATE_ORIGINS";
|
||||
const LEGACY_ENV = "ALLOW_LOCAL_AI_PRIVATE_HOSTS";
|
||||
|
||||
function parsePolicy(): LocalHostPolicy {
|
||||
const raw = process.env[ENV_VAR]?.trim();
|
||||
if (!raw || raw.toLowerCase() === "false") return { kind: "none" };
|
||||
if (raw.toLowerCase() === "true") return { kind: "any" };
|
||||
const hosts = raw
|
||||
.split(",")
|
||||
.map((h) => h.trim().toLowerCase())
|
||||
.filter((h) => h.length > 0);
|
||||
if (hosts.length === 0) return { kind: "none" };
|
||||
return { kind: "hosts", hosts: new Set(hosts) };
|
||||
/** The operator-facing sentence for a legacy `=true`. Shared with the boot summary. */
|
||||
export const LEGACY_ANY_HOST_WARNING =
|
||||
`${LEGACY_ENV}=true is deprecated, set ${ORIGINS_ENV}=<origin> ` +
|
||||
`(for example ${ORIGINS_ENV}="http://ollama.lan:11434"); =true will be ` +
|
||||
`removed in a later release. Until then it only covers the admin AI ` +
|
||||
`provider and AI settings saved on an admin account.`;
|
||||
|
||||
/**
|
||||
* Who a base URL belongs to. `operatorTrusted` is true for the instance-wide
|
||||
* admin provider and for provider settings saved on an admin account: the
|
||||
* configurations the deprecated `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` still
|
||||
* covers. Everything else (any non-admin account's own base URL) is not.
|
||||
*/
|
||||
export interface AiUrlOwner {
|
||||
operatorTrusted?: boolean;
|
||||
}
|
||||
|
||||
/** Extract the lowercased hostname from a URL string, or null if unparseable. */
|
||||
function hostnameOf(url: string): string | null {
|
||||
try {
|
||||
return new URL(url).hostname.toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
interface Grants {
|
||||
origins: ReadonlySet<string>;
|
||||
legacyHosts: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
let cachedKey: string | null = null;
|
||||
let cached: Grants = { origins: new Set(), legacyHosts: new Set() };
|
||||
const warned = new Set<string>();
|
||||
|
||||
function warnOnce(message: string): void {
|
||||
if (warned.has(message)) return;
|
||||
warned.add(message);
|
||||
console.warn(message);
|
||||
}
|
||||
|
||||
/** True when the legacy variable carries its retired `=true` form. */
|
||||
export function legacyAnyHostConfigured(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): boolean {
|
||||
return env[LEGACY_ENV]?.trim().toLowerCase() === "true";
|
||||
}
|
||||
|
||||
function parseLegacyHosts(raw: string | undefined): ReadonlySet<string> {
|
||||
const value = raw?.trim() ?? "";
|
||||
if (!value || value.toLowerCase() === "false") return new Set();
|
||||
// `true` is handled per call in `grantedAiOrigin` (operator-owned
|
||||
// configurations only); it is not a host list.
|
||||
if (value.toLowerCase() === "true") {
|
||||
warnOnce(LEGACY_ANY_HOST_WARNING);
|
||||
return new Set();
|
||||
}
|
||||
const hosts = new Set<string>();
|
||||
for (const entry of value.split(",")) {
|
||||
const host = entry.trim().toLowerCase();
|
||||
if (!host) continue;
|
||||
// A host, not a URL: anything with a scheme, path, port or wildcard is a
|
||||
// typo for the origin list, and granting a guess would widen it.
|
||||
const bare = host.replace(/^\[|\]$/g, "");
|
||||
const isIpv6 = isIP(bare) === 6;
|
||||
if (!/^[a-z0-9.\-[\]:]+$/.test(host) || (host.includes(":") && !isIpv6)) {
|
||||
warnOnce(
|
||||
`${LEGACY_ENV}: ignoring entry "${host.slice(0, 200)}": a host name or address, nothing else; use ${ORIGINS_ENV} for a full origin`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (isNeverGrantableHost(host)) {
|
||||
warnOnce(
|
||||
`${LEGACY_ENV}: ignoring entry "${host}": the unspecified address, link-local and metadata addresses cannot be granted`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
hosts.add(bare);
|
||||
}
|
||||
return hosts;
|
||||
}
|
||||
|
||||
function grants(): Grants {
|
||||
const originsRaw = process.env.AI_PRIVATE_ORIGINS;
|
||||
const legacyRaw = process.env.ALLOW_LOCAL_AI_PRIVATE_HOSTS;
|
||||
const key = `${originsRaw ?? ""}\u0000${legacyRaw ?? ""}`;
|
||||
if (key === cachedKey) return cached;
|
||||
cached = {
|
||||
origins: parsePrivateOrigins(originsRaw, (entry, reason) => {
|
||||
warnOnce(`${ORIGINS_ENV}: ignoring entry "${entry}": ${reason}`);
|
||||
}),
|
||||
legacyHosts: parseLegacyHosts(legacyRaw),
|
||||
};
|
||||
cachedKey = key;
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the operator has explicitly permitted this (private) host for a
|
||||
* local AI endpoint. `"true"` permits any host (legacy); a host list permits
|
||||
* only listed hostnames. An unparseable URL is never permitted.
|
||||
*
|
||||
* NOTE: this answers "did the operator opt this host in?", NOT "is this host
|
||||
* public?". Callers still run the `isPublicUrl` / `safeFetch` floor; this is
|
||||
* the private-host override on top of it.
|
||||
* The granted origin for this URL, or null. A URL is granted when its exact
|
||||
* origin is listed in `AI_PRIVATE_ORIGINS`, or its host is listed in the
|
||||
* legacy host list. A host no grant may name is never granted, whatever the
|
||||
* lists say.
|
||||
*/
|
||||
export function isLocalAiHostAllowed(url: string): boolean {
|
||||
const policy = parsePolicy();
|
||||
if (policy.kind === "none") return false;
|
||||
if (policy.kind === "any") return true;
|
||||
const host = hostnameOf(url);
|
||||
return host !== null && policy.hosts.has(host);
|
||||
export function grantedAiOrigin(
|
||||
url: string,
|
||||
owner: AiUrlOwner = {},
|
||||
): string | null {
|
||||
// Parse the grants first, so a malformed entry is reported on the first
|
||||
// call that could have used it rather than never.
|
||||
const { origins, legacyHosts } = grants();
|
||||
const origin = originOfUrl(url);
|
||||
if (!origin) return null;
|
||||
const hostname = new URL(origin).hostname.toLowerCase();
|
||||
// Both lists refuse a never-grantable literal when they are parsed, so
|
||||
// neither can hold one; a listed name that resolves into those ranges is
|
||||
// dropped by the pinned dispatcher at dial time.
|
||||
if (origins.has(origin)) return origin;
|
||||
if (legacyHosts.has(hostname.replace(/^\[|\]$/g, ""))) return origin;
|
||||
// Deprecated `=true`: an operator-owned configuration only, and never a
|
||||
// literal no grant may name. A name that resolves into those ranges is
|
||||
// dropped by the pinned dispatcher at dial time.
|
||||
if (
|
||||
owner.operatorTrusted === true &&
|
||||
legacyAnyHostConfigured() &&
|
||||
!isNeverGrantableHost(hostname)
|
||||
) {
|
||||
return origin;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `safeFetch` must require a public host for this URL: true unless the
|
||||
* operator has opted this private host into the allowlist. Kept as a thin
|
||||
* wrapper so the call sites read `requirePublicHost: requirePublicHostFor(url)`
|
||||
* and the conditional intent stays obvious.
|
||||
* True when the operator granted this URL's origin. Answers "may this private
|
||||
* host be saved and called?", never "is it public?": callers still accept a
|
||||
* public URL without any grant.
|
||||
*/
|
||||
export function requirePublicHostFor(url: string): boolean {
|
||||
return !isLocalAiHostAllowed(url);
|
||||
export function isLocalAiHostAllowed(
|
||||
url: string,
|
||||
owner: AiUrlOwner = {},
|
||||
): boolean {
|
||||
return grantedAiOrigin(url, owner) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `safeFetch` destination policy for an AI call to `url`: the pinned
|
||||
* public dispatcher unless the operator granted the origin, and then the
|
||||
* pinned operator-approved dispatcher for that one origin. Spread into the
|
||||
* `safeFetch` options.
|
||||
*/
|
||||
export function aiEgressPolicyFor(
|
||||
url: string,
|
||||
owner: AiUrlOwner = {},
|
||||
):
|
||||
| { requirePublicHost: true }
|
||||
| { requirePublicHost: false; operatorApprovedPrivateOrigin: string } {
|
||||
const origin = grantedAiOrigin(url, owner);
|
||||
return origin === null
|
||||
? { requirePublicHost: true }
|
||||
: { requirePublicHost: false, operatorApprovedPrivateOrigin: origin };
|
||||
}
|
||||
|
||||
/**
|
||||
* Host names that only resolve on a local network. `isPublicUrl` judges the
|
||||
* text of a name, not where it resolves, so `ollama.lan` reads as public to it
|
||||
* while the connect-time pin would refuse it.
|
||||
*/
|
||||
const LAN_SUFFIXES = [
|
||||
".lan",
|
||||
".local",
|
||||
".home",
|
||||
".home.arpa",
|
||||
".internal",
|
||||
".intranet",
|
||||
".localdomain",
|
||||
".corp",
|
||||
];
|
||||
|
||||
/**
|
||||
* The origin an operator would add to `AI_PRIVATE_ORIGINS` for this saved
|
||||
* base URL, or null when it needs none: a hosted endpoint, an address no grant
|
||||
* can open, an origin the exact list or the legacy host list already covers,
|
||||
* or an unparseable value. Used by the admin AI settings page to name what the
|
||||
* deprecated `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` is currently standing in for.
|
||||
*/
|
||||
export function originNeedingAiGrant(url: string): string | null {
|
||||
const origin = originOfUrl(url);
|
||||
if (!origin) return null;
|
||||
const hostname = new URL(origin).hostname.toLowerCase();
|
||||
if (isNeverGrantableHost(hostname)) return null;
|
||||
const lanName =
|
||||
!hostname.includes(".") ||
|
||||
LAN_SUFFIXES.some((suffix) => hostname.endsWith(suffix));
|
||||
if (isPublicUrl(url) && !lanName) return null;
|
||||
if (grantedAiOrigin(url) !== null) return null;
|
||||
return origin;
|
||||
}
|
||||
|
||||
/** Test helper: forget the parsed grants and the warnings already printed. */
|
||||
export function _resetAiGrantsForTests(): void {
|
||||
cachedKey = null;
|
||||
warned.clear();
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
jsonModeDialectFor,
|
||||
rememberJsonModeDialect,
|
||||
} from "./json-dialect";
|
||||
import { requirePublicHostFor } from "./local-host-allowlist";
|
||||
import { aiEgressPolicyFor } from "./local-host-allowlist";
|
||||
import {
|
||||
buildOpenAIMessages,
|
||||
buildOpenAITools,
|
||||
@@ -46,6 +46,13 @@ interface OpenAIClientConfig {
|
||||
* `codex` keeps the fully pinned posture.
|
||||
*/
|
||||
providerType?: OpenAIProviderType;
|
||||
/**
|
||||
* v1.39.3 — true when the base URL belongs to the operator: the
|
||||
* instance-wide admin provider, or provider settings saved on an admin
|
||||
* account. Only such a URL is still covered by the deprecated
|
||||
* `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` (see `local-host-allowlist.ts`).
|
||||
*/
|
||||
operatorTrusted?: boolean;
|
||||
}
|
||||
|
||||
type OpenAIProviderType = "admin-key" | "codex" | "openai-compatible";
|
||||
@@ -152,7 +159,8 @@ export class OpenAIClient implements AIProvider {
|
||||
// v1.21.5 — honour the caller's per-request timeout override; default 60 s.
|
||||
// v1.33.1 (#470) — the gateway tag reuses the Local provider's host
|
||||
// policy verbatim: public hosts always; a private host only when the
|
||||
// operator allowlisted it via ALLOW_LOCAL_AI_PRIVATE_HOSTS.
|
||||
// operator granted its origin (`AI_PRIVATE_ORIGINS` or the legacy host
|
||||
// list), and then only through the pinned operator-approved dispatcher.
|
||||
//
|
||||
// v1.37.30 — the `admin-key` tag consults the same policy. Its base URL
|
||||
// is the one other operator-typed URL in this client (an operator can
|
||||
@@ -167,10 +175,11 @@ export class OpenAIClient implements AIProvider {
|
||||
// operator legitimately redirects.
|
||||
{
|
||||
timeoutMs: params.timeoutMs ?? 60_000,
|
||||
requirePublicHost:
|
||||
this.isGateway || this.type === "admin-key"
|
||||
? requirePublicHostFor(this.config.baseUrl)
|
||||
: true,
|
||||
...(this.isGateway || this.type === "admin-key"
|
||||
? aiEgressPolicyFor(url, {
|
||||
operatorTrusted: this.config.operatorTrusted,
|
||||
})
|
||||
: { requirePublicHost: true as const }),
|
||||
signal: params.signal,
|
||||
},
|
||||
);
|
||||
|
||||
+34
-4
@@ -55,8 +55,20 @@ type UserAIRow = {
|
||||
aiCompatBaseUrl: string | null;
|
||||
aiCompatKeyEncrypted: string | null;
|
||||
aiCompatModel: string | null;
|
||||
/**
|
||||
* v1.39.3 — the owning account's role. Provider settings saved on an admin
|
||||
* account are the operator's own, and only those (plus the instance-wide
|
||||
* admin provider) are still covered by the deprecated
|
||||
* `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true`.
|
||||
*/
|
||||
role: string;
|
||||
};
|
||||
|
||||
/** v1.39.3 — see `UserAIRow.role`. */
|
||||
function isOperatorOwned(row: { role: string } | null | undefined): boolean {
|
||||
return row?.role === "ADMIN";
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.33.1 (#470) — build the OpenAI-compatible gateway client from the user's
|
||||
* three dedicated columns. The signature names every column this provider is
|
||||
@@ -83,6 +95,7 @@ function buildCompatProvider(row: {
|
||||
aiCompatBaseUrl: string | null;
|
||||
aiCompatKeyEncrypted: string | null;
|
||||
aiCompatModel: string | null;
|
||||
role: string;
|
||||
}): AIProvider | null {
|
||||
const model = row.aiCompatModel?.trim() || row.aiModel?.trim() || null;
|
||||
if (!row.aiCompatBaseUrl || !model) return null;
|
||||
@@ -91,6 +104,7 @@ function buildCompatProvider(row: {
|
||||
model,
|
||||
baseUrl: row.aiCompatBaseUrl,
|
||||
providerType: "openai-compatible",
|
||||
operatorTrusted: isOperatorOwned(row),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -122,6 +136,7 @@ function buildUserProvider(row: UserAIRow): AIProvider | null {
|
||||
: null,
|
||||
model: row.aiModel ?? "local-model",
|
||||
baseUrl: row.aiBaseUrl,
|
||||
operatorTrusted: isOperatorOwned(row),
|
||||
});
|
||||
}
|
||||
case "OPENAI_COMPATIBLE": {
|
||||
@@ -212,6 +227,8 @@ async function resolveAdminProvider(): Promise<AIProvider> {
|
||||
apiKey: decrypt(settings.adminAiKeyEncrypted),
|
||||
model: settings.adminAiModel ?? "gpt-4o",
|
||||
baseUrl,
|
||||
// The instance-wide provider is the operator's own configuration.
|
||||
operatorTrusted: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -480,6 +497,7 @@ export async function resolveProvider(userId: string): Promise<AIProvider> {
|
||||
aiCompatBaseUrl: true,
|
||||
aiCompatKeyEncrypted: true,
|
||||
aiCompatModel: true,
|
||||
role: true,
|
||||
managedProfileAt: true,
|
||||
},
|
||||
});
|
||||
@@ -548,6 +566,7 @@ export async function resolveProviderChain(
|
||||
aiCompatBaseUrl: true,
|
||||
aiCompatKeyEncrypted: true,
|
||||
aiCompatModel: true,
|
||||
role: true,
|
||||
aiProviderChain: true,
|
||||
useCentralCodex: true,
|
||||
managedProfileAt: true,
|
||||
@@ -1023,6 +1042,7 @@ async function resolveProviderForType(
|
||||
aiCompatBaseUrl: string | null;
|
||||
aiCompatKeyEncrypted: string | null;
|
||||
aiCompatModel: string | null;
|
||||
role: string;
|
||||
} | null;
|
||||
policy: ProviderCredentialPolicy;
|
||||
},
|
||||
@@ -1062,6 +1082,7 @@ async function resolveProviderForType(
|
||||
: null,
|
||||
model: ctx.userRow.aiModel ?? "local-model",
|
||||
baseUrl: ctx.userRow.aiBaseUrl,
|
||||
operatorTrusted: isOperatorOwned(ctx.userRow),
|
||||
});
|
||||
}
|
||||
case "openai-compatible": {
|
||||
@@ -1138,8 +1159,12 @@ export async function resolveProviderForTest(
|
||||
aiCompatBaseUrl: true,
|
||||
aiCompatKeyEncrypted: true,
|
||||
aiCompatModel: true,
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
// v1.39.3 — an unsaved override is tested under the same owner rule as a
|
||||
// saved one: it belongs to the calling account.
|
||||
const owner = { operatorTrusted: isOperatorOwned(stored) };
|
||||
|
||||
const provider = (override.provider ?? stored?.aiProvider ?? "")
|
||||
.toString()
|
||||
@@ -1189,9 +1214,9 @@ export async function resolveProviderForTest(
|
||||
if (!baseUrl) {
|
||||
throw new AITestConfigError(422, "Local provider requires a base URL");
|
||||
}
|
||||
// v1.18.7 (SECURITY LOW) — host allowlist (`true` = any private host;
|
||||
// a comma-separated host list = only those) replaces the binary flag.
|
||||
const allowPrivate = isLocalAiHostAllowed(baseUrl);
|
||||
// A private host only when the operator granted its origin
|
||||
// (`AI_PRIVATE_ORIGINS` or the legacy host list; v1.39.3).
|
||||
const allowPrivate = isLocalAiHostAllowed(baseUrl, owner);
|
||||
if (!allowPrivate && !isPublicUrl(baseUrl)) {
|
||||
throw new AITestConfigError(
|
||||
422,
|
||||
@@ -1207,6 +1232,7 @@ export async function resolveProviderForTest(
|
||||
apiKey,
|
||||
model: model || "local-model",
|
||||
baseUrl,
|
||||
...owner,
|
||||
});
|
||||
}
|
||||
case "OPENAI_COMPATIBLE": {
|
||||
@@ -1227,7 +1253,10 @@ export async function resolveProviderForTest(
|
||||
"OpenAI-compatible provider requires a base URL",
|
||||
);
|
||||
}
|
||||
if (!isLocalAiHostAllowed(compatBaseUrl) && !isPublicUrl(compatBaseUrl)) {
|
||||
if (
|
||||
!isLocalAiHostAllowed(compatBaseUrl, owner) &&
|
||||
!isPublicUrl(compatBaseUrl)
|
||||
) {
|
||||
throw new AITestConfigError(
|
||||
422,
|
||||
"Base URL points to an internal/private host",
|
||||
@@ -1253,6 +1282,7 @@ export async function resolveProviderForTest(
|
||||
model: compatModel,
|
||||
baseUrl: compatBaseUrl,
|
||||
providerType: "openai-compatible",
|
||||
...owner,
|
||||
});
|
||||
}
|
||||
case "CHATGPT_OAUTH": {
|
||||
|
||||
@@ -129,3 +129,23 @@ describe("formatReadiness + logReadinessSummary", () => {
|
||||
expect(errors).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectReadiness — retired AI grant", () => {
|
||||
it("warns, without blocking, when ALLOW_LOCAL_AI_PRIVATE_HOSTS is still `true`", () => {
|
||||
const env = { ...baseEnv(), ALLOW_LOCAL_AI_PRIVATE_HOSTS: " TRUE " };
|
||||
const line = lineFor(env, "ALLOW_LOCAL_AI_PRIVATE_HOSTS");
|
||||
expect(line?.status).toBe("warn");
|
||||
expect(line?.detail).toContain("AI_PRIVATE_ORIGINS");
|
||||
expect(collectReadiness(env).hasBlocker).toBe(false);
|
||||
});
|
||||
|
||||
it("says nothing for a host list or an unset variable", () => {
|
||||
expect(
|
||||
lineFor(
|
||||
{ ...baseEnv(), ALLOW_LOCAL_AI_PRIVATE_HOSTS: "ollama.lan" },
|
||||
"ALLOW_LOCAL_AI_PRIVATE_HOSTS",
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(lineFor(baseEnv(), "ALLOW_LOCAL_AI_PRIVATE_HOSTS")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,11 @@
|
||||
* signpost in front of them.
|
||||
*/
|
||||
|
||||
import {
|
||||
LEGACY_ANY_HOST_WARNING,
|
||||
legacyAnyHostConfigured,
|
||||
} from "@/lib/ai/local-host-allowlist";
|
||||
|
||||
export interface ReadinessLine {
|
||||
/** Short label, e.g. "ENCRYPTION_KEY". */
|
||||
label: string;
|
||||
@@ -121,6 +126,19 @@ export function collectReadiness(env: Env = process.env): ReadinessReport {
|
||||
: "Secure cookie flag OFF — fine for plain-HTTP LAN; do NOT expose plain HTTP to the internet",
|
||||
});
|
||||
|
||||
// --- Deprecated AI grant (a warning, never a blocker) -------------------
|
||||
// `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` used to open every private host on
|
||||
// the network to every user's AI base URL. Since v1.39.3 it only covers the
|
||||
// operator's own AI configurations and is on its way out; this line names
|
||||
// the replacement. The admin AI settings page lists the origins in use.
|
||||
if (legacyAnyHostConfigured(env)) {
|
||||
lines.push({
|
||||
label: "ALLOW_LOCAL_AI_PRIVATE_HOSTS",
|
||||
status: "warn",
|
||||
detail: LEGACY_ANY_HOST_WARNING,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Optional subsystems (informational) --------------------------------
|
||||
const optional: Array<[string, boolean]> = [
|
||||
["SMTP (email)", allSet(env, ["SMTP_HOST", "SMTP_PORT", "SMTP_FROM"])],
|
||||
|
||||
@@ -216,6 +216,8 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
|
||||
// ───── Coach (Bytes columns) ─────
|
||||
{ model: "CoachMessage", field: "encryptedContent", kind: "bytes" },
|
||||
{ model: "CoachConversation", field: "summaryEncrypted", kind: "bytes" },
|
||||
// v1.39.3 — the title: the opening words of the first message, or a rename.
|
||||
{ model: "CoachConversation", field: "titleEncrypted", kind: "bytes" },
|
||||
{ model: "CoachFact", field: "factEncrypted", kind: "bytes" },
|
||||
// v1.21.3 (B1) — Coach goal / if-then plan free-text columns.
|
||||
{ model: "CoachPlan", field: "ifCueEncrypted", kind: "bytes" },
|
||||
@@ -318,6 +320,12 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
|
||||
{ model: "MedicationDoseChange", field: "noteEncrypted", kind: "bytes" },
|
||||
{ model: "MedicationInventoryItem", field: "notesEncrypted", kind: "bytes" },
|
||||
|
||||
// ───── v1.39.3 custom-metric reading note (Bytes column) ─────
|
||||
// The one free-text field on a reading of a metric the person defined. The
|
||||
// catalog columns (name, unit, description) stay plaintext: they are the
|
||||
// definition of a series, listed and sorted by.
|
||||
{ model: "CustomMetricEntry", field: "noteEncrypted", kind: "bytes" },
|
||||
|
||||
// ───── v1.25 mental-health screener item answers (Bytes column) ─────
|
||||
// The PHQ-9 / GAD-7 per-item responses (incl. the safety-critical item 9)
|
||||
// ride a single AES-256-GCM blob. The most sensitive payload in the wave;
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
/**
|
||||
* v1.25.5 — server-side serialisers for the user-defined custom-metric store.
|
||||
*
|
||||
* Custom metrics are PLAINTEXT (name / unit / description), so — unlike the
|
||||
* Biomarker catalog with its AES-256-GCM `contextEncrypted` codec — there is no
|
||||
* encrypt/decrypt layer here. These helpers just map a Prisma row into the
|
||||
* stable wire DTO the web + iOS clients render.
|
||||
* The catalog columns (name / unit / description) are plaintext: they are the
|
||||
* definition of a series and are listed and sorted by. The one free-text field
|
||||
* on a reading, its note, is AES-256-GCM at rest since v1.39.3
|
||||
* (`noteEncrypted`), read here through the shared note boundary. These
|
||||
* helpers map a Prisma row into the stable wire DTO the web + iOS clients
|
||||
* render.
|
||||
*
|
||||
* The store is deliberately ISOLATED from the closed `MeasurementType` system:
|
||||
* no rollup, no sync, no FHIR, no insights. Charts read entries LIVE.
|
||||
*/
|
||||
|
||||
import { readNote } from "@/lib/crypto/note-cipher";
|
||||
|
||||
/** A custom-metric catalog row as the API serialises it. */
|
||||
export interface CustomMetricRow {
|
||||
id: string;
|
||||
@@ -37,7 +41,9 @@ export interface CustomMetricEntryRow {
|
||||
value: number;
|
||||
unit: string;
|
||||
measuredAt: Date;
|
||||
/** Legacy readable note, set only on rows the backfill has not reached. */
|
||||
note: string | null;
|
||||
noteEncrypted: Uint8Array | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@@ -79,7 +85,7 @@ export function serialiseCustomMetricEntry(row: CustomMetricEntryRow) {
|
||||
value: row.value,
|
||||
unit: row.unit,
|
||||
measuredAt: row.measuredAt.toISOString(),
|
||||
note: row.note,
|
||||
note: readNote(row.noteEncrypted, row.note),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* v1.39.3 — the Coach conversation title in both backup formats.
|
||||
*
|
||||
* A disaster-recovery file carries the title as stored: the ciphertext, or
|
||||
* for a row the free-text backfill has not reached yet the old readable value
|
||||
* (sealing it on the way out would make the export non-deterministic). A
|
||||
* portable file carries it readable, for the person who owns it. A restore of
|
||||
* any of these writes the ciphertext column and never the readable one, and a
|
||||
* file written before this release (readable title only) still restores.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
buildCoachBackupSection,
|
||||
restoreCoachData,
|
||||
type RestoredCoachConversation,
|
||||
} from "../coach-backup";
|
||||
import { parseBackupPayload } from "@/lib/validations/backup";
|
||||
|
||||
const at = "2026-09-20T10:00:00.000Z";
|
||||
|
||||
function row(id: string, title: string | null, sealed: string | null) {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
titleEncrypted: sealed === null ? null : encryptToBytes(sealed),
|
||||
documentScoped: false,
|
||||
summaryEncrypted: null,
|
||||
summaryUpdatedAt: null,
|
||||
summaryTurnCount: 0,
|
||||
createdAt: new Date(at),
|
||||
updatedAt: new Date(at),
|
||||
messages: [],
|
||||
attachments: [],
|
||||
};
|
||||
}
|
||||
|
||||
function prismaWith(rows: ReturnType<typeof row>[]) {
|
||||
return {
|
||||
coachConversation: { findMany: vi.fn().mockResolvedValue(rows) },
|
||||
} as never;
|
||||
}
|
||||
|
||||
const rows = [
|
||||
row("c1", null, "Sealed title"),
|
||||
row("c2", "Legacy readable title", null),
|
||||
];
|
||||
|
||||
describe("coach backup title", () => {
|
||||
it("a disaster-recovery file carries the title as stored", async () => {
|
||||
const section = await buildCoachBackupSection(prismaWith(rows), "u1", {
|
||||
purpose: "disaster-recovery",
|
||||
});
|
||||
const [sealed, legacy] = section.coachConversations;
|
||||
expect(JSON.stringify(sealed)).not.toContain("Sealed title");
|
||||
expect(sealed).not.toHaveProperty("title");
|
||||
expect(
|
||||
decryptFromBytes(Buffer.from(sealed.titleEncrypted!, "base64")),
|
||||
).toBe("Sealed title");
|
||||
expect(legacy).toMatchObject({ title: "Legacy readable title" });
|
||||
expect(legacy).not.toHaveProperty("titleEncrypted");
|
||||
// The same input exports byte-identically twice: nothing is sealed with
|
||||
// a fresh IV on the way out.
|
||||
const again = await buildCoachBackupSection(prismaWith(rows), "u1", {
|
||||
purpose: "disaster-recovery",
|
||||
});
|
||||
expect(JSON.stringify(again)).toBe(JSON.stringify(section));
|
||||
});
|
||||
|
||||
it("a portable file carries the readable title and no ciphertext", async () => {
|
||||
const section = await buildCoachBackupSection(prismaWith(rows), "u1", {
|
||||
purpose: "portable-export",
|
||||
});
|
||||
expect(section.coachConversations.map((c) => c.title)).toEqual([
|
||||
"Sealed title",
|
||||
"Legacy readable title",
|
||||
]);
|
||||
for (const c of section.coachConversations) {
|
||||
expect(c).not.toHaveProperty("titleEncrypted");
|
||||
}
|
||||
});
|
||||
|
||||
it("a restore of either format, and of an older file, writes only the ciphertext", async () => {
|
||||
const created: Array<Record<string, unknown>> = [];
|
||||
const tx = {
|
||||
coachMessage: { count: vi.fn().mockResolvedValue(0) },
|
||||
coachConversationDocument: {
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
coachConversation: {
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
create: vi.fn(async (args: { data: Record<string, unknown> }) => {
|
||||
created.push(args.data);
|
||||
return args.data;
|
||||
}),
|
||||
},
|
||||
};
|
||||
const base = {
|
||||
documentScoped: false,
|
||||
createdAt: at,
|
||||
updatedAt: at,
|
||||
messages: [],
|
||||
attachments: [],
|
||||
};
|
||||
const conversations: RestoredCoachConversation[] = [
|
||||
{
|
||||
...base,
|
||||
id: "dr",
|
||||
titleEncrypted: Buffer.from(encryptToBytes("From DR")).toString(
|
||||
"base64",
|
||||
),
|
||||
},
|
||||
{ ...base, id: "portable", title: "From a portable file" },
|
||||
];
|
||||
await restoreCoachData(
|
||||
tx as never,
|
||||
"u1",
|
||||
{ coachConversations: conversations },
|
||||
new Set(),
|
||||
{ entries: [] } as never,
|
||||
);
|
||||
expect(created.map((d) => d.title)).toEqual([undefined, undefined]);
|
||||
expect(
|
||||
created.map((d) => decryptFromBytes(d.titleEncrypted as Uint8Array)),
|
||||
).toEqual(["From DR", "From a portable file"]);
|
||||
});
|
||||
|
||||
it("the wire schema accepts a pre-release file with a readable title", () => {
|
||||
const parsed = parseBackupPayload({
|
||||
schemaVersion: "2",
|
||||
exportedAt: at,
|
||||
userId: "u1",
|
||||
coachConversations: [
|
||||
{
|
||||
id: "old",
|
||||
title: "Written before the title was sealed",
|
||||
documentScoped: false,
|
||||
createdAt: at,
|
||||
updatedAt: at,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(parsed.coachConversations[0].title).toBe(
|
||||
"Written before the title was sealed",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1286,7 +1286,10 @@ describe("buildFullBackupPayload — profile and custom metrics", () => {
|
||||
value: 44.5,
|
||||
unit: "kg",
|
||||
measuredAt: "2026-07-19T06:30:00.000Z",
|
||||
// v1.39.3 — the fixture row predates the encryption backfill:
|
||||
// a DR file carries it as stored, and the restore seals it.
|
||||
note: "felt strong",
|
||||
noteEncrypted: null,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -578,8 +578,11 @@ describe("backup round trip — export, wire schema, restore", () => {
|
||||
expect(entries[0]).toMatchObject({
|
||||
value: 44.5,
|
||||
unit: "kg",
|
||||
note: "felt strong",
|
||||
});
|
||||
// v1.39.3 — the note comes back sealed; the readable column is never
|
||||
// written, even for a row that was still readable when it was exported.
|
||||
expect(entries[0]).not.toHaveProperty("note");
|
||||
expect(entries[0].noteEncrypted).toBeInstanceOf(Uint8Array);
|
||||
});
|
||||
|
||||
it("writes persisted pattern identity and dismissal evidence back", async () => {
|
||||
|
||||
@@ -92,7 +92,14 @@ export interface CoachConversationBackupEntry {
|
||||
/** Always carried: messages, attachments and the Coach's own facts, plans
|
||||
* and reminders all address a conversation by it. */
|
||||
id: string;
|
||||
title: string;
|
||||
/**
|
||||
* The readable title. Present on a portable payload, on every file written
|
||||
* before v1.39.3, and on a disaster-recovery payload for a row the free-text
|
||||
* backfill had not reached yet.
|
||||
*/
|
||||
title?: string;
|
||||
/** v1.39.3 — the title's ciphertext as base64. Disaster-recovery only. */
|
||||
titleEncrypted?: string;
|
||||
/**
|
||||
* The permanent fence marker. Carried and restored verbatim; see the file
|
||||
* header for why letting this default is a security regression and not a
|
||||
@@ -130,6 +137,7 @@ export interface CoachBackupCounts {
|
||||
const COACH_CONVERSATION_BACKUP_SELECT = {
|
||||
id: true,
|
||||
title: true,
|
||||
titleEncrypted: true,
|
||||
documentScoped: true,
|
||||
summaryEncrypted: true,
|
||||
summaryUpdatedAt: true,
|
||||
@@ -174,6 +182,17 @@ function decryptTurnSoft(bytes: Uint8Array | null): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** A title for a reader: ciphertext first, legacy readable column after. */
|
||||
function readTitleSoft(row: {
|
||||
title: string | null;
|
||||
titleEncrypted: Uint8Array | null;
|
||||
}): string {
|
||||
if (row.titleEncrypted && row.titleEncrypted.byteLength > 0) {
|
||||
return decryptTurnSoft(row.titleEncrypted) ?? "";
|
||||
}
|
||||
return row.title ?? "";
|
||||
}
|
||||
|
||||
export async function buildCoachBackupSection(
|
||||
prisma: Pick<PrismaClient, "coachConversation">,
|
||||
userId: string,
|
||||
@@ -190,7 +209,18 @@ export async function buildCoachBackupSection(
|
||||
return {
|
||||
coachConversations: rows.map((row) => ({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
// A disaster-recovery file carries the stored bytes: the ciphertext,
|
||||
// or, for a row the free-text backfill has not reached yet, the old
|
||||
// readable title exactly as stored (the restore seals it). Sealing it
|
||||
// here instead would make the export non-deterministic, and the
|
||||
// streaming writer and this builder must produce the same file.
|
||||
...(disasterRecovery && row.titleEncrypted
|
||||
? {
|
||||
titleEncrypted: Buffer.from(row.titleEncrypted).toString("base64"),
|
||||
}
|
||||
: disasterRecovery
|
||||
? { title: row.title ?? "" }
|
||||
: { title: readTitleSoft(row) }),
|
||||
documentScoped: row.documentScoped,
|
||||
...(disasterRecovery
|
||||
? {
|
||||
@@ -293,12 +323,17 @@ export type RestoredCoachMessage = Pick<
|
||||
|
||||
export type RestoredCoachConversation = Pick<
|
||||
CoachConversationBackupEntry,
|
||||
"id" | "title" | "documentScoped" | "createdAt" | "updatedAt"
|
||||
"id" | "documentScoped" | "createdAt" | "updatedAt"
|
||||
> &
|
||||
OptionalNullable<
|
||||
Pick<
|
||||
CoachConversationBackupEntry,
|
||||
"summaryEncrypted" | "summary" | "summaryUpdatedAt" | "summaryTurnCount"
|
||||
| "title"
|
||||
| "titleEncrypted"
|
||||
| "summaryEncrypted"
|
||||
| "summary"
|
||||
| "summaryUpdatedAt"
|
||||
| "summaryTurnCount"
|
||||
>
|
||||
> & {
|
||||
messages: RestoredCoachMessage[];
|
||||
@@ -343,7 +378,9 @@ export async function restoreCoachData(
|
||||
data: {
|
||||
id: conversation.id,
|
||||
userId: ownerId,
|
||||
title: conversation.title,
|
||||
// v1.39.3 — the title is stored encrypted; the readable column is
|
||||
// never written on a restore.
|
||||
titleEncrypted: resolveTitleBytes(conversation),
|
||||
// Verbatim. See the file header: this is the one field whose loss is a
|
||||
// security regression rather than a gap in the history.
|
||||
documentScoped: conversation.documentScoped,
|
||||
@@ -420,6 +457,15 @@ function resolveTurnBytes(
|
||||
return encryptToBytes(message.content ?? "");
|
||||
}
|
||||
|
||||
function resolveTitleBytes(
|
||||
conversation: RestoredCoachConversation,
|
||||
): Uint8Array<ArrayBuffer> {
|
||||
if (conversation.titleEncrypted !== undefined) {
|
||||
return decodeBase64(conversation.titleEncrypted);
|
||||
}
|
||||
return encryptToBytes(conversation.title ?? "");
|
||||
}
|
||||
|
||||
function resolveSummaryBytes(
|
||||
conversation: RestoredCoachConversation,
|
||||
): Uint8Array<ArrayBuffer> | null {
|
||||
|
||||
@@ -25,6 +25,7 @@ import { Buffer } from "node:buffer";
|
||||
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import {
|
||||
DEFAULT_HEALTH_PROFILE_AI_SECTIONS,
|
||||
@@ -116,7 +117,16 @@ export interface CustomMetricEntryBackupEntry {
|
||||
/** Unit snapshot taken from the metric at write time, not a live join. */
|
||||
unit: string;
|
||||
measuredAt: string;
|
||||
/**
|
||||
* The readable note: portable exports, and DR payloads for a row the
|
||||
* free-text backfill had not reached yet. Null otherwise.
|
||||
*/
|
||||
note: string | null;
|
||||
/**
|
||||
* v1.39.3 — base64 ciphertext of the note, present only in disaster-recovery
|
||||
* payloads (null when the reading has no note).
|
||||
*/
|
||||
noteEncrypted?: string | null;
|
||||
createdAt?: string;
|
||||
/** v1.37.20 (A3-11) — soft-delete tombstone; DR payloads only. */
|
||||
deletedAt?: string | null;
|
||||
@@ -227,6 +237,32 @@ function decryptProfileFieldSoft(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.39.3 — a custom-metric reading's note for a portable export: ciphertext
|
||||
* first, the legacy readable column for a row the backfill has not reached.
|
||||
* An unreadable ciphertext is recorded in the manifest and exported as no
|
||||
* note, never replaced by the readable column.
|
||||
*/
|
||||
function readEntryNoteSoft(
|
||||
entry: { id: string; note: string | null; noteEncrypted: Uint8Array | null },
|
||||
failures: string[],
|
||||
): string | null {
|
||||
if (entry.noteEncrypted && entry.noteEncrypted.byteLength > 0) {
|
||||
try {
|
||||
return decryptFromBytes(entry.noteEncrypted);
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`custom metric entry note decrypt failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
failures.push(`customMetricEntry.${entry.id}.note`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return entry.note;
|
||||
}
|
||||
|
||||
function toBase64(buf: Uint8Array | null): string | null {
|
||||
return buf && buf.byteLength > 0 ? Buffer.from(buf).toString("base64") : null;
|
||||
}
|
||||
@@ -447,12 +483,21 @@ export async function buildProfileBackupSection(
|
||||
id: entry.id,
|
||||
createdAt: entry.createdAt.toISOString(),
|
||||
deletedAt: entry.deletedAt?.toISOString() ?? null,
|
||||
// v1.39.3 — the ciphertext rides verbatim. A row the free-text
|
||||
// backfill has not reached yet carries its old readable note in
|
||||
// `note` instead, as stored; the restore seals it. Sealing it
|
||||
// here would make the export non-deterministic.
|
||||
noteEncrypted: toBase64(entry.noteEncrypted),
|
||||
}
|
||||
: {}),
|
||||
value: entry.value,
|
||||
unit: entry.unit,
|
||||
measuredAt: entry.measuredAt.toISOString(),
|
||||
note: entry.note,
|
||||
note: disasterRecovery
|
||||
? entry.noteEncrypted
|
||||
? null
|
||||
: entry.note
|
||||
: readEntryNoteSoft(entry, decryptFailures),
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -571,6 +616,16 @@ function resolveProfileColumn(
|
||||
return plaintext == null ? null : encryptToBytes(plaintext);
|
||||
}
|
||||
|
||||
function resolveEntryNote(entry: {
|
||||
note?: string | null;
|
||||
noteEncrypted?: string | null;
|
||||
}): Uint8Array<ArrayBuffer> | null {
|
||||
if (entry.noteEncrypted !== undefined && entry.noteEncrypted !== null) {
|
||||
return decodeEncryptedField(entry.noteEncrypted, "customMetricEntry.note");
|
||||
}
|
||||
return encryptNote(entry.note);
|
||||
}
|
||||
|
||||
function resolveFactValue(
|
||||
entry: HealthProfileFactBackupEntry,
|
||||
): Uint8Array<ArrayBuffer> {
|
||||
@@ -848,7 +903,10 @@ export async function restoreProfileData(
|
||||
value: entry.value,
|
||||
unit: entry.unit,
|
||||
measuredAt: new Date(entry.measuredAt),
|
||||
note: entry.note,
|
||||
// v1.39.3 — ciphertext wins when a DR payload carried it; a
|
||||
// portable file's readable note is sealed under this instance's
|
||||
// key. The readable column is never written.
|
||||
noteEncrypted: resolveEntryNote(entry),
|
||||
deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null,
|
||||
...(entry.createdAt
|
||||
? { createdAt: new Date(entry.createdAt) }
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { readNote } from "@/lib/crypto/note-cipher";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
|
||||
// ── in-memory store backing a minimal prisma mock ──────────────────────────
|
||||
interface ConversationRow {
|
||||
id: string;
|
||||
userId: string;
|
||||
title: string | null;
|
||||
titleEncrypted: Uint8Array | null;
|
||||
}
|
||||
interface EntryRow {
|
||||
id: string;
|
||||
userId: string;
|
||||
note: string | null;
|
||||
noteEncrypted: Uint8Array | null;
|
||||
}
|
||||
|
||||
const store = vi.hoisted(() => ({
|
||||
conversations: [] as ConversationRow[],
|
||||
entries: [] as EntryRow[],
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: () => null }));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db", () => {
|
||||
function delegate<R extends { id: string; userId: string }>(
|
||||
rows: () => R[],
|
||||
readable: keyof R,
|
||||
) {
|
||||
return {
|
||||
// The handler pages on `{ userId, <readable>: { not: null } }`.
|
||||
findMany: async (args: { where: { userId: string }; take: number }) =>
|
||||
rows()
|
||||
.filter((r) => r.userId === args.where.userId && r[readable] !== null)
|
||||
.slice(0, args.take)
|
||||
.map((r) => ({ id: r.id })),
|
||||
findUnique: async (args: { where: { id: string } }) =>
|
||||
rows().find((x) => x.id === args.where.id) ?? null,
|
||||
update: async (args: { where: { id: string }; data: Partial<R> }) => {
|
||||
const r = rows().find((x) => x.id === args.where.id)!;
|
||||
Object.assign(r, args.data);
|
||||
return r;
|
||||
},
|
||||
};
|
||||
}
|
||||
const delegates = {
|
||||
coachConversation: delegate(() => store.conversations, "title"),
|
||||
customMetricEntry: delegate(() => store.entries, "note"),
|
||||
};
|
||||
return {
|
||||
prisma: {
|
||||
...delegates,
|
||||
$transaction: async (fn: (tx: typeof delegates) => unknown) =>
|
||||
fn(delegates),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { runFreeTextEncryptionBackfillForUser } from "@/lib/jobs/free-text-encryption-backfill";
|
||||
|
||||
const KEY = "a".repeat(64);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("ENCRYPTION_KEYS", "");
|
||||
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
|
||||
vi.stubEnv("ENCRYPTION_KEY", KEY);
|
||||
store.conversations = [
|
||||
{
|
||||
id: "c1",
|
||||
userId: "u1",
|
||||
title: "Why is my pressure up after the new tablets?",
|
||||
titleEncrypted: null,
|
||||
},
|
||||
// Already migrated: left alone.
|
||||
{
|
||||
id: "c2",
|
||||
userId: "u1",
|
||||
title: null,
|
||||
titleEncrypted: encryptToBytes("Sleep this week"),
|
||||
},
|
||||
{ id: "c3", userId: "u2", title: "other user", titleEncrypted: null },
|
||||
];
|
||||
store.entries = [
|
||||
{
|
||||
id: "e1",
|
||||
userId: "u1",
|
||||
note: "after the long ride",
|
||||
noteEncrypted: null,
|
||||
},
|
||||
{ id: "e2", userId: "u1", note: null, noteEncrypted: null }, // no note
|
||||
// An empty legacy note is still a readable value; it migrates to "none".
|
||||
{ id: "e3", userId: "u1", note: "", noteEncrypted: null },
|
||||
{ id: "e4", userId: "u2", note: "other user", noteEncrypted: null },
|
||||
];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("runFreeTextEncryptionBackfillForUser", () => {
|
||||
it("seals titles and notes, nulls the readable column, and reads back the same text", async () => {
|
||||
const summary = await runFreeTextEncryptionBackfillForUser("u1");
|
||||
|
||||
expect(summary).toEqual({
|
||||
conversationTitlesMigrated: 1,
|
||||
metricNotesMigrated: 2,
|
||||
});
|
||||
|
||||
const c1 = store.conversations.find((r) => r.id === "c1")!;
|
||||
expect(c1.title).toBeNull();
|
||||
expect(readNote(c1.titleEncrypted, null)).toBe(
|
||||
"Why is my pressure up after the new tablets?",
|
||||
);
|
||||
|
||||
const e1 = store.entries.find((r) => r.id === "e1")!;
|
||||
expect(e1.note).toBeNull();
|
||||
expect(readNote(e1.noteEncrypted, null)).toBe("after the long ride");
|
||||
|
||||
const e3 = store.entries.find((r) => r.id === "e3")!;
|
||||
expect(e3.note).toBeNull();
|
||||
expect(e3.noteEncrypted).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps an already sealed row and a row without a note as they were", async () => {
|
||||
const before = store.conversations.find(
|
||||
(r) => r.id === "c2",
|
||||
)!.titleEncrypted;
|
||||
await runFreeTextEncryptionBackfillForUser("u1");
|
||||
const c2 = store.conversations.find((r) => r.id === "c2")!;
|
||||
expect(c2.titleEncrypted).toBe(before);
|
||||
const e2 = store.entries.find((r) => r.id === "e2")!;
|
||||
expect(e2).toMatchObject({ note: null, noteEncrypted: null });
|
||||
});
|
||||
|
||||
it("seals the readable value when a row holds both, since only an older writer can have put it there", async () => {
|
||||
store.conversations[1].title = "Renamed by the previous release";
|
||||
await runFreeTextEncryptionBackfillForUser("u1");
|
||||
const c2 = store.conversations.find((r) => r.id === "c2")!;
|
||||
expect(c2.title).toBeNull();
|
||||
expect(readNote(c2.titleEncrypted, null)).toBe(
|
||||
"Renamed by the previous release",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not touch another account's rows", async () => {
|
||||
await runFreeTextEncryptionBackfillForUser("u1");
|
||||
expect(store.conversations.find((r) => r.id === "c3")).toMatchObject({
|
||||
title: "other user",
|
||||
titleEncrypted: null,
|
||||
});
|
||||
expect(store.entries.find((r) => r.id === "e4")).toMatchObject({
|
||||
note: "other user",
|
||||
noteEncrypted: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("is idempotent: a second run changes nothing", async () => {
|
||||
await runFreeTextEncryptionBackfillForUser("u1");
|
||||
const sealed = store.conversations.find(
|
||||
(r) => r.id === "c1",
|
||||
)!.titleEncrypted;
|
||||
const second = await runFreeTextEncryptionBackfillForUser("u1");
|
||||
expect(second).toEqual({
|
||||
conversationTitlesMigrated: 0,
|
||||
metricNotesMigrated: 0,
|
||||
});
|
||||
expect(store.conversations.find((r) => r.id === "c1")!.titleEncrypted).toBe(
|
||||
sealed,
|
||||
);
|
||||
});
|
||||
|
||||
it("is fail-closed: without a key every readable row stays intact", async () => {
|
||||
vi.stubEnv("ENCRYPTION_KEY", "");
|
||||
vi.stubEnv("ENCRYPTION_KEYS", "");
|
||||
await expect(runFreeTextEncryptionBackfillForUser("u1")).rejects.toThrow();
|
||||
expect(store.conversations.find((r) => r.id === "c1")).toMatchObject({
|
||||
title: "Why is my pressure up after the new tablets?",
|
||||
titleEncrypted: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -107,6 +107,7 @@ describe("maintenance registrar queue policies", () => {
|
||||
["INTAKE_SLOT_DEDUP_QUEUE", "exclusive"],
|
||||
["NOTE_ENCRYPTION_BACKFILL_QUEUE", "exclusive"],
|
||||
["MED_NOTES_ENCRYPTION_BACKFILL_QUEUE", "exclusive"],
|
||||
["FREE_TEXT_ENCRYPTION_BACKFILL_QUEUE", "exclusive"],
|
||||
["DOCUMENT_THUMBNAIL_BACKFILL_QUEUE", "exclusive"],
|
||||
["CONTENT_INDEX_BACKFILL_QUEUE", "exclusive"],
|
||||
["ENCRYPTION_KEY_ROTATE_QUEUE", "exclusive"],
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* v1.39.3 — boot-time converging backfill that moves the last two readable
|
||||
* free-text columns to AES-256-GCM at rest:
|
||||
* `CoachConversation.title` -> `titleEncrypted` (the opening words of the
|
||||
* first Coach message, or a rename) and
|
||||
* `CustomMetricEntry.note` -> `noteEncrypted`.
|
||||
*
|
||||
* Modelled on `med-notes-encryption-backfill.ts`: a discovery query enqueues
|
||||
* one job per user still holding an un-migrated row, the per-user handler walks
|
||||
* that user's rows, and the pass is idempotent across reboots — once a row is
|
||||
* migrated it drops off the discovery + candidate sets.
|
||||
*
|
||||
* DATA-LOSS SAFETY:
|
||||
* - Per row, the encrypt-then-null happens in a SINGLE interactive
|
||||
* transaction guarded by a re-read inside the tx, so the readable column is
|
||||
* only nulled AFTER the ciphertext is written in the same atomic unit. A
|
||||
* row that had text is never left with neither.
|
||||
* - FAIL-CLOSED: the encryption helper throws on a missing / malformed key.
|
||||
* The throw aborts the transaction (the row is untouched) and propagates so
|
||||
* pg-boss retries.
|
||||
* - IDEMPOTENT: the guard (readable column IS NOT NULL) is re-checked inside
|
||||
* the tx, so a re-run, or two workers racing, migrates a row at most once;
|
||||
* a second pass migrates zero rows.
|
||||
* - The guard is the readable column alone, not "and no ciphertext yet".
|
||||
* From this release on nothing writes the readable column, so a row holding
|
||||
* both can only come from a writer of the previous release still running
|
||||
* after the upgrade (a rename, an edited note). Its readable value is then
|
||||
* the newer one, and it is the one sealed.
|
||||
*
|
||||
* The readable columns are NOT dropped in this release. That is a follow-up
|
||||
* release, once this backfill reports zero remaining rows on every instance;
|
||||
* both columns carry the marker in `schema.prisma`.
|
||||
*
|
||||
* Boot discovery is staggered past the startup storm (`startAfter`), like the
|
||||
* other boot backfills; the daily discovery tick passes no offset.
|
||||
*
|
||||
* The queue name MUST be registered in the maintenance registrar
|
||||
* (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it at
|
||||
* boot; an unregistered queue silently never drains.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
|
||||
export const FREE_TEXT_ENCRYPTION_BACKFILL_QUEUE =
|
||||
"free-text-encryption-backfill";
|
||||
|
||||
/** Serial — one transaction per row, kept off the request pool. */
|
||||
export const FREE_TEXT_ENCRYPTION_BACKFILL_CONCURRENCY = 1;
|
||||
|
||||
/** How many candidate rows to pull per page within a user's pass. */
|
||||
const PAGE_SIZE = 200;
|
||||
|
||||
export interface FreeTextEncryptionBackfillPayload {
|
||||
/** Absent on the daily discovery tick; present for a per-user job. */
|
||||
userId?: string;
|
||||
enqueuedAt?: string;
|
||||
}
|
||||
|
||||
export interface FreeTextEncryptionBackfillSummary {
|
||||
conversationTitlesMigrated: number;
|
||||
metricNotesMigrated: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate one conversation's readable title into `titleEncrypted` and null the
|
||||
* readable column, atomically and idempotently. Returns true if it changed the
|
||||
* row, false if a concurrent pass already did.
|
||||
*/
|
||||
async function migrateConversationTitle(id: string): Promise<boolean> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const fresh = await tx.coachConversation.findUnique({
|
||||
where: { id },
|
||||
select: { title: true },
|
||||
});
|
||||
if (!fresh || fresh.title === null) return false;
|
||||
// FAIL-CLOSED: a key error throws here and rolls the tx back.
|
||||
await tx.coachConversation.update({
|
||||
where: { id },
|
||||
data: { titleEncrypted: encryptToBytes(fresh.title), title: null },
|
||||
});
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate one custom-metric reading's readable note into `noteEncrypted` and
|
||||
* null the readable column, atomically and idempotently.
|
||||
*/
|
||||
async function migrateMetricNote(id: string): Promise<boolean> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const fresh = await tx.customMetricEntry.findUnique({
|
||||
where: { id },
|
||||
select: { note: true },
|
||||
});
|
||||
if (!fresh || fresh.note === null) return false;
|
||||
await tx.customMetricEntry.update({
|
||||
where: { id },
|
||||
// An empty note reads back as "no note" either way, so it stores none.
|
||||
data: { noteEncrypted: encryptNote(fresh.note), note: null },
|
||||
});
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-user queue handler. Walks every un-migrated title and note of one
|
||||
* account and migrates each in its own transaction. Safe to re-run.
|
||||
*/
|
||||
export async function runFreeTextEncryptionBackfillForUser(
|
||||
userId: string,
|
||||
): Promise<FreeTextEncryptionBackfillSummary> {
|
||||
let conversationTitlesMigrated = 0;
|
||||
let metricNotesMigrated = 0;
|
||||
// Every row this pass has sealed. A row that comes back on a later page was
|
||||
// not cleared, so the pass would never converge; it stops loudly instead of
|
||||
// re-sealing the same rows forever.
|
||||
const sealed = new Set<string>();
|
||||
const once = (id: string) => {
|
||||
if (sealed.has(id)) {
|
||||
throw new Error(
|
||||
`free-text encryption backfill: row ${id} still readable after sealing`,
|
||||
);
|
||||
}
|
||||
sealed.add(id);
|
||||
};
|
||||
|
||||
// Each migration nulls the readable column, so a migrated row drops out of
|
||||
// the next `IS NOT NULL` page.
|
||||
for (;;) {
|
||||
const rows = await prisma.coachConversation.findMany({
|
||||
where: { userId, title: { not: null } },
|
||||
select: { id: true },
|
||||
take: PAGE_SIZE,
|
||||
});
|
||||
if (rows.length === 0) break;
|
||||
let migratedThisPage = 0;
|
||||
for (const { id } of rows) {
|
||||
once(id);
|
||||
if (await migrateConversationTitle(id)) {
|
||||
conversationTitlesMigrated += 1;
|
||||
migratedThisPage += 1;
|
||||
}
|
||||
}
|
||||
// A page where nothing moved (every row taken by a racing worker) ends
|
||||
// the loop, so it always terminates.
|
||||
if (migratedThisPage === 0) break;
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
const rows = await prisma.customMetricEntry.findMany({
|
||||
where: { userId, note: { not: null } },
|
||||
select: { id: true },
|
||||
take: PAGE_SIZE,
|
||||
});
|
||||
if (rows.length === 0) break;
|
||||
let migratedThisPage = 0;
|
||||
for (const { id } of rows) {
|
||||
once(id);
|
||||
if (await migrateMetricNote(id)) {
|
||||
metricNotesMigrated += 1;
|
||||
migratedThisPage += 1;
|
||||
}
|
||||
}
|
||||
if (migratedThisPage === 0) break;
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "free_text.encryption.backfill",
|
||||
details: {
|
||||
conversation_titles_migrated: conversationTitlesMigrated,
|
||||
metric_notes_migrated: metricNotesMigrated,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { conversationTitlesMigrated, metricNotesMigrated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovery. Finds every user still holding a readable title or note and
|
||||
* enqueues one backfill job per account. The 0357 partial indexes match both
|
||||
* predicates, so the scan is index-only and converges to empty. pg-boss
|
||||
* `singletonKey` coalesces duplicate sends. Best-effort: errors come back
|
||||
* through the result value so worker boot never fails on a miss.
|
||||
*/
|
||||
export async function enqueueBootTimeFreeTextEncryptionBackfill(
|
||||
// Boot-storm stagger in seconds; 0 (the cron tick) keeps immediate semantics.
|
||||
startAfterSeconds: number = 0,
|
||||
): Promise<{ enqueued: number; skipped: number; error: string | null }> {
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) {
|
||||
return { enqueued: 0, skipped: 0, error: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const [titleUsers, noteUsers] = await Promise.all([
|
||||
prisma.$queryRaw<{ user_id: string }[]>`
|
||||
SELECT DISTINCT user_id FROM coach_conversations
|
||||
WHERE title IS NOT NULL`,
|
||||
prisma.$queryRaw<{ user_id: string }[]>`
|
||||
SELECT DISTINCT user_id FROM custom_metric_entries
|
||||
WHERE note IS NOT NULL`,
|
||||
]);
|
||||
|
||||
const userIds = new Set<string>();
|
||||
for (const { user_id } of titleUsers) userIds.add(user_id);
|
||||
for (const { user_id } of noteUsers) userIds.add(user_id);
|
||||
|
||||
let enqueued = 0;
|
||||
let skipped = 0;
|
||||
for (const userId of userIds) {
|
||||
const payload: FreeTextEncryptionBackfillPayload = {
|
||||
userId,
|
||||
enqueuedAt: new Date().toISOString(),
|
||||
};
|
||||
const jobId = await boss.send(
|
||||
FREE_TEXT_ENCRYPTION_BACKFILL_QUEUE,
|
||||
payload,
|
||||
{
|
||||
retryLimit: 5,
|
||||
retryDelay: 60,
|
||||
retryBackoff: true,
|
||||
singletonKey: `free-text-encryption-backfill|${userId}`,
|
||||
...(startAfterSeconds > 0 ? { startAfter: startAfterSeconds } : {}),
|
||||
},
|
||||
);
|
||||
if (jobId) {
|
||||
enqueued += 1;
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
return { enqueued, skipped, error: null };
|
||||
} catch (err) {
|
||||
return {
|
||||
enqueued: 0,
|
||||
skipped: 0,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet<string> = new Set([
|
||||
"context_surfaced",
|
||||
"continuation",
|
||||
"continued",
|
||||
"conversation_titles_migrated",
|
||||
"created",
|
||||
"daily_rows_retired",
|
||||
"days_consolidated",
|
||||
@@ -130,6 +131,7 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet<string> = new Set([
|
||||
"medications",
|
||||
"medications_evaluated",
|
||||
"metric_assessments_warmed",
|
||||
"metric_notes_migrated",
|
||||
"missed_doses_minted",
|
||||
"module_off",
|
||||
"mood_entries_migrated",
|
||||
|
||||
@@ -97,6 +97,13 @@ import {
|
||||
enqueueBootTimeMedNotesEncryptionBackfill,
|
||||
type MedNotesEncryptionBackfillPayload,
|
||||
} from "@/lib/jobs/med-notes-encryption-backfill";
|
||||
import {
|
||||
FREE_TEXT_ENCRYPTION_BACKFILL_QUEUE,
|
||||
FREE_TEXT_ENCRYPTION_BACKFILL_CONCURRENCY,
|
||||
runFreeTextEncryptionBackfillForUser,
|
||||
enqueueBootTimeFreeTextEncryptionBackfill,
|
||||
type FreeTextEncryptionBackfillPayload,
|
||||
} from "@/lib/jobs/free-text-encryption-backfill";
|
||||
import {
|
||||
CONTENT_INDEX_BACKFILL_QUEUE,
|
||||
CONTENT_INDEX_BACKFILL_CONCURRENCY,
|
||||
@@ -343,6 +350,13 @@ const MCP_TOKEN_CLEANUP_CRON = "0 4 * * *";
|
||||
// boss poll.
|
||||
|
||||
const MED_NOTES_ENCRYPTION_BACKFILL_CRON = "5 4 * * *";
|
||||
// v1.39.3 — converging backfill that moves the Coach conversation title and the
|
||||
// custom-metric reading note to AES-256-GCM at rest. Boot discovery (staggered
|
||||
// past the startup storm) enqueues one per-user job; a daily 04:07
|
||||
// Europe/Berlin discovery tick re-fans for rows written by a previous-release
|
||||
// process after the upgrade. Two minutes after the medication-note tick so the
|
||||
// two do not share a poll.
|
||||
const FREE_TEXT_ENCRYPTION_BACKFILL_CRON = "7 4 * * *";
|
||||
|
||||
const allQueues = [
|
||||
// v1.25 (W-ENV) — nightly environmental-context fetch. A daily discovery tick
|
||||
@@ -445,6 +459,10 @@ const allQueues = [
|
||||
// plaintext medication note; without this entry pg-boss never provisions the
|
||||
// queue and both the boot enqueue and the cron silently no-op.
|
||||
MED_NOTES_ENCRYPTION_BACKFILL_QUEUE,
|
||||
// v1.39.3 — Coach title + custom-metric note encryption backfill. Without
|
||||
// this entry pg-boss never provisions the queue and both the boot enqueue
|
||||
// and the cron silently no-op.
|
||||
FREE_TEXT_ENCRYPTION_BACKFILL_QUEUE,
|
||||
// Document vault — daily physical purge for tombstones past the 30-day
|
||||
// undo grace (returns the encrypted blob's TOAST space). Without this entry
|
||||
// the daily schedule silently no-ops and "deleted" documents hold backup
|
||||
@@ -605,6 +623,9 @@ const schedules: ScheduleEntry[] = [
|
||||
// fan out one per-user job per account still holding a plaintext medication
|
||||
// note.
|
||||
[MED_NOTES_ENCRYPTION_BACKFILL_QUEUE, MED_NOTES_ENCRYPTION_BACKFILL_CRON],
|
||||
// v1.39.3 — daily 04:07 Europe/Berlin discovery for the Coach title +
|
||||
// custom-metric note backfill. Empty payload = fan out per user.
|
||||
[FREE_TEXT_ENCRYPTION_BACKFILL_QUEUE, FREE_TEXT_ENCRYPTION_BACKFILL_CRON],
|
||||
// Document vault — daily 04:10 Europe/Berlin purge for tombstoned
|
||||
// documents past the 30-day undo grace.
|
||||
[DOCUMENT_PURGE_QUEUE, DOCUMENT_PURGE_CRON, cronIsTheRetry],
|
||||
@@ -663,6 +684,11 @@ const queuePolicies: QueuePolicyTable = {
|
||||
reason:
|
||||
"Per-user medication-note encryption backfill; discovery drops the user once no plaintext note remains.",
|
||||
},
|
||||
[FREE_TEXT_ENCRYPTION_BACKFILL_QUEUE]: {
|
||||
policy: "exclusive",
|
||||
reason:
|
||||
"Per-user Coach title + custom-metric note encryption backfill; discovery drops the user once no readable value remains.",
|
||||
},
|
||||
[DOCUMENT_THUMBNAIL_BACKFILL_QUEUE]: {
|
||||
policy: "exclusive",
|
||||
reason:
|
||||
@@ -1206,6 +1232,65 @@ export async function registerMaintenanceQueues(
|
||||
},
|
||||
);
|
||||
|
||||
// v1.39.3 — Coach title + custom-metric note encryption backfill worker.
|
||||
// Same shape as the medication-note worker above: an empty-userId payload is
|
||||
// the daily discovery tick, a userId payload migrates that account's rows.
|
||||
await createAndWork<Partial<FreeTextEncryptionBackfillPayload>>(
|
||||
boss,
|
||||
FREE_TEXT_ENCRYPTION_BACKFILL_QUEUE,
|
||||
{ localConcurrency: FREE_TEXT_ENCRYPTION_BACKFILL_CONCURRENCY },
|
||||
async (jobs) => {
|
||||
let users = 0;
|
||||
let discoveryEnqueued = 0;
|
||||
let discoverySkipped = 0;
|
||||
let discoveryFailed = 0;
|
||||
let titles = 0;
|
||||
let notes = 0;
|
||||
for (const job of jobs) {
|
||||
const { userId } = job.data;
|
||||
if (!userId) {
|
||||
const result = await enqueueBootTimeFreeTextEncryptionBackfill();
|
||||
discoveryEnqueued += result.enqueued;
|
||||
discoverySkipped += result.skipped;
|
||||
if (result.error) discoveryFailed++;
|
||||
workerLog(
|
||||
"info",
|
||||
`[free-text-encryption-backfill] daily discovery enqueued=${result.enqueued} skipped=${result.skipped}${result.error ? ` error=${result.error}` : ""}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const { conversationTitlesMigrated, metricNotesMigrated } =
|
||||
await runFreeTextEncryptionBackfillForUser(userId);
|
||||
users++;
|
||||
titles += conversationTitlesMigrated;
|
||||
notes += metricNotesMigrated;
|
||||
workerLog(
|
||||
"info",
|
||||
`[free-text-encryption-backfill] user=${userId} titles=${conversationTitlesMigrated} notes=${metricNotesMigrated}`,
|
||||
);
|
||||
} catch (err) {
|
||||
recordError();
|
||||
workerLog(
|
||||
"error",
|
||||
`[free-text-encryption-backfill] user=${userId} failed`,
|
||||
err,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return jobDone({
|
||||
jobs: jobs.length,
|
||||
users,
|
||||
discovery_enqueued: discoveryEnqueued,
|
||||
discovery_skipped: discoverySkipped,
|
||||
discovery_failed: discoveryFailed,
|
||||
conversation_titles_migrated: titles,
|
||||
metric_notes_migrated: notes,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Document vault P2 — on-demand content-search index backfill worker. The
|
||||
// trigger endpoint sends one per-user job; this handler indexes that user's
|
||||
// not-yet-indexed documents (consent + budget gated, bounded + resumable).
|
||||
@@ -1568,6 +1653,33 @@ export async function enqueueMaintenanceBootDiscovery(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// v1.39.3 — Coach title + custom-metric note encryption backfill. Staggered
|
||||
// one step past the note-encryption backfill so it starts after the boot
|
||||
// storm rather than on the first poll.
|
||||
try {
|
||||
const { enqueued, skipped, error } =
|
||||
await enqueueBootTimeFreeTextEncryptionBackfill(
|
||||
BOOT_BACKFILL_STAGGER_SECONDS * 6,
|
||||
);
|
||||
if (error) {
|
||||
workerLog(
|
||||
"error",
|
||||
`[free-text-encryption-backfill] boot discovery failed: ${error}`,
|
||||
);
|
||||
} else {
|
||||
workerLog(
|
||||
"info",
|
||||
`[free-text-encryption-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
workerLog(
|
||||
"error",
|
||||
"[free-text-encryption-backfill] boot discovery threw an unexpected error",
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
// Document vault — preview-thumbnail backfill. Finds every account holding a
|
||||
// thumbnailable document (image or PDF) without a preview and enqueues one
|
||||
// per-user backfill pass. Idempotent across reboots: a rendered thumbnail
|
||||
|
||||
@@ -128,6 +128,7 @@ const subsystemSurface = {
|
||||
"encryption-key-rotate": { audience: "system" },
|
||||
"mcp-token-cleanup": { audience: "system" },
|
||||
"med-notes-encryption-backfill": { audience: "system" },
|
||||
"free-text-encryption-backfill": { audience: "system" },
|
||||
"document-tombstone-purge": { audience: "system" },
|
||||
// v1.37.19 (A7-4) — hourly heal of summary rows stuck on PENDING.
|
||||
"document-summary-reaper": { audience: "system" },
|
||||
|
||||
@@ -2011,7 +2011,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
responses: {
|
||||
"302": {
|
||||
description:
|
||||
"Always a redirect. To the provider on success. On failure to `/auth/login?error=<reason>` (browser) or `healthlog://oidc-callback?error=<reason>` (native), where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_invalid_request`, `oidc_failed`.",
|
||||
"Always a redirect. To the provider on success. On failure to `/auth/login?error=<reason>` (browser) or `healthlog://oidc-callback?error=<reason>` (native), where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_invalid_request`, `oidc_failed`. The provider then returns to the callback (not listed here, because the identity provider drives it rather than a client), which ends the native leg at `healthlog://oidc-callback` with `code=<hlh_…>`, with `mfa_ticket=<ticket>&methods=<list>`, or with `error=<reason>`, where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_denied`, `oidc_no_email`, `oidc_email_unverified`, `oidc_identity_conflict`, `oidc_registration_disabled`, `oidc_failed`. The reasons are this exact snake_case vocabulary on both legs; a client maps a value it does not know to a generic sign-in failure.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -183,7 +183,7 @@ export const importPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Import"],
|
||||
summary: "Import measurements from CSV",
|
||||
description:
|
||||
"Bulk-import measurements from a CSV file (web cold-start escape hatch). Body is raw `text/csv` (≤ 16 MB, ≤ 10 000 valid rows). Header (order-independent): `type,value,unit,measuredAt[,glucoseContext,notes,externalId]`. `measuredAt` must carry an explicit ISO-8601 offset and is bounded (no future beyond a 5-min skew, no instant before 1900). Glucose accepts `mmol/L` (converted to canonical `mg/dL`); weight accepts `lb` (converted to `kg`). `glucoseContext` is optional on a `BLOOD_GLUCOSE` row — a blank cell stores no context, which is the normal shape of a continuous-sensor export; a non-empty value is still checked against the enum, and the column is refused on any other type. An `externalId` column makes re-upload idempotent (upsert on `(userId, type, source=IMPORT, externalId)`); without it a re-upload duplicates. `?dryRun=1` validates + previews without writing. Shares the 5/hour `import:` rate bucket.",
|
||||
"Bulk-import measurements from a CSV file (web cold-start escape hatch). Body is raw `text/csv` (≤ 16 MB, ≤ 10 000 valid rows). Header (order-independent): `type,value,unit,measuredAt[,glucoseContext,notes,externalId]`. `measuredAt` must carry an explicit ISO-8601 offset and is bounded (no future beyond a 5-min skew, no instant before 1900). Glucose accepts `mmol/L` (converted to canonical `mg/dL`); weight accepts `lb` (converted to `kg`). `glucoseContext` is optional on a `BLOOD_GLUCOSE` row — a blank cell stores no context, which is the normal shape of a continuous-sensor export; a non-empty value is still checked against the enum, and the column is refused on any other type. Re-uploading a file is idempotent. With an `externalId` column a row upserts on `(userId, type, source=IMPORT, externalId)`: a changed row is `updated`, an unchanged one is `skipped` with reason `duplicate` and not written. Without it a row is deduplicated on `(type, measuredAt)` and a repeat is `skipped` / `duplicate`. Writes commit in chunks rather than as one transaction; a failure part-way leaves earlier chunks written, and re-uploading the same file completes the rest. `?dryRun=1` validates + previews without writing. Shares the 5/hour `import:` rate bucket.",
|
||||
parameters: [
|
||||
{
|
||||
name: "dryRun",
|
||||
|
||||
@@ -799,6 +799,8 @@ const customMetricEntryBackupSchema = z
|
||||
unit: z.string().min(1),
|
||||
measuredAt: isoDateTime,
|
||||
note: z.string().nullable().default(null),
|
||||
/// v1.39.3 — the note's ciphertext, DR payloads only.
|
||||
noteEncrypted: base64BytesSchema.nullable().optional(),
|
||||
createdAt: isoDateTime.optional(),
|
||||
/// v1.37.20 (A3-11) — the entry tombstone rides DR payloads so a restore
|
||||
/// brings the account back exactly as it stood, undo affordance included.
|
||||
@@ -1366,7 +1368,10 @@ const coachConversationDocumentBackupSchema = z
|
||||
const coachConversationBackupSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
title: z.string(),
|
||||
// v1.39.3 — a portable file (and every file written before) carries the
|
||||
// readable title; a disaster-recovery file carries only the ciphertext.
|
||||
title: z.string().optional(),
|
||||
titleEncrypted: base64BytesSchema.optional(),
|
||||
// NOT optional with a default. The fence is permanent and a file that does
|
||||
// not state it is a file that cannot be trusted to re-fence the
|
||||
// conversation, so the absence has to be visible rather than defaulted to
|
||||
|
||||
@@ -112,6 +112,8 @@ const COACH_PLAN_CUE = "if the evening reading is over 140";
|
||||
const COACH_PLAN_ACTION = "then walk twenty minutes before dinner tomorrow";
|
||||
const COACH_REMINDER_NOTE = "ask how the evening walks are going";
|
||||
const COACH_SUMMARY = "earlier turns: weight trend and evening walks";
|
||||
const METRIC_LEGACY_NOTE = "left hand, after the long ride";
|
||||
const METRIC_SEALED_NOTE = "right hand, rested";
|
||||
const COACH_USER_TURN = "my readings look higher this week, is that real?";
|
||||
const COACH_ASSISTANT_TURN =
|
||||
"the last seven mornings average 4 mmHg above the fortnight before";
|
||||
@@ -723,13 +725,26 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
|
||||
userId: OWNER_ID,
|
||||
name: "Grip strength",
|
||||
unit: "kg",
|
||||
// v1.39.3 — one reading whose note is still readable (a row the
|
||||
// free-text backfill has not reached) and one sealed the current way.
|
||||
// Both must come back sealed, and neither readable.
|
||||
entries: {
|
||||
create: {
|
||||
userId: OWNER_ID,
|
||||
value: 44,
|
||||
unit: "kg",
|
||||
measuredAt: AT("2026-07-01T18:00:00.000Z"),
|
||||
},
|
||||
create: [
|
||||
{
|
||||
userId: OWNER_ID,
|
||||
value: 44,
|
||||
unit: "kg",
|
||||
measuredAt: AT("2026-07-01T18:00:00.000Z"),
|
||||
note: METRIC_LEGACY_NOTE,
|
||||
},
|
||||
{
|
||||
userId: OWNER_ID,
|
||||
value: 45,
|
||||
unit: "kg",
|
||||
measuredAt: AT("2026-07-02T18:00:00.000Z"),
|
||||
noteEncrypted: encryptToBytes(METRIC_SEALED_NOTE),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -923,7 +938,9 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
|
||||
await prisma.coachConversation.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
title: "About my June labs",
|
||||
// v1.39.3 — sealed the current way; the thread above keeps a readable
|
||||
// legacy title, the shape of a row the backfill has not reached.
|
||||
titleEncrypted: encryptToBytes("About my June labs"),
|
||||
documentScoped: true,
|
||||
messages: {
|
||||
create: [
|
||||
@@ -1524,6 +1541,20 @@ describe("every model the plan claims two-ended survives a real restore", () =>
|
||||
sourceId: "4711",
|
||||
aiReadDeferred: true,
|
||||
});
|
||||
const metricEntries = await prisma.customMetricEntry.findMany({
|
||||
where: { userId: OWNER_ID },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
});
|
||||
expect(
|
||||
metricEntries.map((entry) => ({
|
||||
note: readNote(entry.noteEncrypted, entry.note),
|
||||
readable: entry.note,
|
||||
})),
|
||||
"both reading notes come back, sealed, with nothing readable",
|
||||
).toEqual([
|
||||
{ note: METRIC_LEGACY_NOTE, readable: null },
|
||||
{ note: METRIC_SEALED_NOTE, readable: null },
|
||||
]);
|
||||
const threads = await prisma.coachConversation.findMany({
|
||||
where: { userId: OWNER_ID },
|
||||
orderBy: { createdAt: "asc" },
|
||||
@@ -1534,7 +1565,9 @@ describe("every model the plan claims two-ended survives a real restore", () =>
|
||||
});
|
||||
expect(
|
||||
threads.map((thread) => ({
|
||||
title: thread.title,
|
||||
title: readNote(thread.titleEncrypted, thread.title),
|
||||
// A restore seals every title and never writes the readable column.
|
||||
readableTitle: thread.title,
|
||||
documentScoped: thread.documentScoped,
|
||||
summary: thread.summaryEncrypted
|
||||
? decryptFromBytes(thread.summaryEncrypted)
|
||||
@@ -1552,6 +1585,7 @@ describe("every model the plan claims two-ended survives a real restore", () =>
|
||||
).toEqual([
|
||||
{
|
||||
title: "How is my blood pressure trending?",
|
||||
readableTitle: null,
|
||||
documentScoped: false,
|
||||
summary: COACH_SUMMARY,
|
||||
summaryTurnCount: 4,
|
||||
@@ -1573,6 +1607,7 @@ describe("every model the plan claims two-ended survives a real restore", () =>
|
||||
},
|
||||
{
|
||||
title: "About my June labs",
|
||||
readableTitle: null,
|
||||
documentScoped: true,
|
||||
summary: null,
|
||||
summaryTurnCount: 0,
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* v1.39.3 — importing the same large CSV twice.
|
||||
*
|
||||
* A re-upload of a 9 000-row export with an externalId column ended in a 500:
|
||||
* every row already existed, so the route ran one update per row, serially,
|
||||
* inside a single interactive transaction, and that outlived Prisma's 5 s
|
||||
* timeout. Only a real database shows it. This imports the same file twice and
|
||||
* then a lightly edited copy, and reads the table back after each pass.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const USER = "user-csv-reimport";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const HEADER = "type,value,unit,measuredAt,glucoseContext,notes,externalId";
|
||||
const ROWS = 9000;
|
||||
|
||||
/** 8 000 externalId rows and 1 000 without one, a minute apart. */
|
||||
function fileLines(changed: ReadonlySet<number> = new Set()): string[] {
|
||||
return Array.from({ length: ROWS }, (_, i) => {
|
||||
const at = new Date(Date.UTC(2025, 0, 1) + i * 60_000).toISOString();
|
||||
const value = (70 + (i % 50) / 10 + (changed.has(i) ? 1 : 0)).toFixed(1);
|
||||
const note = i % 100 === 0 ? "after the long ride" : "";
|
||||
const externalId = i < 8000 ? `export-${i}` : "";
|
||||
return `WEIGHT,${value},kg,${at},,${note},${externalId}`;
|
||||
});
|
||||
}
|
||||
|
||||
function csvRequest(lines: string[]): NextRequest {
|
||||
return new NextRequest("http://localhost/api/import/csv", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "text/csv" },
|
||||
body: [HEADER, ...lines].join("\n"),
|
||||
});
|
||||
}
|
||||
|
||||
interface CsvEnvelope {
|
||||
data: {
|
||||
inserted: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
total: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
async function signIn() {
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: USER,
|
||||
username: "csv-reimport",
|
||||
email: "csv-reimport@example.test",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: { userId: USER, expiresAt: new Date(Date.now() + 60 * 60 * 1000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
});
|
||||
|
||||
describe("POST /api/import/csv — re-importing a 9 000-row file (real Postgres)", () => {
|
||||
it("imports once, writes nothing the second time, and updates only what changed", async () => {
|
||||
await signIn();
|
||||
const { POST } = await import("@/app/api/import/csv/route");
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
const first = await POST(csvRequest(fileLines()));
|
||||
expect(first.status).toBe(200);
|
||||
expect(((await first.json()) as CsvEnvelope).data).toMatchObject({
|
||||
inserted: ROWS,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
});
|
||||
expect(await prisma.measurement.count({ where: { userId: USER } })).toBe(
|
||||
ROWS,
|
||||
);
|
||||
const before = await prisma.measurement.findMany({
|
||||
where: { userId: USER },
|
||||
select: { id: true, value: true, notesEncrypted: true },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
});
|
||||
|
||||
const second = await POST(csvRequest(fileLines()));
|
||||
expect(second.status).toBe(200);
|
||||
expect(((await second.json()) as CsvEnvelope).data).toMatchObject({
|
||||
inserted: 0,
|
||||
updated: 0,
|
||||
skipped: ROWS,
|
||||
});
|
||||
const after = await prisma.measurement.findMany({
|
||||
where: { userId: USER },
|
||||
select: { id: true, value: true, notesEncrypted: true },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
});
|
||||
// Same rows, untouched: not even the note ciphertext was re-sealed.
|
||||
expect(after).toEqual(before);
|
||||
|
||||
const changed = new Set([0, 1, 4999, 7999]);
|
||||
const third = await POST(csvRequest(fileLines(changed)));
|
||||
expect(third.status).toBe(200);
|
||||
expect(((await third.json()) as CsvEnvelope).data).toMatchObject({
|
||||
inserted: 0,
|
||||
updated: changed.size,
|
||||
skipped: ROWS - changed.size,
|
||||
});
|
||||
expect(await prisma.measurement.count({ where: { userId: USER } })).toBe(
|
||||
ROWS,
|
||||
);
|
||||
const edited = await prisma.measurement.findFirst({
|
||||
where: { userId: USER, externalId: "export-4999" },
|
||||
select: { value: true },
|
||||
});
|
||||
expect(edited?.value).toBeCloseTo(70 + (4999 % 50) / 10 + 1, 5);
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* v1.39.3 — the Coach conversation title and the custom-metric reading note
|
||||
* move to AES-256-GCM at rest. Migration 0357 only adds the ciphertext
|
||||
* columns; the existing rows are sealed by the boot-time backfill. This runs
|
||||
* the real discovery and the real per-user pass against Postgres, with the
|
||||
* migrated schema:
|
||||
*
|
||||
* - discovery finds exactly the accounts still holding a readable value;
|
||||
* - the pass seals every such row in its own transaction, nulls the readable
|
||||
* column, and reads back the same text;
|
||||
* - a second pass and a second discovery find nothing (it converges);
|
||||
* - a row written the current way is left as it was.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { setGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { readNote } from "@/lib/crypto/note-cipher";
|
||||
import {
|
||||
enqueueBootTimeFreeTextEncryptionBackfill,
|
||||
runFreeTextEncryptionBackfillForUser,
|
||||
} from "@/lib/jobs/free-text-encryption-backfill";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
function makeCapturingBoss() {
|
||||
const sent: { queue: string; userId: string; startAfter?: number }[] = [];
|
||||
const boss = {
|
||||
send: async (
|
||||
queue: string,
|
||||
payload: { userId?: string },
|
||||
opts: { startAfter?: number },
|
||||
) => {
|
||||
if (payload.userId) sent.push({ queue, userId: payload.userId, ...opts });
|
||||
return `job-${sent.length}`;
|
||||
},
|
||||
};
|
||||
return { boss, sent };
|
||||
}
|
||||
|
||||
async function seedUser(id: string) {
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id,
|
||||
username: id,
|
||||
email: `${id}@example.test`,
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setGlobalBoss(null as never);
|
||||
});
|
||||
|
||||
describe("free-text encryption backfill (real Postgres)", () => {
|
||||
it("discovers, seals, and converges", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await Promise.all([seedUser("ft-a"), seedUser("ft-b"), seedUser("ft-c")]);
|
||||
|
||||
// ft-a: a legacy readable title and a sealed one.
|
||||
await prisma.coachConversation.create({
|
||||
data: { id: "conv-legacy", userId: "ft-a", title: "Legacy title" },
|
||||
});
|
||||
await prisma.coachConversation.create({
|
||||
data: {
|
||||
id: "conv-sealed",
|
||||
userId: "ft-a",
|
||||
titleEncrypted: encryptToBytes("Sealed title"),
|
||||
},
|
||||
});
|
||||
// ft-b: a legacy readable note on a custom-metric reading.
|
||||
const metric = await prisma.customMetric.create({
|
||||
data: { userId: "ft-b", name: "Grip", unit: "kg" },
|
||||
});
|
||||
await prisma.customMetricEntry.create({
|
||||
data: {
|
||||
id: "entry-legacy",
|
||||
userId: "ft-b",
|
||||
customMetricId: metric.id,
|
||||
value: 40,
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2026-09-01T08:00:00.000Z"),
|
||||
note: "legacy note",
|
||||
},
|
||||
});
|
||||
await prisma.customMetricEntry.create({
|
||||
data: {
|
||||
id: "entry-none",
|
||||
userId: "ft-b",
|
||||
customMetricId: metric.id,
|
||||
value: 41,
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2026-09-02T08:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
// ft-c: nothing readable, only a sealed title.
|
||||
await prisma.coachConversation.create({
|
||||
data: { userId: "ft-c", titleEncrypted: encryptToBytes("Only sealed") },
|
||||
});
|
||||
|
||||
const first = makeCapturingBoss();
|
||||
setGlobalBoss(first.boss as never);
|
||||
const discovered = await enqueueBootTimeFreeTextEncryptionBackfill(180);
|
||||
expect(discovered.error).toBeNull();
|
||||
expect(first.sent.map((s) => s.userId).sort()).toEqual(["ft-a", "ft-b"]);
|
||||
// The boot call is deferred past the startup storm.
|
||||
expect(first.sent.every((s) => s.startAfter === 180)).toBe(true);
|
||||
|
||||
expect(await runFreeTextEncryptionBackfillForUser("ft-a")).toEqual({
|
||||
conversationTitlesMigrated: 1,
|
||||
metricNotesMigrated: 0,
|
||||
});
|
||||
expect(await runFreeTextEncryptionBackfillForUser("ft-b")).toEqual({
|
||||
conversationTitlesMigrated: 0,
|
||||
metricNotesMigrated: 1,
|
||||
});
|
||||
|
||||
const conversations = await prisma.coachConversation.findMany({
|
||||
where: { userId: "ft-a" },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
expect(
|
||||
conversations.map((c) => ({
|
||||
id: c.id,
|
||||
readable: c.title,
|
||||
text: readNote(c.titleEncrypted, c.title),
|
||||
})),
|
||||
).toEqual([
|
||||
{ id: "conv-legacy", readable: null, text: "Legacy title" },
|
||||
{ id: "conv-sealed", readable: null, text: "Sealed title" },
|
||||
]);
|
||||
const entries = await prisma.customMetricEntry.findMany({
|
||||
where: { userId: "ft-b" },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
expect(
|
||||
entries.map((e) => ({
|
||||
id: e.id,
|
||||
readable: e.note,
|
||||
text: readNote(e.noteEncrypted, e.note),
|
||||
})),
|
||||
).toEqual([
|
||||
{ id: "entry-legacy", readable: null, text: "legacy note" },
|
||||
{ id: "entry-none", readable: null, text: null },
|
||||
]);
|
||||
|
||||
// Converged: nothing left to discover, nothing left to seal.
|
||||
const second = makeCapturingBoss();
|
||||
setGlobalBoss(second.boss as never);
|
||||
expect(
|
||||
(await enqueueBootTimeFreeTextEncryptionBackfill()).error,
|
||||
).toBeNull();
|
||||
expect(second.sent).toEqual([]);
|
||||
expect(await runFreeTextEncryptionBackfillForUser("ft-a")).toEqual({
|
||||
conversationTitlesMigrated: 0,
|
||||
metricNotesMigrated: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the partial indexes migration 0357 adds", async () => {
|
||||
const indexes = await getPrismaClient().$queryRaw<
|
||||
{ indexname: string; indexdef: string }[]
|
||||
>`
|
||||
SELECT indexname, indexdef FROM pg_indexes
|
||||
WHERE indexname IN (
|
||||
'coach_conversations_title_backfill_idx',
|
||||
'custom_metric_entries_note_backfill_idx'
|
||||
)
|
||||
ORDER BY indexname`;
|
||||
expect(indexes.map((i) => i.indexname)).toEqual([
|
||||
"coach_conversations_title_backfill_idx",
|
||||
"custom_metric_entries_note_backfill_idx",
|
||||
]);
|
||||
expect(indexes[0].indexdef).toContain("WHERE (title IS NOT NULL)");
|
||||
expect(indexes[1].indexdef).toContain("WHERE (note IS NOT NULL)");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user