diff --git a/.env.example b/.env.example
index aa5a7e3c3..10bc7095e 100644
--- a/.env.example
+++ b/.env.example
@@ -4,6 +4,9 @@
# Copy this file to .env and fill in the values.
# $ cp .env.example .env
#
+# This is a short starter. Every variable the app reads, with what it does,
+# is documented in .env.production.example.
+#
# Required variables: DATABASE_URL or POSTGRES_PASSWORD,
# ENCRYPTION_KEY, API_TOKEN_HMAC_KEY
#
@@ -76,10 +79,9 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
# -----------------------------------------------------------------------------
# Withings OAuth (optional — for automatic scale/BP/activity sync)
# -----------------------------------------------------------------------------
-# Register an app at https://developer.withings.com/ and paste the credentials
-# here. Per-user OAuth credentials can also be configured in Settings.
-# WITHINGS_CLIENT_ID=""
-# WITHINGS_CLIENT_SECRET=""
+# Register an app at https://developer.withings.com/ and enter its client id
+# and secret in Settings -> Integrations -> Withings. There is no environment
+# variable for them; only the redirect override and webhook secret live here.
# WITHINGS_REDIRECT_URI="https://healthlog.example.com/api/withings/callback"
# WITHINGS_WEBHOOK_SECRET="" # any random string — used to harden the webhook URL
@@ -103,16 +105,35 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
# NOTIFICATION_PRIVATE_ORIGINS=""
+# -----------------------------------------------------------------------------
+# Document picker: Paperless-ngx and Papra -- optional
+# -----------------------------------------------------------------------------
+# The Paperless-ngx and Papra instances people may connect to pick documents
+# from, comma-separated exact origins (scheme://host[:port], no path), e.g.
+# "https://paperless.example.com,http://papra.lan:1221". Unset, the document
+# picker is switched off. Only a listed origin is ever contacted, public or
+# private; requests are pinned to it, redirects are not followed, and
+# link-local, metadata and unspecified addresses stay refused even when
+# listed. A malformed entry is logged once and grants nothing.
+# See docs/self-hosting/document-sources.md.
+# DOCUMENT_SOURCE_ORIGINS=""
+
+
# -----------------------------------------------------------------------------
# AI Provider (optional)
# -----------------------------------------------------------------------------
# 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
@@ -181,10 +202,11 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
# operator — there is no way for the server to detect whether the flag
# matches the real deployment topology. When in doubt, leave it off.
# Number of trusted reverse-proxy hops in front of the app. The client IP is
-# read that many entries from the right of the X-Forwarded-For chain. Leave
-# unset for a directly-exposed host; set to 1 behind a single reverse proxy.
-# A wrong value makes the app refuse to read XFF and share one rate-limit
-# bucket across all anonymous callers.
+# read that many entries from the right of the X-Forwarded-For chain. Set 0
+# for a directly-exposed host; 1 (the default when unset) behind a single
+# reverse proxy. X-Real-IP is read only at 1. A wrong value makes the app
+# refuse the headers, log one warning, and share one rate-limit bucket across
+# all anonymous callers.
# TRUST_PROXY_HOPS="1"
# TRUST_CF_CONNECTING_IP=""
@@ -233,6 +255,7 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
# BACKUP_S3_REGION="auto" # "auto" for Cloudflare R2; real region for AWS S3
# BACKUP_RETENTION_DAYS="30" # the number for your bucket lifecycle rule (see above)
# BACKUP_ENCRYPTION_KEY="" # 64-hex-char or base64 32-byte; SEPARATE from ENCRYPTION_KEY
+# BACKUP_ENCRYPTION_PREVIOUS_KEYS="" # retired off-host keys, comma-separated, read-only during a rotation
# -----------------------------------------------------------------------------
# Stored backups in the database (optional)
@@ -292,6 +315,15 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
# DEPLOY_WEBHOOK_SECRET=""
+# -----------------------------------------------------------------------------
+# Outbound requests HealthLog makes on its own (optional switches)
+# -----------------------------------------------------------------------------
+# Each accepts "1", "true" or "yes". See .env.production.example for detail.
+# PASSWORD_BREACH_CHECK_DISABLED="1" # no HaveIBeenPwned range query when a password is set
+# UPDATE_CHECK_DISABLED="1" # no daily GitHub releases request
+# IP_GEO_LOOKUP_DISABLED="1" # no online IP geolocation for login events
+
+
# -----------------------------------------------------------------------------
# IP geolocation (optional)
# -----------------------------------------------------------------------------
diff --git a/.env.production.example b/.env.production.example
index ead149065..af58314bd 100644
--- a/.env.production.example
+++ b/.env.production.example
@@ -85,6 +85,20 @@ API_TOKEN_HMAC_KEY=""
# NOTIFICATION_PRIVATE_ORIGINS=""
+# -----------------------------------------------------------------------------
+# Document picker: Paperless-ngx and Papra -- optional
+# -----------------------------------------------------------------------------
+# The Paperless-ngx and Papra instances people may connect to pick documents
+# from, comma-separated exact origins (scheme://host[:port], no path), e.g.
+# "https://paperless.example.com,http://papra.lan:1221". Unset, the document
+# picker is switched off. Only a listed origin is ever contacted, public or
+# private; requests are pinned to it, redirects are not followed, and
+# link-local, metadata and unspecified addresses stay refused even when
+# listed. A malformed entry is logged once and grants nothing.
+# See docs/self-hosting/document-sources.md.
+# DOCUMENT_SOURCE_ORIGINS=""
+
+
# -----------------------------------------------------------------------------
# WHOOP -- optional integration
# -----------------------------------------------------------------------------
@@ -267,10 +281,15 @@ API_TOKEN_HMAC_KEY=""
# 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
@@ -345,13 +364,17 @@ API_TOKEN_HMAC_KEY=""
# BACKUP_S3_REGION="auto"
#
# How long backup objects are meant to live, in days. Default 30, minimum 1.
-# The worker never deletes a BACKUP object -- its grant is PutObject, GetObject
-# and AbortMultipartUpload -- so this number is what you set the bucket's own
-# lifecycle rule to. The one key it does delete is its own connection test's
-# `_healthcheck/.bin`, and that delete is best-effort: without DeleteObject
-# on that prefix every Test-connection press leaves one 1-byte orphan behind,
-# silently. See docs/ops/backup-restore.md.
+# The app does not expire objects by age: this number is what you set the
+# bucket's own lifecycle rule to. It does delete objects in two cases, so the
+# credential needs DeleteObject: every copy of an account that is deleted or
+# whose data is wiped, and its own connection test's `_healthcheck/.bin`.
+# See docs/ops/backup-restore.md.
# BACKUP_RETENTION_DAYS="30"
+#
+# Off-host keys no longer written with, comma-separated, kept so older
+# objects stay readable during a rotation of BACKUP_ENCRYPTION_KEY. Remove a
+# key once the lifecycle rule has retired every object written under it.
+# BACKUP_ENCRYPTION_PREVIOUS_KEYS=""
# -----------------------------------------------------------------------------
# Stored backups in the database (optional)
@@ -450,13 +473,34 @@ API_TOKEN_HMAC_KEY=""
# ONLY when Cloudflare is in front -- a direct-exposed self-host must leave
# it unset, or an attacker could forge the header. Off by default.
# Number of trusted reverse-proxy hops in front of the app; the client IP is
-# read that many entries from the right of the X-Forwarded-For chain. Unset
-# for a directly-exposed host, 1 behind a single reverse proxy. A wrong
-# value makes the app refuse XFF and collapse anonymous callers into one
+# read that many entries from the right of the X-Forwarded-For chain. 0 for a
+# directly-exposed host, 1 (the default when unset) behind a single reverse
+# proxy; X-Real-IP is read only at 1. A wrong value makes the app refuse the
+# headers, log one warning, and collapse anonymous callers into one
# rate-limit bucket.
# TRUST_PROXY_HOPS="1"
# TRUST_CF_CONNECTING_IP="1"
+# -----------------------------------------------------------------------------
+# Outbound requests HealthLog makes on its own -- optional switches
+# -----------------------------------------------------------------------------
+# Besides the services you connect yourself (AI provider, integrations,
+# notification channels, off-host backups), the server contacts three third
+# parties by default. Each has an off-switch that accepts "1", "true" or "yes":
+#
+# - HaveIBeenPwned, when a password is set (registration, change, admin
+# reset). Only the first five hex characters of the password's SHA-1 are
+# sent (k-anonymity); the password and its full hash never leave. With the
+# switch on, known-breached passwords are no longer refused.
+# PASSWORD_BREACH_CHECK_DISABLED="1"
+#
+# - The GitHub releases API, at most once a day per browser, for the "update
+# available" badge on the About page and the admin version tile.
+# UPDATE_CHECK_DISABLED="1"
+#
+# - The online IP geolocation provider for login events; see
+# IP_GEO_LOOKUP_DISABLED in the next section.
+
# -----------------------------------------------------------------------------
# IP geolocation lookup -- optional
# -----------------------------------------------------------------------------
@@ -476,8 +520,9 @@ API_TOKEN_HMAC_KEY=""
# IP_GEO_LOOKUP_URL="http://ip-api.com/json"
# IP_GEO_ALLOW_INSECURE="true"
#
-# Set to "1" to disable all third-party IP egress (GDPR Art. 44). With egress
-# off the resolver uses only the offline GeoLite2 MMDB tier, if present.
+# Set to "1" (or "true" / "yes") to disable all third-party IP egress (GDPR
+# Art. 44). With egress off the resolver uses only the offline GeoLite2 MMDB
+# tier, if present, and the monthly MaxMind download is skipped too.
# IP_GEO_LOOKUP_DISABLED="1"
# -----------------------------------------------------------------------------
diff --git a/.gitignore b/.gitignore
index e121cf65a..f446e2043 100644
--- a/.gitignore
+++ b/.gitignore
@@ -55,6 +55,9 @@ next-env.d.ts
# the cache-version baseline.
/public/sw-version.js
/public/i18n/
+# In-browser OCR engine and language data, copied from node_modules by
+# scripts/stage-tesseract-assets.mjs at prebuild/predev.
+/public/tesseract/
# v1.4.27 B3 — MaxMind GeoLite2 databases are fetched at build time
# via `scripts/fetch-geolite2.sh`. Too large for git; license key
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dc3f840dc..764d85206 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,352 @@
# Changelog
+## [1.39.3] — 2026-09-27
+
+Documents can be picked straight from Paperless-ngx and Papra. Exports,
+share links, tokens, invitations and new second factors ask for a recent
+confirmation, and sign out everywhere reaches every way into the record.
+Medication days west of UTC, long-range figures and synced daily totals
+land on the right day. Deleting an account removes its off-site copies,
+restores check the keys they need, and the privacy wording says what is
+and is not encrypted.
+
+### Added
+
+- **Document picker for Paperless-ngx and Papra.** Settings, Integrations
+ has a Document archives card (address, API token, for Papra the
+ organization id); the connection is tested before it is saved, the
+ token is stored encrypted and never shown again, and it is sent only to
+ the origin it was saved for. The Documents page and the document link
+ dialogs on visits, vaccinations and conditions get an Import button:
+ search by name, filter by tag or date, tick documents, and each is
+ copied with its title and date and linked where the picker was opened.
+ Documents already imported are marked and linked instead of stored
+ twice; documents deleted in HealthLog are marked and not brought back.
+ Picked documents follow the owner's automatic AI reading setting. The
+ archive is contacted only on search or import, never in the background.
+ Off until the operator sets `DOCUMENT_SOURCE_ORIGINS`. Paperless-ngx
+ 2.16 or later; a Papra key needs read access to documents and tags.
+ Seven cookie-only routes under `/api/documents/sources` (a Bearer token
+ gets `403 documents.sources.browserOnly`). Thanks to @AntonPalmqvist for
+ #1038 and to the self-hoster behind #938.
+- **Source instance for imports.** `POST /api/documents/inbound` and
+ `GET /api/documents/inbound/source` take an optional `sourceInstance`
+ (the archive's address, normalised to its origin), so the same id in two
+ archives is two documents. Keys stored without it match any instance, as
+ before. The import script sends it and skips files larger than the
+ server accepts once it has learned the limit. `sourceId` refuses `.` and
+ `..`.
+- **Body site in the FHIR export.** A condition's site goes out as
+ `Condition.bodySite.text` ("knee (left)"); a recorded side also goes out
+ as a contained `BodyStructure` with an active SNOMED CT laterality
+ qualifier (7771000 left, 24028007 right, 51440002 both). Applies to the
+ health-record download, `Patient/$everything` and the clinician share
+ link. Thanks to @Cnote43 for #1025.
+- **MCP search and fetch cover visits, conditions, documents and
+ vaccinations.** Ids `visit:`, `condition:`, `document:` and
+ `vaccination:`; `fetch` returns fields in `metadata` (unrecorded
+ values `null`) and linked records in `metadata.links`. Matching covers
+ visit type, reason, outcome, body site and side, practitioner and
+ specialty, condition name, document title, file name and type, and
+ vaccine name, with German and English body-part names treated alike.
+ Documents are searchable by their indexed text and `fetch` returns a
+ short excerpt, never the file, a link or the AI summary. A document held
+ back from AI reading answers `metadata.reason: "ai_read_deferred"` with
+ no excerpt. Condition and vaccination notes are never sent. The consent
+ page says all of this. Thanks to @Cnote43 for #1025.
+- **Low-supply notice for as-needed medications.** Sent once when two
+ doses or fewer are left, re-armed by a refill, naming the units left
+ rather than a date. Push metadata carries `asNeeded: true`,
+ `runwayDays: null`, `triggerDays: null`.
+- **Switches for the remaining outbound requests.**
+ `PASSWORD_BREACH_CHECK_DISABLED` stops the HaveIBeenPwned range check
+ (known leaked passwords are then no longer refused);
+ `UPDATE_CHECK_DISABLED` stops the daily GitHub update check, and
+ `/api/version/check-updates` answers `status: "unknown"` with
+ `reason: "disabled"`. Both default to on and are on the compose
+ whitelist.
+- **Admin, Encryption lists the keys backups still need.** Per key, the
+ stored backups and off-site copies written under it; the "safe to drop"
+ badge appears only when backups are clear too. Backups written before
+ this release show as unrecorded.
+- **`BACKUP_ENCRYPTION_PREVIOUS_KEYS`** keeps retired off-site backup keys
+ readable, so `BACKUP_ENCRYPTION_KEY` can be rotated.
+- **`POST /api/auth/reproof`** (cookie only) takes the step-up body and
+ counts as a recent confirmation for five minutes.
+
+### Changed
+
+- **Sign out everywhere ends everything that can open the record.** Besides
+ browser and phone sign-ins it now revokes connected AI assistants, API
+ tokens (except the caller's own access token), clinician share links
+ (kept with `?keepShareLinks=1`, a checkbox in the dialog) and invitations
+ nobody has accepted. Accepted access is kept and listed on the sessions
+ card with End access. `DELETE /api/auth/me/sessions` adds
+ `accessTokensRevoked`, `connectorsRevoked`, `shareLinksRevoked`,
+ `pendingInvitesRevoked` and `grantsKept`.
+- **A password change revokes connected assistants and share links too.**
+- **Turning on the first second factor signs out other devices.** A second
+ key added later does not.
+- **Password sign-in is limited per account without locking it.** From a
+ place the account has not signed in from, five wrong passwords a day are
+ free, then each doubles the wait from 30 seconds up to 15 minutes. A
+ remembered browser, a phone whose `X-Device-Id` a login of this account
+ was issued to, and an address used by this account in the last 30 days
+ are never held back. The per-address limit of five in fifteen minutes is
+ unchanged. Answers are `429` with `Retry-After`.
+- **Request bodies are capped at 1 MB before authentication** (was 512 MB).
+ Apple Health archives, backup uploads, documents, CSV and dose history
+ imports, lab scans, profile pictures and the app's measurement, workout,
+ ECG, mood, medication and cycle batches read their own bodies after
+ authenticating, with their own limits enforced while reading (`413`).
+- **`X-Real-IP` is read only with `TRUST_PROXY_HOPS=1`** (the default) and
+ only when `X-Forwarded-For` is absent. With `0`, or `2` and more, it is
+ ignored. The log says once when a proxy header arrives that the setting
+ does not allow reading, naming the header and the setting.
+- **CSV re-import.** An unchanged row with an `externalId` is left alone and
+ reported `skipped` with reason `duplicate` (was `updated`); lookups and
+ writes run in small batches.
+- **`GET /api/analytics/range`.** For step-like totals `mean` is the
+ average daily total and `count` the number of days; for
+ `SLEEP_DURATION` it is the average night asleep, `count` the number of
+ nights, `granularity` `live`.
+- **Synced daily totals from Withings, Fitbit, Google Health, Polar and
+ Oura** are stored at noon in the user's zone (was noon UTC). The
+ `externalId` is unchanged; rows the next sync rereads move and reach the
+ change feed as updates. Fitbit weigh-ins without an offset are read in
+ the user's zone.
+- **Check-up `anchorDate`** accepts a bare `YYYY-MM-DD`, read as that day in
+ the profile zone; the web form sends it. Responses keep the date-time.
+- **"Send raw data" is now "Send detailed history"** and says what it sends:
+ daily averages for 90 days, weekly for the year before, monthly up to
+ five years, each with its date and reading count.
+- **Encryption wording.** The app, the Coach and the README said all health
+ data is encrypted at rest. Notes and other free text, documents,
+ questionnaire answers, AI-written text, the Coach conversation and every
+ access token are; measurement values, lab values, medication names and
+ mood scores are not. The wording now says so. Storage did not change.
+- **Local lab OCR is served from the instance.** The engine and its German
+ and English data (about 17 MB) come from the server instead of a public
+ CDN, which the page policy blocked; `/labs`, `/documents` and the OCR
+ worker may run WebAssembly.
+- **The image ships `scripts/reset-password.mjs`** and the intake repair
+ script from the operator guide. Key rotation is documented as Admin,
+ Encryption, Rotate now.
+- `IP_GEO_LOOKUP_DISABLED`, like every on/off setting, accepts `1`, `true`,
+ `yes` and `on`.
+- `.env.production.example` is the complete settings list; `.env.example`
+ is a starter and no longer lists the unused `WITHINGS_CLIENT_ID` and
+ `WITHINGS_CLIENT_SECRET`.
+
+### Deprecated
+
+- **`ALLOW_LOCAL_AI_PRIVATE_HOSTS=true`.** It now covers only the admin AI
+ key and AI settings saved on an admin account, over a pinned connection
+ without redirects, and will be removed in a later release. Other
+ accounts' private endpoints need `AI_PRIVATE_ORIGINS`. The host-list form
+ (`ollama.lan,10.0.0.5`) keeps working with the same pinning. The boot
+ summary names the setting, and the admin AI settings page shows the
+ `AI_PRIVATE_ORIGINS` line for the endpoints saved on the instance.
+
+### Fixed
+
+- **Medication days west of UTC.** A Monday plan reminded on Sunday, a
+ course starting on the 14th had a dose on the 13th, and an evening dose
+ on the last day could be dropped; monthly and one-time doses were a day
+ early too. Start and end dates are read as calendar days and the times
+ placed on them in the user's zone, for the list, today list, reminders,
+ history and adherence. History for affected weeks can show the wrongly
+ named day as an off-schedule entry beside the planned day.
+- **Weeks on and weeks off count whole weeks from the start day** instead
+ of Sunday-based UTC weeks; the pause of a plan that did not start on a
+ Sunday can move.
+- **A snooze holds only the dose it answered.** Skipping the morning dose
+ from Telegram no longer silences the evening dose. A snooze set while no
+ dose is open holds the next one.
+- **Check-up reminders with several times of day are sent at each.** The
+ evening half of the morning-and-evening blood pressure course waited
+ until the next morning.
+- **Supply estimate.** Honours `INTERVAL` on daily, monthly and yearly
+ rules, several days of the month or months of the year, every weekday a
+ monthly rule names (every Monday counts four or five times; the first
+ Monday once), the pause of an on/off plan, and as-needed schedules as
+ not consuming. An injection every third month with two doses left reads
+ about 180 days, not 60. Whole-dose counts no longer lose one to rounding
+ (2.4 mL at 0.8 mL is three doses).
+- **The doctor report keeps same-named medications apart**, in the PDF, the
+ FHIR export, the clinician link and the AI summary.
+- **A doctor report for a past period stays inside it** and compares course
+ dates by day in the report's zone, so west of UTC it no longer drops a
+ course ending on the first day or lists one starting after the last.
+- **Weekly, monthly and yearly figures pick the source per day** instead of
+ once per bucket, so mixed manual and synced readings and a device switch
+ mid-month count every day. Affects year and All ranges, baselines, the
+ one-year change and assessments.
+- **"vs. last year"** is the average of the 30 days starting a year and a
+ month ago, not whichever month began inside that window.
+- **All-time average, minimum and maximum** count one source per day for
+ histories older than five years, and so do the all-time figures the
+ assessments and the Coach see for weight, blood pressure and pulse.
+- **The logging streak** was one day short west of UTC in the evening.
+- **The activity part of the health score** uses the 28 completed days up
+ to yesterday, so it no longer dips every morning.
+- **Heatmaps, digest milestones, assessments and mood and cycle
+ comparisons** use the user's day instead of the server's or Berlin's.
+- **Days that start at a skipped midnight** (Chile, Cuba) or on a clock
+ change in New Zealand and eastern Australia start at their first real
+ minute.
+- **Synced daily totals from UTC+12 and beyond** showed a day late. The next
+ sync fixes the recent days, about the last month for most services;
+ reconnecting Fitbit or Google Health rereads the whole history. Oura
+ cycle phases are fetched over the user's days as well.
+- **Apple Health export import** split a day on which the phone or watch
+ updated and kept only the larger half of its steps. Importing the export
+ again fixes stored days.
+- **Step and sleep period comparisons** compare average days and nights
+ instead of single readings, and end with yesterday.
+- **Long ranges showed no data** when all readings fell in the first,
+ partial year; they fall back to monthly figures.
+- **Assessments refresh after readings from every source** (assistant,
+ Telegram, CSV, backup import, Apple Health export), not only in-app entry.
+- **CSV re-import** of a few thousand rows timed out. A re-import that moves
+ a reading recalculates the day it left, and a failed import recalculates
+ the days it wrote; uploading the same file again finishes it.
+- **A queued Apple Health import lost its upload** when it waited longer
+ than seven hours; the cleanup keeps files a queued or running import
+ needs.
+- **Empty settings from the compose file count as not set**: weather and
+ daylight lookups, the login-location lookup, `TRUST_PROXY_HOPS`, and the
+ Withings, Fitbit and Google Health redirect URIs.
+- **The first due date of a check-up** no longer moves a day when the
+ browser is in another zone than the profile.
+- **The password reset command failed** with "Cannot find module" in the
+ published image.
+- **FHIR export codes.** Body water and bone mass use the body-composition
+ codes; step length, walking speed, walking asymmetry and VO2 max go out
+ under their Apple Health names; mood (now outside vital signs), estimated
+ A1C and average cycle and period length go out as named values without a
+ code; LDL, vitamin D and eGFR use the method-less codes; mean glucose is
+ coded (in mg/dL) only from a continuous monitor; allergy and injury use
+ current SNOMED CT concepts; a medication code no longer carries the
+ user's name as its display; resting heart rate and oxygen saturation add
+ the required vital-signs codes; an export without vital signs has no
+ empty vital-signs report. No longer sent: 73704-9, 73708-0, 41955-6,
+ 41957-2, 91557-1, 96402-2, 76542-6, 41995-2, 64700-8, 64698-4, 18262-6,
+ 1989-3, 33914-3, SNOMED CT 106190000.
+- **MCP tools follow the module switches.** With Medications, Labs,
+ Conditions, Documents or Vaccinations off, search and fetch skip them and
+ `get_labs`, `get_medication_schedule`, `get_medication_compliance` and
+ the lab and medication resources answer `present: false`,
+ `reason: "module_disabled"`; `get_visits` sets
+ `conditionsReason: "module_disabled"`.
+
+### Security
+
+- **Recent confirmation for actions that outlast a session.** Whole-record
+ exports (full backup, encrypted backup, JSON and CSV of everything, the
+ doctor report, FHIR `$everything`), share links, API and
+ MCP tokens, invitations, connecting an AI assistant, and the admin
+ backup download, upload, restore, delete all data and reset password
+ need a sign-in or confirmation within five minutes, with the second
+ factor on an account that has one. Otherwise `401 auth.reproof.required`
+ with `meta.methods`, and the app shows a confirm dialog and continues.
+ The encrypted backup now asks accounts without a second factor too. On a
+ token, `GET /api/export/full-backup` and `GET /api/export?type=all` need
+ an `X-Step-Up` elevation and `POST /api/export/encrypted` a second-factor
+ one on accounts that have a second factor; the doctor report
+ (`POST /api/export/health-record`) and `$everything` keep taking the
+ token. Migration `0359`.
+- **Adding a second factor needs a recent confirmation.** Without a second
+ factor: a sign-in within five minutes or the password. With one: that
+ factor or a passkey, never the password alone, also for passkeys and on
+ the token path. An authenticator never confirmed with a code no longer
+ counts as a factor anywhere.
+- **Confirmation attempts share one limit** of five per fifteen minutes per
+ account, including password change and email change; each wrong attempt
+ is in the security activity and a correct one is not counted. Second
+ factor attempts at sign-in are counted before they are checked.
+- **Single sign-on links an existing account only after its own sign-in**
+ (password and second factor, or passkey) within ten minutes, also on
+ SSO-only instances. The app receives `oidc_link_required`. Existing links
+ stay.
+- **Changing the email address** needs the current password or a sign-in
+ within five minutes; on an account with a second factor, that factor or
+ a passkey. The rest of the save still lands; the address is reported in
+ `rejectedFields`.
+- **Private AI endpoints by exact origin.** New `AI_PRIVATE_ORIGINS`
+ (comma-separated `scheme://host[:port]`). Granted endpoints are called
+ over a pinned connection without redirects; metadata, link-local and
+ unspecified addresses stay refused. A failed lab scan no longer logs the
+ endpoint's answer.
+- **Coach conversation titles and custom-metric notes are encrypted at
+ rest.** Existing rows are encrypted in the background after start;
+ reads fall back to the old column until then. Migration `0357`.
+- **Deleting an account, a managed profile or all data removes its
+ off-site backup copies**, right after and again the next night; refused
+ deletions are retried and shown on Admin, Backups, Off-host, which also
+ warns when the bucket has no lifecycle rule. Admin Wipe all data leaves
+ the bucket alone. Migration `0358`.
+- **Restores check the keys a backup needs.** Preview, restore, upload and
+ the monthly drill refuse a backup whose encrypted content needs a key the
+ server lacks (`backup.key.missing` with `keyIds`), before changing
+ anything. A restore that would reference another account is rolled back
+ (`backup.foreign_reference`).
+- **Off-site copies are bound to their account and date** (envelope
+ version 4 with a key id); older copies still restore.
+- **Apple Health imports.** An archive may inflate to 200 times its size on
+ disk (64 MiB to 8 GiB); one import runs per account, and a second one
+ answers `409` `import.apple_health.busy` with `meta.jobId`; the upload
+ is removed on every exit and swept every fifteen minutes; a job that
+ cannot be queued answers `503` and is marked failed. The admin backup
+ upload stops at 100 times its compressed size (256 MB to 4 GB) with
+ `413`.
+- Every credential the server issues, invite links and the sign-in handoff
+ code included, is kept out of logs and the replay cache.
+- A passkey or security-key challenge is used up when checked.
+- A device-bound refresh token is refused without `X-Device-Id`.
+- Upload routes that read their own body send the same security headers
+ as the rest.
+
+### Upgrade notes
+
+- Take a database backup before upgrading.
+- Migrations: `0356` adds document archive connections and a nullable
+ `source_instance` to the document import tables, `0357` adds encrypted
+ columns for Coach titles and custom-metric notes, `0358` records the
+ keys each backup needs and adds off-site purge requests, `0359` adds
+ `reproof_at` to sessions. None deletes anything.
+- Going back to 1.39.2 is not supported once the server has started: it
+ cannot read Coach titles stored only encrypted. Restore the database
+ backup from before the upgrade instead.
+- Off-site backup: the bucket credential needs `DeleteObject` (an R2 Object
+ Read & Write token has it). To rotate `BACKUP_ENCRYPTION_KEY`, move the
+ old key into `BACKUP_ENCRYPTION_PREVIOUS_KEYS`.
+- Key rotation does not reach inside earlier backups. Keep a retired
+ `ENCRYPTION_KEYS` entry until Admin, Encryption lists nothing for it.
+- `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true`: nothing changes for the admin key
+ or an admin account. If other accounts use an AI endpoint on your
+ network, copy the `AI_PRIVATE_ORIGINS` line from the admin AI settings
+ page, restart and remove the old setting.
+- `TRUST_PROXY_HOPS`: with a proxy that sends only `X-Real-IP`, set `1`
+ (unset means `1`); a host reachable directly wants `0`. Check the log for
+ a line starting `[getClientIp] requests carry`.
+- Request bodies over 1 MB are refused outside the upload routes. Keep any
+ upload size limit in your reverse proxy as it is.
+- New settings, all on the compose whitelist: `DOCUMENT_SOURCE_ORIGINS`
+ (empty, picker off), `AI_PRIVATE_ORIGINS` (empty),
+ `BACKUP_ENCRYPTION_PREVIOUS_KEYS` (empty),
+ `PASSWORD_BREACH_CHECK_DISABLED` and `UPDATE_CHECK_DISABLED` (unset,
+ checks on).
+- Users are asked to confirm before exports, share links, tokens,
+ invitations and connecting an assistant when their last sign-in is older
+ than five minutes. After a password change or sign out everywhere, share
+ links have to be made again.
+- The image is about 17 MB larger for the local OCR engine.
+- Past medication history west of UTC is not rewritten; a wrongly named day
+ stays as an off-schedule entry and can be deleted by hand.
+- iPhone app 1.0.3 and 1.0.4 keep working. Reminders, supply figures,
+ aggregates and synced rows change value without a schema change.
+
## [1.39.2] — 2026-09-26
Documents can be brought over from Paperless-ngx and Papra, conditions
diff --git a/Dockerfile b/Dockerfile
index f9e8bc5a5..6b871db84 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -170,6 +170,24 @@ COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
COPY --from=builder /app/src/generated ./src/generated
+# Operator maintenance scripts that a runbook tells the operator to run inside
+# this container (`docs/ops/password-reset.md`, `docs/ops/intake-repair.md`).
+# The standalone output carries none of `scripts/`, so without these lines the
+# documented `docker compose exec app node scripts/reset-password.mjs` failed
+# with "Cannot find module". Only self-contained scripts belong here: plain
+# `node` or the `healthlog-tsx` launcher below, resolving `pg` and
+# `@node-rs/argon2` from the traced runtime tree. Scripts that import the
+# application graph (`@/…`, the generated Prisma client) run from a source
+# checkout instead. `src/__tests__/container-scripts-ship-guard.test.ts` holds
+# the runbooks and this list in step. Root-owned on purpose: the app user can
+# run them but not rewrite them.
+COPY --from=builder /app/scripts/reset-password.mjs ./scripts/reset-password.mjs
+COPY --from=builder /app/src/lib/auth/argon2-params.mjs ./src/lib/auth/argon2-params.mjs
+COPY --from=builder /app/scripts/repair-intake-anomalies.ts ./scripts/repair-intake-anomalies.ts
+# Prove the reset CLI's imports resolve in this tree: with every import found
+# it stops at its own usage line; a missing module throws before reaching it.
+RUN node scripts/reset-password.mjs 2>&1 | grep -q "reset-password: usage:"
+
# Install the migration CLI, its config dependency, and a pinned launcher for
# the maintenance scripts already shipped in the standalone tree. The Prisma
# config loads dotenv from /app, so expose the isolated copy there before
@@ -213,6 +231,10 @@ RUN mkdir -p /opt/prisma-cli && \
rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /root/.cache/node/corepack /root/.npm && \
rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack /usr/local/bin/pnpm /usr/local/bin/pnpx
+# Same proof for the intake-repair script under the launcher it is documented
+# with: its `pg` import resolves, so it reaches its own DATABASE_URL check.
+RUN env -u DATABASE_URL healthlog-tsx scripts/repair-intake-anomalies.ts 2>&1 | grep -q "DATABASE_URL must be set"
+
# v1.4.27 B3 — offline GeoLite2 databases for IP→location and IP→ASN
# lookups. The MMDB files live in `/opt/geolite2/` and are read by
# `src/lib/geo.ts` via `mmdb-lib`. They are downloaded outside the
diff --git a/README.md b/README.md
index a98998596..52dc91cff 100644
--- a/README.md
+++ b/README.md
@@ -42,7 +42,7 @@
## Why HealthLog
-HealthLog is a self-hosted health tracking app (PWA + native iOS client) that runs from a single `docker compose up` on a NAS, homelab, or small VPS. It brings weight, blood pressure, pulse, blood glucose, body composition, sleep, mood, menstrual cycle, and medications onto one timeline — synced from the devices you already own — and keeps everything encrypted at rest on hardware you control. No vendor cloud, no subscription, no telemetry.
+HealthLog is a self-hosted health tracking app (PWA + native iOS client) that runs from a single `docker compose up` on a NAS, homelab, or small VPS. It brings weight, blood pressure, pulse, blood glucose, body composition, sleep, mood, menstrual cycle, and medications onto one timeline, synced from the devices you already own, and keeps it in a Postgres database on hardware you control. No vendor cloud, no subscription, no usage telemetry. The few requests the server makes on its own (an update check, a password breach check, an IP geolocation lookup for logins) can each be switched off.
HealthLog is a personal health record-keeping and wellness tool. It is not a medical device and does not diagnose, treat, cure, or prevent any disease — it helps you log, visualise, and export your own data, and leaves clinical judgement to a clinician.
@@ -52,14 +52,14 @@ Try the [live demo](https://demo.healthlog.dev) to see a working install — sig
- **Every vital on one timeline.** Weight, blood pressure, pulse, glucose, body composition, sleep, SpO₂, respiratory rate, mood, and cycle tracking — charted with trends, moving averages, and clinical reference ranges (ESH 2023, ADA 2024), overridable with the targets your clinician set.
- **Clinical signals, opt-in and additive.** Validated self-checks for mental wellbeing and sleep (WHO-5, PHQ-9, GAD-7, Sleep Condition Indicator — official questionnaire wording, calm crisis-resource handling), grip strength, a 0–10 pain score, waist and body measurements, and a longevity lab panel — each added through one signal registry, so a new signal wires into charts, correlations, the coach, the FHIR map, and the API at once.
-- **Records, structured.** Allergies and family history as encrypted structured records that flow into the doctor report, plus lab values you key in or scan from a printed report — OCR extracts the stated values, units, and dates for you to review and confirm before anything is saved. HealthLog transcribes what a report says; it never interprets or diagnoses from it. A vaccination record keeps a lifetime immunization log, doses grouped under the disease they protect against, and prints in the doctor report.
+- **Records, structured.** Allergies and family history as structured records that flow into the doctor report (reactions and notes are encrypted, the substance and condition names are not), plus lab values you key in or scan from a printed report — OCR extracts the stated values, units, and dates for you to review and confirm before anything is saved. HealthLog transcribes what a report says; it never interprets or diagnoses from it. A vaccination record keeps a lifetime immunization log, doses grouped under the disease they protect against, and prints in the doctor report.
- **A document vault for the paper trail.** An opt-in module stores every letter, report, and scan encrypted at rest — PDFs and photos preview inline, Office and text files stay download-only, and nothing user-uploaded can ever execute in the app. One searchable timeline with type, condition, and year filters, drag-and-drop and paste-to-upload, bulk filing, undo-able delete with a 30-day grace, and per-file/per-account limits the operator tunes.
- **Context that explains the numbers.** An opt-in environmental module folds weather, daylight, and temperature in as influence signals (winter darkness against mood, heat against blood pressure) using a home location you set, so a pattern has somewhere to point.
- **Your devices, one server.** Withings, WHOOP, Oura and Polar sync over OAuth2, Google Health connects Fitbit and Pixel Watch through your own Google Cloud client, Nightscout streams CGM glucose, an Apple Health `export.zip` folds your full history in, and the native iOS app streams HealthKit live. A per-metric source priority decides which reading is canonical when wearables overlap.
- **Medication tracking that tells the truth.** Flexible schedules (weekly injections, weekday-only, intervals, PRN, cyclic), a configurable intake window per dose, and a traceable dose history where every slot reads taken, late, skipped, or missed — the same ledger the compliance rate is computed from, so the percentage can never disagree with the timeline.
-- **AI insights you own.** Daily briefing, health scores, correlations, and a conversational coach grounded in your own measurements and interpreted against cited guideline reference bands — where a value sits and what that means in plain words, never a diagnosis. The coach proposes concrete if-then plans you accept or decline, and suppresses proactive check-ins when there's nothing genuine to say. Bring your own OpenAI or Anthropic key, or point at a local endpoint (Ollama, LM Studio, vLLM) so nothing leaves your network.
-- **Clinician-ready output.** A doctor-report PDF generated client-side, a read-only HL7 FHIR R4 API, and scoped, time-limited share links you revoke after the visit.
-- **Private by construction.** AES-256-GCM encryption at rest with zero-downtime key rotation, passkey login, server-side sessions, strict CSP — and no third-party tracking anywhere.
+- **AI insights you own.** Daily briefing, health scores, correlations, and a conversational coach grounded in your own measurements and interpreted against cited guideline reference bands — where a value sits and what that means in plain words, never a diagnosis. The coach proposes concrete if-then plans you accept or decline, and suppresses proactive check-ins when there's nothing genuine to say. Bring your own OpenAI or Anthropic key, or point at a local endpoint (Ollama, LM Studio, vLLM) so AI requests stay on your network.
+- **Clinician-ready output.** A doctor-report PDF rendered on your server, a read-only HL7 FHIR R4 API, and scoped, time-limited share links you revoke after the visit.
+- **Private by construction.** AES-256-GCM encryption at rest for notes and other free text, documents, questionnaire answers, AI-written text and credentials, with zero-downtime key rotation. Measurement values, lab values, medication names and mood scores are stored unencrypted in the database, so the database volume and its backups need the same protection as the host. Passkey login, server-side sessions, strict CSP, and no third-party tracking anywhere.
- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, Polish, and Korean end to end.
The full feature tour, integration guides, and API reference live at [docs.healthlog.dev](https://docs.healthlog.dev).
@@ -97,7 +97,7 @@ NAS and homelab users have ready-made paths: [Unraid Community Applications](doc
## Self-hosting
-One container hosts both the web server and the job worker by default; split them via `HEALTHLOG_PROCESS_TYPE=web|worker` for horizontal scale. Encrypted daily backups to any S3-compatible bucket are opt-in via the admin panel, new users join by invite link or QR code, and the stack works out of the box behind Caddy, Traefik, Nginx, or [Coolify](https://coolify.io/).
+One container hosts both the web server and the job worker by default; split them via `HEALTHLOG_PROCESS_TYPE=web|worker` for horizontal scale. Encrypted nightly backups to any S3-compatible bucket are opt-in through five `BACKUP_*` environment variables (the admin panel shows their status), new users join by invite link or QR code, and the stack works out of the box behind Caddy, Traefik, Nginx, or [Coolify](https://coolify.io/).
HealthLog ships releases roughly weekly. Pin a tag, back up before upgrades, and skim the [CHANGELOG](CHANGELOG.md) before pulling `latest`. The operator manual — reverse proxy, migrations, encryption-key rotation, backup and restore — lives at [docs.healthlog.dev](https://docs.healthlog.dev).
diff --git a/docker-compose.yml b/docker-compose.yml
index a24b90ba0..28b1b9ad1 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -102,6 +102,14 @@ x-healthlog-env: &healthlog_env
IP_GEO_LOOKUP_URL: "${IP_GEO_LOOKUP_URL:-}"
IP_GEO_LOOKUP_DISABLED: "${IP_GEO_LOOKUP_DISABLED:-}"
IP_GEO_ALLOW_INSECURE: "${IP_GEO_ALLOW_INSECURE:-}"
+ # Outbound requests an operator can turn off (1 / true / yes). Both are on
+ # by default. PASSWORD_BREACH_CHECK_DISABLED stops the HaveIBeenPwned range
+ # query made when a password is set (only the first five characters of the
+ # password's SHA-1 are sent). UPDATE_CHECK_DISABLED stops the daily request
+ # to the GitHub releases API behind the About page and the admin version
+ # tile. Listed here so an operator value in .env reaches the container.
+ PASSWORD_BREACH_CHECK_DISABLED: "${PASSWORD_BREACH_CHECK_DISABLED:-}"
+ UPDATE_CHECK_DISABLED: "${UPDATE_CHECK_DISABLED:-}"
# Directory the offline GeoLite2 databases are read from. Default
# `/opt/geolite2` — the path the published image bakes its own copy
# into, if the build had a MaxMind licence key. Point this at a
@@ -125,6 +133,7 @@ x-healthlog-env: &healthlog_env
# web = serve HTTP only; pair with a separate app-worker service
HEALTHLOG_PROCESS_TYPE: "${HEALTHLOG_PROCESS_TYPE:-all}"
BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}"
+ BACKUP_ENCRYPTION_PREVIOUS_KEYS: "${BACKUP_ENCRYPTION_PREVIOUS_KEYS:-}"
BACKUP_S3_ENDPOINT: "${BACKUP_S3_ENDPOINT:-}"
BACKUP_S3_BUCKET: "${BACKUP_S3_BUCKET:-}"
BACKUP_S3_ACCESS_KEY: "${BACKUP_S3_ACCESS_KEY:-}"
@@ -215,6 +224,13 @@ x-healthlog-env: &healthlog_env
# and unspecified addresses stay refused even when listed. See
# docs/self-hosting/notifications.md.
NOTIFICATION_PRIVATE_ORIGINS: "${NOTIFICATION_PRIVATE_ORIGINS:-}"
+ # Paperless-ngx and Papra instances the document picker may reach,
+ # comma-separated exact origins (e.g. "https://paperless.example.com,
+ # http://papra.lan:1221"). Unset, the picker is off. Only listed origins are
+ # ever contacted, public or private; redirects are not followed and
+ # link-local, metadata and unspecified addresses stay refused even when
+ # listed. See docs/self-hosting/document-sources.md.
+ DOCUMENT_SOURCE_ORIGINS: "${DOCUMENT_SOURCE_ORIGINS:-}"
# Open-Meteo endpoints for the environment/weather correlation. Default to
# the hosted service; point them at your own Open-Meteo deployment to keep
# the coordinate lookups off a third party.
@@ -253,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
diff --git a/docs/api/mcp-capabilities.md b/docs/api/mcp-capabilities.md
index 51d98ae79..0d65f025e 100644
--- a/docs/api/mcp-capabilities.md
+++ b/docs/api/mcp-capabilities.md
@@ -66,38 +66,78 @@ optional so a `{ present: false }` miss and a full hit both validate.
argument. Every read is a thin wrapper over an existing
server-authoritative path; no new analytics is computed at the wire.
-| Tool | What it returns |
-| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `list_metrics` | One row per domain: whether data is present, an approximate sample count, and the tool that retrieves it. **Call this first.** |
-| `get_metric_series` | One metric's aggregate (count, min, max, mean, slope) plus recent-daily and weekly timelines, with units and population reference bands. |
-| `get_metrics` | Several metric series in one call — a paginated fan-out over `get_metric_series`, one grounded result per metric. |
-| `get_glucose_panel` | Per-context daily means plus the trailing-30-day clinical panel (time-in-range, GMI, CV%, estimated A1c). |
-| `get_sleep` | Per-night asleep + stage minutes plus the sleep-rhythm summary (sleep debt + chronotype). |
-| `get_workouts` | Most recent sessions (sport, duration, energy, distance, avg/max HR) plus a per-sport rollup over the window. |
-| `get_medication_compliance` | Cadence-aware adherence: dose-weighted compliance rate, expected vs taken/missed, current-cycle status, any GLP-1 titration context. |
-| `get_labs` | Latest reading per biomarker over the last 12 months (optionally one analyte). With `history:true` + an analyte, that analyte's paginated reading trajectory. |
-| `get_illness_recovery` | Rest mode, active and recently-resolved illnesses, recovery / strain composites, and the illness retrospective (recovery-gap, nadir, red flags). |
-| `get_cycle` | Menstrual-cycle context: phase + day-of-cycle, next predicted event, headline phase-correlation. Gated on cycle tracking; descriptive only. |
-| `get_correlations` | FDR-controlled day-to-next-day driver pairs between behaviours and outcomes, each with direction, lag, sample size, and a descriptive (never causal) note. |
-| `get_correlation` | The vetted, lag-aware association between **two named metrics**: direction, lag, sample size, Pearson r, and a descriptive note. |
-| `compare_metric` | One metric vs another over the same horizon, **or** one metric across two horizons (fixed windows or `{from,to}` ranges), with per-side stats + a delta. |
-| `get_metric_baseline` | Where the latest reading sits against the user's own usual range (median ± robust deviation), plus the strongest lagged driver. Needs ≥ 7 days of history. |
-| `detect_changepoints` | Points where a metric's level shifted over a window or `{from,to}` range — date, direction, before/after means. High firing bar. |
-| `get_medication_schedule` | When each active medication is next due and which are overdue right now — name, dose, next-due, overdue flag, as-needed flag. Reuses the recurrence engine. |
-| `get_integration_status` | Sync health of connected devices/services — connected, last sync, reauth-required/failing — to answer "why is my data stale?". No secrets or tokens. |
-| `get_preventive_care` | What preventive care is coming up: the user's own configured Vorsorge reminders (upcoming/overdue checkups with next-due dates) plus the appointments booked as future visits. Surfaces configured items, invents nothing. |
-| `get_visits` | The user's own past doctor visits over a bounded window (default 12 months, optional practitioner-name filter): date, status, kind, practitioner + specialty, the visit's own reason/outcome, and any linked condition labels. `{ present: false }` when none was ever recorded. |
-| `get_intraday_pulse` | The 10-minute heart-rate shape for one local day (hourly grain outside the dense-retention window), with at most one cautious descriptive elevated-at-rest window. Gated on the `insights` module. |
-| `get_ecg_recordings` | Metadata for the user's ECG recordings (time, duration, sampling rate, lead, the device's own rhythm classification). Never the waveform, never a re-classification. Gated on the `insights` module. |
-| `get_nutrients` | Without an argument, the presence overview per logged nutrient code; with one, that code's per-day summed series plus its EFSA reference. Gated on the opt-in `nutrients` module. |
-| `search` | Free-text search over the user's record (metric domains, medications, lab analytes). Returns `{ results: [{ id, title, url }], nextCursor? }`. |
-| `fetch` | Hydrate one record by the id `search` returned (`metric:weight`, `med:`, `lab:LDL`). Returns `{ id, title, text, url, metadata }` with a citation deep-link. |
+| Tool | What it returns |
+| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `list_metrics` | One row per domain: whether data is present, an approximate sample count, and the tool that retrieves it. **Call this first.** |
+| `get_metric_series` | One metric's aggregate (count, min, max, mean, slope) plus recent-daily and weekly timelines, with units and population reference bands. |
+| `get_metrics` | Several metric series in one call — a paginated fan-out over `get_metric_series`, one grounded result per metric. |
+| `get_glucose_panel` | Per-context daily means plus the trailing-30-day clinical panel (time-in-range, GMI, CV%, estimated A1c). |
+| `get_sleep` | Per-night asleep + stage minutes plus the sleep-rhythm summary (sleep debt + chronotype). |
+| `get_workouts` | Most recent sessions (sport, duration, energy, distance, avg/max HR) plus a per-sport rollup over the window. |
+| `get_medication_compliance` | Cadence-aware adherence: dose-weighted compliance rate, expected vs taken/missed, current-cycle status, any GLP-1 titration context. |
+| `get_labs` | Latest reading per biomarker over the last 12 months (optionally one analyte). With `history:true` + an analyte, that analyte's paginated reading trajectory. |
+| `get_illness_recovery` | Rest mode, active and recently-resolved illnesses, recovery / strain composites, and the illness retrospective (recovery-gap, nadir, red flags). |
+| `get_cycle` | Menstrual-cycle context: phase + day-of-cycle, next predicted event, headline phase-correlation. Gated on cycle tracking; descriptive only. |
+| `get_correlations` | FDR-controlled day-to-next-day driver pairs between behaviours and outcomes, each with direction, lag, sample size, and a descriptive (never causal) note. |
+| `get_correlation` | The vetted, lag-aware association between **two named metrics**: direction, lag, sample size, Pearson r, and a descriptive note. |
+| `compare_metric` | One metric vs another over the same horizon, **or** one metric across two horizons (fixed windows or `{from,to}` ranges), with per-side stats + a delta. |
+| `get_metric_baseline` | Where the latest reading sits against the user's own usual range (median ± robust deviation), plus the strongest lagged driver. Needs ≥ 7 days of history. |
+| `detect_changepoints` | Points where a metric's level shifted over a window or `{from,to}` range — date, direction, before/after means. High firing bar. |
+| `get_medication_schedule` | When each active medication is next due and which are overdue right now — name, dose, next-due, overdue flag, as-needed flag. Reuses the recurrence engine. |
+| `get_integration_status` | Sync health of connected devices/services — connected, last sync, reauth-required/failing — to answer "why is my data stale?". No secrets or tokens. |
+| `get_preventive_care` | What preventive care is coming up: the user's own configured Vorsorge reminders (upcoming/overdue checkups with next-due dates) plus the appointments booked as future visits. Surfaces configured items, invents nothing. |
+| `get_visits` | The user's own past doctor visits over a bounded window (default 12 months, optional practitioner-name filter): date, status, kind, practitioner + specialty, the visit's own reason/outcome, and any linked condition labels. `{ present: false }` when none was ever recorded. |
+| `get_intraday_pulse` | The 10-minute heart-rate shape for one local day (hourly grain outside the dense-retention window), with at most one cautious descriptive elevated-at-rest window. Gated on the `insights` module. |
+| `get_ecg_recordings` | Metadata for the user's ECG recordings (time, duration, sampling rate, lead, the device's own rhythm classification). Never the waveform, never a re-classification. Gated on the `insights` module. |
+| `get_nutrients` | Without an argument, the presence overview per logged nutrient code; with one, that code's per-day summed series plus its EFSA reference. Gated on the opt-in `nutrients` module. |
+| `search` | Free-text search over the user's record: metric domains, medications, lab analytes, and the clinical records (visits including procedures, conditions, documents, vaccinations). Ranked, deterministic, record results capped at 50. Returns `{ results: [{ id, title, url }], nextCursor? }`. |
+| `fetch` | Hydrate one record by the id `search` returned (`metric:weight`, `med:`, `lab:LDL`, `visit:`, `condition:`, `document:`, `vaccination:`). Returns `{ id, title, text, url, metadata }` with a citation deep-link; clinical records carry their one-hop links. |
`search` + `fetch` are the de-facto two-tool retrieval convention and the
**only** tools ChatGPT calls in its default (non-Developer) mode. Each
result carries a real, user-openable HTTPS deep link into the HealthLog
web app so the assistant can cite it.
+What `search` matches for the clinical records:
+
+- `visit:`: visit kind (a procedure also answers to "surgery" and
+ "operation"), reason, outcome, body site and side, practitioner name and
+ specialty.
+- `condition:`: label, type, body site and side.
+- `document:`: title, file name, document kind ("discharge letter"),
+ and whole words of the document's indexed text when one is stored. The
+ document itself is never read for a search. A document uploaded with AI
+ reading deferred is matched on its title and details only.
+- `vaccination:`: vaccine name, catalogue name, practitioner.
+
+Condition and vaccination notes are never searched or returned, the same
+rule the Coach and the doctor report follow. A deleted practitioner is not
+named or matched. Query words shorter than four letters match whole words or
+word starts only. Titles and file names are also looked up in the database,
+so an older document is found by them even outside the newest 500.
+
+Body sites are matched after decryption, folded the way the body-sites view
+folds them, with German and English body-part names treated as one ("knee"
+finds "Knie"). Results are ordered by how many query words they match, then
+by field weight, then newest first, so the same query always returns the
+same list. A kind whose module is switched off (`illness`,
+`inboundDocuments`, `vaccinations`, and for the older kinds `medications`
+and `labs`) is neither searched nor fetched; `fetch` answers such an id with
+`metadata.reason: "module_disabled"`. The same switches apply to every other
+MCP read: `get_labs`, `get_medication_schedule`, `get_medication_compliance`
+and the lab and medication resources answer
+`{ present: false, reason: "module_disabled" }`, and `get_visits` withholds
+condition labels (`conditionsReason: "module_disabled"`) when the illness
+module is off.
+
+`fetch` on a clinical record returns its key fields, with never-recorded
+fields as `null`, and `metadata.links`: the records it is directly linked to,
+each with an id `fetch` accepts. A document returns its metadata and at most
+a 1,500-character excerpt of its indexed text, never the file, a download
+URL, or its stored summary. A document held back from AI reading returns no
+excerpt and `metadata.reason: "ai_read_deferred"`. All user-written text is wrapped in the
+`<<>>` / `<<>>` fence.
+
Every read tool is annotated read-only / non-destructive / idempotent /
closed-world — the read-only guarantee is structural (only read tools are
registered for a read session), and the annotation merely advertises it.
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 89ecc1799..824b25a94 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
- version: 1.39.2
+ version: 1.39.3
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -391,7 +391,7 @@ paths:
"403": *a2
"409": *a7
"422": *a4
- "429": &a10
+ "429": &a11
description: "Rate limit exceeded. This route shares one per-account bucket with the other single-record writes —
`record-write:`, 300 requests per 60 seconds — keyed on the ACTING account, so a delegate burns
their own allowance rather than the record owner's. Nothing was written. `meta.errorCode` is
@@ -701,7 +701,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/CyclePrefsPatchEnvelope"
- "400": &a12
+ "400": &a13
description: The request body is not parseable JSON. Nothing was read and nothing was written. `meta.errorCode` names
the surface that refused it (`.body.invalid_json`). This is a client-side serialisation fault, not
a validation failure — a body that parses but fails the schema is the 422 beside this.
@@ -833,6 +833,10 @@ paths:
"unknown"` and a machine-readable `reason`, so the UI can offer a retry instead of a red banner nobody can act
on. The one genuine error is a feed that answers successfully with no version tag in it. Nothing is cached
server-side; the client is expected to hold the result.
+
+
+ An operator who sets `UPDATE_CHECK_DISABLED` turns the outbound request off: the route then answers `status:
+ "unknown"` with `reason: "disabled"` and contacts nobody.
responses:
"200":
description: Up to date, an update is available, or the check could not be completed.
@@ -860,7 +864,11 @@ paths:
`selection` is REQUIRED and carries the leaf inclusion list — membership is inclusion, absence is exclusion, and
there is no server-side default. An unknown leaf id is a 422 that names it; the retired grouped `sections` shape
is a 422 too, because accepting it would mean choosing a scope the caller did not express. Auth via cookie or
- Bearer; shared `export:` rate bucket (10/h). Strict validation: unknown keys 422."
+ Bearer; shared `export:` rate bucket (10/h). Strict validation: unknown keys 422. A browser session
+ needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes
+ (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with
+ `meta.methods` naming the proofs the account can give. When a manager exports a record they manage, it is their
+ own session that is asked. A Bearer caller is not asked for more than its token."
requestBody:
required: true
content:
@@ -885,7 +893,7 @@ paths:
type: string
format: binary
"401": *a1
- "403": &a9
+ "403": &a10
description: "Refused: this request named a record the caller may not act on (`meta.errorCode` =
`sharing.access.denied`). Byte-identical for an account that does not exist, an account that granted
nothing, a grant whose sections do not reach this surface, and a read grant on a request that writes — the
@@ -913,7 +921,11 @@ paths:
`INSURANCE` leaf is refused with a 422 because the share view never decrypts the insurance number. An optional
`documentIds` array (bounded ≤50) is validated as the caller's own live documents and frozen onto the link; a
documents-only share (no report scope, non-empty `documentIds`) is valid. `expiresAt` is required and capped at
- 90 days. Auth via cookie or Bearer; rate-limited (`share-link:`, 20/h). Strict: unknown keys 422."
+ 90 days. Auth via cookie or Bearer; rate-limited (`share-link:`, 20/h). Strict: unknown keys 422. A
+ browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last
+ five minutes (with a second factor, on an account that has one). Without it the answer is 401
+ `auth.reproof.required`, with `meta.methods` naming the proofs the account can give. A Bearer caller is not
+ asked for more than its token yet."
requestBody:
required: true
content:
@@ -1478,9 +1490,13 @@ paths:
summary: FHIR R4 $everything (v1.11.0)
description: "Read-only `Patient/$everything`: every resource the document bundle carries — Composition, Device,
Patient, Coverage, Observations (including the cycle ones), MedicationStatements, MedicationAdministrations,
- Conditions, Encounters, AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport — flattened into one
- `searchset` Bundle. Scoped to the owner's SAVED report selection; an account that never saved one gets nothing.
- Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`."
+ Conditions, Encounters, AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport (present when the
+ window holds a vital sign) — flattened into one `searchset` Bundle. Scoped to the owner's SAVED report
+ selection; an account that never saved one gets nothing. Auth: `fhir:read` scope. Offset paging via `_count`
+ (≤200) / `_offset`. A browser session needs a fresh proof as well as a live one: a sign-in or `POST
+ /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it
+ the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give. A token,
+ including a `fhir:read` one, is not asked for more."
parameters:
- in: query
name: _count
@@ -1515,7 +1531,13 @@ paths:
notes ride their exported column. Shares the `export:` bucket with the encrypted variant (10 per hour)
and audits every attempt as `export.download`. Cookie or Bearer auth; the caller is always resolved as
themselves, so this download cannot be delegated to a shared record. Not module-gated: a disabled module's rows
- are still exported, because this is the account's own data leaving on request."
+ are still exported, because this is the account's own data leaving on request. `type=all`, the default, is the
+ whole record and asks for a fresh proof like the full backup; the single-type exports do not. A browser session
+ needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes
+ (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with
+ `meta.methods` naming the proofs the account can give. A Bearer caller asking for `type=all` presents an
+ `X-Step-Up` elevation from `POST /api/auth/step-up` (a second-factor one on an account with a second factor) and
+ receives 401 `auth.stepup.required` without it."
parameters:
- in: query
name: format
@@ -1582,7 +1604,11 @@ paths:
`Content-Disposition: attachment` with `healthlog-backup--.json`, `Cache-Control: no-store`.
Shares the `export:` bucket (10 per hour) with every other export route. Audits as
`user.export.full-backup` with the row counts. Cookie or Bearer auth; the caller is always resolved as
- themselves, so this download cannot be delegated to a shared record."
+ themselves, so this download cannot be delegated to a shared record. A browser session needs a fresh proof as
+ well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on
+ an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the
+ proofs the account can give. A Bearer caller presents an `X-Step-Up` elevation from `POST /api/auth/step-up` (a
+ second-factor one on an account with a second factor) and receives 401 `auth.stepup.required` without it."
responses:
"200":
description: The backup payload. `application/json; charset=utf-8`, served as an attachment.
@@ -1783,12 +1809,15 @@ paths:
tags:
- Export
summary: Download the full backup as a passphrase-encrypted archive
- description: v1.23. Same restore-compatible payload as the plaintext full backup, sealed into an `HLX1` archive
+ description: "v1.23. Same restore-compatible payload as the plaintext full backup, sealed into an `HLX1` archive
(Argon2id-derived key + AES-256-GCM) under the supplied `passphrase`. Returns `application/octet-stream` (a
- `.hlx` file). When the account has a second factor enrolled, the call is step-up gated (`requireFreshMfa`,
- cookie-only) and returns 401 `auth.stepup.required` without a fresh factor; single-factor accounts use a normal
- session or Bearer. Shared `export:` rate bucket (10/h). The passphrase is never stored — a forgotten
- passphrase makes the archive unrecoverable.
+ `.hlx` file). Asks for a fresh proof, like the plaintext full backup. A browser session needs a fresh proof as
+ well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on
+ an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the
+ proofs the account can give. On Bearer, an account without a second factor passes on its token, and one with a
+ second factor presents a second-factor `X-Step-Up` elevation (401 `auth.stepup.required` without it). Shared
+ `export:` rate bucket (10/h). The passphrase is never stored — a forgotten passphrase makes the archive
+ unrecoverable."
requestBody:
required: true
content:
@@ -1836,6 +1865,15 @@ paths:
200 with `data: null, error: null` and `meta.mfaRequired: true` plus a single-use `meta.mfaTicket` and the
`meta.methods` list. The client must POST the ticket + a code to `/api/auth/mfa/verify` to obtain the token
bundle. Accounts without MFA are unchanged.
+
+
+ Two limits apply. Per source address, five attempts in fifteen minutes. Per account, failed passwords from a
+ place the account has not signed in from before make the next attempt wait: five failures a day are free, then
+ each one doubles the wait, from thirty seconds up to at most fifteen minutes. A place the account has signed in
+ from is not counted and not held back: a remembered browser (trusted-device cookie), a phone that sends the
+ `X-Device-Id` a login of this account was issued to, or an address a session or device login of this account
+ came from in the last thirty days. Both answer 429 with `Retry-After`. An identifier that names no account is
+ limited the same way, so a 429 says nothing about whether it exists.
requestBody:
required: true
content:
@@ -1853,8 +1891,11 @@ paths:
- $ref: "#/components/schemas/MfaRequiredResponse"
"401": *a1
"403":
- description: Password login is disabled — the operator runs `OIDC_ONLY=true`. `meta.errorCode` = `oidc_only`; sign in
- through SSO instead.
+ description: "Password login is disabled — the operator runs `OIDC_ONLY=true`. `meta.errorCode` = `oidc_only`; sign in
+ through SSO instead. The one exception is a browser the SSO callback sent back with a pending link to
+ confirm an existing account: it may sign in with that one account's password, which links the account. A
+ sign-in to any other account on that browser is refused before its password is checked, so the answer is the
+ same whether the password was right or wrong."
content:
application/json:
schema:
@@ -2049,15 +2090,20 @@ paths:
Presenting the token alone mints nothing: the body must carry a fresh factor proof. Every failure — wrong
password, no password set on an SSO-provisioned account, an assertion for another account, a stale challenge, a
replayed TOTP step — returns the same 401 with the same prose, and is audited server-side. Rate-limited per
- account (5 / 15 min) and per source address.
+ source address and per account (5 / 15 min); the per-account budget is shared with the web's
+ credential-enrollment proofs. A `totp` proof counts only from an authenticator whose enrollment was confirmed: a
+ code from a pending secret is refused like a wrong one.
Accepting routes (the complete set): POST /api/auth/me/mfa/totp/setup; POST /api/auth/me/mfa/totp/confirm; POST
/api/auth/me/mfa/disable; POST /api/auth/me/mfa/recovery-codes/regenerate; POST
/api/auth/me/mfa/webauthn/register/options; POST /api/auth/me/mfa/webauthn/register/verify; PATCH and DELETE
- /api/auth/me/mfa/webauthn/{id}; DELETE /api/settings/account; DELETE /api/settings/data. The last three MFA
- routes, disable, and both erasure routes require a fresh-factor proof. GET /api/auth/me/mfa needs no elevation
- at all. Nothing else accepts one — admin endpoints stay cookie-only.
+ /api/auth/me/mfa/webauthn/{id}; DELETE /api/settings/account; DELETE /api/settings/data; GET
+ /api/export/full-backup; POST /api/export/encrypted. The last three MFA routes, disable, and both erasure routes
+ require a fresh-factor proof. The two whole-record exports take any elevation on an account without a second
+ factor and a fresh-factor one on an account with one; the full backup requires an elevation on every Bearer
+ call, the encrypted export only on an account with a second factor. GET /api/auth/me/mfa needs no elevation at
+ all. Nothing else accepts one — admin endpoints stay cookie-only.
The two erasure routes joined the set so an account with a second factor can be deleted from a native app: their
@@ -2080,14 +2126,94 @@ paths:
"401": *a1
"422": *a4
"429": *a5
+ /api/auth/reproof:
+ post:
+ tags:
+ - Auth
+ summary: Re-prove a credential on a browser session (cookie only)
+ description: >-
+ Stamps the calling browser session as recently re-proved, so for the next five minutes it may run the actions
+ that ask for a fresh proof: GET /api/export/full-backup, POST /api/export/encrypted, POST /api/share-links, POST
+ /api/mcp/tokens, POST /api/tokens/measurements, POST /api/tokens/documents, and the admin backup download,
+ upload and restore, the admin data wipe and the admin password reset. Those answer 401 `auth.reproof.required`
+ with `meta.methods` (the proofs this account can give) when the session signed in or re-proved more than five
+ minutes ago.
+
+
+ The body takes the same shapes as POST /api/auth/step-up. On an account with a second factor only `totp`,
+ `webauthn` (a security key) or `passkey` are accepted, because a password has never stood in for the second
+ factor; on one without, `password` or `passkey`. A method the account cannot use here is refused with 422
+ `auth.reproof.too_weak` and `meta.methods`, before any guess is spent. A second-factor or passkey proof also
+ refreshes the session's second-factor stamp, exactly as signing in again would. Assertions begin at POST
+ /api/auth/passkey/register-options with `{ method: "passkey" | "webauthn" }`.
+
+
+ Proofs draw on the account's shared re-proof budget (five per fifteen minutes, 429 once spent). A proof that
+ does not verify is 401 `auth.reproof.failed` and is audited. Bearer callers use POST /api/auth/step-up instead.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/StepUpMintRequest"
+ responses:
+ "200":
+ description: Proof accepted; the session is stamped.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ReproofEnvelope"
+ "401": *a1
+ "422": *a4
+ "429": *a5
+ get:
+ tags:
+ - Auth
+ summary: Ask whether this browser session's proof is recent
+ description: "The question the gated routes ask, without acting: 200 `{ recent: true }` when the session signed in or
+ re-proved within five minutes (a second factor on an account that has one), otherwise 401
+ `auth.reproof.required` with `meta.methods`. Lets a client re-prove before sending a large upload rather than
+ after it was refused."
+ responses:
+ "200":
+ description: The session's proof is recent.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ReproofStatusEnvelope"
+ "401": *a1
+ "422": *a4
+ "429": *a5
/api/auth/me/mfa/totp/setup:
post:
tags:
- Auth
summary: Begin TOTP enrollment (cookie session or step-up elevation)
- description: Generates and stores a pending (encrypted) TOTP secret and returns the otpauth URI + Base32 secret. MFA is
- not active until /confirm. Accepts a cookie session, or a Bearer token presenting a single-use elevation from
- POST /api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is still refused.
+ description: >-
+ Generates and stores a pending (encrypted) TOTP secret and returns the otpauth URI + Base32 secret. MFA is not
+ active until /confirm, and a pending secret is not a factor anywhere: a code from it is refused by every route
+ that verifies one. Accepts a cookie session, or a Bearer token presenting a single-use elevation from POST
+ /api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is still refused.
+
+
+ Adding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof: a
+ password-proved one is enough on an account without a second factor, and on an account that already has one the
+ elevation must come from that factor or a passkey. On the cookie path, for an account without a second factor,
+ the request passes when the session signed in, or completed a passkey sign-in, within the last five minutes;
+ otherwise the body must carry a proof in the shape POST /api/auth/step-up takes (`password`, or a passkey
+ assertion begun at POST /api/auth/passkey/register-options). For an account that already has a second factor,
+ only that factor counts: a second factor completed within the last five minutes, or a `totp` code, security-key
+ or passkey assertion in the body. A password or a young session (for example one that skipped the factor on a
+ remembered browser) is not enough there. Without one the answer is 401 `auth.reproof.required` with
+ `meta.methods` naming the proofs the account can give; a proof that does not verify is 401 `auth.reproof.failed`
+ and is audited. Proofs draw on the account's shared re-proof budget of five per fifteen minutes (429 once
+ spent), the same budget as the step-up mint.
+ requestBody:
+ required: false
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/StepUpMintRequest"
responses:
"200":
description: Pending secret created.
@@ -2095,7 +2221,13 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/TotpSetupEnvelope"
- "401": *a1
+ "401": &a9
+ description: No session or elevation; or, on the cookie path, `auth.reproof.required` (no recent sign-in and no proof in
+ the body) or `auth.reproof.failed` (the proof did not verify).
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
"409":
description: A second factor is already active on this account.
content:
@@ -2206,9 +2338,30 @@ paths:
tags:
- Auth
summary: Begin registering a security key as a second factor
- description: Returns SimpleWebAuthn creation options + a challenge id. Accepts a cookie session, or a Bearer token
- presenting a single-use elevation from POST /api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is
- still refused.
+ description: >-
+ Returns SimpleWebAuthn creation options + a challenge id. Accepts a cookie session, or a Bearer token presenting
+ a single-use elevation from POST /api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is still
+ refused.
+
+
+ Adding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof: a
+ password-proved one is enough on an account without a second factor, and on an account that already has one the
+ elevation must come from that factor or a passkey. On the cookie path, for an account without a second factor,
+ the request passes when the session signed in, or completed a passkey sign-in, within the last five minutes;
+ otherwise the body must carry a proof in the shape POST /api/auth/step-up takes (`password`, or a passkey
+ assertion begun at POST /api/auth/passkey/register-options). For an account that already has a second factor,
+ only that factor counts: a second factor completed within the last five minutes, or a `totp` code, security-key
+ or passkey assertion in the body. A password or a young session (for example one that skipped the factor on a
+ remembered browser) is not enough there. Without one the answer is 401 `auth.reproof.required` with
+ `meta.methods` naming the proofs the account can give; a proof that does not verify is 401 `auth.reproof.failed`
+ and is audited. Proofs draw on the account's shared re-proof budget of five per fifteen minutes (429 once
+ spent), the same budget as the step-up mint.
+ requestBody:
+ required: false
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/StepUpMintRequest"
responses:
"200":
description: Registration options issued.
@@ -2216,7 +2369,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/MfaWebauthnRegisterOptionsEnvelope"
- "401": *a1
+ "401": *a9
"422": *a4
"429": *a5
/api/auth/me/mfa/webauthn/register/verify:
@@ -2474,8 +2627,24 @@ paths:
tags:
- Auth
summary: Sign out everywhere else
- description: v1.23 — revokes every OTHER web session plus all native refresh tokens, keeping the caller's current
- session. API tokens are not touched (manage those under /settings/api-tokens).
+ description: "Ends every credential except the caller's own: every OTHER web session, every other device login (refresh
+ token and its paired access token), every trusted device and step-up elevation, every connected AI assistant
+ (the OAuth connection, so it cannot mint a new access token), every programmatic token (connector, measurement
+ and document tokens), and every clinician share link. Share links are revoked by default because a link made by
+ whoever held a lost session or device would otherwise keep opening the record; send `keepShareLinks=1` to keep
+ them. Record-sharing invitations this account offered that nobody has accepted yet are withdrawn too, because an
+ invitation sent from a stolen session would otherwise wait for the thief's own account. Accepted grants are not
+ ended: the response lists them as `grantsKept` so the client can show who can still read the record and offer to
+ end each one. A Bearer caller keeps its own device login."
+ parameters:
+ - in: query
+ name: keepShareLinks
+ schema:
+ description: "`1` keeps the clinician share links. Anything else, or nothing, revokes them."
+ type: string
+ enum:
+ - "1"
+ description: "`1` keeps the clinician share links. Anything else, or nothing, revokes them."
responses:
"200":
description: Other sessions revoked.
@@ -2849,12 +3018,14 @@ paths:
holder actually has. And it cannot mint another token, this endpoint included.
- Minting requires a COOKIE SESSION. No Bearer credential reaches this endpoint at any scope, wildcard included —
- not because a wildcard lacks the reach, but because of the lifetimes involved: a native access token lives a day
- and what it could mint here lives a year, so admitting one would let a short-lived compromise leave behind a
- credential that outlives revoking it. Gated by the operator's instance-wide API switch. Body capped at 16 KiB;
- 10 mints per user per minute, and at most 10 live tokens held at once. Tokens appear in `GET /api/tokens` and
- are revoked at `DELETE /api/tokens/{id}` like any other — revoking frees a slot against the ceiling.
+ Minting requires a COOKIE SESSION with a fresh proof: a sign-in or `POST /api/auth/reproof` within the last five
+ minutes (with a second factor, on an account that has one), otherwise 401 `auth.reproof.required` with
+ `meta.methods`. No Bearer credential reaches this endpoint at any scope, wildcard included — not because a
+ wildcard lacks the reach, but because of the lifetimes involved: a native access token lives a day and what it
+ could mint here lives a year, so admitting one would let a short-lived compromise leave behind a credential that
+ outlives revoking it. Gated by the operator's instance-wide API switch. Body capped at 16 KiB; 10 mints per user
+ per minute, and at most 10 live tokens held at once. Tokens appear in `GET /api/tokens` and are revoked at
+ `DELETE /api/tokens/{id}` like any other — revoking frees a slot against the ceiling.
requestBody:
required: true
content:
@@ -2941,10 +3112,10 @@ paths:
it 403.
- Minting requires a COOKIE SESSION, for the reason the measurement mint gives: a day-lived native access token
- must not be able to leave behind a year-lived credential. Gated by the operator's instance-wide API switch. Body
- capped at 16 KiB; 10 mints per user per minute, and at most 10 live document tokens held at once. Tokens appear
- in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}`.
+ Minting requires a COOKIE SESSION with a fresh proof, as for the measurement mint, for the reason it gives: a
+ day-lived native access token must not be able to leave behind a year-lived credential. Gated by the operator's
+ instance-wide API switch. Body capped at 16 KiB; 10 mints per user per minute, and at most 10 live document
+ tokens held at once. Tokens appear in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}`.
requestBody:
required: true
content:
@@ -3202,7 +3373,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
- "429": &a14
+ "429": &a15
description: "Too many email-address changes, and the request asked for nothing else. The address change shares one
per-account bucket across both profile routes — `profile-email:`, 10 requests per 3600 seconds —
keyed on the ACTING account, so an account cannot collect a fresh allowance by probing a different address.
@@ -3251,6 +3422,16 @@ paths:
configure: the scheme is a compile-time constant, so the open-redirect class is removed by construction.
+ **An account that exists but is not linked yet.** When the provider's verified email matches a local account
+ with no SSO identity, nothing is linked and nobody is signed in: the local address was never verified, so
+ matching it proves nothing about who owns the account. The browser arm redirects to
+ `/auth/login?error=oidc_link_required` with a sealed, ten-minute, HttpOnly pending-link cookie; the person then
+ signs in to that account once with its own password (and second factor) or passkey, and that sign-in makes the
+ link. Under `OIDC_ONLY=true` the password sign-in is admitted for exactly the account the pending link names.
+ The native arm redirects to `healthlog://oidc-callback?error=oidc_link_required`; the link is confirmed once on
+ the web, after which the app's SSO sign-in works.
+
+
Two PKCE exchanges run here and must not be confused: the server↔provider verifier the server generates for
itself, and the app↔server challenge supplied above. The native flag and the app's challenge travel only inside
an AES-256-GCM state cookie, so the callback branches on tamper-authenticated state rather than on anything the
@@ -3283,7 +3464,15 @@ paths:
"302":
description: Always a redirect. To the provider on success. On failure to `/auth/login?error=` (browser) or
`healthlog://oidc-callback?error=` (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=`, with `mfa_ticket=&methods=`, or with
+ `error=`, where reason is one of `oidc_disabled`, `oidc_rate_limited`, `oidc_denied`,
+ `oidc_no_email`, `oidc_email_unverified`, `oidc_identity_conflict`, `oidc_link_required`,
+ `oidc_registration_disabled`, `oidc_failed`. `oidc_link_required` means the provider's e-mail matches an
+ existing local account that is not linked yet; the owner links it by signing in once with that account's own
+ password or passkey in a browser. 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:
@@ -3301,7 +3490,9 @@ paths:
**Enrollment arm.** Any other body must be a full factor proof (the same shape POST /api/auth/step-up takes): a
password, a TOTP code, or a completed passkey / security-key assertion. Only then are registration options
- issued.
+ issued. On an account that already has a second factor a password is refused before it is checked, with 422
+ `auth.reproof.too_weak` and `meta.methods` naming the proofs that work: a passkey added on the password alone
+ would sign in without the second factor from then on.
Which factor you prove changes the session, and the direction is not the obvious one: a strong proof stamps the
@@ -3311,7 +3502,13 @@ paths:
Every refusal on the proof path is the same 401 with the same prose — a missing content type, an unparseable
- body, a body that is not a valid proof shape, and a wrong password are indistinguishable to the caller.
+ body, a body that is not a valid proof shape, and a wrong password are indistinguishable to the caller. A `totp`
+ proof counts only from an authenticator whose enrollment was confirmed.
+
+
+ Each proof on the enrollment arm draws on the account's shared re-proof budget (five per fifteen minutes, the
+ same budget as POST /api/auth/step-up), and every failed proof writes an `auth.mfa.failed` audit row. Once the
+ budget is spent the answer is 429 until the window passes, even for a correct proof.
requestBody:
required: true
content:
@@ -3342,7 +3539,13 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
- "422": *a4
+ "422":
+ description: "`auth.reproof.too_weak`: a password on an account that already has a second factor. `meta.methods` names
+ the proofs that would be accepted. Refused before the password is checked, so it spends no attempt."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
"429": *a5
/api/auth/passkey/register-verify:
post:
@@ -3583,7 +3786,7 @@ paths:
schema:
$ref: "#/components/schemas/GetMeasurementsSeriesBatchResponse"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/measurements/series:
@@ -3635,7 +3838,7 @@ paths:
schema:
$ref: "#/components/schemas/GetMeasurementsSeriesResponse"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/measurements:
@@ -3833,7 +4036,7 @@ paths:
schema:
$ref: "#/components/schemas/ListMeasurementsResponse"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
post:
@@ -3889,7 +4092,7 @@ paths:
schema:
$ref: "#/components/schemas/CreateMeasurementResponse"
"401": *a1
- "403": *a9
+ "403": *a10
"409":
description: A measurement with this data already exists — the `(userId, type, source, externalId)` dedup index rejected
the write. Never for a named sleep-stage body, which answers a re-post with 200 instead.
@@ -3905,7 +4108,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
- "429": *a10
+ "429": *a11
/api/measurements/{id}:
get:
tags:
@@ -3926,7 +4129,7 @@ paths:
schema:
$ref: "#/components/schemas/GetMeasurementResponse"
"401": *a1
- "403": *a9
+ "403": *a10
"404":
description: Measurement not found (or owned by another user).
content:
@@ -3974,7 +4177,7 @@ paths:
schema:
$ref: "#/components/schemas/UpdateMeasurementResponse"
"401": *a1
- "403": *a9
+ "403": *a10
"404":
description: Measurement not found (or owned by another user).
content:
@@ -4010,7 +4213,7 @@ paths:
schema:
$ref: "#/components/schemas/DeleteMeasurementResponse"
"401": *a1
- "403": *a9
+ "403": *a10
"404":
description: Measurement not found (or owned by another user).
content:
@@ -4116,7 +4319,7 @@ paths:
schema:
$ref: "#/components/schemas/MeasurementsBulkDeleteResponse"
"401": *a1
- "403": *a9
+ "403": *a10
"409": *a7
"422": *a4
"429": *a5
@@ -4145,7 +4348,7 @@ paths:
schema:
$ref: "#/components/schemas/MeasurementsRestoreResponse"
"401": *a1
- "403": *a9
+ "403": *a10
"409": *a7
"422": *a4
"429": *a5
@@ -4176,7 +4379,7 @@ paths:
schema:
$ref: "#/components/schemas/SleepNightResponse"
"401": *a1
- "403": &a11
+ "403": &a12
description: >-
Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` =
`module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a
@@ -4217,7 +4420,7 @@ paths:
schema:
$ref: "#/components/schemas/SleepRhythmResponse"
"401": *a1
- "403": *a11
+ "403": *a12
"422": *a4
"429": *a5
/api/personal-records:
@@ -4264,7 +4467,7 @@ paths:
schema:
$ref: "#/components/schemas/ListPersonalRecordsEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422":
description: "`metricType` or `limit` carried an unacceptable value. `meta.errorCode` =
`personal_records.invalid_query`, with the offending path under `details.issues`."
@@ -4319,7 +4522,7 @@ paths:
schema:
$ref: "#/components/schemas/ListWorkoutsResponse"
"401": *a1
- "403": *a11
+ "403": *a12
"422": *a4
"429": *a5
/api/workouts/{id}:
@@ -4355,7 +4558,7 @@ paths:
schema:
$ref: "#/components/schemas/GetWorkoutResponse"
"401": *a1
- "403": &a17
+ "403": &a18
description: 'Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` =
`module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a
valid Bearer token. Clients hide the whole module surface end-to-end rather than retry.'
@@ -4524,7 +4727,7 @@ paths:
schema:
$ref: "#/components/schemas/MedicationIntakeAggregateEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422":
description: The query failed validation — a missing or unknown `scope`, or a `days` outside 1..365. Multi-issue
envelope; an audit breadcrumb is filed under the RECORD as `medications.intake.list.validation-failed` with
@@ -4649,7 +4852,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
- "429": *a10
+ "429": *a11
/api/medications/{id}/glp1:
get:
tags:
@@ -4674,7 +4877,7 @@ paths:
schema:
$ref: "#/components/schemas/Glp1DetailResponseEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"404":
description: Medication not found (or owned by another account).
content:
@@ -4758,7 +4961,7 @@ paths:
schema:
$ref: "#/components/schemas/Glp1PostResponseEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"404":
description: Medication not found (or owned by another account).
content:
@@ -4893,7 +5096,7 @@ paths:
schema:
$ref: "#/components/schemas/ReminderPhaseConfigEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"404":
description: Medication not found (or owned by another account).
content:
@@ -5009,7 +5212,7 @@ paths:
schema:
$ref: "#/components/schemas/ListMedicationSideEffectsEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"404":
description: Medication not found (or owned by another account).
content:
@@ -5048,7 +5251,7 @@ paths:
schema:
$ref: "#/components/schemas/CreateMedicationSideEffectEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"404":
description: Medication not found (or owned by another account).
content:
@@ -5093,7 +5296,7 @@ paths:
schema:
$ref: "#/components/schemas/DeleteMedicationSideEffectEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"404":
description: No such entry for this medication and this record. An unknown id, another medication's entry and another
record's entry are indistinguishable.
@@ -5141,7 +5344,7 @@ paths:
schema:
$ref: "#/components/schemas/UpdateMedicationIntakeEventEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"404":
description: No live event with that id under this medication and record. An unknown id and an already-tombstoned one
are indistinguishable.
@@ -5189,7 +5392,7 @@ paths:
schema:
$ref: "#/components/schemas/DeleteMedicationIntakeEventEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"404":
description: No event with that id under this medication and record.
content:
@@ -5232,7 +5435,7 @@ paths:
schema:
$ref: "#/components/schemas/BulkDeleteMedicationIntakeEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"404":
description: Medication not found (or owned by another account).
content:
@@ -5274,7 +5477,7 @@ paths:
schema:
$ref: "#/components/schemas/PurgeMedicationIntakeEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"404":
description: Medication not found (or owned by another account).
content:
@@ -5319,7 +5522,7 @@ paths:
schema:
$ref: "#/components/schemas/MedicationIntakeImportAcceptedEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"404":
description: Medication not found (or owned by another account).
content:
@@ -5399,7 +5602,7 @@ paths:
schema:
$ref: "#/components/schemas/ListMedicationsResponse"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
post:
@@ -5430,7 +5633,7 @@ paths:
schema:
$ref: "#/components/schemas/CreateMedicationResponse"
"401": *a1
- "403": *a9
+ "403": *a10
"409": *a7
"422": *a4
"429": *a5
@@ -5450,7 +5653,7 @@ paths:
schema:
$ref: "#/components/schemas/MedicationListLayoutResponse"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
put:
@@ -5484,7 +5687,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
- "422": &a13
+ "422": &a14
description: Request validation failed. When the body carried a `baseUpdatedAt` that could not be parsed as an ISO-8601
timestamp — including an explicit `null` — `meta.errorCode` = `invalid_base_updated_at` and nothing was
written. Omit the key entirely for the unconditional write; do not send `null` for it.
@@ -5529,7 +5732,7 @@ paths:
schema:
$ref: "#/components/schemas/GetMedicationResponse"
"401": *a1
- "403": *a9
+ "403": *a10
"404":
description: Medication not found (or owned by another user).
content:
@@ -5566,7 +5769,7 @@ paths:
schema:
$ref: "#/components/schemas/UpdateMedicationResponse"
"401": *a1
- "403": *a9
+ "403": *a10
"404":
description: Medication not found (or owned by another user).
content:
@@ -5639,7 +5842,7 @@ paths:
schema:
$ref: "#/components/schemas/CreateMedicationIntakeResponse"
"401": *a1
- "403": *a9
+ "403": *a10
"404":
description: Medication not found (or owned by another user).
content:
@@ -5648,7 +5851,7 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"409": *a7
"422": *a4
- "429": *a10
+ "429": *a11
get:
tags:
- Medications
@@ -5713,7 +5916,7 @@ paths:
schema:
$ref: "#/components/schemas/ListMedicationIntakeEventsResponse"
"401": *a1
- "403": *a9
+ "403": *a10
"404":
description: Medication not found (or owned by another user).
content:
@@ -5746,7 +5949,7 @@ paths:
schema:
$ref: "#/components/schemas/ListMedicationInventoryResponse"
"401": *a1
- "403": *a9
+ "403": *a10
"404":
description: Medication not found (or owned by another user).
content:
@@ -5782,7 +5985,7 @@ paths:
schema:
$ref: "#/components/schemas/CreateMedicationInventoryItemResponse"
"401": *a1
- "403": *a9
+ "403": *a10
"404":
description: Medication not found (or owned by another user).
content:
@@ -5826,7 +6029,7 @@ paths:
schema:
$ref: "#/components/schemas/UpdateMedicationInventoryItemResponse"
"401": *a1
- "403": *a9
+ "403": *a10
"404":
description: Inventory item not found (or owned by another user / medication).
content:
@@ -5861,7 +6064,7 @@ paths:
schema:
$ref: "#/components/schemas/DeleteMedicationInventoryItemResponse"
"401": *a1
- "403": *a9
+ "403": *a10
"404":
description: Inventory item not found (or owned by another user / medication).
content:
@@ -5957,7 +6160,7 @@ paths:
schema:
$ref: "#/components/schemas/GetMedicationCadenceResponse"
"401": *a1
- "403": *a9
+ "403": *a10
"404":
description: Medication not found (or owned by another user).
content:
@@ -5983,7 +6186,7 @@ paths:
schema:
$ref: "#/components/schemas/ListMedicationComplianceResponse"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/medications/{id}/compliance:
@@ -6164,7 +6367,7 @@ paths:
schema:
$ref: "#/components/schemas/ListScheduleRevisionsResponse"
"401": *a1
- "403": *a9
+ "403": *a10
"404":
description: Medication not found (or owned by another user).
content:
@@ -6202,7 +6405,7 @@ paths:
schema:
$ref: "#/components/schemas/CreateScheduleRevisionResponse"
"401": *a1
- "403": *a9
+ "403": *a10
"404":
description: Medication not found (or owned by another user).
content:
@@ -6246,7 +6449,7 @@ paths:
schema:
$ref: "#/components/schemas/UpdateScheduleRevisionResponse"
"401": *a1
- "403": *a9
+ "403": *a10
"404":
description: Medication or revision not found (or owned by another user).
content:
@@ -6287,7 +6490,7 @@ paths:
schema:
$ref: "#/components/schemas/DeleteScheduleRevisionResponse"
"401": *a1
- "403": *a9
+ "403": *a10
"404":
description: Medication or revision not found (or owned by another user).
content:
@@ -6350,7 +6553,7 @@ paths:
schema:
$ref: "#/components/schemas/MedicationDoseHistoryImportQueuedEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"409":
description: A dose-history import is already in progress.
content:
@@ -6412,7 +6615,7 @@ paths:
schema:
$ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"404":
description: No such job for this medication (or owned by another user).
content:
@@ -6446,7 +6649,7 @@ paths:
schema:
$ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"404":
description: No such job for this account (or owned by another user).
content:
@@ -6476,7 +6679,7 @@ paths:
schema:
$ref: "#/components/schemas/ProfileSummaryEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/ai/test:
@@ -6681,7 +6884,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/PutCoachPrefsResponse"
- "400": *a12
+ "400": *a13
"401": *a1
"409":
description: Coach preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read
@@ -6691,7 +6894,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
- "422": *a13
+ "422": *a14
"429": *a5
/api/auth/me/disable-coach:
get:
@@ -6729,7 +6932,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/PatchDisableCoachResponse"
- "400": *a12
+ "400": *a13
"401": *a1
"422": *a4
"429": *a5
@@ -6884,7 +7087,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/PatchModulesResponse"
- "400": *a12
+ "400": *a13
"401": *a1
"409":
description: Module preferences changed since it was loaded (optimistic-concurrency conflict). No write happened.
@@ -6894,7 +7097,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
- "422": *a13
+ "422": *a14
"429": *a5
/api/auth/me/source-priority:
get:
@@ -7763,7 +7966,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/PutSourcePriorityResponse"
- "400": *a12
+ "400": *a13
"401": *a1
"422": *a4
"429": *a5
@@ -7916,7 +8119,7 @@ paths:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
- "429": *a14
+ "429": *a15
/api/auth/me/notification-prefs:
get:
tags:
@@ -7957,7 +8160,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/PatchNotificationPrefsResponse"
- "400": *a12
+ "400": *a13
"401": *a1
"409":
description: Notification preferences changed since it was loaded (optimistic-concurrency conflict). No write happened.
@@ -7967,7 +8170,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
- "422": *a13
+ "422": *a14
"429": *a5
/api/auth/me/report-selection:
get:
@@ -8009,7 +8212,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/PutReportSelectionResponseEnvelope"
- "400": *a12
+ "400": *a13
"401": *a1
"422": *a4
"429": *a5
@@ -8721,7 +8924,7 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
- "503": &a16
+ "503": &a17
description: "The AI capability could not be resolved (its inputs could not be read) and the answer failed closed:
`meta.errorCode` = `ai.unavailable`, `meta.reason` = `check_failed`. Retry later; no data depends on it."
content:
@@ -9045,7 +9248,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
- "422": *a13
+ "422": *a14
"429": *a5
delete:
tags:
@@ -9136,7 +9339,7 @@ paths:
schema:
$ref: "#/components/schemas/CoachNudgeStatus"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/insights/coach/seen:
@@ -9254,7 +9457,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
- "422": *a13
+ "422": *a14
"429": *a5
/api/anamnesis/facts:
get:
@@ -9272,7 +9475,7 @@ paths:
schema:
$ref: "#/components/schemas/GetAnamnesisFactsResponse"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
post:
@@ -9382,7 +9585,7 @@ paths:
schema:
$ref: "#/components/schemas/CorrectAnamnesisFactResponse"
"401": *a1
- "404": &a15
+ "404": &a16
description: No current revision with this id exists for the authenticated account.
content:
application/json:
@@ -9418,7 +9621,7 @@ paths:
schema:
$ref: "#/components/schemas/RemoveAnamnesisFactResponse"
"401": *a1
- "404": *a15
+ "404": *a16
"409":
description: Anamnesis fact changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read
the resource, re-apply the user's change against the fresh state, and resend with the new token.
@@ -9778,7 +9981,7 @@ paths:
schema:
$ref: "#/components/schemas/CoachRemindersList"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
post:
@@ -10127,7 +10330,7 @@ paths:
schema:
$ref: "#/components/schemas/DashboardSnapshotResponseEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/insights/comprehensive:
@@ -10149,7 +10352,7 @@ paths:
schema:
$ref: "#/components/schemas/InsightsComprehensiveResponseEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/insights/cards:
@@ -10170,7 +10373,7 @@ paths:
schema:
$ref: "#/components/schemas/InsightsCardsEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/insights/pregenerate:
@@ -10305,7 +10508,7 @@ paths:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"429": *a5
- "503": *a16
+ "503": *a17
post:
tags:
- Insights
@@ -10507,7 +10710,7 @@ paths:
schema:
$ref: "#/components/schemas/CoachReadStripEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/insights/coach/seeded-question:
@@ -10580,7 +10783,7 @@ paths:
schema:
$ref: "#/components/schemas/NarrativeEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/insights/glp1-timeline:
@@ -10617,7 +10820,7 @@ paths:
schema:
$ref: "#/components/schemas/Glp1TimelineEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/insights/pulse/intraday:
@@ -10653,7 +10856,7 @@ paths:
schema:
$ref: "#/components/schemas/IntradayPulseEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/insights/settings:
@@ -10727,7 +10930,7 @@ paths:
schema:
$ref: "#/components/schemas/TargetsEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/analytics/range:
@@ -10800,7 +11003,7 @@ paths:
schema:
$ref: "#/components/schemas/BloodPressureStatusResponseEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/insights/pulse-status:
@@ -10832,7 +11035,7 @@ paths:
schema:
$ref: "#/components/schemas/PulseStatusResponseEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/insights/weight-status:
@@ -10864,7 +11067,7 @@ paths:
schema:
$ref: "#/components/schemas/WeightStatusResponseEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/insights/bmi-status:
@@ -10896,7 +11099,7 @@ paths:
schema:
$ref: "#/components/schemas/BmiStatusResponseEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/insights/mood-status:
@@ -10928,7 +11131,7 @@ paths:
schema:
$ref: "#/components/schemas/MoodStatusResponseEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/insights/medication-compliance-status:
@@ -10961,7 +11164,7 @@ paths:
schema:
$ref: "#/components/schemas/MedicationComplianceStatusResponseEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/insights/metric-status:
@@ -11056,7 +11259,7 @@ paths:
schema:
$ref: "#/components/schemas/MetricStatusResponseEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/insights/biomarker-assessment:
@@ -11101,7 +11304,7 @@ paths:
schema:
$ref: "#/components/schemas/BiomarkerAssessmentResponseEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/insights/derived:
@@ -11213,7 +11416,7 @@ paths:
schema:
$ref: "#/components/schemas/DerivedMetricResponseEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/insights/derived/batch:
@@ -11253,7 +11456,7 @@ paths:
schema:
$ref: "#/components/schemas/DerivedBatchResponseEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/insights/glp1-plateau:
@@ -11273,7 +11476,7 @@ paths:
schema:
$ref: "#/components/schemas/InsightsGlp1PlateauResponseEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/insights/ecg:
@@ -11295,7 +11498,7 @@ paths:
schema:
$ref: "#/components/schemas/EcgListResponseEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
post:
@@ -11372,7 +11575,7 @@ paths:
schema:
$ref: "#/components/schemas/EcgDetailResponseEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"404":
description: No ECG recording with that id for the authenticated user (existence sealed — a foreign id is
indistinguishable from a missing one).
@@ -11404,7 +11607,7 @@ paths:
schema:
$ref: "#/components/schemas/RhythmEventsResponseEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/insights/correlations:
@@ -11426,7 +11629,7 @@ paths:
schema:
$ref: "#/components/schemas/CorrelationDiscoveryResponseEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/insights/patterns:
@@ -11444,7 +11647,7 @@ paths:
schema:
$ref: "#/components/schemas/CorrelationPatternListResponseEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/insights/patterns/{id}:
@@ -11474,7 +11677,7 @@ paths:
schema:
$ref: "#/components/schemas/UpdateCorrelationPatternResponseEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"404":
description: No current pattern with that id for this account.
content:
@@ -11499,7 +11702,7 @@ paths:
schema:
$ref: "#/components/schemas/InsightsHealthStatusEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/insights/breathing-screening:
@@ -11518,7 +11721,7 @@ paths:
schema:
$ref: "#/components/schemas/InsightsBreathingScreeningEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/insights/labs-changes:
@@ -11537,7 +11740,7 @@ paths:
schema:
$ref: "#/components/schemas/InsightsLabsChangesEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/mood/analytics:
@@ -11560,7 +11763,7 @@ paths:
schema:
$ref: "#/components/schemas/MoodDailySeriesEnvelope"
"401": *a1
- "403": *a17
+ "403": *a18
"422": *a4
"429": *a5
/api/mood/insights:
@@ -11582,7 +11785,7 @@ paths:
schema:
$ref: "#/components/schemas/MoodAggregatesEnvelope"
"401": *a1
- "403": *a17
+ "403": *a18
"422": *a4
"429": *a5
/api/mood/prognosis:
@@ -11605,7 +11808,7 @@ paths:
schema:
$ref: "#/components/schemas/MoodPrognosisEnvelope"
"401": *a1
- "403": *a11
+ "403": *a12
"422": *a4
"429": *a5
/api/mood-entries/bulk:
@@ -11715,7 +11918,7 @@ paths:
schema:
$ref: "#/components/schemas/MoodCustomTagPatchEnvelope"
"401": *a1
- "404": &a18
+ "404": &a19
description: Not found / not owned by the caller.
content:
application/json:
@@ -11750,7 +11953,7 @@ paths:
schema:
$ref: "#/components/schemas/MoodCustomTagDeleteEnvelope"
"401": *a1
- "404": *a18
+ "404": *a19
"422": *a4
"429": *a5
/api/mood/tags/{key}/hidden:
@@ -11786,7 +11989,7 @@ paths:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"401": *a1
- "404": *a18
+ "404": *a19
"422": *a4
"429": *a5
/api/mood/tags/groups:
@@ -11839,7 +12042,7 @@ paths:
schema:
$ref: "#/components/schemas/MoodTagGroupPatchEnvelope"
"401": *a1
- "404": *a18
+ "404": *a19
"422": *a4
"429": *a5
delete:
@@ -11870,7 +12073,7 @@ paths:
schema:
$ref: "#/components/schemas/MoodTagGroupDeleteEnvelope"
"401": *a1
- "404": *a18
+ "404": *a19
"422": *a4
"429": *a5
/api/mood/tags/layout:
@@ -11918,7 +12121,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
- "422": *a13
+ "422": *a14
"429": *a5
/api/mood-entries:
get:
@@ -12001,7 +12204,7 @@ paths:
schema:
$ref: "#/components/schemas/MoodEntryListEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
post:
@@ -12034,8 +12237,8 @@ paths:
schema:
$ref: "#/components/schemas/MoodEntryEnvelope"
"401": *a1
- "403": *a9
- "409": &a19
+ "403": *a10
+ "409": &a20
description: A mood entry with the same `(date, moodLoggedAt)` instant already exists. `meta.errorCode` =
`mood.duplicate_timestamp`. Only a request without `externalId` can answer this — with one, the write
upserts in place.
@@ -12044,7 +12247,7 @@ paths:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
- "429": *a10
+ "429": *a11
/api/mood-entries/{id}:
get:
tags:
@@ -12066,8 +12269,8 @@ paths:
schema:
$ref: "#/components/schemas/MoodEntryEnvelope"
"401": *a1
- "403": *a9
- "404": *a18
+ "403": *a10
+ "404": *a19
"422": *a4
"429": *a5
put:
@@ -12101,9 +12304,9 @@ paths:
schema:
$ref: "#/components/schemas/MoodEntryEnvelope"
"401": *a1
- "403": *a9
- "404": *a18
- "409": *a19
+ "403": *a10
+ "404": *a19
+ "409": *a20
"422": *a4
"429": *a5
delete:
@@ -12128,8 +12331,8 @@ paths:
schema:
$ref: "#/components/schemas/MoodEntryDeleteEnvelope"
"401": *a1
- "403": *a9
- "404": *a18
+ "403": *a10
+ "404": *a19
"422": *a4
"429": *a5
/api/mood-entries/restore:
@@ -12158,7 +12361,7 @@ paths:
schema:
$ref: "#/components/schemas/MoodEntriesRestoreEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"409": *a7
"422": *a4
"429": *a5
@@ -12187,7 +12390,7 @@ paths:
schema:
$ref: "#/components/schemas/MoodEntriesBulkDeleteEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"409": *a7
"422": *a4
"429": *a5
@@ -12231,7 +12434,7 @@ paths:
schema:
$ref: "#/components/schemas/MoodLinkedContextEnvelope"
"401": *a1
- "403": *a11
+ "403": *a12
"422": *a4
"429": *a5
/api/settings/ntfy:
@@ -12279,7 +12482,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/PutNtfySettingsResponse"
- "400": &a20
+ "400": &a21
description: Body is not parseable JSON.
content:
application/json:
@@ -12292,13 +12495,13 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
- "413": &a21
+ "413": &a22
description: Body exceeds 64 KiB.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
- "415": &a22
+ "415": &a23
description: Content-Type is not `application/json`.
content:
application/json:
@@ -12358,7 +12561,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/PutTelegramSettingsResponse"
- "400": *a20
+ "400": *a21
"401": *a1
"403":
description: The operator switched Telegram off for this instance and the body asked to enable it. No webhook was
@@ -12373,8 +12576,8 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
- "413": *a21
- "415": *a22
+ "413": *a22
+ "415": *a23
"422":
description: Body matched neither accepted shape; or `enabled` is true without both a bot token and a chat id; or the
deployment's `APP_URL` is not a public HTTPS host on port 80/88/443/8443; or Telegram refused the webhook
@@ -12428,10 +12631,10 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/PutWebhookSettingsResponse"
- "400": *a20
+ "400": *a21
"401": *a1
- "413": *a21
- "415": *a22
+ "413": *a22
+ "415": *a23
"422":
description: Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored; or the URL is on
a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` =
@@ -12458,7 +12661,7 @@ paths:
schema:
$ref: "#/components/schemas/GetReminderThresholdsResponse"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/settings/global-services:
@@ -12988,7 +13191,7 @@ paths:
anyOf:
- $ref: "#/components/schemas/ConsentLatestForKindEnvelope"
- $ref: "#/components/schemas/ConsentLatestByKindEnvelope"
- "400": &a23
+ "400": &a24
description: "`kind` was present but is not one of `ai_full`, `ai_insights_only`, `ai_coach`, `ai_extraction`.
Multi-issue envelope; nothing was read or revoked. The consent family answers 400 here where the rest of the
API answers 422."
@@ -13048,7 +13251,7 @@ paths:
anyOf:
- $ref: "#/components/schemas/ConsentRevokeForKindEnvelope"
- $ref: "#/components/schemas/ConsentRevokeAllEnvelope"
- "400": *a23
+ "400": *a24
"401": *a1
"422": *a4
"429": *a5
@@ -13063,9 +13266,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
@@ -13201,6 +13407,14 @@ paths:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"401": *a1
+ "409":
+ description: An import for this account is already queued or running, and these bytes are not the ones it carries (the
+ same bytes resolve to that job as a 202). `meta.errorCode` is `import.apple_health.busy` and `meta.jobId`
+ names the running job; poll it, then upload again. The upload was discarded.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
"413":
description: The declared `Content-Length` exceeds 1.5 GB. A body that exceeds the cap without declaring it is refused
by the streaming sink instead, which surfaces as the 422 below.
@@ -13222,8 +13436,8 @@ paths:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"503":
- description: No background worker is bound to this process, so the job cannot be queued. The upload was staged and is
- discarded; retry once a worker is running.
+ description: No background worker is bound to this process, or the queue did not accept the job, so nothing will run it.
+ The staged upload is discarded (and a created job is marked failed); retry once a worker is running.
content:
application/json:
schema:
@@ -13289,7 +13503,7 @@ paths:
schema:
$ref: "#/components/schemas/MeasurementReminderListEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
post:
@@ -13314,7 +13528,7 @@ paths:
schema:
$ref: "#/components/schemas/MeasurementReminderCreatedEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"409": *a7
"422": *a4
"429": *a5
@@ -13338,8 +13552,8 @@ paths:
schema:
$ref: "#/components/schemas/MeasurementReminderEnvelope"
"401": *a1
- "403": *a9
- "404": &a24
+ "403": *a10
+ "404": &a25
description: Measurement reminder not found / not owned.
content:
application/json:
@@ -13376,8 +13590,8 @@ paths:
schema:
$ref: "#/components/schemas/MeasurementReminderPatchEnvelope"
"401": *a1
- "403": *a9
- "404": *a24
+ "403": *a10
+ "404": *a25
"422": *a4
"429": *a5
delete:
@@ -13399,8 +13613,8 @@ paths:
schema:
$ref: "#/components/schemas/MeasurementReminderDeleteEnvelope"
"401": *a1
- "403": *a9
- "404": *a24
+ "403": *a10
+ "404": *a25
"422": *a4
"429": *a5
/api/measurement-reminders/{id}/satisfy:
@@ -13424,8 +13638,8 @@ paths:
schema:
$ref: "#/components/schemas/MeasurementReminderSatisfyEnvelope"
"401": *a1
- "403": *a9
- "404": *a24
+ "403": *a10
+ "404": *a25
"422": *a4
"429": *a5
/api/measurement-reminders/{id}/complete:
@@ -13452,8 +13666,8 @@ paths:
schema:
$ref: "#/components/schemas/MeasurementReminderCompleteEnvelope"
"401": *a1
- "403": *a9
- "404": *a24
+ "403": *a10
+ "404": *a25
"422": *a4
"429": *a5
/api/measurement-reminders/{id}/skip:
@@ -13482,8 +13696,8 @@ paths:
schema:
$ref: "#/components/schemas/MeasurementReminderSkipEnvelope"
"401": *a1
- "403": *a9
- "404": *a24
+ "403": *a10
+ "404": *a25
"422": *a4
"429": *a5
/api/measurement-reminders/{id}/snooze:
@@ -13516,8 +13730,8 @@ paths:
schema:
$ref: "#/components/schemas/MeasurementReminderSnoozeEnvelope"
"401": *a1
- "403": *a9
- "404": *a24
+ "403": *a10
+ "404": *a25
"422": *a4
"429": *a5
/api/measurement-reminders/{id}/history:
@@ -13557,8 +13771,8 @@ paths:
schema:
$ref: "#/components/schemas/MeasurementReminderHistoryEnvelope"
"401": *a1
- "403": *a9
- "404": *a24
+ "403": *a10
+ "404": *a25
"422": *a4
"429": *a5
/api/labs:
@@ -13630,7 +13844,7 @@ paths:
schema:
$ref: "#/components/schemas/ListLabResultsEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
post:
@@ -13657,10 +13871,10 @@ paths:
schema:
$ref: "#/components/schemas/CreateLabResultResponse"
"401": *a1
- "403": *a9
+ "403": *a10
"409": *a7
"422": *a4
- "429": *a10
+ "429": *a11
/api/labs/{id}:
get:
tags:
@@ -13681,8 +13895,8 @@ paths:
schema:
$ref: "#/components/schemas/GetLabResultResponse"
"401": *a1
- "403": *a9
- "404": &a25
+ "403": *a10
+ "404": &a26
description: Lab result not found (or owned by another user).
content:
application/json:
@@ -13716,8 +13930,8 @@ paths:
schema:
$ref: "#/components/schemas/UpdateLabResultResponse"
"401": *a1
- "403": *a9
- "404": *a25
+ "403": *a10
+ "404": *a26
"422": *a4
"429": *a5
delete:
@@ -13739,8 +13953,8 @@ paths:
schema:
$ref: "#/components/schemas/DeleteLabResultResponse"
"401": *a1
- "403": *a9
- "404": *a25
+ "403": *a10
+ "404": *a26
"422": *a4
"429": *a5
/api/labs/restore:
@@ -13766,7 +13980,7 @@ paths:
schema:
$ref: "#/components/schemas/RestoreLabResultsResponse"
"401": *a1
- "403": *a9
+ "403": *a10
"409": *a7
"422": *a4
"429": *a5
@@ -13897,7 +14111,7 @@ paths:
schema:
$ref: "#/components/schemas/ListBiomarkersEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
post:
@@ -13922,15 +14136,15 @@ paths:
schema:
$ref: "#/components/schemas/CreateBiomarkerResponse"
"401": *a1
- "403": *a9
- "409": &a27
+ "403": *a10
+ "409": &a28
description: A biomarker with this name already exists for the caller.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
- "429": *a10
+ "429": *a11
/api/biomarkers/{id}:
get:
tags:
@@ -13951,8 +14165,8 @@ paths:
schema:
$ref: "#/components/schemas/GetBiomarkerResponse"
"401": *a1
- "403": *a9
- "404": &a26
+ "403": *a10
+ "404": &a27
description: Biomarker not found (or owned by another user).
content:
application/json:
@@ -13986,9 +14200,9 @@ paths:
schema:
$ref: "#/components/schemas/UpdateBiomarkerResponse"
"401": *a1
- "403": *a9
- "404": *a26
- "409": *a27
+ "403": *a10
+ "404": *a27
+ "409": *a28
"422": *a4
"429": *a5
delete:
@@ -14011,7 +14225,7 @@ paths:
schema:
$ref: "#/components/schemas/DeleteBiomarkerResponse"
"401": *a1
- "404": *a26
+ "404": *a27
"422": *a4
"429": *a5
/api/encounters:
@@ -14069,7 +14283,7 @@ paths:
schema:
$ref: "#/components/schemas/ListEncountersEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
post:
@@ -14095,10 +14309,10 @@ paths:
schema:
$ref: "#/components/schemas/CreateEncounterEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"409": *a7
"422": *a4
- "429": *a10
+ "429": *a11
/api/encounters/procedures:
get:
tags:
@@ -14129,7 +14343,7 @@ paths:
schema:
$ref: "#/components/schemas/ListProceduresEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/body-sites:
@@ -14166,7 +14380,7 @@ paths:
schema:
$ref: "#/components/schemas/ListBodySitesEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/encounters/suggest:
@@ -14196,7 +14410,7 @@ paths:
schema:
$ref: "#/components/schemas/SuggestEncounterEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/encounters/{id}:
@@ -14219,8 +14433,8 @@ paths:
schema:
$ref: "#/components/schemas/GetEncounterEnvelope"
"401": *a1
- "403": *a9
- "404": &a28
+ "403": *a10
+ "404": &a29
description: Visit not found (or owned by another user).
content:
application/json:
@@ -14253,8 +14467,8 @@ paths:
schema:
$ref: "#/components/schemas/UpdateEncounterEnvelope"
"401": *a1
- "403": *a9
- "404": *a28
+ "403": *a10
+ "404": *a29
"422": *a4
"429": *a5
delete:
@@ -14278,8 +14492,8 @@ paths:
schema:
$ref: "#/components/schemas/DeleteEncounterEnvelope"
"401": *a1
- "403": *a9
- "404": *a28
+ "403": *a10
+ "404": *a29
"422": *a4
"429": *a5
/api/encounters/{id}/restore:
@@ -14304,8 +14518,8 @@ paths:
schema:
$ref: "#/components/schemas/RestoreEncounterEnvelope"
"401": *a1
- "403": *a9
- "404": *a28
+ "403": *a10
+ "404": *a29
"422": *a4
"429": *a5
/api/encounters/{id}/links:
@@ -14335,7 +14549,7 @@ paths:
schema:
$ref: "#/components/schemas/LinkEncounterEnvelope"
"401": *a1
- "404": *a28
+ "404": *a29
"422": *a4
"429": *a5
delete:
@@ -14364,7 +14578,7 @@ paths:
schema:
$ref: "#/components/schemas/UnlinkEncounterEnvelope"
"401": *a1
- "404": *a28
+ "404": *a29
"422": *a4
"429": *a5
/api/practitioners:
@@ -14394,7 +14608,7 @@ paths:
schema:
$ref: "#/components/schemas/ListPractitionersEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
post:
@@ -14418,7 +14632,7 @@ paths:
schema:
$ref: "#/components/schemas/CreatePractitionerEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"409": *a7
"422": *a4
"429": *a5
@@ -14442,8 +14656,8 @@ paths:
schema:
$ref: "#/components/schemas/GetPractitionerEnvelope"
"401": *a1
- "403": *a9
- "404": &a29
+ "403": *a10
+ "404": &a30
description: Practitioner not found (or owned by another user).
content:
application/json:
@@ -14476,8 +14690,8 @@ paths:
schema:
$ref: "#/components/schemas/UpdatePractitionerEnvelope"
"401": *a1
- "403": *a9
- "404": *a29
+ "403": *a10
+ "404": *a30
"422": *a4
"429": *a5
delete:
@@ -14500,8 +14714,8 @@ paths:
schema:
$ref: "#/components/schemas/DeletePractitionerEnvelope"
"401": *a1
- "403": *a9
- "404": *a29
+ "403": *a10
+ "404": *a30
"422": *a4
"429": *a5
/api/practitioners/{id}/restore:
@@ -14526,8 +14740,8 @@ paths:
schema:
$ref: "#/components/schemas/RestorePractitionerEnvelope"
"401": *a1
- "403": *a9
- "404": *a29
+ "403": *a10
+ "404": *a30
"422": *a4
"429": *a5
/api/vaccinations:
@@ -14570,7 +14784,7 @@ paths:
schema:
$ref: "#/components/schemas/ListVaccinationsEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
post:
@@ -14597,10 +14811,10 @@ paths:
schema:
$ref: "#/components/schemas/CreateVaccinationEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"409": *a7
"422": *a4
- "429": *a10
+ "429": *a11
/api/vaccinations/{id}:
get:
tags:
@@ -14621,8 +14835,8 @@ paths:
schema:
$ref: "#/components/schemas/GetVaccinationEnvelope"
"401": *a1
- "403": *a9
- "404": &a30
+ "403": *a10
+ "404": &a31
description: Vaccination not found (or owned by another user).
content:
application/json:
@@ -14656,8 +14870,8 @@ paths:
schema:
$ref: "#/components/schemas/UpdateVaccinationEnvelope"
"401": *a1
- "403": *a9
- "404": *a30
+ "403": *a10
+ "404": *a31
"422": *a4
"429": *a5
delete:
@@ -14681,8 +14895,8 @@ paths:
schema:
$ref: "#/components/schemas/DeleteVaccinationEnvelope"
"401": *a1
- "403": *a9
- "404": *a30
+ "403": *a10
+ "404": *a31
"422": *a4
"429": *a5
/api/vaccinations/{id}/restore:
@@ -14706,8 +14920,8 @@ paths:
schema:
$ref: "#/components/schemas/RestoreVaccinationEnvelope"
"401": *a1
- "403": *a9
- "404": *a30
+ "403": *a10
+ "404": *a31
"422": *a4
"429": *a5
/api/vaccinations/{id}/booster:
@@ -14773,7 +14987,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
- "404": *a30
+ "404": *a31
"422": *a4
"429": *a5
/api/vaccinations/suggest:
@@ -14801,7 +15015,7 @@ paths:
schema:
$ref: "#/components/schemas/VaccinationSuggestEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/vaccinations/{id}/links:
@@ -14831,7 +15045,7 @@ paths:
schema:
$ref: "#/components/schemas/LinkVaccinationEnvelope"
"401": *a1
- "404": *a30
+ "404": *a31
"422": *a4
"429": *a5
delete:
@@ -14860,7 +15074,7 @@ paths:
schema:
$ref: "#/components/schemas/UnlinkVaccinationEnvelope"
"401": *a1
- "404": *a30
+ "404": *a31
"422": *a4
"429": *a5
/api/illness/episodes:
@@ -14893,7 +15107,7 @@ paths:
schema:
$ref: "#/components/schemas/ListIllnessEpisodesEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
post:
@@ -14919,7 +15133,7 @@ paths:
schema:
$ref: "#/components/schemas/CreateIllnessEpisodeEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"409": *a7
"422": *a4
"429": *a5
@@ -14943,8 +15157,8 @@ paths:
schema:
$ref: "#/components/schemas/GetIllnessEpisodeEnvelope"
"401": *a1
- "403": *a9
- "404": &a31
+ "403": *a10
+ "404": &a32
description: Illness episode not found (or owned by another user).
content:
application/json:
@@ -14978,8 +15192,8 @@ paths:
schema:
$ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope"
"401": *a1
- "403": *a9
- "404": *a31
+ "403": *a10
+ "404": *a32
"422": *a4
"429": *a5
delete:
@@ -15002,8 +15216,8 @@ paths:
schema:
$ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope"
"401": *a1
- "403": *a9
- "404": *a31
+ "403": *a10
+ "404": *a32
"422": *a4
"429": *a5
/api/illness/episodes/{id}/restore:
@@ -15030,8 +15244,8 @@ paths:
schema:
$ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope"
"401": *a1
- "403": *a9
- "404": *a31
+ "403": *a10
+ "404": *a32
"409": *a7
"422": *a4
"429": *a5
@@ -15063,8 +15277,8 @@ paths:
schema:
$ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope"
"401": *a1
- "403": *a9
- "404": *a31
+ "403": *a10
+ "404": *a32
"422": *a4
"429": *a5
/api/illness/episodes/{id}/day-logs:
@@ -15114,8 +15328,8 @@ paths:
schema:
$ref: "#/components/schemas/GetIllnessDayLogEnvelope"
"401": *a1
- "403": *a9
- "404": *a31
+ "403": *a10
+ "404": *a32
"422": *a4
"429": *a5
post:
@@ -15151,8 +15365,8 @@ paths:
schema:
$ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope"
"401": *a1
- "403": *a9
- "404": *a31
+ "403": *a10
+ "404": *a32
"422": *a4
"429": *a5
/api/illness/episodes/{id}/correlation:
@@ -15179,7 +15393,7 @@ paths:
schema:
$ref: "#/components/schemas/IllnessCorrelationEnvelope"
"401": *a1
- "404": *a31
+ "404": *a32
"422": *a4
"429": *a5
/api/illness/insights:
@@ -15252,7 +15466,7 @@ paths:
schema:
$ref: "#/components/schemas/ListMentalHealthAssessmentsEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
post:
@@ -15285,7 +15499,7 @@ paths:
schema:
$ref: "#/components/schemas/CreateMentalHealthAssessmentEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"409": *a7
"422": *a4
"429": *a5
@@ -15312,7 +15526,7 @@ paths:
schema:
$ref: "#/components/schemas/GetMentalHealthAssessmentEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/allergies:
@@ -15345,7 +15559,7 @@ paths:
schema:
$ref: "#/components/schemas/ListAllergiesEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
post:
@@ -15370,10 +15584,10 @@ paths:
schema:
$ref: "#/components/schemas/CreateAllergyEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"409": *a7
"422": *a4
- "429": *a10
+ "429": *a11
/api/allergies/{id}:
get:
tags:
@@ -15394,8 +15608,8 @@ paths:
schema:
$ref: "#/components/schemas/GetAllergyEnvelope"
"401": *a1
- "403": *a9
- "404": &a32
+ "403": *a10
+ "404": &a33
description: Allergy not found (or owned by another user).
content:
application/json:
@@ -15428,8 +15642,8 @@ paths:
schema:
$ref: "#/components/schemas/UpdateAllergyEnvelope"
"401": *a1
- "403": *a9
- "404": *a32
+ "403": *a10
+ "404": *a33
"422": *a4
"429": *a5
delete:
@@ -15452,8 +15666,8 @@ paths:
schema:
$ref: "#/components/schemas/DeleteAllergyEnvelope"
"401": *a1
- "403": *a9
- "404": *a32
+ "403": *a10
+ "404": *a33
"422": *a4
"429": *a5
/api/family-history:
@@ -15477,7 +15691,7 @@ paths:
schema:
$ref: "#/components/schemas/ListFamilyHistoryEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
post:
@@ -15502,7 +15716,7 @@ paths:
schema:
$ref: "#/components/schemas/CreateFamilyHistoryEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"409": *a7
"422": *a4
"429": *a5
@@ -15526,8 +15740,8 @@ paths:
schema:
$ref: "#/components/schemas/GetFamilyHistoryEnvelope"
"401": *a1
- "403": *a9
- "404": &a33
+ "403": *a10
+ "404": &a34
description: Family history entry not found (or owned by another user).
content:
application/json:
@@ -15560,8 +15774,8 @@ paths:
schema:
$ref: "#/components/schemas/UpdateFamilyHistoryEnvelope"
"401": *a1
- "403": *a9
- "404": *a33
+ "403": *a10
+ "404": *a34
"422": *a4
"429": *a5
delete:
@@ -15584,8 +15798,8 @@ paths:
schema:
$ref: "#/components/schemas/DeleteFamilyHistoryEnvelope"
"401": *a1
- "403": *a9
- "404": *a33
+ "403": *a10
+ "404": *a34
"422": *a4
"429": *a5
/api/documents/inbound:
@@ -15698,7 +15912,7 @@ paths:
schema:
$ref: "#/components/schemas/InboundDocumentListEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
post:
@@ -15721,21 +15935,23 @@ paths:
**Importing from another system (v1.39.2).** Optional `sourceSystem` (`PAPERLESS`, `PAPRA`, `OTHER`) and
- `sourceId` (its id there, printable, up to 128 characters; needs `sourceSystem`) key the upload. A re-send with
- a known key is a duplicate: 200 with the live row (`meta.duplicate: true`), or, when the owner DELETED that
- document, 200 with `data.deleted: true` and `meta.deleted: true` and nothing stored; this holds after the 30-day
- purge too. The key may also ride the query string (`?sourceSystem=…&sourceId=…`): a key the vault already holds
- is then answered at once, the file is not stored or processed, and the request counts only toward the lookup
- allowance shared with `GET /api/documents/inbound/source` (5000 an hour per token, per person for a session; 429
- past it), not toward the hourly upload limit. A key in both places must agree (else 422). Every other upload
- counts toward the hourly upload limit, duplicates included. A key that is answered with an existing document by
- its bytes is remembered for that document, so deleting it keeps that key deleted too; one document takes at most
- 20 such keys, beyond which the upload is refused 409 (`documents.inbound.sourceAliasLimit`). `OTHER` is one
- shared namespace per account: two different systems both sent as `OTHER` must not reuse each other's ids.
- `aiRead=defer` holds back automatic AI reading for this upload: the thumbnail and a local text index still run,
- the summary and lab staging do not, and the document stays out of every automatic AI path (the summary catch-up,
- the automatic index, Index all for search) until the person reads it with AI or generates its summary. Absent,
- behaviour is unchanged.
+ `sourceId` (its id there, printable, up to 128 characters, never `.` or `..`; needs `sourceSystem`) key the
+ upload, optionally with `sourceInstance` (v1.39.3: the address of the Paperless-ngx or Papra it came from,
+ normalised to its origin; without it the key matches any instance of the system). A re-send with a known key is
+ a duplicate: 200 with the live row (`meta.duplicate: true`), or, when the owner DELETED that document, 200 with
+ `data.deleted: true` and `meta.deleted: true` and nothing stored; this holds after the 30-day purge too. The key
+ may also ride the query string (`?sourceSystem=…&sourceId=…`): a key the vault already holds is then answered at
+ once, the file is not stored or processed, and the request counts only toward the lookup allowance shared with
+ `GET /api/documents/inbound/source` (5000 an hour per token, per person for a session; 429 past it), not toward
+ the hourly upload limit. A key in both places must agree (else 422). Every other upload counts toward the hourly
+ upload limit, duplicates included. A key that is answered with an existing document by its bytes is remembered
+ for that document, so deleting it keeps that key deleted too; one document takes at most 20 such keys, beyond
+ which the upload is refused 409 (`documents.inbound.sourceAliasLimit`). `OTHER` is one shared namespace per
+ account: two different systems both sent as `OTHER` must not reuse each other's ids. `aiRead=defer` holds back
+ automatic AI reading for this upload: the thumbnail and a local text index still run, the summary and lab
+ staging do not, and the document stays out of every automatic AI path (the summary catch-up, the automatic
+ index, Index all for search) until the person reads it with AI or generates its summary. Absent, behaviour is
+ unchanged.
**Narrow token.** Also reachable with a `documents:write` Bearer (minted at `POST /api/tokens/documents`), which
@@ -15767,6 +15983,16 @@ paths:
type: string
maxLength: 128
description: Import source key, second half. Both halves or neither; half a key is 422.
+ - name: sourceInstance
+ in: query
+ required: false
+ schema:
+ type: string
+ maxLength: 512
+ description: "Optional, with a key. The instance of `sourceSystem` the id belongs to, as its http(s) address; normalised
+ to the origin (`scheme://host[:port]`). The same id in two instances is two documents. Optional: a key
+ stored without an instance (every upload before v1.39.3) matches any instance of its system, and a key sent
+ without one matches any stored instance. If also sent in the form it must agree."
requestBody:
required: true
content:
@@ -15821,6 +16047,13 @@ paths:
documents.
type: string
maxLength: 128
+ sourceInstance:
+ description: "Optional; requires `sourceId`. The instance of `sourceSystem` the id belongs to, as its http(s) address;
+ normalised to the origin (`scheme://host[:port]`). The same id in two instances is two documents.
+ Optional: a key stored without an instance (every upload before v1.39.3) matches any instance of its
+ system, and a key sent without one matches any stored instance."
+ type: string
+ maxLength: 512
aiRead:
description: Optional. `defer` holds back automatic AI reading for this upload (local index and thumbnail only).
type: string
@@ -15896,6 +16129,16 @@ paths:
schema:
type: string
maxLength: 128
+ - name: sourceInstance
+ in: query
+ required: false
+ schema:
+ type: string
+ maxLength: 512
+ description: "The instance of `sourceSystem` the id belongs to, as its http(s) address; normalised to the origin
+ (`scheme://host[:port]`). The same id in two instances is two documents. Optional: a key stored without an
+ instance (every upload before v1.39.3) matches any instance of its system, and a key sent without one
+ matches any stored instance."
responses:
"200":
description: The answer for this key.
@@ -15948,7 +16191,7 @@ paths:
schema:
$ref: "#/components/schemas/DocumentUsageEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/documents/inbound/bulk:
@@ -15995,7 +16238,7 @@ paths:
schema:
$ref: "#/components/schemas/InboundDocumentDetailEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
patch:
@@ -16114,7 +16357,7 @@ paths:
type: string
format: binary
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/documents/inbound/{id}/thumbnail:
@@ -16143,7 +16386,7 @@ paths:
type: string
format: binary
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/documents/inbound/{id}/extract:
@@ -16182,7 +16425,7 @@ paths:
schema:
$ref: "#/components/schemas/InboundExtractEnvelope"
"401": *a1
- "403": &a34
+ "403": &a35
description: "AI refusal: the `documentAi` capability is unavailable for this record. The envelope carries
`meta.errorCode`, `meta.capability` and `meta.reason` (one of `AiUnavailableReason`).
`assistant.disabled.documentAi` — the operator turned off reading documents, or every AI feature (the master
@@ -16206,7 +16449,7 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
- "503": &a35
+ "503": &a36
description: "`ai.unavailable` — the inputs behind the AI capability could not be read, and the answer failed closed.
Nothing was sent. Retry later."
content:
@@ -16268,10 +16511,10 @@ paths:
schema:
$ref: "#/components/schemas/DocumentSuggestEnvelope"
"401": *a1
- "403": *a34
+ "403": *a35
"422": *a4
"429": *a5
- "503": *a35
+ "503": *a36
/api/documents/inbound/{id}/summary:
post:
tags:
@@ -16350,10 +16593,10 @@ paths:
schema:
$ref: "#/components/schemas/DocumentSummaryEnvelope"
"401": *a1
- "403": *a34
+ "403": *a35
"422": *a4
"429": *a5
- "503": *a35
+ "503": *a36
/api/documents/inbound/{id}/index:
post:
tags:
@@ -16410,10 +16653,10 @@ paths:
schema:
$ref: "#/components/schemas/DocumentIndexEnvelope"
"401": *a1
- "403": *a34
+ "403": *a35
"422": *a4
"429": *a5
- "503": *a35
+ "503": *a36
/api/documents/inbound/{id}/chat:
post:
tags:
@@ -16453,10 +16696,10 @@ paths:
type: string
description: "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes."
"401": *a1
- "403": *a34
+ "403": *a35
"422": *a4
"429": *a5
- "503": *a35
+ "503": *a36
get:
tags:
- Documents
@@ -16519,10 +16762,10 @@ paths:
schema:
$ref: "#/components/schemas/DocumentReindexEnvelope"
"401": *a1
- "403": *a34
+ "403": *a35
"422": *a4
"429": *a5
- "503": *a35
+ "503": *a36
/api/documents/inbound/{id}/facts/{factId}:
patch:
tags:
@@ -16604,7 +16847,7 @@ paths:
schema:
$ref: "#/components/schemas/ListCustomMetricsEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
post:
@@ -16629,7 +16872,7 @@ paths:
schema:
$ref: "#/components/schemas/CreateCustomMetricResponse"
"401": *a1
- "409": &a37
+ "409": &a38
description: A custom metric with this name already exists for the caller.
content:
application/json:
@@ -16657,8 +16900,8 @@ paths:
schema:
$ref: "#/components/schemas/GetCustomMetricResponse"
"401": *a1
- "403": *a9
- "404": &a36
+ "403": *a10
+ "404": &a37
description: Custom metric not found (or owned by another user).
content:
application/json:
@@ -16692,8 +16935,8 @@ paths:
schema:
$ref: "#/components/schemas/UpdateCustomMetricResponse"
"401": *a1
- "404": *a36
- "409": *a37
+ "404": *a37
+ "409": *a38
"422": *a4
"429": *a5
delete:
@@ -16716,7 +16959,7 @@ paths:
schema:
$ref: "#/components/schemas/DeleteCustomMetricResponse"
"401": *a1
- "404": *a36
+ "404": *a37
"422": *a4
"429": *a5
/api/custom-metrics/{id}/entries:
@@ -16759,8 +17002,8 @@ paths:
schema:
$ref: "#/components/schemas/ListCustomMetricEntriesEnvelope"
"401": *a1
- "403": *a9
- "404": *a36
+ "403": *a10
+ "404": *a37
"422": *a4
"429": *a5
post:
@@ -16789,10 +17032,10 @@ paths:
schema:
$ref: "#/components/schemas/CreateCustomMetricEntryResponse"
"401": *a1
- "404": *a36
+ "404": *a37
"409": *a7
"422": *a4
- "429": *a10
+ "429": *a11
/api/custom-metrics/{id}/entries/{entryId}:
patch:
tags:
@@ -16825,7 +17068,7 @@ paths:
schema:
$ref: "#/components/schemas/UpdateCustomMetricEntryResponse"
"401": *a1
- "404": &a38
+ "404": &a39
description: Custom metric entry not found (or owned by another user).
content:
application/json:
@@ -16858,7 +17101,7 @@ paths:
schema:
$ref: "#/components/schemas/DeleteCustomMetricEntryResponse"
"401": *a1
- "404": *a38
+ "404": *a39
"422": *a4
"429": *a5
/api/custom-metrics/{id}/entries/restore:
@@ -17270,7 +17513,7 @@ paths:
schema:
$ref: "#/components/schemas/NutrientOverviewEnvelope"
"401": *a1
- "403": &a39
+ "403": &a40
description: >-
The opt-in `nutrients` module is off for this account (errorCode `module.disabled`). Stop syncing and do not
retry; offer the enable-in-settings hint. Only run the HealthKit read-authorization flow while the module is
@@ -17325,7 +17568,7 @@ paths:
schema:
$ref: "#/components/schemas/NutrientDailySeriesEnvelope"
"401": *a1
- "403": *a39
+ "403": *a40
"422": *a4
"429": *a5
/api/nutrients/water:
@@ -17354,7 +17597,7 @@ paths:
schema:
$ref: "#/components/schemas/NutrientWaterWriteEnvelope"
"401": *a1
- "403": *a39
+ "403": *a40
"409": *a7
"422": *a4
"429": *a5
@@ -17856,26 +18099,26 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/WithingsCredentialsUpdateEnvelope"
- "400": &a40
+ "400": &a41
description: Body is not parseable JSON.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"401": *a1
- "413": &a41
+ "413": &a42
description: Body exceeds 64 KiB.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
- "415": &a42
+ "415": &a43
description: Content-Type is not `application/json`.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
- "422": &a43
+ "422": &a44
description: "`clientId` and `clientSecret` are both required and each must be 1–200 characters. The envelope carries a
single fixed message, not per-field issues."
content:
@@ -18061,11 +18304,11 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/FitbitCredentialsUpdateEnvelope"
- "400": *a40
+ "400": *a41
"401": *a1
- "413": *a41
- "415": *a42
- "422": *a43
+ "413": *a42
+ "415": *a43
+ "422": *a44
"429": *a5
delete:
tags:
@@ -18208,11 +18451,11 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/GoogleHealthCredentialsUpdateEnvelope"
- "400": *a40
+ "400": *a41
"401": *a1
- "413": *a41
- "415": *a42
- "422": *a43
+ "413": *a42
+ "415": *a43
+ "422": *a44
"429": *a5
delete:
tags:
@@ -18365,11 +18608,11 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/OuraCredentialsUpdateEnvelope"
- "400": *a40
+ "400": *a41
"401": *a1
- "413": *a41
- "415": *a42
- "422": *a43
+ "413": *a42
+ "415": *a43
+ "422": *a44
"429": *a5
delete:
tags:
@@ -18445,21 +18688,21 @@ paths:
schema:
$ref: "#/components/schemas/OuraTestEnvelope"
"401": *a1
- "422": &a44
+ "422": &a45
description: No usable connection for this provider — nothing stored, or the stored grant could no longer be resolved
into an access token. `meta.errorCode` = `not_configured`.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
- "429": &a45
+ "429": &a46
description: More than 5 requests in 60 s from this user. `meta.errorCode` = `rate_limited_self` — this is the local
limiter, distinct from a `rate_limited` 502 which is the provider's.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
- "502": &a46
+ "502": &a47
description: "The probe did not complete. `meta.errorCode` names the class: `credentials_rejected` (the provider
answered 401/403 — the stored grant is gone), `rate_limited` (the PROVIDER answered 429, which is not this
endpoint's own limiter), `upstream_error` (5xx), `timeout` (no answer within 5 s), `connection_failed`
@@ -18504,11 +18747,11 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/PolarCredentialsUpdateEnvelope"
- "400": *a40
+ "400": *a41
"401": *a1
- "413": *a41
- "415": *a42
- "422": *a43
+ "413": *a42
+ "415": *a43
+ "422": *a44
"429": *a5
delete:
tags:
@@ -18584,9 +18827,9 @@ paths:
schema:
$ref: "#/components/schemas/PolarTestEnvelope"
"401": *a1
- "422": *a44
- "429": *a45
- "502": *a46
+ "422": *a45
+ "429": *a46
+ "502": *a47
/api/strava/credentials:
get:
tags:
@@ -18622,11 +18865,11 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/StravaCredentialsUpdateEnvelope"
- "400": *a40
+ "400": *a41
"401": *a1
- "413": *a41
- "415": *a42
- "422": *a43
+ "413": *a42
+ "415": *a43
+ "422": *a44
"429": *a5
delete:
tags:
@@ -18704,9 +18947,9 @@ paths:
schema:
$ref: "#/components/schemas/StravaTestEnvelope"
"401": *a1
- "422": *a44
- "429": *a45
- "502": *a46
+ "422": *a45
+ "429": *a46
+ "502": *a47
/api/integrations/status:
get:
tags:
@@ -18742,7 +18985,7 @@ paths:
$ref: "#/components/schemas/FitbitResumeEnvelope"
"401": *a1
"422": *a4
- "429": *a45
+ "429": *a46
/api/integrations/fitbit/test:
post:
tags:
@@ -18760,9 +19003,9 @@ paths:
schema:
$ref: "#/components/schemas/FitbitIntegrationTestEnvelope"
"401": *a1
- "422": *a44
- "429": *a45
- "502": *a46
+ "422": *a45
+ "429": *a46
+ "502": *a47
/api/integrations/google-health/resume:
post:
tags:
@@ -18779,7 +19022,7 @@ paths:
$ref: "#/components/schemas/GoogleHealthResumeEnvelope"
"401": *a1
"422": *a4
- "429": *a45
+ "429": *a46
/api/integrations/google-health/test:
post:
tags:
@@ -18812,9 +19055,9 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
- "422": *a44
- "429": *a45
- "502": *a46
+ "422": *a45
+ "429": *a46
+ "502": *a47
/api/integrations/whoop/resume:
post:
tags:
@@ -18831,7 +19074,7 @@ paths:
$ref: "#/components/schemas/WhoopResumeEnvelope"
"401": *a1
"422": *a4
- "429": *a45
+ "429": *a46
/api/integrations/whoop/test:
post:
tags:
@@ -18848,9 +19091,9 @@ paths:
schema:
$ref: "#/components/schemas/WhoopIntegrationTestEnvelope"
"401": *a1
- "422": *a44
- "429": *a45
- "502": *a46
+ "422": *a45
+ "429": *a46
+ "502": *a47
/api/integrations/withings/resume:
post:
tags:
@@ -18868,7 +19111,7 @@ paths:
$ref: "#/components/schemas/WithingsResumeEnvelope"
"401": *a1
"422": *a4
- "429": *a45
+ "429": *a46
/api/integrations/withings/test:
post:
tags:
@@ -18886,8 +19129,8 @@ paths:
schema:
$ref: "#/components/schemas/WithingsIntegrationTestEnvelope"
"401": *a1
- "422": *a44
- "429": *a45
+ "422": *a45
+ "429": *a46
"502":
description: "The probe did not complete. The shared codes apply (`credentials_rejected`, `rate_limited`,
`upstream_error`, `timeout`, `connection_failed`) and this path adds one of its own:
@@ -18920,7 +19163,7 @@ paths:
schema:
$ref: "#/components/schemas/DailyDigestEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/daily/digest/dismiss:
@@ -19011,7 +19254,7 @@ paths:
schema:
$ref: "#/components/schemas/DashboardSummaryEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/dashboard/widgets:
@@ -19030,7 +19273,7 @@ paths:
schema:
$ref: "#/components/schemas/DashboardLayout"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
put:
@@ -19064,7 +19307,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
- "422": *a13
+ "422": *a14
"429": *a5
delete:
tags:
@@ -19127,7 +19370,7 @@ paths:
schema:
$ref: "#/components/schemas/RecordActivityEnvelope"
"401": *a1
- "403": &a47
+ "403": &a48
description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another
account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the
account they named. The latter is byte-identical for an account that does not exist and one that granted
@@ -19152,7 +19395,7 @@ paths:
schema:
$ref: "#/components/schemas/AccountGrantListEnvelope"
"401": *a1
- "403": *a47
+ "403": *a48
"422": *a4
"429": *a5
post:
@@ -19167,7 +19410,12 @@ paths:
(`requireFreshMfaIfEnrolled`, cookie-only): an enrolled account without a fresh factor gets 401
`auth.stepup.required`, and a Bearer caller gets 403 `sharing.invite.manage_browser_only` before anything else
happens, because the gate it would hit resolves through the session cookie and would otherwise answer 'not
- authenticated' to a caller it had just authenticated."
+ authenticated' to a caller it had just authenticated. Every invitation, at any level, also needs a fresh proof:
+ a browser session must have signed in or used `POST /api/auth/reproof` within the last five minutes (with the
+ second factor, on an account that has one), or it gets 401 `auth.reproof.required` with `meta.methods`; a Bearer
+ caller presents an `X-Step-Up` elevation from `POST /api/auth/step-up` (a second-factor one on an account with a
+ second factor) and gets 401 `auth.stepup.required` without it. `DELETE /api/auth/me/sessions` withdraws the
+ invitations nobody has accepted yet."
requestBody:
required: true
content:
@@ -19182,8 +19430,10 @@ paths:
schema:
$ref: "#/components/schemas/AccountGrantEnvelope"
"401":
- description: "Offering MANAGE from an MFA-enrolled account without a recent second-factor proof (`meta.errorCode:
- auth.stepup.required`). Re-prove the factor and retry; READ and WRITE invitations are never gated."
+ description: "No recent proof. A browser session that has not signed in or re-proved within five minutes gets
+ `meta.errorCode: auth.reproof.required` with `meta.methods`; re-prove at POST /api/auth/reproof and retry. A
+ Bearer caller without a valid `X-Step-Up` elevation gets `auth.stepup.required`, as does offering MANAGE
+ from an MFA-enrolled browser session without a recent second-factor proof."
content:
application/json:
schema:
@@ -19247,7 +19497,7 @@ paths:
schema:
$ref: "#/components/schemas/AcceptedAccountGrantEnvelope"
"401": *a1
- "403": *a47
+ "403": *a48
"404":
description: No such invitation for this account.
content:
@@ -19291,7 +19541,7 @@ paths:
schema:
$ref: "#/components/schemas/RevokedAccountGrantEnvelope"
"401": *a1
- "403": *a47
+ "403": *a48
"404":
description: No such grant on this account's record.
content:
@@ -19332,7 +19582,7 @@ paths:
schema:
$ref: "#/components/schemas/RenouncedAccountGrantEnvelope"
"401": *a1
- "403": *a47
+ "403": *a48
"404":
description: No such grant held by this account.
content:
@@ -19714,7 +19964,7 @@ paths:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"401": *a1
- "403": *a47
+ "403": *a48
"409":
description: "The record this browser session is on is not the record the request asserted. `meta.errorCode` is
`sharing.session.changed`. It is not a refusal of access — the caller may well be entitled to both records —
@@ -20044,7 +20294,7 @@ paths:
schema:
$ref: "#/components/schemas/McpConnectionListEnvelope"
"401": *a1
- "403": &a48
+ "403": &a49
description: The operator has disabled the API instance-wide. Every route in this module refuses while that setting is
off, for a cookie session as well as for a Bearer token; it is not a per-user permission and retrying will
not help.
@@ -20077,7 +20327,7 @@ paths:
schema:
$ref: "#/components/schemas/McpConnectionRevokeEnvelope"
"401": *a1
- "403": *a48
+ "403": *a49
"404":
description: No live connection with this id for this caller — also the answer for an already-revoked one, so a repeat
revoke is 404 rather than an idempotent 200.
@@ -20104,16 +20354,19 @@ paths:
schema:
$ref: "#/components/schemas/McpConnectorTokenListEnvelope"
"401": *a1
- "403": *a48
+ "403": *a49
"422": *a4
"429": *a5
post:
tags:
- MCP
summary: Mint an MCP connector token
- description: Mints a `health:read` (optionally read+write) Bearer for the manual / stdio path and audits the mint. THE
+ description: "Mints a `health:read` (optionally read+write) Bearer for the manual / stdio path and audits the mint. THE
RESPONSE CARRIES THE RAW TOKEN — this is the only place it ever exists, and it cannot be retrieved again. Body
- capped at 16 KiB. Auth via cookie or Bearer.
+ capped at 16 KiB. Auth via cookie or Bearer. A browser session needs a fresh proof as well as a live one: a
+ sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has
+ one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account
+ can give. A Bearer caller is not asked for more than its token yet."
requestBody:
required: true
content:
@@ -20134,7 +20387,7 @@ paths:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"401": *a1
- "403": *a48
+ "403": *a49
"413":
description: Body exceeds 16 KiB.
content:
@@ -20177,7 +20430,7 @@ paths:
schema:
$ref: "#/components/schemas/McpConnectorTokenRevokeEnvelope"
"401": *a1
- "403": *a48
+ "403": *a49
"404":
description: No `health:read` token with this id for this caller. An already-revoked token is still found, so a repeat
revoke answers 200.
@@ -20204,7 +20457,7 @@ paths:
schema:
$ref: "#/components/schemas/RecordSettingsEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/record-settings/integrations:
@@ -20224,7 +20477,7 @@ paths:
schema:
$ref: "#/components/schemas/RecordSettingsIntegrationsEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"422": *a4
"429": *a5
/api/record-settings/{family}:
@@ -20235,7 +20488,7 @@ paths:
description: Returns the named family's resolved settings for the record the session is acting on. Cookie session or
Bearer, while acting on a managed record the caller guards.
parameters:
- - &a49
+ - &a50
name: family
in: path
required: true
@@ -20259,7 +20512,7 @@ paths:
schema:
$ref: "#/components/schemas/GetRecordSettingsFamilyEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"404":
description: Either the path named a family outside the closed set, or the record row itself is gone. The two are not
distinguished, and the first is answered before the caller is authenticated at all.
@@ -20279,7 +20532,7 @@ paths:
ride the audit row. Body capped at 64 KiB. Cookie session or Bearer, while acting on a managed record the caller
guards.
parameters:
- - *a49
+ - *a50
requestBody:
required: true
content:
@@ -20300,7 +20553,7 @@ paths:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"401": *a1
- "403": *a9
+ "403": *a10
"404":
description: The path named a family outside the closed set. Answered before authentication.
content:
@@ -20413,7 +20666,7 @@ paths:
schema:
$ref: "#/components/schemas/AchievementsEnvelope"
"401": *a1
- "403": *a11
+ "403": *a12
"422": *a4
"429": *a5
/api/environment:
@@ -20431,7 +20684,7 @@ paths:
schema:
$ref: "#/components/schemas/EnvironmentOverviewEnvelope"
"401": *a1
- "403": *a17
+ "403": *a18
"422": *a4
"429": *a5
/api/environment/home:
@@ -20468,7 +20721,7 @@ paths:
schema:
$ref: "#/components/schemas/EnvironmentHomeEnvelope"
"401": *a1
- "403": *a17
+ "403": *a18
"413":
description: Body exceeds 4 KiB.
content:
@@ -20522,7 +20775,7 @@ paths:
schema:
$ref: "#/components/schemas/EnvironmentBackfillEnvelope"
"401": *a1
- "403": *a17
+ "403": *a18
"409":
description: No home location is set, so there is nothing to resolve the days against. `meta.errorCode` =
`environment.no_home`.
@@ -20581,7 +20834,7 @@ paths:
schema:
$ref: "#/components/schemas/EnvironmentGeocodeEnvelope"
"401": *a1
- "403": *a17
+ "403": *a18
"422":
description: "`q` was missing, empty after trimming, or longer than 120 characters. `meta.errorCode` =
`environment.invalid`."
@@ -20620,7 +20873,7 @@ paths:
schema:
$ref: "#/components/schemas/CreateEnvironmentTravelEnvelope"
"401": *a1
- "403": *a17
+ "403": *a18
"413":
description: Body exceeds 4 KiB.
content:
@@ -20673,7 +20926,7 @@ paths:
schema:
$ref: "#/components/schemas/DeleteEnvironmentTravelEnvelope"
"401": *a1
- "403": *a17
+ "403": *a18
"404":
description: No such override for this account. `meta.errorCode` = `environment.travel.not_found`. Also the answer for
an id owned by somebody else — existence is not leaked.
@@ -20798,7 +21051,9 @@ paths:
description: "Decrypts one stored copy and returns the backup document itself, with `Content-Disposition: attachment`
and `Cache-Control: no-store`. The response is the raw document, not the standard envelope — it is the artefact
an operator keeps. The ciphertext stays in the database. Admin session cookie required; Bearer tokens cannot
- reach admin endpoints."
+ reach admin endpoints. A browser session needs a fresh proof as well as a live one: a sign-in or `POST
+ /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it
+ the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give."
parameters:
- in: path
name: id
@@ -20814,13 +21069,13 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/AdminBackupDocument"
- "403": &a50
+ "403": &a51
description: Caller is not an admin. `requireAdmin()` is cookie-only, so a Bearer token lands here whatever its scope.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
- "404": &a51
+ "404": &a52
description: No stored copy with this id.
content:
application/json:
@@ -20862,7 +21117,10 @@ paths:
snapshot was taken for, never the admin running it. The request refuses a missing confirmation, an unknown
backup and a copy that cannot be decrypted; the file's own checks (schema, declared owner, manifest, documents)
run in the job before anything is deleted, and a refusal there ends the job as `failed` with the same reason.
- One restore per account at a time. Admin session cookie required; Bearer tokens cannot reach admin endpoints."
+ One restore per account at a time. Admin session cookie required; Bearer tokens cannot reach admin endpoints. A
+ browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last
+ five minutes (with a second factor, on an account that has one). Without it the answer is 401
+ `auth.reproof.required`, with `meta.methods` naming the proofs the account can give."
parameters:
- in: path
name: id
@@ -20891,8 +21149,8 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
- "403": *a50
- "404": *a51
+ "403": *a51
+ "404": *a52
"409":
description: A restore of the same account is already queued or running (`meta.errorCode` = `backup.restore.active`,
`meta.jobId` names it), the stored copy was replaced by a newer one while it was being checked
@@ -20936,7 +21194,7 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/AdminBackupRestoreJobListResponse"
- "403": *a50
+ "403": *a51
/api/admin/backups/restores/{jobId}:
get:
tags:
@@ -20959,13 +21217,384 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/AdminBackupRestoreJobResponse"
- "403": *a50
+ "403": *a51
"404":
description: No restore job with this id.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
+ /api/documents/sources:
+ get:
+ tags:
+ - Documents
+ summary: "Document archives: availability and connections"
+ description: "Whether the operator enabled the document picker (`available`, true when `DOCUMENT_SOURCE_ORIGINS` lists
+ at least one origin), the listed origins, and the caller's own saved connections to Paperless-ngx or Papra. A
+ connection never carries its token: `hasToken` is always true for a saved one, and no route returns the token in
+ whole or in part. `originAllowed` is false once the operator removed the connection's origin from the list. No
+ request leaves the server. Needs neither the Documents module nor the list: with the list unset, `available` is
+ false and any connection still stored is listed (with `originAllowed: false`) so it can be removed. Cookie
+ session only; not delegable."
+ security: &a53
+ - cookieAuth: []
+ responses:
+ "200":
+ description: The picker's state for the caller.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DocumentSourcesStatusEnvelope"
+ "401": *a1
+ "403": &a54
+ description: "Refused before any request to the source: `documents.sources.browserOnly` for a Bearer caller,
+ `sharing.not_permitted` while acting on another person's record (a delegate or guardian never uses the
+ owner's archive), or `module.disabled` when the Documents module is off."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "422": *a4
+ "429": *a5
+ /api/documents/sources/{system}:
+ put:
+ tags:
+ - Documents
+ summary: Save a connection to a document archive
+ description: "Saves or replaces the caller's connection to Paperless-ngx (`paperless`: base address and API token) or
+ Papra (`papra`: base address, organization id, API key with `documents:read` and `tags:read`). The base address
+ may carry a path; its origin must be listed in `DOCUMENT_SOURCE_ORIGINS`. The connection is tested live before
+ anything is stored. Leaving out `token` keeps the stored one, but only when the new address is on the same
+ origin as the saved one: the first save and a move to another origin must carry it (422
+ `documents.sources.tokenRequired`), so a stored token is never sent to an address it was not saved for. A Papra
+ organization id is letters, digits, `_` and `-`. The token is stored encrypted (AES-256-GCM) and never returned.
+ Ten saves and tests a minute per person. Cookie session only; not delegable."
+ security: *a53
+ parameters:
+ - &a55
+ name: system
+ in: path
+ required: true
+ description: "`paperless` (Paperless-ngx) or `papra` (Papra)."
+ schema:
+ type: string
+ enum:
+ - paperless
+ - papra
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ baseUrl:
+ type: string
+ minLength: 1
+ maxLength: 2048
+ organizationId:
+ type: string
+ pattern: ^org_[a-z0-9]{24}$
+ token:
+ type: string
+ minLength: 1
+ maxLength: 512
+ pattern: ^[\x21-\x7e]+$
+ required:
+ - baseUrl
+ responses:
+ "200":
+ description: Saved and verified. The connection, without its token.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DocumentSourceConnectionEnvelope"
+ "401": *a1
+ "403": *a54
+ "404": &a56
+ description: "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so
+ the picker is off. `documents.sources.notConnected`: no connection is saved for this system.
+ `documents.sources.notFound`: the source has no such document, or (Papra) no such organization, including
+ one the key's user is not a member of (Papra 403 `user.not_in_organization`) and one Papra cannot parse
+ (400)."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "422":
+ description: "`documents.sources.originNotAllowed`, `documents.sources.invalidAddress`,
+ `documents.sources.organizationRequired` (Papra), `documents.sources.tokenRequired` (first save without a
+ token), or body validation."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "429": *a5
+ "502": &a57
+ description: "The source could not answer usefully, with the reason in `meta.errorCode` and, when the source answered at
+ all, its HTTP status in `meta.upstreamStatus` (its body is never passed on): `documents.sources.unreachable`
+ (no connection, timeout, or a destination refused at dial time), `documents.sources.redirected` (the source
+ answered with a redirect, which is never followed), `documents.sources.authRefused` (401/403 from the
+ source), `documents.sources.permissionMissing` (Papra: the key cannot read tags; needs `tags:read`),
+ `documents.sources.versionTooOld` (Paperless-ngx older than 2.16 / API version 9),
+ `documents.sources.badResponse` (not the documented JSON, or larger than 1 MiB)."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ delete:
+ tags:
+ - Documents
+ summary: Remove a connection to a document archive
+ description: Deletes the caller's connection for this system, and its token with it. Works even after the operator
+ removed the origin from the list, and with the Documents module switched off. `disconnected` is false when there
+ was nothing to delete. Cookie session only; not delegable.
+ security: *a53
+ parameters:
+ - *a55
+ responses:
+ "200":
+ description: Removed, or there was nothing to remove.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DocumentSourceDisconnectEnvelope"
+ "401": *a1
+ "403": *a54
+ "422": *a4
+ "429": *a5
+ /api/documents/sources/{system}/test:
+ post:
+ tags:
+ - Documents
+ summary: Test a saved connection
+ description: One request to the source with the stored token. Answers `ok` with the round-trip time and stamps
+ `lastVerifiedAt`, or the reason in `meta.errorCode`. Shares the save's allowance of ten a minute. Cookie session
+ only; not delegable.
+ security: *a53
+ parameters:
+ - *a55
+ responses:
+ "200":
+ description: The source answered.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DocumentSourceTestEnvelope"
+ "401": *a1
+ "403": *a54
+ "404": *a56
+ "422": &a58
+ description: "`documents.sources.originNotAllowed`: the base address's origin is not on the operator's list (checked on
+ every request, so a connection saved earlier is cut off once its origin is removed), or another validation
+ failure."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "429": *a5
+ "502": *a57
+ /api/documents/sources/{system}/tags:
+ get:
+ tags:
+ - Documents
+ summary: Tags of a connected archive
+ description: The source's tags for the picker's filter, the first 250 by name, in the source's own ids. Shares the
+ search allowance of sixty a minute. Cookie session only; not delegable.
+ security: *a53
+ parameters:
+ - *a55
+ responses:
+ "200":
+ description: The tags.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DocumentSourceTagsEnvelope"
+ "401": *a1
+ "403": *a54
+ "404": *a56
+ "422": *a58
+ "429": *a5
+ "502": *a57
+ /api/documents/sources/{system}/search:
+ get:
+ tags:
+ - Documents
+ summary: Search a connected archive by name, tag and date
+ description: "Searches the source 25 documents at a time. `q` matches the document's name (Paperless-ngx title, Papra
+ name); `tag` narrows to one of the source's tags by id; `from` / `to` to a date range, inclusive (Paperless-ngx
+ filters on `created__gte` / `created__lte`; Papra's range is applied to each page after it arrives). When a
+ filter applied after arrival empties a page, the route reads on, up to five source pages, and `page` in the
+ answer is the last source page read; ask for `page + 1` next. Each result carries what the vault already holds
+ under that source key: `new`, `imported` with the vault document's `documentId`, or `deleted` (deleted in
+ HealthLog; importing it again stores nothing). Nothing is stored. Sixty searches a minute, shared with the tag
+ read. Cookie session only; not delegable."
+ security: *a53
+ parameters:
+ - *a55
+ - name: q
+ in: query
+ required: false
+ schema:
+ type: string
+ maxLength: 200
+ - name: tag
+ in: query
+ required: false
+ schema:
+ type: string
+ maxLength: 64
+ - name: from
+ in: query
+ required: false
+ schema:
+ type: string
+ format: date
+ - name: to
+ in: query
+ required: false
+ schema:
+ type: string
+ format: date
+ - name: page
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 1
+ maximum: 500
+ default: 1
+ responses:
+ "200":
+ description: One page of results.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DocumentSourceSearchEnvelope"
+ "401": *a1
+ "403": *a54
+ "404": *a56
+ "422": *a58
+ "429": *a5
+ "502": *a57
+ /api/documents/sources/{system}/import:
+ post:
+ tags:
+ - Documents
+ summary: Import one picked document
+ description: "`sourceId` must be an id the system issues (Paperless-ngx: digits; Papra: letters, digits, `_`, `-`), else
+ 422 before anything is fetched. Fetches one document from the source and stores it in the vault through the same
+ path as an upload, with `sourceSystem` / `sourceId` / `sourceInstance` (the connection's origin) provenance; the
+ same id from another instance is another document. One import per key runs at a time; a concurrent second one
+ waits and then answers `duplicate`. The key check counts toward the lookup allowance, the fetch toward the
+ search allowance, and only a file that arrived toward the upload allowance. A source key the vault already holds
+ is answered without a download: `duplicate` for a live document (same id), `deleted` for one the person deleted
+ in HealthLog, which is never stored again. Bytes already in the vault under another key are `duplicate` too, and
+ the key is remembered for that document. `link` files the document against the record the picker was opened from
+ (a condition, a visit or a vaccination, which must be the caller's own and live; checked before anything is
+ fetched), including a duplicate. `kind` sets the document type (default OTHER). Automatic AI reading follows the
+ person's own setting. Counts against the person's upload allowance (60 an hour) and storage quota. 201 for a new
+ document, 200 otherwise. Cookie session only; not delegable."
+ security: *a53
+ parameters:
+ - *a55
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ sourceId:
+ type: string
+ minLength: 1
+ maxLength: 128
+ pattern: ^[\x21-\x7e]+$
+ kind:
+ type: string
+ enum:
+ - DOCTOR_REPORT
+ - DISCHARGE_LETTER
+ - LAB_RESULT
+ - IMAGING
+ - PRESCRIPTION
+ - REFERRAL
+ - INSURANCE
+ - VACCINATION
+ - OTHER
+ link:
+ type: object
+ properties:
+ kind:
+ type: string
+ enum:
+ - conditionEpisode
+ - encounter
+ - vaccination
+ id:
+ type: string
+ minLength: 1
+ maxLength: 64
+ required:
+ - kind
+ - id
+ required:
+ - sourceId
+ responses:
+ "200":
+ description: Already held (`duplicate`) or deleted in HealthLog (`deleted`).
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DocumentSourceImportHeldEnvelope"
+ "201":
+ description: Imported.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DocumentSourceImportEnvelope"
+ "401": *a1
+ "403": *a54
+ "404":
+ description: "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so
+ the picker is off. `documents.sources.notConnected`: no connection is saved for this system.
+ `documents.sources.notFound`: the source has no such document, or (Papra) no such organization, including
+ one the key's user is not a member of (Papra 403 `user.not_in_organization`) and one Papra cannot parse
+ (400). `documents.sources.linkTargetNotFound`: the record in `link` is not the caller's own live
+ conditionEpisode / encounter / vaccination."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "409":
+ description: "`documents.inbound.sourceAliasLimit`: these bytes are already stored under too many source ids."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "413":
+ description: "`documents.inbound.fileTooLarge` (with `maxFileBytes`) or `documents.inbound.quotaExceeded` (with
+ `quotaBytes` and `usedBytes`)."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "415":
+ description: "`documents.inbound.fileType`: the file is not a type the vault accepts."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "422": *a58
+ "429":
+ description: "`documents.inbound.rateLimited` (the person's hourly upload allowance, with the rate-limit headers) or
+ `documents.inbound.uploadBusy` (too many uploads in progress; `Retry-After: 1`)."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "502": *a57
/api/bugreport:
post:
security: []
@@ -22386,6 +23015,20 @@ components:
anyOf:
- type: string
- type: "null"
+ currentPassword:
+ description: "The account password, sent only alongside a NEW `email`. Moving the account to a new address needs a fresh
+ proof because single sign-on links an existing account by it: a browser session that signed in or completed
+ a second factor in the last five minutes needs nothing more, otherwise this field must carry the current
+ password. Without an accepted proof the address is not changed and comes back in `rejectedFields` as
+ `reproof_required` or `reproof_failed` (or, when the address was the only field sent, 401
+ `auth.reproof.required` / `auth.reproof.failed`). On an account with a second factor the password is not
+ accepted and not checked: only a second factor or passkey completed in the last five minutes counts (at
+ sign-in or at POST /api/auth/reproof), and without one the address comes back as `second_factor_required`
+ (or 401 `profile.update.emailSecondFactorRequired` when it was the only field). A token cannot change the
+ address of such an account. Attempts draw on the account's shared re-proof budget of five per fifteen
+ minutes. Never stored."
+ type: string
+ maxLength: 512
description: Partial profile update. Every field is optional and an omitted field is left untouched. An explicit null
clears any nullable field; an empty string also clears `displayName`, `dateOfBirth`, `gender`, `fullName`,
`insurerName`, `insuranceNumber` and `insurerIkNumber`. `email` is not among them — it is validated as an
@@ -26867,9 +27510,13 @@ components:
- type: "null"
anchorDate:
anyOf:
- - type: string
- format: date-time
- pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
+ - anyOf:
+ - type: string
+ format: date
+ pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$
+ - type: string
+ format: date-time
+ pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
notifyHour:
default: 9
@@ -26941,9 +27588,13 @@ components:
- type: "null"
anchorDate:
anyOf:
- - type: string
- format: date-time
- pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
+ - anyOf:
+ - type: string
+ format: date
+ pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$
+ - type: string
+ format: date-time
+ pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
notifyHour:
type: integer
@@ -30818,19 +31469,20 @@ components:
analytics.invalid_query; anamnesis — anamnesis.fact.conflict, anamnesis.fact.currentExists,
anamnesis.fact.invalidValue; auth — auth.admin.required, auth.already_authenticated,
auth.mfa.code_invalid, auth.missing, auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked,
- auth.scope.insufficient, auth.stepup.required, auth.token.expired, auth.token.invalid; backup —
- backup.payload.undecryptable, backup.restore.active, backup.section.missing, backup.upload.too_large;
- body-site — body-site.invalid; coach — coach.budget.exceeded, coach.conversation.invalidTitle,
- coach.conversation.notFound, coach.fenced.attachmentConflict, coach.fenced.attachmentLimit,
- coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired,
- coach.fenced.attachmentUnavailable, coach.fenced.invalid, coach.request.invalid; coach-prefs —
- coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; consent — consent.ai.required; cycle —
- cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range,
- cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled,
- cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs —
- cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json;
- documents — documents.chat.conversationNotFound, documents.chat.invalid,
- documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed,
+ auth.reproof.failed, auth.reproof.required, auth.reproof.too_weak, auth.scope.insufficient,
+ auth.stepup.required, auth.token.expired, auth.token.invalid; backup — backup.foreign_reference,
+ backup.key.missing, backup.payload.undecryptable, backup.restore.active, backup.section.missing,
+ backup.upload.too_large; body-site — body-site.invalid; coach — coach.budget.exceeded,
+ coach.conversation.invalidTitle, coach.conversation.notFound, coach.fenced.attachmentConflict,
+ coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed,
+ coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, coach.fenced.invalid,
+ coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape;
+ consent — consent.ai.required; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid,
+ cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid,
+ cycle.disabled, cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit;
+ cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach —
+ disable-coach.body.invalid_json; documents — documents.chat.conversationNotFound,
+ documents.chat.invalid, documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed,
documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound,
documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound,
documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge,
@@ -30839,21 +31491,26 @@ components:
documents.inbound.notIndexed, documents.inbound.pdfNeedsAnthropic,
documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited,
documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.sourceAliasLimit,
- documents.inbound.uploadBusy, documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound;
- encounter — encounter.invalid, encounter.link.invalid, encounter.practitioner-not-found,
- encounter.reminder-conflict, encounter.reminder-not-found; environment — environment.invalid,
- environment.no_home, environment.range_too_large, environment.travel.not_found; export —
- export.selection.empty, export.selection.unknown_leaf; familyHistory — familyHistory.invalid; feedback —
- feedback.body.invalid; health_score_config — health_score_config.invalid,
+ documents.inbound.uploadBusy, documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound,
+ documents.sources.authRefused, documents.sources.badResponse, documents.sources.browserOnly,
+ documents.sources.invalidAddress, documents.sources.linkTargetNotFound, documents.sources.notConnected,
+ documents.sources.notFound, documents.sources.organizationRequired, documents.sources.originNotAllowed,
+ documents.sources.permissionMissing, documents.sources.rateLimited, documents.sources.redirected,
+ documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable,
+ documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid,
+ encounter.practitioner-not-found, encounter.reminder-conflict, encounter.reminder-not-found; environment
+ — environment.invalid, environment.no_home, environment.range_too_large, environment.travel.not_found;
+ export — export.selection.empty, export.selection.unknown_leaf; familyHistory — familyHistory.invalid;
+ feedback — feedback.body.invalid; health_score_config — health_score_config.invalid,
health_score_config.too_narrow; illness — illness.day-log.invalid, illness.disabled,
illness.episode.chronic-no-resolve, illness.episode.invalid, illness.episode.invalid-window,
- illness.episode.parent-not-found, illness.insights.invalid; import — import.write_failed; ingest —
- ingest.rate_limited; insights — insights.generate.budgetExceeded, insights.generate.outboundScreened,
- insights.generate.profileScopeChanged, insights.generate.scopeChanged; labs —
- labs.biomarker.create.invalid, labs.biomarker.duplicate, labs.biomarker.notFound,
- labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, labs.create.invalid,
- labs.ocr.budgetExceeded, labs.ocr.extractFailed, labs.ocr.fileTooLarge, labs.ocr.fileType,
- labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic, labs.ocr.providerUnsupported,
+ illness.episode.parent-not-found, illness.insights.invalid; import — import.apple_health.busy,
+ import.write_failed; ingest — ingest.rate_limited; insights — insights.generate.budgetExceeded,
+ insights.generate.outboundScreened, insights.generate.profileScopeChanged,
+ insights.generate.scopeChanged; labs — labs.biomarker.create.invalid, labs.biomarker.duplicate,
+ labs.biomarker.notFound, labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid,
+ labs.create.invalid, labs.ocr.budgetExceeded, labs.ocr.extractFailed, labs.ocr.fileTooLarge,
+ labs.ocr.fileType, labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic, labs.ocr.providerUnsupported,
labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound, labs.update.invalid,
labs.update.linkedFieldsImmutable, labs.update.numericExpected, labs.update.qualitativeExpected,
labs.update.referenceRangeInvalid, labs.update.sourceReferenceRangeInvalid; managed_profile —
@@ -30879,29 +31536,29 @@ components:
onboarding.disclaimer.invalid, onboarding.restart.invalid, onboarding.restart.rateLimited,
onboarding.tour.invalid; personal_records — personal_records.invalid_query; practitioner —
practitioner.invalid; profile — profile.update.emailInUse, profile.update.emailRateLimited,
- profile.update.invalidBody, profile.update.nothingSaved; record_write — record_write.rate_limited;
- report-selection — report-selection.body.invalid_json, report-selection.body.invalid_shape,
- report-selection.leaves.unknown; route — route.retired; share-link — share-link.document.decryptFailed,
- share-link.document.notFound, share-link.document.rateLimited, share-link.documents.invalid,
- share-link.report.notFound, share-link.report.rateLimited, share-link.selection.forbidden_leaf,
- share-link.selection.unknown_leaf; sharing — sharing.accept.expired, sharing.accept.not_found,
- sharing.accept.not_pending, sharing.accept.revoked, sharing.access.denied, sharing.invite.duplicate,
- sharing.invite.invalid, sharing.invite.invalid_scope, sharing.invite.manage_browser_only,
- sharing.invite.refused, sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended,
- sharing.renounce.not_found, sharing.revoke.already_ended, sharing.revoke.not_found,
- sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority —
- source-priority.body.invalid_json, source-priority.body.invalid_shape; tokens —
- tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached; vaccination —
- vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.booster-out-of-scope,
- vaccination.encounter-not-found, vaccination.identity-required, vaccination.invalid,
- vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout —
- workout.batch.invalid, workout.batch.payload_too_large, workout.batch.too_large; (unprefixed) —
- about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file,
- health_score_config_conflict, insights_layout_conflict, invalid_base_updated_at,
- json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, missing_required_columns,
- modules_conflict, mood_tag_layout_conflict, not_configured, notification_prefs_conflict, oidc_only,
- private_origin_not_approved, private_origin_not_grantable, rate_limited_self, too_many_rows,
- unreadable_json, upstream_invalid_json, vapid_not_configured"
+ profile.update.emailSecondFactorRequired, profile.update.invalidBody, profile.update.nothingSaved;
+ record_write — record_write.rate_limited; report-selection — report-selection.body.invalid_json,
+ report-selection.body.invalid_shape, report-selection.leaves.unknown; route — route.retired; share-link
+ — share-link.document.decryptFailed, share-link.document.notFound, share-link.document.rateLimited,
+ share-link.documents.invalid, share-link.report.notFound, share-link.report.rateLimited,
+ share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing —
+ sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked,
+ sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, sharing.invite.invalid_scope,
+ sharing.invite.manage_browser_only, sharing.invite.refused, sharing.invite.self, sharing.not_permitted,
+ sharing.renounce.already_ended, sharing.renounce.not_found, sharing.revoke.already_ended,
+ sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid,
+ sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json,
+ source-priority.body.invalid_shape; tokens — tokens.documents.ceiling_reached,
+ tokens.measurements.ceiling_reached; vaccination — vaccination.booster-invalid,
+ vaccination.booster-no-antigen, vaccination.booster-out-of-scope, vaccination.encounter-not-found,
+ vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found,
+ vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large,
+ workout.batch.too_large; (unprefixed) — about_me_conflict, ai_response_truncated, coach_prefs_conflict,
+ dashboard_layout_conflict, empty_file, health_score_config_conflict, insights_layout_conflict,
+ invalid_base_updated_at, json_carries_no_intake_time, json_not_an_array, medication_layout_conflict,
+ missing_required_columns, modules_conflict, mood_tag_layout_conflict, not_configured,
+ notification_prefs_conflict, oidc_only, private_origin_not_approved, private_origin_not_grantable,
+ rate_limited_self, too_many_rows, unreadable_json, upstream_invalid_json, vapid_not_configured"
type: string
capability:
description: "On an AI refusal: the capability that is unavailable, one of the keys of `AiCapabilities` (`coach`,
@@ -35405,6 +36062,11 @@ components:
- type: string
maxLength: 128
- type: "null"
+ sourceInstance:
+ anyOf:
+ - type: string
+ maxLength: 512
+ - type: "null"
aiReadDeferred:
type: boolean
summary:
@@ -35818,6 +36480,11 @@ components:
anyOf:
- type: string
- type: "null"
+ noteEncrypted:
+ anyOf:
+ - type: string
+ minLength: 1
+ - type: "null"
createdAt:
type: string
minLength: 1
@@ -36495,6 +37162,9 @@ components:
minLength: 1
title:
type: string
+ titleEncrypted:
+ type: string
+ minLength: 1
documentScoped:
type: boolean
summaryEncrypted:
@@ -36586,7 +37256,6 @@ components:
additionalProperties: {}
required:
- id
- - title
- documentScoped
- createdAt
- updatedAt
@@ -37806,6 +38475,67 @@ components:
- method
- satisfiesFreshFactor
additionalProperties: false
+ ReproofEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/ReproofResponse"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ ReproofResponse:
+ type: object
+ properties:
+ method:
+ type: string
+ enum:
+ - password
+ - totp
+ - webauthn
+ - passkey
+ verifiedAt:
+ type: string
+ format: date-time
+ pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$
+ required:
+ - method
+ - verifiedAt
+ additionalProperties: false
+ ReproofStatusEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/ReproofStatus"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ ReproofStatus:
+ type: object
+ properties:
+ recent:
+ type: boolean
+ const: true
+ required:
+ - recent
+ additionalProperties: false
TotpSetupEnvelope:
type: object
properties:
@@ -38312,8 +39042,73 @@ components:
minimum: -9007199254740991
maximum: 9007199254740991
description: Number of OTHER sessions removed (the current one is kept).
+ accessTokensRevoked:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ description: "Tokens revoked: the access tokens of other device logins plus every programmatic token (connector,
+ measurement, document), never the caller's own."
+ connectorsRevoked:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ description: Connected AI-assistant (MCP OAuth) connections revoked.
+ shareLinksRevoked:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ description: Clinician share links revoked; 0 when `keepShareLinks=1` was sent.
+ pendingInvitesRevoked:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ description: "Record-sharing invitations this account offered that nobody had accepted yet, now withdrawn (`revokedBy:
+ GRANTOR`)."
+ grantsKept:
+ type: array
+ items:
+ type: object
+ properties:
+ id:
+ type: string
+ description: The grant id, for DELETE /api/account/grants/{id}.
+ account:
+ type: object
+ properties:
+ id:
+ type: string
+ username:
+ type: string
+ displayName:
+ anyOf:
+ - type: string
+ - type: "null"
+ required:
+ - id
+ - username
+ - displayName
+ additionalProperties: false
+ access:
+ type: string
+ enum:
+ - READ
+ - WRITE
+ - MANAGE
+ required:
+ - id
+ - account
+ - access
+ additionalProperties: false
+ description: Accepted grants this account gave that are still live. Signing out does not end them, because each is a
+ person the owner chose and ending one silently could cut off a carer; they are listed so a client can show
+ who can still read the record and offer to end each with DELETE /api/account/grants/{id}.
required:
- sessionsRevoked
+ - accessTokensRevoked
+ - connectorsRevoked
+ - shareLinksRevoked
+ - pendingInvitesRevoked
+ - grantsKept
additionalProperties: false
SessionRevokeEnvelope:
type: object
@@ -54940,8 +55735,8 @@ components:
description: Trailing-window length in days for the chosen range.
granularity:
type: string
- description: Rollup granularity the read resolved against (`DAY` / `WEEK` / `MONTH` / `YEAR`, or `none` on a coverage
- miss).
+ description: Rollup granularity the read resolved against (`DAY` / `WEEK` / `MONTH` / `YEAR`), `live` for SLEEP_DURATION
+ (nights are reconstructed from the stage rows), or `none` when there is no data.
current:
description: Aggregate over the current window `[now-N, now)`.
$ref: "#/components/schemas/AnalyticsWindowAggregate"
@@ -54968,9 +55763,10 @@ components:
- delta
- deltaPct
additionalProperties: false
- description: Single-metric period-over-period aggregate. Reads the current and previous comparable windows from the WMY
- rollup tier and composes a count-weighted-mean delta. `count/min/max/mean/sum` are linearly composable across
- buckets; SD/slope/r² are intentionally excluded (not composable).
+ description: Single-metric period-over-period aggregate. Reads the current and previous comparable windows from the
+ rollup tier and composes a count-weighted-mean delta, one source per day. Step-like totals compare average daily
+ totals and SLEEP_DURATION compares average nights, not per-reading means. `count/min/max/mean/sum` are linearly
+ composable across buckets; SD/slope/r² are intentionally excluded (not composable).
AnalyticsWindowAggregate:
type: object
properties:
@@ -54978,7 +55774,8 @@ components:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
- description: Reading count composed across buckets.
+ description: Reading count composed across buckets. For step-like totals it is the number of days with a total, for
+ SLEEP_DURATION the number of nights.
min:
anyOf:
- type: number
@@ -54993,7 +55790,8 @@ components:
anyOf:
- type: number
- type: "null"
- description: Count-weighted mean across buckets; null when empty.
+ description: Count-weighted mean across buckets; null when empty. For step-like totals (steps, energy, distance,
+ flights) it is the average daily total, for SLEEP_DURATION the average time asleep per night.
sum:
anyOf:
- type: number
@@ -72376,21 +73174,26 @@ components:
type: string
description: "Stable reason: `backup_not_found`, `backup_changed`, `backup.payload.undecryptable`, `schema_invalid`,
`incompatible_schema_version`, `owner_mismatch`, `owner_not_found`, `backup.section.missing`,
- `document_ciphertext_missing`, `time_budget`, `transaction_failed`, `interrupted`, `not_started`,
- `enqueue_failed`, `failed_after_commit`, `unexpected`. Every code but `failed_after_commit` means the
- account was not changed."
+ `document_ciphertext_missing`, `backup.key.missing`, `backup.foreign_reference`, `time_budget`,
+ `transaction_failed`, `interrupted`, `not_started`, `enqueue_failed`, `failed_after_commit`, `unexpected`.
+ Every code but `failed_after_commit` means the account was not changed."
message:
type: string
sections:
type: array
items:
type: string
+ keyIds:
+ type: array
+ items:
+ type: string
required:
- code
- message
additionalProperties: false
description: Why the restore did not happen, in a sentence an operator can act on. `sections` names the missing sections
- for `backup.section.missing`.
+ for `backup.section.missing`; `keyIds` names the encryption key ids the file needs and this server cannot open
+ it with, for `backup.key.missing`.
AdminBackupRestoreJobResponse:
type: object
properties:
@@ -72408,6 +73211,315 @@ components:
- data
- error
additionalProperties: false
+ DocumentSourcesStatusEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/DocumentSourcesStatus"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ DocumentSourcesStatus:
+ type: object
+ properties:
+ available:
+ type: boolean
+ allowedOrigins:
+ type: array
+ items:
+ type: string
+ connections:
+ type: array
+ items:
+ $ref: "#/components/schemas/DocumentSourceConnection"
+ required:
+ - available
+ - allowedOrigins
+ - connections
+ additionalProperties: false
+ DocumentSourceConnection:
+ type: object
+ properties:
+ system:
+ type: string
+ enum:
+ - PAPERLESS
+ - PAPRA
+ baseUrl:
+ type: string
+ organizationId:
+ anyOf:
+ - type: string
+ - type: "null"
+ hasToken:
+ type: boolean
+ const: true
+ lastVerifiedAt:
+ anyOf:
+ - type: string
+ - type: "null"
+ originAllowed:
+ type: boolean
+ required:
+ - system
+ - baseUrl
+ - organizationId
+ - hasToken
+ - lastVerifiedAt
+ - originAllowed
+ additionalProperties: false
+ DocumentSourceConnectionEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/DocumentSourceConnection"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ DocumentSourceDisconnectEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/DocumentSourceDisconnect"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ DocumentSourceDisconnect:
+ type: object
+ properties:
+ disconnected:
+ type: boolean
+ required:
+ - disconnected
+ additionalProperties: false
+ DocumentSourceTestEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/DocumentSourceTest"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ DocumentSourceTest:
+ type: object
+ properties:
+ ok:
+ type: boolean
+ const: true
+ latencyMs:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ lastVerifiedAt:
+ type: string
+ required:
+ - ok
+ - latencyMs
+ - lastVerifiedAt
+ additionalProperties: false
+ DocumentSourceTagsEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/DocumentSourceTags"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ DocumentSourceTags:
+ type: object
+ properties:
+ tags:
+ type: array
+ items:
+ $ref: "#/components/schemas/DocumentSourceTag"
+ required:
+ - tags
+ additionalProperties: false
+ DocumentSourceTag:
+ type: object
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+ required:
+ - id
+ - name
+ additionalProperties: false
+ DocumentSourceSearchEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/DocumentSourceSearch"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ DocumentSourceSearch:
+ type: object
+ properties:
+ results:
+ type: array
+ items:
+ $ref: "#/components/schemas/DocumentSourceResult"
+ page:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ hasMore:
+ type: boolean
+ required:
+ - results
+ - page
+ - hasMore
+ additionalProperties: false
+ DocumentSourceResult:
+ type: object
+ properties:
+ sourceId:
+ type: string
+ title:
+ type: string
+ date:
+ anyOf:
+ - type: string
+ - type: "null"
+ tags:
+ type: array
+ items:
+ type: string
+ sizeBytes:
+ anyOf:
+ - type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ - type: "null"
+ state:
+ type: string
+ enum:
+ - new
+ - imported
+ - deleted
+ documentId:
+ anyOf:
+ - type: string
+ - type: "null"
+ required:
+ - sourceId
+ - title
+ - date
+ - tags
+ - sizeBytes
+ - state
+ - documentId
+ additionalProperties: false
+ DocumentSourceImportHeldEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/DocumentSourceImport"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ DocumentSourceImport:
+ type: object
+ properties:
+ outcome:
+ type: string
+ enum:
+ - imported
+ - duplicate
+ - deleted
+ documentId:
+ anyOf:
+ - type: string
+ - type: "null"
+ linked:
+ type: boolean
+ required:
+ - outcome
+ - documentId
+ - linked
+ additionalProperties: false
+ DocumentSourceImportEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/DocumentSourceImport"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
__schema0:
anyOf:
- type: string
diff --git a/docs/integrations/ai-providers.md b/docs/integrations/ai-providers.md
index e4574e949..a097541e5 100644
--- a/docs/integrations/ai-providers.md
+++ b/docs/integrations/ai-providers.md
@@ -140,16 +140,35 @@ Base URL `http://: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
diff --git a/docs/integrations/document-import.md b/docs/integrations/document-import.md
index 48e7d39c1..d7a0163f1 100644
--- a/docs/integrations/document-import.md
+++ b/docs/integrations/document-import.md
@@ -43,12 +43,13 @@ This needs Paperless-ngx 3.0 or later, the first release with the
- **Use parameters for webhook body:** on. **Send webhook payload as
JSON:** off. **Webhook params:**
- | Name | Value |
- | -------------- | ---------------------------------------------------- |
- | `title` | `{{doc_title}}` |
- | `documentDate` | `{{created_year}}-{{created_month}}-{{created_day}}` |
- | `sourceSystem` | `PAPERLESS` |
- | `sourceId` | `{{doc_id}}` |
+ | Name | Value |
+ | ---------------- | -------------------------------------------------------- |
+ | `title` | `{{doc_title}}` |
+ | `documentDate` | `{{created_year}}-{{created_month}}-{{created_day}}` |
+ | `sourceSystem` | `PAPERLESS` |
+ | `sourceId` | `{{doc_id}}` |
+ | `sourceInstance` | `https://paperless.example.com` (your Paperless address) |
- **Include document:** on. Paperless then sends the original file along
with the parameters, which is exactly what HealthLog expects.
@@ -151,8 +152,15 @@ What it does:
quick. Documents you deleted in HealthLog stay deleted, also after they
are removed for good. The same goes for a file you had already uploaded
by hand: the import recognises it by its content (sending it once to
- compare), and if you delete it later, the import leaves it deleted. An
- interrupted run simply picks up where it stopped.
+ compare), and if you delete it later, the import leaves it deleted. Since
+ v1.39.3 a deleted document is also recognised by its content while it is
+ in the 30-day undo window: the same file arriving from another system or
+ under a new id is answered "deleted" and not stored again. After the purge
+ HealthLog keeps the ids it saw, not the content. An interrupted run simply
+ picks up where it stopped.
+- Once HealthLog has refused one file as too large, the script skips any
+ further document the source already lists as larger (Papra lists sizes)
+ without downloading it.
- When HealthLog asks it to slow down, it waits as long as HealthLog says
and carries on. By default a document token may send 120 uploads an hour,
so an archive of a thousand documents takes a night. Every upload that
@@ -231,6 +239,15 @@ per account: if you push documents from two other systems, make sure their
ids cannot collide, for example by prefixing them (`nextcloud-123`,
`scanner-123`).
+Since v1.39.3 a key can also name its instance: `sourceInstance`, the
+address of the Paperless-ngx or Papra it came from (only its origin,
+`scheme://host[:port]`, counts). With it, document 123 on one Paperless and
+document 123 on another are two documents. The import script sends it
+automatically; add it to a Paperless workflow as shown above. A key sent or
+stored without an instance (everything imported with v1.39.2, or a workflow
+without the parameter) matches that id on any instance of its system, so
+nothing imported earlier is stored twice or brought back after a deletion.
+
The same file sent under a different id is stored once, and HealthLog
remembers the extra id for it, up to 20 extra ids per document. A 21st is
refused with `409` (`documents.inbound.sourceAliasLimit`) and nothing is
diff --git a/docs/migration/v1.3-to-v1.4.md b/docs/migration/v1.3-to-v1.4.md
index 5b29c4d24..ff89c40d1 100644
--- a/docs/migration/v1.3-to-v1.4.md
+++ b/docs/migration/v1.3-to-v1.4.md
@@ -70,10 +70,11 @@ worker`. Default `all` keeps the single-container behaviour.
- **`ENCRYPTION_KEY`** _(unchanged)_ — still honoured as a fallback
when `ENCRYPTION_KEYS` is empty. No action needed for existing
installs.
-- Rotation: `pnpm dlx tsx scripts/rotate-encryption-key.ts `
- re-encrypts every ciphertext under the new key without downtime.
+- Rotation: **Admin → Encryption → Rotate now** re-encrypts every
+ ciphertext under the new key without downtime; the script
+ `scripts/rotate-encryption-key.ts` does the same from a source checkout.
Walk-through in `docs/ops/encryption-key-rotation.md` (the current
- playbook; the production image strips `tsx`, hence `pnpm dlx`).
+ playbook).
### Off-host backup target _(optional)_
diff --git a/docs/ops/backup-restore.md b/docs/ops/backup-restore.md
index 4a975bece..4d0a70f0e 100644
--- a/docs/ops/backup-restore.md
+++ b/docs/ops/backup-restore.md
@@ -16,13 +16,23 @@ container (queue `data-backup-offhost`). Object key layout:
```
magic = "HLBK" (4 bytes, ASCII)
-version = 0x03 (1 byte)
+version = 0x04 (1 byte)
+keyIdLen, keyId (1 byte + 12 ASCII hex: the first 12 hex digits of SHA-256(key))
iv = 12 random bytes (AES-GCM nonce)
-ciphertext = N bytes (AES-256-GCM over gzip(JSON dump), key = BACKUP_ENCRYPTION_KEY)
+ciphertext = N bytes (AES-256-GCM over gzip(JSON dump), key = BACKUP_ENCRYPTION_KEY,
+ associated data = "healthlog/offhost-backup/v4|")
authTag = 16 bytes (AES-GCM tag, trailing)
```
-Three versions exist in the wild and all three restore. `0x01` encrypted the
+Version `0x04` (from v1.39.3) adds two things. The key id says which off-host
+key an object needs, so a reader holding a retired key as well finds the right
+one. The associated data is the object's own key in the bucket,
+`YYYY-MM-DD/user-.json.enc`: an object copied to another account's name or
+another date does not decrypt, where before it opened as if it belonged there.
+The restore script passes the key it downloaded, so nothing changes for an
+operator.
+
+Four versions exist in the wild and all four restore. `0x01` encrypted the
JSON directly and `0x02` gzipped it first; both carry the tag in FRONT of the
ciphertext, which is what made them impossible to write a piece at a time —
GCM only produces the tag once the last block is in, so a leading tag means the
@@ -33,32 +43,59 @@ the restore script needs no flag to tell them apart.
## Required env vars
-| Var | Required | Notes |
-| ----------------------- | -------- | -------------------------------------------------------------------- |
-| `BACKUP_ENCRYPTION_KEY` | yes | 64 hex chars or 32-byte base64. **Different from `ENCRYPTION_KEY`.** |
-| `BACKUP_S3_ENDPOINT` | yes | e.g. `https://.r2.cloudflarestorage.com` |
-| `BACKUP_S3_BUCKET` | yes | |
-| `BACKUP_S3_ACCESS_KEY` | yes | |
-| `BACKUP_S3_SECRET_KEY` | yes | |
-| `BACKUP_S3_REGION` | no | defaults to `auto` (Cloudflare R2) |
-| `BACKUP_RETENTION_DAYS` | no | not read by the app; the number for your bucket's lifecycle rule |
+| Var | Required | Notes |
+| --------------------------------- | -------- | ----------------------------------------------------------------------------------- |
+| `BACKUP_ENCRYPTION_KEY` | yes | 64 hex chars or 32-byte base64. **Different from `ENCRYPTION_KEY`.** |
+| `BACKUP_S3_ENDPOINT` | yes | e.g. `https://.r2.cloudflarestorage.com` |
+| `BACKUP_S3_BUCKET` | yes | |
+| `BACKUP_S3_ACCESS_KEY` | yes | |
+| `BACKUP_S3_SECRET_KEY` | yes | |
+| `BACKUP_S3_REGION` | no | defaults to `auto` (Cloudflare R2) |
+| `BACKUP_RETENTION_DAYS` | no | not read by the app; the number for your bucket's lifecycle rule |
+| `BACKUP_ENCRYPTION_PREVIOUS_KEYS` | no | retired off-host keys, comma-separated, read only (see "Rotating the off-host key") |
## Bucket permissions
-The worker needs `PutObject`, `GetObject` and `AbortMultipartUpload`. It never
-calls `DeleteObject` on a backup key, so a compromised worker cannot wipe the
-history; `AbortMultipartUpload` only reaches an upload that same worker started
-and is what clears the parts of a run that failed halfway. Without it, a failed
-upload leaves parts that are billed and do not show in a bucket listing. On
-Cloudflare R2 the **Object Read & Write** token already covers all three.
+The worker needs `PutObject`, `GetObject`, `ListBucket`, `DeleteObject` and
+`AbortMultipartUpload`. On Cloudflare R2 the **Object Read & Write** token
+covers all of them.
-## Bucket lifecycle (recommended)
+`DeleteObject` is needed because the app deletes objects in two cases, and
+only those:
-The worker never deletes a backup object: its grant covers PutObject,
-GetObject and AbortMultipartUpload only, so a compromised worker cannot
-wipe the history, and nothing in the app reads `BACKUP_RETENTION_DAYS`.
-Expiry is the storage provider's lifecycle rule, set to match the number
-you wrote there:
+- **An account is deleted, or its data is wiped.** Deleting an account,
+ deleting a managed profile and "Delete all data" each write a deletion
+ request in the same transaction as the deletion. The `offhost-backup-purge` job lists the bucket, deletes every
+ `YYYY-MM-DD/user-.json.enc` of that account and removes the request once
+ nothing of the account is left. It runs right after the deletion and again
+ every night at 03:40. A copy the nightly upload was writing while the account
+ was deleted is removed by the upload itself.
+- **The connection test** deletes its own `_healthcheck/.bin`.
+
+The admin wipe of every account deletes nothing in the bucket. It is you
+clearing your own host, the bucket is yours, and its copies are the way back
+from a wipe pressed by mistake. If you are handing the host on, empty the
+bucket yourself.
+
+Earlier versions of this page said the worker never deletes an object. That
+was the design until v1.39.3, and it meant a deleted account's copies stayed
+in the bucket for as long as the lifecycle rule kept them, or forever without
+one. If the bucket refuses the delete (a credential without `DeleteObject`),
+the request stays, the job retries it every night, and **Admin → Backups →
+Off-host** shows how many deletions are outstanding and the bucket's answer.
+
+`AbortMultipartUpload` only reaches an upload the same worker started and is
+what clears the parts of a run that failed halfway. Without it, a failed
+upload leaves parts that are billed and do not show in a bucket listing.
+
+## Bucket lifecycle (required for retention)
+
+The app does not expire objects by age, and nothing in the app reads
+`BACKUP_RETENTION_DAYS`. Expiry is the storage provider's lifecycle rule, set
+to match the number you wrote there. Without one, every nightly copy of every
+account is kept forever. **Admin → Backups → Off-host** reads the bucket's
+lifecycle configuration and says so when there is none, or when the credential
+may not read it (grant `GetLifecycleConfiguration` to let it check):
```
Filter: "" (all objects)
@@ -343,6 +380,28 @@ in seconds having backed up nothing is that defect and not your record.
### Key rotation
+Rotation reaches a stored backup's envelope and nothing inside it. The
+content of a disaster-recovery backup is the database's ciphertext, carried
+as it is stored, and a restore writes it back verbatim: a note, a document or
+a coach message inside a copy taken before a rotation still needs the key the
+rotation retired. The same holds for every copy in the off-host bucket and for
+every file an operator has downloaded. So the rule is: **keep a retired
+`ENCRYPTION_KEYS` entry until no backup you may still restore needs it.**
+**Admin → Encryption → Keys the backups still need** lists, per key id, the
+stored copies that need it with the oldest date, and when the last off-host
+copy needing it expires under the bucket's lifecycle rule. Copies written
+before v1.39.3 did not record their keys and are listed as such.
+
+Every restore, the restore preview, the admin upload and the monthly drill
+check this before anything is changed: a file whose content needs a key this
+server does not have (or an id whose key material differs) is refused with the
+key id named, and the account is left as it was.
+
+Re-encrypting the content of stored backups during rotation was considered and
+not done: it would mean decrypting, parsing, re-encrypting and re-writing every
+copy in full, and it still could not reach the off-host copies or downloaded
+files, so the rule above would be needed regardless.
+
`scripts/rotate-encryption-key.ts` and the rotation in the admin console
re-seal every piece under the active key, in small batches, without reading
the backup inside. The link between a piece, its copy and its position is
@@ -367,6 +426,17 @@ read back. Restoring Monday's copy on Wednesday therefore costs the account
everything it recorded on Tuesday. If the current state is worth keeping, take
a fresh snapshot from the backups console before restoring the old one.
+## Rotating the off-host key
+
+`BACKUP_ENCRYPTION_KEY` is the key new objects are written under. To rotate
+it, set the new key there and move the old one into
+`BACKUP_ENCRYPTION_PREVIOUS_KEYS` (comma-separated if there are several), then
+restart. New objects are written under the new key; older objects stay
+readable because every version-4 object names its key and older objects are
+tried against each key in turn. Remove the old key once the lifecycle rule has
+retired every object written under it, that is `BACKUP_RETENTION_DAYS` after
+the restart.
+
## Monthly restore drill (automatic)
Since v1.16.4 a pg-boss job (`data-restore-drill`, cron `11 4 1 * *` —
@@ -386,8 +456,13 @@ Outcomes:
- **Failure** — empty bucket, fetch error, decryption failure (wrong or
rotated key), malformed JSON: pages the same way. A decryption
failure right after a `BACKUP_ENCRYPTION_KEY` change means the new
- key cannot read the existing objects — re-encrypt or accept that
- pre-rotation backups are only readable with the retired key.
+ key cannot read the existing objects: put the old key into
+ `BACKUP_ENCRYPTION_PREVIOUS_KEYS` (see "Rotating the off-host key").
+- **Content under a missing application key** — the object opens, but its
+ content was written under an `ENCRYPTION_KEYS` entry this server no longer
+ has, or under an id whose key material changed. The drill decrypts one value
+ per key id it finds, and pages naming the key. A restore of that object
+ would write back rows nobody can open; put the key back before restoring.
- **Not configured** — deployments without the `BACKUP_S3_*` vars skip
silently (wide-event warning only).
diff --git a/docs/ops/encryption-key-rotation.md b/docs/ops/encryption-key-rotation.md
index 5a62c55e0..0396e2897 100644
--- a/docs/ops/encryption-key-rotation.md
+++ b/docs/ops/encryption-key-rotation.md
@@ -41,15 +41,33 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
Restart the app. New writes are now encrypted under `v2`; existing
`v1`-keyed and legacy bare rows still decrypt because the `v1` entry is
retained.
-3. **Run the rotation script** to re-encrypt every registered encrypted column
- under the new active key:
+3. **Re-encrypt the stored data under the new key.** Open **Admin →
+ Encryption** and press **Rotate now**. The button needs an admin account
+ with a second factor, confirmed recently; it queues one background run
+ (`POST /api/admin/encryption/rotate`) on the app's own worker, so it
+ works on the published image with nothing else installed. The same page
+ shows the per-column counts and the result of the last run.
+
+ Without a second factor on any admin account, run the rotation script
+ instead. It imports the application code and the generated database
+ client, so it runs from a source checkout of the same release, not
+ inside the production container (the image has no package manager and
+ does not contain `scripts/rotate-encryption-key.ts`). It needs the same
+ `ENCRYPTION_KEYS` and `ENCRYPTION_ACTIVE_KEY_ID` as the app and a
+ `DATABASE_URL` that reaches the database:
```
+ git checkout vX.Y.Z # the release the instance runs
+ pnpm install && pnpm db:generate
pnpm dlx tsx scripts/rotate-encryption-key.ts v2
```
- The script is idempotent — running it again is a no-op for rows already
- prefixed with `v2.`. It rotates every column in the canonical registry
+ The bundled `db` service publishes no port. On a stock compose stack,
+ either publish `5432` for the duration, or run the checkout in a
+ throwaway `node:22` container attached to the compose network.
+
+ Both paths are idempotent: running again is a no-op for rows already
+ prefixed with `v2.`. Both rotate every column in the canonical registry
(`src/lib/crypto/encrypted-columns.ts`), which covers the `*Encrypted`
columns plus the ones whose names say nothing about their contents:
`IntegrationStatus.lastError`, `CoachMessage.encryptedContent`,
@@ -58,7 +76,8 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
`IdempotencyKey.responseBody`, and `DataBackup.data` — the whole-account
backup blob.
- Read three things off the output before going further:
+ When you used the script, read three things off its output before going
+ further:
- the per-column line, `scanned` / `rotated` / `errors` / `dropped`.
`scanned` counts the rows that hold ciphertext in that column, not every
@@ -78,10 +97,10 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
4. **Confirm nothing is left on the old key, THEN drop it.** This is the step
that destroys data if it is taken on a bad signal, so check the corpus
- rather than the absence of complaints. Either re-run the script — a clean
- second pass reports `rotated=0`, `errors=0` and a full `Columns walked`
- line — or open **Admin → Encryption** and confirm the status view reports
- zero rows outside the active key. Both read the same registry the rotation
+ rather than the absence of complaints. Open **Admin → Encryption** and
+ confirm the status view reports zero rows outside the active key, or
+ re-run the script: a clean second pass reports `rotated=0`, `errors=0` and
+ a full `Columns walked` line. Both read the same registry the rotation
walks.
A zero only means "nothing left" when the run also says it walked every
@@ -99,14 +118,34 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
Restart. The legacy single-key fallback is now disconnected; only `v2`
exists.
-> **Backups are covered, and were not always.** `DataBackup.data` holds every
-> weekly disaster-recovery snapshot and every uploaded pack, encrypted like
-> everything else but under a column called `data`. It was outside the
-> registry until v1.38.6, so a rotation before that release reported zero
-> rows remaining without ever reading a backup. If you rotated on an older
-> release and dropped the previous key, the stored backups are encrypted
-> under the key you removed: put that key back into `ENCRYPTION_KEYS` and
-> re-run the rotation on this release before removing it again.
+> **The rows in the database are covered. The content of a backup is not.**
+> Rotation re-encrypts every registered column, including `DataBackup.data`
+> and the pieces in `DataBackupChunk`, so every stored copy opens under the
+> new key. What it cannot change is what a disaster-recovery backup carries
+> inside: the database's ciphertext as it was stored when the copy was taken,
+> which a restore writes back verbatim. A copy taken before the rotation still
+> needs the old key for its notes, documents and coach history, and so does
+> every copy in the off-host bucket and every backup file you downloaded.
+>
+> So before step 4, also check **Admin → Encryption → Keys the backups still
+> need**. It lists, per key id, the stored copies that need it with the oldest
+> date, and when the last off-host copy needing it expires under the bucket's
+> lifecycle rule. Keep the old key in `ENCRYPTION_KEYS` until it is no longer
+> listed there (the weekly copy replaces itself within a week; delete old
+> uploaded copies you no longer want, and wait out the off-host retention), or
+> accept that those copies cannot be restored. Copies written before v1.39.3
+> did not record their keys and are listed as such: treat them as needing
+> every key that existed when they were written.
+>
+> Dropping the key early no longer fails silently: a restore, a restore
+> preview or an upload of a copy that needs a missing key is refused with the
+> key id named, and nothing is changed. Putting the key back makes the copy
+> restorable again.
+>
+> If you rotated on a release before v1.38.6 and dropped the previous key,
+> the stored backups themselves are encrypted under the key you removed: put
+> that key back into `ENCRYPTION_KEYS` and re-run the rotation on this release
+> before removing it again.
## Adding a third key (v2 → v3)
@@ -117,13 +156,14 @@ remaining.
```
ENCRYPTION_KEYS='{"v2":"","v3":""}'
ENCRYPTION_ACTIVE_KEY_ID="v3"
-pnpm dlx tsx scripts/rotate-encryption-key.ts v3
+# restart, then Admin → Encryption → Rotate now
+# (or: pnpm dlx tsx scripts/rotate-encryption-key.ts v3 from a checkout)
ENCRYPTION_KEYS='{"v3":""}'
```
## Rollback
-> **Important.** Once the rotation script has run, ciphertexts in the
+> **Important.** Once a rotation has run, ciphertexts in the
> database start with `v2.` (or whatever the active id is). The pre-PR
> v1.3.x image cannot read that prefix — it expects bare base64 — and
> calling `decrypt()` on those rows will throw.
@@ -133,7 +173,7 @@ If you need to revert to the pre-rotation image:
- Either keep the new image. The new code reads both formats, so most
rollback scenarios don't need to undo rotation.
- Or, if you must run the old code, restore a database backup taken
- _before_ the rotation script ran. There is no script to convert
+ _before_ the rotation ran. There is no script to convert
`v2.`-prefixed rows back to legacy format — by design, rotation is a
forward-only operation.
@@ -145,10 +185,10 @@ at least 24 hours.
- `Encryption key id 'v1' is not configured` — the database still contains
`v1.`-prefixed rows but `v1` was removed from `ENCRYPTION_KEYS`. Re-add
- the key, run the rotation script, then remove again.
+ the key, run the rotation again, then remove it.
- `Found a legacy-format ciphertext but no v1 key is configured` — same
cause for legacy bare-base64 rows. Restore `ENCRYPTION_KEY` (or add a
- `v1` entry to `ENCRYPTION_KEYS`) and run the rotation script before
+ `v1` entry to `ENCRYPTION_KEYS`) and run the rotation again before
removing it.
- `Refusing to rotate: argv key id ... does not match the currently active
id ...` — pass the same id you set in `ENCRYPTION_ACTIVE_KEY_ID`. The
diff --git a/docs/ops/password-reset.md b/docs/ops/password-reset.md
index 4ad1eace2..64ca06846 100644
--- a/docs/ops/password-reset.md
+++ b/docs/ops/password-reset.md
@@ -18,10 +18,12 @@ instance).
## Run it
-The reset CLI ships in the image and runs under plain `node` against the built
-runtime (the standalone image strips `tsx`, so this is a `.mjs` that uses the
-in-image `pg` and `@node-rs/argon2` directly and reuses the app's exact Argon2id
-parameters):
+The reset CLI ships in the image from v1.39.3 on. It runs under plain `node`
+against the built runtime: a `.mjs` file that uses the in-image `pg` and
+`@node-rs/argon2` directly and reuses the app's exact Argon2id parameters.
+Images up to and including v1.39.2 did not contain the script, so on those
+the command below fails with "Cannot find module"; use the source-checkout
+form at the end of this page instead.
```sh
docker compose exec app node scripts/reset-password.mjs
@@ -32,7 +34,7 @@ confirmation that names only the user — never the password:
```
New password (input hidden):
-reset-password: password updated for "alice"
+reset-password: password updated for "alice"; revoked 2 sessions, 0 API tokens, 1 refresh tokens, 0 trusted devices, and 0 step-up elevations
```
You can also pass the password as a second argument for non-interactive use,
@@ -57,8 +59,10 @@ docker compose exec app node scripts/reset-password.mjs alice 'a-strong-passphra
## Source checkout
-From a source checkout (not the production image) the same script runs under
-Node directly:
+From a source checkout of the same release (after `pnpm install`) the same
+script runs under Node directly. It needs a `DATABASE_URL` that reaches the
+database; the bundled `db` service publishes no port, so on a stock compose
+stack run it inside the compose network or publish the port temporarily:
```sh
DATABASE_URL='postgres://…' node scripts/reset-password.mjs
diff --git a/docs/self-hosting/document-sources.md b/docs/self-hosting/document-sources.md
new file mode 100644
index 000000000..4a30cdd2c
--- /dev/null
+++ b/docs/self-hosting/document-sources.md
@@ -0,0 +1,101 @@
+# Document picker — Paperless-ngx and Papra
+
+People who keep their paperwork in Paperless-ngx or Papra can search it from
+HealthLog and import the documents they pick: under "Link a document" on a
+visit, a vaccination or a condition, and from the Documents page. They search
+by document name, narrow by tag and date, tick what they want, and HealthLog
+fetches those files and stores its own encrypted copy, exactly as if they had
+been uploaded.
+
+The picker is off until the operator names the instances HealthLog may
+contact.
+
+## Turning it on
+
+List the origins in the server environment:
+
+```env
+DOCUMENT_SOURCE_ORIGINS="https://paperless.example.com,http://papra.lan:1221"
+```
+
+The variable is on the `docker-compose.yml` whitelist, so setting it in `.env`
+is enough; restart the app container afterwards. Unset or empty, the settings
+card and every import button stay hidden and the routes answer 404.
+
+Each entry is one `scheme://host[:port]`, the grammar
+`NOTIFICATION_PRIVATE_ORIGINS` uses (see [notifications](notifications.md)):
+
+- only `http` and `https`; scheme, host and port must match what a person
+ enters (`https://paperless.example.com:8443` and
+ `https://paperless.example.com` are two different entries);
+- no path, query, credentials, wildcard or CIDR; a person may still enter a
+ base address with a path (`https://example.com/paperless`) as long as its
+ origin is listed;
+- the unspecified address, link-local and the cloud-metadata range can never
+ be listed, and a listed name that resolves there is refused when HealthLog
+ connects;
+- a malformed entry is logged once at startup, reduced to scheme and host, and
+ grants nothing; the valid entries beside it keep working.
+
+Unlike the notification list, this list is the whole decision: an origin
+that is not listed is refused even when it is a public address. Removing an
+entry cuts off the connections that point at it on their next request; people
+can still delete their stored connection.
+
+## What each person sets up
+
+Settings → Integrations → Document archives:
+
+- **Paperless-ngx:** the base address and an API token (Paperless-ngx: My
+ Profile → API token). Paperless-ngx 2.16 or later (API version 9).
+- **Papra:** the base address, the organization id (from the Papra address
+ bar, `org_` followed by 24 letters and digits) and an API key with
+ `documents:read` and `tags:read`. The connection test reads the tags too,
+ so a key without `tags:read` is refused at save.
+
+HealthLog tests the connection before saving it. A saved token is only ever
+sent to the address it was saved for: changing the address to another origin
+asks for the token again. The token is stored
+encrypted (AES-256-GCM, rotated with the other keys by
+`scripts/rotate-encryption-key.ts`) and is never shown again, not even in
+part.
+
+## What leaves the server, and when
+
+Only requests the person starts: a search, the tag list for the filter, a
+connection test, and one download per picked document. There is no background
+sync and no polling. Every request goes to the listed origin only, with
+redirects refused, a 10-second limit for searches and 60 seconds for a file,
+and a size cap on every answer (1 MiB for a search page, the vault's per-file
+limit for a document).
+
+## Limits and rules
+
+- Name search on Paperless-ngx uses its own title filter. On a Paperless-ngx
+ that stores its data in SQLite, that filter ignores upper and lower case
+ only for plain ASCII letters: searching `röntgen` does not find `Röntgen`.
+ That is Paperless-ngx's behaviour, not HealthLog's; PostgreSQL-backed
+ Paperless does not have it.
+- A document deleted in HealthLog is recognised by its content during the
+ 30-day undo window, so the same file from another archive is not stored
+ again; after the purge only its ids are remembered.
+
+- Imports count against the person's normal upload allowance (60 an hour) and
+ storage quota.
+- A document that is already in HealthLog is not stored twice, and one the
+ person deleted in HealthLog is not brought back by picking it again. Each
+ import is keyed by system, instance (the archive's origin) and document id,
+ so the same id on two Paperless-ngx instances is two documents; a key
+ imported before v1.39.3 carries no instance and matches any instance. These
+ are the same rules the document token and `scripts/import-documents.mjs`
+ follow.
+- A picked document follows the person's own automatic AI reading setting.
+- Only the owner of a record can connect an archive or import from it. People
+ with shared access to a record, whatever their level, do not see the picker.
+- Connections are not part of backups. After a restore on another server,
+ each person reconnects (one form); the imported documents themselves are in
+ the backup with the rest of the vault.
+- Delete all data and account deletion remove the connection and its token.
+- A stored connection stays visible under Settings, read-only, when this
+ variable is unset or the person switched Documents off, so it can still be
+ removed.
diff --git a/docs/self-hosting/geolite2.md b/docs/self-hosting/geolite2.md
index 98a1d9b73..8dc02ccc2 100644
--- a/docs/self-hosting/geolite2.md
+++ b/docs/self-hosting/geolite2.md
@@ -127,6 +127,9 @@ addresses they could not place. To remove that remainder too:
IP_GEO_LOOKUP_DISABLED="1"
```
+`true` and `yes` work as well. Any other value, including an empty one, leaves
+the lookup on.
+
The resolver then uses only the local databases and returns nothing when
they miss, rather than asking anyone. An instance handling other people's
health data is a reasonable place to make that trade.
diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md
index 0d7c8871d..af157eb85 100644
--- a/docs/self-hosting/getting-started.md
+++ b/docs/self-hosting/getting-started.md
@@ -21,6 +21,13 @@ already installed, longer if a TLS-fronted public hostname is in scope
weather and daylight; it stays off until you
enable it and set a home location, and every outbound call goes
through the SSRF-guarded egress wrapper.
+ Three requests happen by default without anything connected: the
+ HaveIBeenPwned range query when a password is set (only the first five
+ characters of the password's SHA-1 are sent), a daily check of the
+ GitHub releases API for the update badge, and the online IP
+ geolocation lookup for login events. `PASSWORD_BREACH_CHECK_DISABLED`,
+ `UPDATE_CHECK_DISABLED` and `IP_GEO_LOOKUP_DISABLED` turn each off; see
+ `.env.production.example`.
- **Optional but recommended:** a reverse proxy that terminates TLS
and forwards to `http://localhost:3000` (Caddy, Traefik, Nginx
Proxy Manager, Coolify, or bare Nginx — see `reverse-proxy.md`).
@@ -33,9 +40,11 @@ cd HealthLog
cp .env.example .env
```
-`.env.example` is the canonical reference for every env var the app
-reads. Anything you do not uncomment falls back to a sane default or
-disables the optional subsystem.
+`.env.example` is a short starter with the variables most self-hosts
+touch. The complete reference, with every variable the app reads and what
+it does, is `.env.production.example`; CI checks that file against
+`scripts/env-manifest.json`. Anything you do not set falls back to a sane
+default or leaves the optional subsystem off.
## 2. Generate the three required secrets
diff --git a/docs/self-hosting/mcp.md b/docs/self-hosting/mcp.md
index 1753438a1..822c85b38 100644
--- a/docs/self-hosting/mcp.md
+++ b/docs/self-hosting/mcp.md
@@ -113,10 +113,12 @@ access token (60 minutes, with a refresh token) behind the scenes.
2. In ChatGPT's **default** (non-Developer) mode, the only tools it will
call are **`search`** and **`fetch`** — these are HealthLog's two-tool
retrieval façade over the same server-authoritative reads. So in
- default ChatGPT you ask in natural language ("what's my recent LDL?")
- and it searches your record and fetches the matching item with a
- citation deep-link back into the app. Developer mode exposes the full
- tool catalogue.
+ default ChatGPT you ask in natural language ("what's my recent LDL?",
+ "when was my left knee operation?", "what does the discharge letter
+ say?") and it searches your record and fetches the matching item with a
+ citation deep-link back into the app. Search covers metrics,
+ medications, lab values, visits and procedures, conditions, documents
+ and vaccinations. Developer mode exposes the full tool catalogue.
### Claude Desktop (stdio)
diff --git a/docs/self-hosting/reverse-proxy.md b/docs/self-hosting/reverse-proxy.md
index 7f18095ec..5ce2db75f 100644
--- a/docs/self-hosting/reverse-proxy.md
+++ b/docs/self-hosting/reverse-proxy.md
@@ -40,17 +40,29 @@ chain from the **right**, counting back `TRUST_PROXY_HOPS` entries.
This guards against a client rotating `X-Forwarded-For` per request
to defeat IP-based rate limits.
-| Topology | `TRUST_PROXY_HOPS` |
-| --------------------------------------------------------- | -------------------------- |
-| App is internet-facing with no proxy | `0` (XFF ignored entirely) |
-| Single proxy in front (Caddy / Traefik / Nginx / Coolify) | `1` (default) |
-| Cloudflare → your proxy → app | `2` |
-| Cloudflare → Coolify-Tunnel → Coolify → app | `3` |
+| Topology | `TRUST_PROXY_HOPS` |
+| --------------------------------------------------------- | ------------------------------------- |
+| App is internet-facing with no proxy | `0` (no forwarding header is trusted) |
+| Single proxy in front (Caddy / Traefik / Nginx / Coolify) | `1` (default) |
+| Cloudflare → your proxy → app | `2` |
+| Cloudflare → Coolify-Tunnel → Coolify → app | `3` |
A misconfigured count logs a one-shot warning to stderr and collapses
every anonymous caller into one shared rate-limit bucket. Match the
-value to the actual hop count or set it to `0` and let `x-real-ip`
-drive the IP resolution.
+value to the actual hop count.
+
+`X-Real-IP` gets the same trust as `X-Forwarded-For`. It is read only
+when `TRUST_PROXY_HOPS` is exactly `1` and the request carries no
+`X-Forwarded-For`, which covers a single proxy that sets only
+`X-Real-IP`. With `0` neither header is believed, because without a proxy
+in front both come from the caller. With `2` or more, the hop count
+describes an `X-Forwarded-For` chain, so have your proxies send one.
+
+If requests arrive with a forwarding header the setting does not let
+HealthLog read, the log shows one warning naming the header and the
+setting. Every anonymous caller then shares one rate-limit bucket, so one
+person mistyping a password can hold sign-in back for everyone. The usual
+cause is `0` behind a proxy that sends only `X-Real-IP`: set `1`.
## Caddy
diff --git a/docs/self-hosting/sso.md b/docs/self-hosting/sso.md
index deb826f02..aedefa419 100644
--- a/docs/self-hosting/sso.md
+++ b/docs/self-hosting/sso.md
@@ -81,11 +81,19 @@ Client registration essentials only — the IdP's own docs cover the rest.
## Security model
- **Identity is pinned, not guessed.** The first OIDC sign-in either
- provisions a fresh account or links to an existing account matched by
- **verified** email (`email_verified: true` — an IdP that does not
- assert verification is rejected). Either way the account is stamped
- with the provider's `(issuer, sub)` pair, and every later login
- matches on that pair alone. An email change at the IdP updates the
+ provisions a fresh account or links to an existing account with the
+ same email, and only when the IdP asserts `email_verified: true` (an
+ IdP that does not assert verification is rejected). An existing
+ account is never linked on the email alone: HealthLog never verified
+ that address, so anyone could have registered it or typed it into a
+ profile. The browser is sent to the sign-in page instead, and the link
+ is made when the person signs in to that account once with its own
+ password (and second factor) or passkey, within ten minutes. Under
+ `OIDC_ONLY` that password sign-in is allowed for exactly that account.
+ The iOS app gets `oidc_link_required` and the person confirms once on
+ the web. Either way the account is stamped with the provider's
+ `(issuer, sub)` pair, and every later login matches on that pair
+ alone. An email change at the IdP updates the
displayed email; it can never re-point the login at a different
account. An email that matches an account already pinned to a
_different_ identity is rejected and audited.
@@ -141,12 +149,13 @@ Consequences to accept before turning it on:
## Troubleshooting
-| Symptom | Cause / fix |
-| --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Login page shows "Single sign-on failed", log says `discovery issuer mismatch` | `OIDC_ISSUER_URL` must equal the `issuer` inside the discovery document. A single trailing slash either way is tolerated; anything else (scheme, host, port, path, `//`) is a hard reject. |
-| `discovery request failed: 3xx` | Discovery redirects are not followed (egress policy pins `redirect: "manual"`). Point `OIDC_ISSUER_URL` at the final URL — e.g. include the realm/application path, match `http` vs `https`, add/remove `www`. |
-| IdP shows its own "invalid redirect URI" page | The mismatch happens **at the IdP**, before HealthLog is involved. Compare the registered URI against `${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback` byte for byte — scheme and port included. |
-| Sign-in loops back with "failed" and the log shows an `exp`/`nbf` claim error | Clock skew beyond the 60 s tolerance. Fix NTP on the IdP or app box. |
-| "Your identity provider did not mark your email address as verified" | The IdP omits `email_verified` or sends `false`. Mark the address verified at the IdP (Keycloak: user → Email verified; Authentik: verify the email stage) — HealthLog will not link or provision without it. |
-| "That email address belongs to an account that is already linked to a different SSO identity" | Deliberate: identities never re-bind silently. See "Linking is once" above. |
-| SSO button missing | One of the three provider vars is empty or not whitelisted in compose. `pnpm check-env` shows which. |
+| Symptom | Cause / fix |
+| --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Login page shows "Single sign-on failed", log says `discovery issuer mismatch` | `OIDC_ISSUER_URL` must equal the `issuer` inside the discovery document. A single trailing slash either way is tolerated; anything else (scheme, host, port, path, `//`) is a hard reject. |
+| `discovery request failed: 3xx` | Discovery redirects are not followed (egress policy pins `redirect: "manual"`). Point `OIDC_ISSUER_URL` at the final URL — e.g. include the realm/application path, match `http` vs `https`, add/remove `www`. |
+| IdP shows its own "invalid redirect URI" page | The mismatch happens **at the IdP**, before HealthLog is involved. Compare the registered URI against `${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback` byte for byte — scheme and port included. |
+| Sign-in loops back with "failed" and the log shows an `exp`/`nbf` claim error | Clock skew beyond the 60 s tolerance. Fix NTP on the IdP or app box. |
+| "Your identity provider did not mark your email address as verified" | The IdP omits `email_verified` or sends `false`. Mark the address verified at the IdP (Keycloak: user → Email verified; Authentik: verify the email stage) — HealthLog will not link or provision without it. |
+| "An account with this email address already exists here. Sign in to it once …" | The IdP email matches a local account that is not linked yet. Sign in to that account with its password or passkey within ten minutes and the link is made. If nobody knows that account's password, an operator decides which account the identity belongs to. |
+| "That email address belongs to an account that is already linked to a different SSO identity" | Deliberate: identities never re-bind silently. See "Linking is once" above. |
+| SSO button missing | One of the three provider vars is empty or not whitelisted in compose. `pnpm check-env` shows which. |
diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts
index 7f80a9c0f..b809d4784 100644
--- a/e2e/a11y.spec.ts
+++ b/e2e/a11y.spec.ts
@@ -755,6 +755,15 @@ async function installA11yMocks(page: Page) {
freshness: "unknown",
},
],
+ // A deletion the bucket refused, and a bucket with no expiry rule:
+ // both lines paint only in these states, and both are what an
+ // operator has to read and act on.
+ pendingDeletions: {
+ count: 2,
+ oldestRequestedAt: "2026-07-18T03:00:00.000Z",
+ lastFailure: "AccessDenied",
+ },
+ lifecycle: { state: "missing", expirationDays: null },
},
}),
);
diff --git a/e2e/account-sharing.spec.ts b/e2e/account-sharing.spec.ts
index a15a9e0ee..2238bc4d2 100644
--- a/e2e/account-sharing.spec.ts
+++ b/e2e/account-sharing.spec.ts
@@ -65,6 +65,7 @@
import type { BrowserContext, Page } from "@playwright/test";
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
+import { actWithReproof, useStaleSession } from "./setup/recent-proof";
import { expect, test } from "./setup/test";
import {
DELEGATE_STORAGE_STATE_PATH,
@@ -162,15 +163,21 @@ test.describe("account sharing", () => {
let ownerContext: BrowserContext;
let ownerPage: Page;
+ let endOwnerSession: (() => Promise) | null = null;
test.beforeAll(async ({ browser }) => {
ownerContext = await browser.newContext({
storageState: OWNER_STORAGE_STATE_PATH,
});
ownerPage = await ownerContext.newPage();
+ // An invitation asks for a recent proof. The owner acts from a session of
+ // this journey's own, made stale before the press, so the re-proof dialog
+ // is met on every run rather than only once the run is five minutes old.
+ endOwnerSession = await useStaleSession(ownerPage, E2E_OWNER.username);
});
test.afterAll(async () => {
+ await endOwnerSession?.();
await ownerContext.close();
});
@@ -248,7 +255,7 @@ test.describe("account sharing", () => {
(req) =>
req.method() === "POST" && req.url().endsWith("/api/account/grants"),
);
- await submit.click();
+ await actWithReproof(ownerPage, E2E_OWNER.password, () => submit.click());
const posted = JSON.parse((await invitePost).postData() ?? "{}") as {
expiresAt?: string | null;
access?: string;
diff --git a/e2e/backup-restore-journey.spec.ts b/e2e/backup-restore-journey.spec.ts
index 9db3722d2..9104ec5f6 100644
--- a/e2e/backup-restore-journey.spec.ts
+++ b/e2e/backup-restore-journey.spec.ts
@@ -65,6 +65,12 @@ import {
storedMeasurements,
storeTamperedCopy,
} from "./setup/backup-journey-fixture";
+import {
+ actWithReproof,
+ completeReproofWithPassword,
+ expireRecentProof,
+ useStaleSession,
+} from "./setup/recent-proof";
import { expect, test } from "./setup/test";
test.describe.configure({ mode: "serial" });
@@ -256,12 +262,20 @@ function snapshotRow(page: Page, id: string) {
return page.locator(`[data-backup-id="${id}"]:visible`);
}
-/** Drive the typed-confirmation dialog on one row and hand back the answer. */
+/**
+ * Drive the typed-confirmation dialog on one row and hand back the answer.
+ *
+ * A restore asks for a recent proof, and the session is made stale first, so
+ * the first POST is always refused and the answer waited for is the one to
+ * the retry the page sends after the re-proof dialog.
+ */
async function restoreThroughDialog(page: Page, id: string) {
+ await expireRecentProof(page);
const answered = page.waitForResponse(
(response) =>
response.url().includes(`/api/admin/backups/${id}/restore`) &&
- response.request().method() === "POST",
+ response.request().method() === "POST" &&
+ response.status() !== 401,
);
await snapshotRow(page, id).getByTestId("backup-restore-trigger").click();
// The instance-wide opt-in, left exactly where the dialog puts it. Asserted
@@ -273,12 +287,26 @@ async function restoreThroughDialog(page: Page, id: string) {
).not.toBeChecked();
await page.getByTestId("backup-restore-prompt").fill("RESTORE");
await page.getByTestId("backup-restore-confirm").click();
+ await completeReproofWithPassword(page, E2E_BACKUP_ADMIN.password);
return answered;
}
test.describe("Backup and restore, through the settings surfaces", () => {
test.use({ storageState: BACKUP_STORAGE_STATE_PATH });
+ // The archive, the snapshot download and every restore ask for a recent
+ // proof. Each test acts from a session of its own that is made stale before
+ // each of those presses, so the re-proof dialog is met every time, whatever
+ // minute of the run the journey lands in.
+ let endStaleSession: (() => Promise) | null = null;
+ test.beforeEach(async ({ page }) => {
+ endStaleSession = await useStaleSession(page, E2E_BACKUP_ADMIN.username);
+ });
+ test.afterEach(async () => {
+ await endStaleSession?.();
+ endStaleSession = null;
+ });
+
test.beforeAll(async () => {
seeded = null;
snapshotId = null;
@@ -327,7 +355,9 @@ test.describe("Backup and restore, through the settings surfaces", () => {
.getByTestId("export-full-backup-passphrase")
.fill(ARCHIVE_PASSPHRASE);
const archiveDownload = page.waitForEvent("download");
- await page.getByTestId("export-action-full-backup").click();
+ await actWithReproof(page, E2E_BACKUP_ADMIN.password, () =>
+ page.getByTestId("export-action-full-backup").click(),
+ );
const bytes = await readFile(await (await archiveDownload).path());
// The header `passphrase-archive.ts` documents, field by field: magic,
@@ -420,7 +450,9 @@ test.describe("Backup and restore, through the settings surfaces", () => {
// ── what the stored copy actually carries ────────────────────────────
const snapshotDownload = page.waitForEvent("download");
- await snapshotRow(page, row.id).getByTestId("backup-download").click();
+ await actWithReproof(page, E2E_BACKUP_ADMIN.password, () =>
+ snapshotRow(page, row.id).getByTestId("backup-download").click(),
+ );
const payload = JSON.parse(
await readFile(await (await snapshotDownload).path(), "utf8"),
) as {
diff --git a/e2e/delegated-writes.spec.ts b/e2e/delegated-writes.spec.ts
index 1074b44ed..de3bffcd4 100644
--- a/e2e/delegated-writes.spec.ts
+++ b/e2e/delegated-writes.spec.ts
@@ -36,9 +36,11 @@
*/
import type { BrowserContext, Page } from "@playwright/test";
+import { actWithReproof, useStaleSession } from "./setup/recent-proof";
import { expect, test } from "./setup/test";
import {
DELEGATE_STORAGE_STATE_PATH,
+ E2E_OWNER,
E2E_OWNER_FULL_NAME,
E2E_USER,
OWNER_STORAGE_STATE_PATH,
@@ -76,12 +78,15 @@ test.describe("delegated writes", () => {
let ownerContext: BrowserContext;
let ownerPage: Page;
let levelControlPresent = false;
+ let endOwnerSession: (() => Promise) | null = null;
test.beforeAll(async ({ browser }) => {
ownerContext = await browser.newContext({
storageState: OWNER_STORAGE_STATE_PATH,
});
ownerPage = await ownerContext.newPage();
+ // The invitation asks for a recent proof; see `useStaleSession`.
+ endOwnerSession = await useStaleSession(ownerPage, E2E_OWNER.username);
await ownerPage.goto("/settings/access");
levelControlPresent =
(await ownerPage.locator(`[data-slot="${GRANT_LEVEL_SLOT}"]`).count()) >
@@ -89,6 +94,7 @@ test.describe("delegated writes", () => {
});
test.afterAll(async () => {
+ await endOwnerSession?.();
await ownerContext.close();
});
@@ -134,7 +140,7 @@ test.describe("delegated writes", () => {
(req) =>
req.method() === "POST" && req.url().endsWith("/api/account/grants"),
);
- await submit.click();
+ await actWithReproof(ownerPage, E2E_OWNER.password, () => submit.click());
const posted = JSON.parse((await invitePost).postData() ?? "{}") as {
access?: string;
};
diff --git a/e2e/doctor-report-delegate.spec.ts b/e2e/doctor-report-delegate.spec.ts
index c17512663..6cf5db593 100644
--- a/e2e/doctor-report-delegate.spec.ts
+++ b/e2e/doctor-report-delegate.spec.ts
@@ -40,9 +40,11 @@
*/
import type { Page } from "@playwright/test";
+import { reproveWithPassword, useStaleSession } from "./setup/recent-proof";
import { expect, test } from "./setup/test";
import {
E2E_LEVEL_RECORDS,
+ E2E_SCOPE_DELEGATE,
REPORT_DELEGATE_STORAGE_STATE_PATH,
} from "./setup/test-helpers";
@@ -223,6 +225,15 @@ test.describe.serial("a read-level delegate and the doctor report", () => {
throw new Error("the MANAGE-level record fixture is missing");
}
+ // Generating the report exports a whole record, which asks the actor for a
+ // recent proof. The test runs on a session of its own, signed in ten minutes
+ // ago, so the gate is met on every run and proved explicitly below rather
+ // than passed or failed by where in the run this file lands.
+ let endStaleSession: (() => Promise) | null = null;
+ test.beforeEach(async ({ page }) => {
+ endStaleSession = await useStaleSession(page, E2E_SCOPE_DELEGATE.username);
+ });
+
test.afterEach(async ({ page }) => {
// Hand the session back to its own record even when an assertion above
// failed: this jar is this spec's, but a switched row left behind would
@@ -230,6 +241,8 @@ test.describe.serial("a read-level delegate and the doctor report", () => {
await page.request
.post("/api/account/switch", { data: { accountId: null } })
.catch(() => {});
+ await endStaleSession?.();
+ endStaleSession = null;
});
test("cannot generate on a record it may only read, and is not offered the control", async ({
@@ -245,6 +258,11 @@ test.describe.serial("a read-level delegate and the doctor report", () => {
await expect(
page.locator('[data-slot="shared-record-banner"]'),
).toHaveAttribute("data-access-level", "manage");
+ // Without a recent proof the manager is asked for one, not refused.
+ const unproven = await attemptReport(page, SELECTION);
+ expect(unproven.status).toBe(401);
+ expect(unproven.errorCode).toBe("auth.reproof.required");
+ await reproveWithPassword(page, E2E_SCOPE_DELEGATE.password);
const managed = await attemptReport(page, SELECTION);
expect(managed.status).toBe(200);
expect(managed.contentType).toContain("application/pdf");
diff --git a/e2e/doctor-report.spec.ts b/e2e/doctor-report.spec.ts
index c11fdee63..2ce774311 100644
--- a/e2e/doctor-report.spec.ts
+++ b/e2e/doctor-report.spec.ts
@@ -51,7 +51,14 @@ import { readFile } from "node:fs/promises";
import type { Page, Response } from "@playwright/test";
import { PDFParse } from "pdf-parse";
-import { REPORT_OWNER_STORAGE_STATE_PATH } from "./setup/global-setup";
+import {
+ E2E_REPORT_OWNER,
+ REPORT_OWNER_STORAGE_STATE_PATH,
+} from "./setup/global-setup";
+import {
+ completeReproofWithPassword,
+ useStaleSession,
+} from "./setup/recent-proof";
import { expect, test } from "./setup/test";
/** The allergy and the drug the report has to name. Both plain ASCII: the
@@ -83,6 +90,17 @@ const BP_SECTION = "ESH classification";
const EMPTY_WINDOW_START_DAYS_BACK = 400;
const EMPTY_WINDOW_DAYS = 20;
+/** Where the drug's course starts: before the empty control's window opens,
+ * so the drug is ongoing across every window this file asks for. */
+const MEDICATION_COURSE_START_DAYS_BACK = EMPTY_WINDOW_START_DAYS_BACK + 30;
+
+/** The UTC calendar day `offset` days back, as `YYYY-MM-DD`. */
+function isoDayBack(offset: number): string {
+ const day = new Date();
+ day.setUTCDate(day.getUTCDate() - offset);
+ return day.toISOString().slice(0, 10);
+}
+
/** Byte floor for "a real document". A cover page with charts is far above
* this; a truncated stream or an error body is far below it. */
const MIN_PDF_BYTES = 10_000;
@@ -189,6 +207,12 @@ async function seedReportFixture(page: Page): Promise {
// No schedule: an as-needed drug is active indefinitely and needs
// no dose ledger, which keeps the fixture about the drug LIST.
asNeeded: true,
+ // The report lists a medication only for a window its course had
+ // begun by, so a drug entered today is rightly absent from a report
+ // for last year. The course therefore starts before the empty
+ // control's window: an ongoing drug the patient was already taking
+ // then belongs in both reports.
+ startsOn: fixture.medicationStartsOn,
});
const emergency = await send("PATCH", "/api/anamnesis/emergency", {
@@ -202,6 +226,7 @@ async function seedReportFixture(page: Page): Promise {
allergySubstance: ALLERGY_SUBSTANCE,
medicationName: MEDICATION_NAME,
medicationDose: MEDICATION_DOSE,
+ medicationStartsOn: isoDayBack(MEDICATION_COURSE_START_DAYS_BACK),
},
);
}
@@ -276,16 +301,21 @@ interface GeneratedReport {
* PDF and never handed it to the browser.
*/
async function generateReport(page: Page): Promise {
+ // The first press is refused for a recent proof (the session is a stale one,
+ // see the `beforeEach`); the report is the answer to the retry that follows
+ // the re-proof, so the refusal is not the response waited for.
const responseArrived = page.waitForResponse(
(response) =>
response.request().method() === "POST" &&
- new URL(response.url()).pathname === "/api/export/health-record",
+ new URL(response.url()).pathname === "/api/export/health-record" &&
+ response.status() !== 401,
{ timeout: 60_000 },
);
const downloadStarted = page.waitForEvent("download", { timeout: 60_000 });
const generate = page.getByTestId("health-record-generate");
await generate.click();
+ await completeReproofWithPassword(page, E2E_REPORT_OWNER.password);
const response = await responseArrived;
const download = await downloadStarted;
@@ -424,6 +454,19 @@ test.describe.serial("the doctor report", () => {
// the page's own `fetch` writes them under its session.
test.use({ storageState: REPORT_OWNER_STORAGE_STATE_PATH });
+ // Generating the report exports the whole record, which asks for a sign-in
+ // or re-proof within five minutes. The shared jar is fresh only early in a
+ // run, so each test starts from a session signed in ten minutes ago and
+ // answers the re-proof dialog the way the owner would.
+ let endStaleSession: (() => Promise) | null = null;
+ test.beforeEach(async ({ page }) => {
+ endStaleSession = await useStaleSession(page, E2E_REPORT_OWNER.username);
+ });
+ test.afterEach(async () => {
+ await endStaleSession?.();
+ endStaleSession = null;
+ });
+
test("generates a PDF that names the period, the vitals and the medication", async ({
page,
}) => {
@@ -484,17 +527,12 @@ test.describe.serial("the doctor report", () => {
);
await expect(dateFields).toHaveCount(2);
- const dayBack = (offset: number): string => {
- const day = new Date();
- day.setUTCDate(day.getUTCDate() - offset);
- return day.toISOString().slice(0, 10);
- };
// The field commits a clean ISO string whatever the locale's field order is.
- await dateFields.first().fill(dayBack(EMPTY_WINDOW_START_DAYS_BACK));
+ await dateFields.first().fill(isoDayBack(EMPTY_WINDOW_START_DAYS_BACK));
await dateFields.first().press("Enter");
await dateFields
.nth(1)
- .fill(dayBack(EMPTY_WINDOW_START_DAYS_BACK - EMPTY_WINDOW_DAYS));
+ .fill(isoDayBack(EMPTY_WINDOW_START_DAYS_BACK - EMPTY_WINDOW_DAYS));
await dateFields.nth(1).press("Enter");
const report = await generateReport(page);
@@ -515,9 +553,11 @@ test.describe.serial("the doctor report", () => {
// Nothing was measured in it, so the section that needs readings is gone.
expect(report.text).not.toContain(BP_SECTION);
- // The positive control for that absence: the reference data that is NOT
- // window-scoped is still in the document. Without this, a renderer that
- // produced a blank page would pass the line above.
+ // The positive control for that absence: what the window does not empty
+ // is still in the document. The allergy is not window-scoped at all, and
+ // the drug's course began before the window and never ended, so it was
+ // being taken then. Without this, a renderer that produced a blank page
+ // would pass the line above.
expect(report.text).toContain(MEDICATION_NAME);
expect(report.text).toContain(ALLERGY_SUBSTANCE);
});
diff --git a/e2e/mcp-oauth-consent-redirect.spec.ts b/e2e/mcp-oauth-consent-redirect.spec.ts
index 8477e2181..7863d6648 100644
--- a/e2e/mcp-oauth-consent-redirect.spec.ts
+++ b/e2e/mcp-oauth-consent-redirect.spec.ts
@@ -4,7 +4,11 @@ import type { AddressInfo } from "node:net";
import { expect, test } from "./setup/test";
-import { STORAGE_STATE_PATH } from "./setup/global-setup";
+import { E2E_USER, STORAGE_STATE_PATH } from "./setup/global-setup";
+import {
+ completeReproofWithPassword,
+ useStaleSession,
+} from "./setup/recent-proof";
import { SSR_PREFETCH_BASE_URL } from "./setup/ssr-prefetch-server";
/**
@@ -20,6 +24,11 @@ import { SSR_PREFETCH_BASE_URL } from "./setup/ssr-prefetch-server";
* It runs against the second server, the one started with `APP_URL`: the MCP
* surface fails closed without a configured origin. The callback is a local
* loopback listener, which is also what desktop MCP clients register.
+ *
+ * Allowing a connection asks for a sign-in or re-proof within five minutes, so
+ * the journey starts from a session signed in ten minutes ago: the consent
+ * page must withhold the Allow form and link to the confirm page, and the
+ * person confirms with their password there before they can allow.
*/
test.describe("MCP OAuth consent", () => {
test.use({
@@ -94,14 +103,34 @@ test.describe("MCP OAuth consent", () => {
authorize.searchParams.set(key, value);
}
- await page.goto(authorize.toString());
- const allow = page.getByRole("button", { name: "Allow" });
- await expect(allow).toBeVisible();
- await allow.click();
+ const endSession = await useStaleSession(page, E2E_USER.username);
+ try {
+ // Without a recent proof there is no form to allow with, only the way to
+ // give one.
+ await page.goto(authorize.toString());
+ const allow = page.getByRole("button", { name: "Allow" });
+ await expect(
+ page.getByRole("heading", { name: "Confirm it is you" }),
+ ).toBeVisible();
+ await expect(allow).toHaveCount(0);
+
+ await page.getByRole("link", { name: "Confirm and continue" }).click();
+ await page.waitForURL((url) => url.pathname === "/confirm-access");
+ await page.getByRole("button", { name: "Confirm", exact: true }).click();
+ await completeReproofWithPassword(page, E2E_USER.password);
+
+ // The confirm page returns to the exact consent request it came from.
+ await page.waitForURL((url) => url.pathname === authorize.pathname);
+ await expect(allow).toBeVisible();
+ await allow.click();
+
+ await page.waitForURL((url) => url.toString().startsWith(callbackUrl), {
+ timeout: 15_000,
+ });
+ } finally {
+ await endSession();
+ }
- await page.waitForURL((url) => url.toString().startsWith(callbackUrl), {
- timeout: 15_000,
- });
const hit = hits.at(-1);
expect(hit, "the callback listener was never reached").toBeDefined();
expect(hit!.searchParams.get("code")).toMatch(/^hlac_/);
diff --git a/e2e/setup/recent-proof.ts b/e2e/setup/recent-proof.ts
new file mode 100644
index 000000000..f8a7a88e0
--- /dev/null
+++ b/e2e/setup/recent-proof.ts
@@ -0,0 +1,176 @@
+/**
+ * A browser session whose sign-in is older than the recent-proof window.
+ *
+ * Exports of the whole record, share links, token minting and connecting an
+ * assistant ask for a sign-in or re-proof within five minutes. The shared jar
+ * every authenticated spec reads is minted once, in global setup, so whether a
+ * spec meets that gate depends on how far into the run it happens to start:
+ * early it passes untouched, a few minutes later the web opens the re-proof
+ * dialog. A spec that assumes either one is timing-dependent.
+ *
+ * `useStaleSession` removes the timing. It inserts a session row of its own
+ * for the account, signed in ten minutes ago and never re-proved, and points
+ * the page's session cookie at it. The row belongs to this one test, so a
+ * re-proof in a spec running in the other worker cannot make it fresh, and its
+ * own re-proof cannot make the shared session fresh for anyone else. The
+ * returned function removes the row.
+ *
+ * The cookie secret is hashed the way the server hashes it (HMAC-SHA256 under
+ * `API_TOKEN_HMAC_KEY`); the web server inherits the runner's environment, so
+ * both sides hold the same key.
+ */
+import { createHmac, randomBytes } from "node:crypto";
+
+import type { Page } from "@playwright/test";
+import pg from "pg";
+
+import { expect } from "./test";
+
+const SESSION_COOKIE = "healthlog_session";
+
+/** Older than the five-minute recent-proof window, with room for a slow run. */
+const STALE_SIGN_IN_MINUTES = 10;
+
+function env(name: string): string {
+ const value = process.env[name];
+ if (!value) throw new Error(`[recent-proof] ${name} is not set`);
+ return value;
+}
+
+export async function useStaleSession(
+ page: Page,
+ username: string,
+): Promise<() => Promise> {
+ const secret = `hls_${randomBytes(32).toString("hex")}`;
+ const tokenHash = createHmac("sha256", env("API_TOKEN_HMAC_KEY"))
+ .update(secret)
+ .digest("hex");
+ const id = `e2estale${randomBytes(8).toString("hex")}`;
+
+ const pool = new pg.Pool({ connectionString: env("DATABASE_URL") });
+ try {
+ const inserted = await pool.query(
+ `INSERT INTO sessions (id, user_id, token_hash, expires_at, created_at)
+ SELECT $1, id, $2, NOW() + INTERVAL '1 day',
+ NOW() - make_interval(mins => $4)
+ FROM users WHERE username = $3`,
+ [id, tokenHash, username, STALE_SIGN_IN_MINUTES],
+ );
+ if ((inserted.rowCount ?? 0) !== 1) {
+ throw new Error(`[recent-proof] no account named ${username}`);
+ }
+ } finally {
+ await pool.end();
+ }
+
+ // Replace the jar's session cookie in place: same name, domain and path, so
+ // the browser holds exactly one and cannot send the shared one alongside.
+ const context = page.context();
+ const current = (await context.cookies()).filter(
+ (cookie) => cookie.name === SESSION_COOKIE,
+ );
+ if (current.length === 0) {
+ throw new Error("[recent-proof] the page carries no session cookie");
+ }
+ await context.addCookies(
+ current.map((cookie) => ({ ...cookie, value: secret })),
+ );
+
+ return async () => {
+ const cleanup = new pg.Pool({ connectionString: env("DATABASE_URL") });
+ try {
+ await cleanup.query(`DELETE FROM sessions WHERE id = $1`, [id]);
+ } finally {
+ await cleanup.end();
+ }
+ };
+}
+
+/** The page's current session secret, hashed the way the server stores it. */
+async function pageSessionHash(page: Page): Promise {
+ const cookie = (await page.context().cookies()).find(
+ (c) => c.name === SESSION_COOKIE,
+ );
+ if (!cookie) {
+ throw new Error("[recent-proof] the page carries no session cookie");
+ }
+ return createHmac("sha256", env("API_TOKEN_HMAC_KEY"))
+ .update(cookie.value)
+ .digest("hex");
+}
+
+/**
+ * Make the page's session stale again after a re-proof, so the next gated
+ * action meets the dialog whatever the clock says. Only a row that
+ * `useStaleSession` inserted is touched: a journey that re-proves more than
+ * five minutes apart must not depend on which side of the window it lands.
+ */
+export async function expireRecentProof(page: Page): Promise {
+ const tokenHash = await pageSessionHash(page);
+ const pool = new pg.Pool({ connectionString: env("DATABASE_URL") });
+ try {
+ const updated = await pool.query(
+ `UPDATE sessions
+ SET created_at = LEAST(created_at, NOW() - make_interval(mins => $2)),
+ reproof_at = NULL,
+ mfa_verified_at = NULL
+ WHERE token_hash = $1 AND id LIKE 'e2estale%'`,
+ [tokenHash, STALE_SIGN_IN_MINUTES],
+ );
+ if ((updated.rowCount ?? 0) !== 1) {
+ throw new Error(
+ "[recent-proof] the page is not on a session from useStaleSession",
+ );
+ }
+ } finally {
+ await pool.end();
+ }
+}
+
+/**
+ * Re-prove with the password over the wire the dialog uses, for a spec that
+ * calls a gated route from the page rather than through a control.
+ */
+export async function reproveWithPassword(
+ page: Page,
+ password: string,
+): Promise {
+ const status = await page.evaluate(async (secret: string) => {
+ const res = await fetch("/api/auth/reproof", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ method: "password", password: secret }),
+ });
+ return res.status;
+ }, password);
+ expect(status, "the password re-proof was accepted").toBe(200);
+}
+
+/**
+ * Stale the session, run the action that meets the gate, and answer the
+ * dialog it opens. The page retries the action once the proof is in.
+ */
+export async function actWithReproof(
+ page: Page,
+ password: string,
+ act: () => Promise,
+): Promise {
+ await expireRecentProof(page);
+ await act();
+ await completeReproofWithPassword(page, password);
+}
+
+/**
+ * Answer the re-proof dialog with the account's password, the way a person
+ * does, and wait for it to close. The action behind it is retried by the page.
+ */
+export async function completeReproofWithPassword(
+ page: Page,
+ password: string,
+): Promise {
+ const dialog = page.getByTestId("factor-reauth-dialog");
+ await expect(dialog).toBeVisible();
+ await dialog.getByTestId("factor-reauth-password").fill(password);
+ await dialog.getByTestId("factor-reauth-submit").click();
+ await expect(dialog).toBeHidden();
+}
diff --git a/e2e/share-documents.spec.ts b/e2e/share-documents.spec.ts
index 862f4a426..72ed059b5 100644
--- a/e2e/share-documents.spec.ts
+++ b/e2e/share-documents.spec.ts
@@ -27,7 +27,11 @@
import type { Browser, Page } from "@playwright/test";
import { expect, test } from "./setup/test";
-import { STORAGE_STATE_PATH } from "./setup/global-setup";
+import { E2E_USER, STORAGE_STATE_PATH } from "./setup/global-setup";
+import {
+ completeReproofWithPassword,
+ useStaleSession,
+} from "./setup/recent-proof";
import {
ensureShareDocFixture,
ensureVaultFixture,
@@ -96,6 +100,9 @@ async function createShareWithDocs(page: Page): Promise {
await expect(page.getByRole("button", { name: "Create link" })).toBeEnabled();
await page.getByRole("button", { name: "Create link" }).click();
+ // Making a link asks for a recent proof, and the session is a stale one
+ // (see the `beforeEach`), so the owner confirms it is them first.
+ await completeReproofWithPassword(page, E2E_USER.password);
const reveal = page.getByTestId("share-token-reveal");
await expect(reveal).toBeVisible();
@@ -146,6 +153,18 @@ async function openUnlockedClinician(
test.describe("clinician document sharing", () => {
test.use({ storageState: STORAGE_STATE_PATH });
+ // Every test here starts from a session signed in ten minutes ago, so making
+ // a link always meets the re-proof dialog rather than only when the run has
+ // been going for five minutes.
+ let endStaleSession: (() => Promise) | null = null;
+ test.beforeEach(async ({ page }) => {
+ endStaleSession = await useStaleSession(page, E2E_USER.username);
+ });
+ test.afterEach(async () => {
+ await endStaleSession?.();
+ endStaleSession = null;
+ });
+
test.beforeAll(async () => {
await ensureVaultFixture();
await ensureShareDocFixture();
@@ -298,6 +317,7 @@ test.describe("clinician document sharing", () => {
// Create the link — the one-time reveal (with the scannable QR) lands in
// the same sheet, carrying the one attached document.
await shareSheet.getByRole("button", { name: "Create link" }).click();
+ await completeReproofWithPassword(page, E2E_USER.password);
const reveal = shareSheet.getByTestId("share-token-reveal");
await expect(reveal).toBeVisible();
await expect(
diff --git a/eslint-plugins/healthlog/__tests__/no-default-zone-literal.test.mjs b/eslint-plugins/healthlog/__tests__/no-default-zone-literal.test.mjs
new file mode 100644
index 000000000..f5f45dabe
--- /dev/null
+++ b/eslint-plugins/healthlog/__tests__/no-default-zone-literal.test.mjs
@@ -0,0 +1,80 @@
+/**
+ * Unit tests for the `healthlog/no-default-zone-literal` rule: the literal is
+ * banned in application source and allowed only in `src/lib/tz/` and in tests.
+ */
+import { describe, it } from "vitest";
+import { RuleTester } from "eslint";
+import rule from "../no-default-zone-literal.js";
+
+RuleTester.describe = describe;
+RuleTester.it = it;
+RuleTester.itOnly = it.only;
+
+const ruleTester = new RuleTester({
+ languageOptions: {
+ ecmaVersion: 2023,
+ sourceType: "module",
+ parserOptions: { ecmaFeatures: { jsx: true } },
+ },
+});
+
+ruleTester.run("no-default-zone-literal", rule, {
+ valid: [
+ {
+ code: 'import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; const tz = user.timezone || DEFAULT_TIMEZONE;',
+ filename: "/repo/src/app/api/insights/cards/route.ts",
+ },
+ {
+ // The one definition.
+ code: 'export const DEFAULT_TIMEZONE = "Europe/Berlin";',
+ filename: "/repo/src/lib/tz/format.ts",
+ },
+ {
+ // Fixtures pick a concrete zone on purpose.
+ code: 'const TZ = "Europe/Berlin";',
+ filename: "/repo/src/lib/insights/__tests__/graded-series.test.ts",
+ },
+ {
+ // Comments are not checked.
+ code: "// read on Europe/Berlin days before v1.39.3\nconst x = 1;",
+ filename: "/repo/src/lib/insights/graded-series.ts",
+ },
+ {
+ code: 'const other = "America/New_York";',
+ filename: "/repo/src/lib/insights/graded-series.ts",
+ },
+ ],
+ invalid: [
+ {
+ // The medication and reminder trees carry no exemption.
+ code: 'const DEFAULT_TZ = "Europe/Berlin";',
+ filename: "/repo/src/lib/medications/window-status.ts",
+ errors: [{ messageId: "zoneLiteral" }],
+ },
+ {
+ code: 'const tz = timezone || "Europe/Berlin";',
+ filename: "/repo/src/app/api/measurement-reminders/route.ts",
+ errors: [{ messageId: "zoneLiteral" }],
+ },
+ {
+ code: 'const tz = user.timezone || "Europe/Berlin";',
+ filename: "/repo/src/app/api/insights/cards/route.ts",
+ errors: [{ messageId: "zoneLiteral" }],
+ },
+ {
+ code: 'readDayAggregates({ timeZone: "Europe/Berlin" });',
+ filename: "/repo/src/lib/insights/graded-series.ts",
+ errors: [{ messageId: "zoneLiteral" }],
+ },
+ {
+ code: "const label = `Date: ${todayKey} (Europe/Berlin)`;",
+ filename: "/repo/src/lib/ai/prompts/pulse.ts",
+ errors: [{ messageId: "zoneLiteral" }],
+ },
+ {
+ code: ' ',
+ filename: "/repo/src/components/settings/timezone-picker.tsx",
+ errors: [{ messageId: "zoneLiteral" }],
+ },
+ ],
+});
diff --git a/eslint-plugins/healthlog/index.js b/eslint-plugins/healthlog/index.js
index 8147b1540..961372c8b 100644
--- a/eslint-plugins/healthlog/index.js
+++ b/eslint-plugins/healthlog/index.js
@@ -10,6 +10,8 @@
* and no off-scale `5` step on a bg-card shell.
* - `job-handler-outcome` — pg-boss bindings route through createAndWork,
* whose handler returns a JobOutcome.
+ * - `no-default-zone-literal` — the default zone is DEFAULT_TIMEZONE from
+ * src/lib/tz/format, never written out.
*/
"use strict";
@@ -20,6 +22,7 @@ const apiFetchRequired = require("./api-fetch-required.js");
const noRawPaletteColor = require("./no-raw-palette-color.js");
const spacingScale = require("./spacing-scale.js");
const jobHandlerOutcome = require("./job-handler-outcome.js");
+const noDefaultZoneLiteral = require("./no-default-zone-literal.js");
module.exports = {
rules: {
@@ -34,5 +37,6 @@ module.exports = {
// the semantic sweep). See the rule header.
"no-dracula-utility": noRawPaletteColor,
"job-handler-outcome": jobHandlerOutcome,
+ "no-default-zone-literal": noDefaultZoneLiteral,
},
};
diff --git a/eslint-plugins/healthlog/no-default-zone-literal.js b/eslint-plugins/healthlog/no-default-zone-literal.js
new file mode 100644
index 000000000..25a53ba8c
--- /dev/null
+++ b/eslint-plugins/healthlog/no-default-zone-literal.js
@@ -0,0 +1,91 @@
+/**
+ * @fileoverview ESLint rule — the default zone is named in one place.
+ *
+ * `"Europe/Berlin"` was written out in some eighty places: fallbacks for a
+ * user with no zone, day keys cut in Berlin whatever the user's zone, a
+ * graded series folded on Berlin days, a prompt that labelled every date as
+ * Berlin's. Each copy was a place where a user outside that zone got
+ * someone else's calendar. `DEFAULT_TIMEZONE` in `src/lib/tz/format.ts` is
+ * the one definition; everything else imports it, and a surface that means
+ * the user's own zone asks for it (`resolveUserTimezone`).
+ *
+ * The rule flags a string literal or template element containing
+ * `Europe/Berlin` in `src/`, outside `src/lib/tz/` and outside test files
+ * (fixtures pick concrete zones on purpose). Comments are not checked.
+ *
+ * WHAT THIS RULE DOES NOT CATCH
+ *
+ * A zone assembled from parts (`"Europe/" + city`), a different hard-coded
+ * zone, or a Berlin day computed through an `Intl.DateTimeFormat` built from
+ * a variable. It removes the literal, not every way to hard-code a calendar.
+ *
+ */
+
+"use strict";
+
+const ZONE = "Europe/Berlin";
+
+const EXEMPT_ROOTS = ["src/lib/tz/"];
+
+function toPosix(filename) {
+ return filename.replace(/\\/g, "/");
+}
+
+function isTestFile(posix) {
+ return (
+ /\.test\.[cm]?[jt]sx?$/.test(posix) ||
+ /\.spec\.[cm]?[jt]sx?$/.test(posix) ||
+ posix.includes("/__tests__/") ||
+ posix.includes("/__mocks__/")
+ );
+}
+
+function isEnforced(filename) {
+ const posix = toPosix(filename);
+ if (!posix.includes("/src/") && !posix.startsWith("src/")) return false;
+ if (EXEMPT_ROOTS.some((root) => posix.includes(root))) return false;
+ if (isTestFile(posix)) return false;
+ return true;
+}
+
+/** @type {import("eslint").Rule.RuleModule} */
+const noDefaultZoneLiteralRule = {
+ meta: {
+ type: "problem",
+ docs: {
+ description:
+ "Import DEFAULT_TIMEZONE from @/lib/tz/format instead of writing the zone out.",
+ },
+ schema: [],
+ messages: {
+ zoneLiteral:
+ 'Do not write "Europe/Berlin" out. Import DEFAULT_TIMEZONE from "@/lib/tz/format" for a fallback, or use the user\'s own zone (resolveUserTimezone / the profile timezone) for a day boundary.',
+ },
+ },
+ create(context) {
+ const filename = context.filename ?? context.getFilename?.();
+ if (!filename || !isEnforced(filename)) {
+ return {};
+ }
+ return {
+ Literal(node) {
+ if (typeof node.value === "string" && node.value.includes(ZONE)) {
+ context.report({ node, messageId: "zoneLiteral" });
+ }
+ },
+ TemplateElement(node) {
+ const raw = node.value && (node.value.cooked ?? node.value.raw);
+ if (typeof raw === "string" && raw.includes(ZONE)) {
+ context.report({ node, messageId: "zoneLiteral" });
+ }
+ },
+ JSXText(node) {
+ if (typeof node.value === "string" && node.value.includes(ZONE)) {
+ context.report({ node, messageId: "zoneLiteral" });
+ }
+ },
+ };
+ },
+};
+
+module.exports = noDefaultZoneLiteralRule;
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 36fb7684e..10070eaa6 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -29,6 +29,9 @@ const eslintConfig = defineConfig([
"coverage/**",
"playwright-report/**",
"test-results/**",
+ // Third-party OCR engine copied from node_modules at prebuild/predev by
+ // scripts/stage-tesseract-assets.mjs; gitignored, minified, not ours.
+ "public/tesseract/**",
]),
// Factory enforcement for every client module: all components/hooks plus
// `"use client"` app/lib modules. Tests and `src/lib/query-keys/**` own
@@ -101,6 +104,10 @@ const eslintConfig = defineConfig([
// catches the omission only — a handler that returns `{ ok: true }`
// after swallowing still passes, and its header says so.
"healthlog/job-handler-outcome": "error",
+ // The default zone is named once, in src/lib/tz/format.ts. A copy of the
+ // literal anywhere else is where a user outside that zone got someone
+ // else's calendar. src/lib/tz/ and test files are exempt; see the rule.
+ "healthlog/no-default-zone-literal": "error",
},
},
// v1.28.17 — every recharts-rendering component funnels through the
diff --git a/messages/de.json b/messages/de.json
index 06801e37e..5fea285f0 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -126,7 +126,14 @@
"nothingSaved": "Es wurde nichts gespeichert. Korrigiere das markierte Feld und versuche es erneut.",
"invalidBody": "Die Profilaktualisierung konnte nicht gelesen werden.",
"emailInUse": "Diese E-Mail-Adresse wird bereits verwendet.",
- "emailRateLimited": "Zu viele Änderungen der E-Mail-Adresse. Versuche es später erneut."
+ "emailRateLimited": "Zu viele Änderungen der E-Mail-Adresse. Versuche es später erneut.",
+ "emailSecondFactorRequired": "Bestätige mit deinem zweiten Faktor oder einem Passkey, um die E-Mail-Adresse zu ändern."
+ }
+ },
+ "auth": {
+ "reproof": {
+ "required": "Bestätige dein aktuelles Passwort, um das zu ändern.",
+ "failed": "Die Bestätigung ist fehlgeschlagen. Es wurde nichts geändert."
}
}
},
@@ -440,7 +447,8 @@
"errorEmailUnverified": "Dein Identitätsanbieter hat deine E-Mail-Adresse nicht als bestätigt markiert.",
"errorIdentityConflict": "Diese E-Mail-Adresse gehört zu einem Konto, das bereits mit einer anderen SSO-Identität verknüpft ist.",
"errorRegistrationDisabled": "Kein Konto passt zu dieser Identität, und neue Registrierungen sind derzeit geschlossen.",
- "errorFailed": "Single Sign-On ist fehlgeschlagen. Bitte versuche es erneut."
+ "errorFailed": "Single Sign-On ist fehlgeschlagen. Bitte versuche es erneut.",
+ "errorLinkRequired": "Hier gibt es bereits ein Konto mit dieser E-Mail-Adresse. Melde dich einmal mit seinem Passwort oder Passkey an, um es mit Single Sign-on zu verbinden."
},
"native": {
"signingInToApp": "Anmeldung bei der HealthLog-App"
@@ -464,6 +472,13 @@
"title": "Zweiten Faktor einrichten",
"body": "Dein Administrator verlangt einen zweiten Faktor für dieses Konto. Füge eine Authenticator-App oder einen Sicherheitsschlüssel hinzu, um fortzufahren.",
"signOut": "Abmelden"
+ },
+ "confirmAccess": {
+ "title": "Bestätige, dass du es bist",
+ "body": "Ein Assistent möchte sich mit deinen HealthLog-Daten verbinden. Bestätige, dass du es bist; danach entscheidest du, ob du es erlaubst.",
+ "action": "Bestätigen",
+ "failed": "Das hat nicht geklappt. Bitte versuche es noch einmal.",
+ "nothingToConfirm": "Hier gibt es nichts zu bestätigen. Starte die Verbindung noch einmal in deinem Assistenten."
}
},
"dashboard": {
@@ -4591,7 +4606,7 @@
"sampleBody": "Gewicht in zwei Wochen um 1,2 kg gesunken. Ruhepuls stabil bei 58 bpm. Blutdruck an 6 von 7 Tagen im Zielbereich — der einzige hohe Wert folgte auf ein spätes, salziges Abendessen.",
"sampleCaption": "Nur ein Beispiel — nicht deine eigenen Daten. So liest sich ein Briefing, sobald Insights aktiv sind.",
"ladderTitle": "Drei Wege, es zu aktivieren",
- "ladderLocalTitle": "Lokales Modell — nichts verlässt dein Netzwerk",
+ "ladderLocalTitle": "Lokales Modell — KI-Anfragen bleiben in deinem Netzwerk",
"ladderLocalBody": "Richte HealthLog auf Ollama oder LM Studio auf deinem eigenen Rechner aus. Kostenlos und vollständig privat — der ruhige Standard.",
"ladderByokTitle": "Eigenen Schlüssel verwenden",
"ladderByokBody": "Nutze deinen eigenen OpenAI- oder Anthropic-Schlüssel. Er läuft über dein Konto und deine Abrechnung, und die API-Stufe hält deine Daten standardmäßig aus dem Training heraus.",
@@ -5173,7 +5188,6 @@
"passphrasePlaceholder": "Passphrase (mind. 12 Zeichen)",
"passphraseHelp": "Leitet den Archivschlüssel per Argon2id ab und versiegelt ihn mit AES-256-GCM. Speichere die Datei mit der Endung .hlx.",
"noRecoveryWarning": "Es gibt keine Wiederherstellung — geht diese Passphrase verloren, lässt sich das Archiv nicht mehr öffnen. Bewahre sie sicher auf.",
- "stepUpNeeded": "Bestätige deinen zweiten Faktor und versuche es erneut.",
"detail": "Gleiche Form wie die wöchentliche automatische Sicherung. Einige Satzarten sind noch nicht enthalten — das Manifest der Datei nennt, was drinsteht.",
"contents": "Deine Messwerte, Medikamente, Stimmung, Zyklus, Laborwerte, Krankheitsepisoden, Allergien, Familienanamnese, Nährstoffe, Workouts, dein Gesundheitsprofil und deine eigenen Messgrößen in einer JSON-Datei."
}
@@ -5553,7 +5567,10 @@
"invalidFormat": "Das ist nicht in einer Form geschrieben, die dieses Feld lesen kann.",
"invalidType": "Der Wert kam nicht in einer Form an, die dieses Feld speichern kann.",
"rateLimited": "Zu oft geändert. Warte kurz und versuche es dann erneut.",
- "other": "Dieser Wert wurde nicht angenommen. Bitte prüfen und erneut versuchen."
+ "other": "Dieser Wert wurde nicht angenommen. Bitte prüfen und erneut versuchen.",
+ "reproofRequired": "Gib dein aktuelles Passwort ein, um die E-Mail-Adresse zu ändern.",
+ "reproofFailed": "Das Passwort stimmt nicht, die E-Mail-Adresse wurde nicht geändert.",
+ "secondFactorRequired": "Bestätige mit deinem zweiten Faktor oder einem Passkey, um die E-Mail-Adresse zu ändern."
},
"avatar": {
"title": "Profilfoto",
@@ -5594,7 +5611,9 @@
"currentPassword": "Aktuelles Passwort",
"authenticatorCode": "Authenticator-Code",
"authenticatorPlaceholder": "Sechsstelliger Authenticator-Code",
- "verifyAndAdd": "Bestätigen und hinzufügen"
+ "verifyAndAdd": "Bestätigen und hinzufügen",
+ "factorDescription": "Bestätige eine vorhandene Anmeldemethode, bevor du einen zweiten Faktor hinzufügst.",
+ "verifyAndContinue": "Bestätigen und fortfahren"
},
"passkeyName": "Name",
"noPasskeys": "Keine Passkeys registriert.",
@@ -5655,10 +5674,10 @@
"codexConnected": "ChatGPT erfolgreich verbunden! Insights sind jetzt aktiv.",
"codexDisconnected": "ChatGPT-Verbindung getrennt.",
"codexConnectionFailed": "ChatGPT-Verbindung fehlgeschlagen. Bitte versuche es erneut.",
- "rawData": "Rohdaten mitsenden",
- "rawDataOnDescription": "Es werden aggregierte Kennzahlen plus anonymisierte einzelne Messpunkte der letzten 30 Tage mit Zeitbezug gesendet — ohne Name, E-Mail oder direkte Kontodaten.",
- "rawDataOffDescription": "Es werden nur zusammengefasste Werte gesendet (z. B. Durchschnitt, Trend, Minimum/Maximum) — ohne einzelne Messpunkte und exakte Zeitbezüge.",
- "rawDataWarning": "Rohdaten aktiv: Der Provider erhält zusätzlich anonymisierte Messpunkte der letzten 30 Tage für eine präzisere Auswertung.",
+ "rawData": "Ausführlichen Verlauf senden",
+ "rawDataOnDescription": "Zusätzlich zu den Zusammenfassungen erhält der Anbieter Durchschnittswerte deiner Messungen: pro Tag für die letzten 90 Tage, pro Woche für das Jahr davor und pro Monat bis zu 5 Jahre zurück, jeweils mit Datum und Anzahl der Messungen. Einzelne Messwerte, dein Name und deine E-Mail werden nicht gesendet.",
+ "rawDataOffDescription": "Es werden nur Zusammenfassungen gesendet: der letzte Wert, Durchschnitte, Trends, Minimum und Maximum über feste Zeiträume. Keine Messreihen.",
+ "rawDataWarning": "Ausführlicher Verlauf ist an: Jede Auswertung sendet zusätzlich Tagesdurchschnitte der letzten 90 Tage, Wochendurchschnitte für das Jahr davor und Monatsdurchschnitte bis zu 5 Jahre zurück.",
"regenerateInsights": "Reports neu generieren",
"regenerateSuccess": "Reports wurden neu generiert",
"regenerateRateLimit": "Bitte warte — du hast das stündliche Limit für Analysen erreicht.",
@@ -6545,7 +6564,12 @@
"revokeSessionConfirmTitle": "Dieses Gerät abmelden?",
"revokeSessionConfirmBody": "{device} muss sich neu anmelden. An den dort erfassten Daten ändert sich nichts.",
"signOutEverywhereConfirmTitle": "Alle anderen Geräte abmelden?",
- "signOutEverywhereConfirmBody": "Jedes Gerät außer diesem muss sich neu anmelden. Dieses Gerät bleibt angemeldet. An deinen erfassten Daten ändert sich nichts."
+ "signOutEverywhereConfirmBody": "Alle anderen Geräte, jeder verbundene KI-Assistent und jedes API-Token müssen sich neu anmelden oder neu eingerichtet werden. Dieses Gerät bleibt angemeldet. Deine Einträge bleiben unberührt.",
+ "signOutEverywhereShareLinks": "Auch die Freigabelinks für Ärztinnen und Ärzte beenden",
+ "signOutEverywhereInvites": "Einladungen zu deiner Akte, die noch niemand angenommen hat, werden zurückgezogen. Wer schon angenommen hat, behält den Zugriff; du kannst ihn gleich danach beenden.",
+ "signOutEverywhereInvitesDone": "{count} nicht angenommene Einladung(en) zurückgezogen.",
+ "grantsKeptTitle": "Diese Personen sehen deine Akte weiterhin",
+ "grantsKeptBody": "Das Abmelden beendet keinen Zugriff, den du jemandem gegeben hast. Beende ihn hier, wenn du ihn nicht gegeben hast oder nicht mehr willst."
},
"privacy": {
"encryption": {
@@ -6579,7 +6603,7 @@
"delete": {
"title": "Löschen oder zurücksetzen",
"description": "Lösche dein Konto vollständig oder lösche deine Gesundheitsdaten und behalte das Konto.",
- "backupLag": "Die Löschung entfernt deine Daten sofort. Verschlüsselte Off-Host-Backups enthalten sie weiterhin, bis sie aus dem Aufbewahrungsfenster des Betreibers herausfallen.",
+ "backupLag": "Die Löschung entfernt deine Daten sofort. Wenn dieser Server verschlüsselte externe Sicherungen anlegt, werden die Kopien deiner Daten gleich danach aus diesem Speicher gelöscht, oder in der nächsten Nacht, falls der Speicher nicht erreichbar ist.",
"openAdvanced": "Löschen oder zurücksetzen"
},
"posture": {
@@ -6698,6 +6722,41 @@
"showChecklist": "Checkliste wieder anzeigen",
"checklistShown": "Die Checkliste ist wieder auf dem Dashboard.",
"checklistNothingHidden": "Die Checkliste war nicht ausgeblendet; sie öffnet sich auf dem Dashboard."
+ },
+ "documentSources": {
+ "title": "Dokumentarchive",
+ "description": "Paperless-ngx oder Papra aus HealthLog durchsuchen und ausgewählte Dokumente importieren.",
+ "explainer": "Ein Import-Knopf erscheint überall, wo du Dokumente verknüpfst: bei Arztbesuchen, Impfungen und Erkrankungen sowie unter Dokumente. HealthLog speichert von jedem importierten Dokument eine eigene verschlüsselte Kopie und fragt dein Archiv nur, wenn du suchst oder importierst.",
+ "systemLabel": "Archiv",
+ "statusConnected": "Verbunden",
+ "statusNotConnected": "Nicht verbunden",
+ "connectedSince": "Verbunden, zuletzt geprüft am {date}",
+ "originRemoved": "Diese Adresse steht nicht mehr auf der Liste der erlaubten Archive dieses Servers. Frag die Administration oder speichere eine andere Adresse.",
+ "baseUrl": "Adresse",
+ "allowedOrigins": "Auf diesem Server erlaubt: {origins}",
+ "organizationId": "Organisations-ID",
+ "organizationHelp": "Der Teil nach /organizations/ in der Adresszeile von Papra. Papra-Organisations-IDs sehen aus wie org_ gefolgt von 24 Buchstaben und Ziffern.",
+ "token": "API-Token",
+ "tokenSaved": "Für diese Adresse gespeichert. Leer lassen, um es zu behalten.",
+ "tokenHelpPaperless": "In Paperless-ngx: Mein Profil, API-Token.",
+ "tokenHelpPapra": "In Papra: ein API-Schlüssel mit documents:read und tags:read.",
+ "save": "Speichern und testen",
+ "saving": "Wird getestet …",
+ "saved": "Verbunden. Das Archiv hat geantwortet.",
+ "test": "Testen",
+ "testOk": "Das Archiv hat in {latency} ms geantwortet.",
+ "disconnect": "Trennen",
+ "disconnectTitle": "{name} trennen?",
+ "disconnectDescription": "HealthLog vergisst Adresse und Token. Bereits importierte Dokumente bleiben in HealthLog.",
+ "unavailableExplainer": "Dieser Server erlaubt keine Dokumentarchive mehr, die Verbindung unten ist daher nicht nutzbar. Du kannst sie weiterhin entfernen.",
+ "moduleOffExplainer": "Dokumente sind ausgeschaltet, die Verbindung unten wird daher nicht genutzt. Du kannst sie weiterhin entfernen.",
+ "tokenNewAddress": "Für eine neue Adresse wird das API-Token erneut gebraucht."
+ },
+ "emailChangePassword": "Aktuelles Passwort (nötig, um die E-Mail-Adresse zu ändern)",
+ "reproof": {
+ "description": "Damit werden deine Gesundheitsdaten weitergegeben oder exportiert. Bestätige, dass du es bist, um fortzufahren.",
+ "signInAgain": "Melde dich neu an und versuche es dann noch einmal. Dein Konto hat kein Passwort und keinen Passkey, den du hier bestätigen könntest.",
+ "failed": "Das hat nicht geklappt. Prüfe deine Eingabe und versuche es noch einmal."
}
},
"admin": {
@@ -6746,7 +6805,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",
@@ -7063,6 +7126,7 @@
"versionTileBuiltAt": "Gebaut am",
"versionTileUpdateAvailable": "Update verfügbar: {tag}",
"versionTileUpToDate": "Aktuell",
+ "versionTileCheckOff": "Update-Prüfung aus",
"snapshotLoadError": "System-Snapshot konnte nicht geladen werden.",
"snapshotLoading": "Snapshot wird geladen…",
"auditTitle": "Letzte Aktivitäten",
@@ -7267,7 +7331,7 @@
"description": "Wann dieser Host zuletzt die verschlüsselte Kopie jedes Kontos in deinen Bucket gelegt hat.",
"loadError": "Der Status außer Haus konnte nicht geladen werden.",
"notConfiguredTitle": "Sicherung außer Haus ist nicht eingerichtet",
- "notConfiguredDescription": "Es sind weder Bucket noch Schlüssel gesetzt, also verlässt nichts diesen Host. Setze BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY und BACKUP_ENCRYPTION_KEY, um den nächtlichen Upload einzuschalten.",
+ "notConfiguredDescription": "Es sind weder Bucket noch Schlüssel gesetzt, also verlässt keine Sicherung diesen Host. Setze BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY und BACKUP_ENCRYPTION_KEY, um den nächtlichen Upload einzuschalten.",
"stateFresh": "Frisch",
"stateDue": "Fällig",
"stateStale": "Veraltet",
@@ -7276,7 +7340,12 @@
"neverDetail": "Für dieses Konto ist nichts angekommen. Zuletzt geprüft {when}.",
"unknownDetail": "Noch kein nächtlicher Lauf hat dieses Konto erfasst.",
"age": "vor {hours} h",
- "scheduleNote": "Der Upload läuft alle {hours} h. Frisch heißt innerhalb eines Laufs, fällig heißt ein Lauf blieb ohne Ergebnis, veraltet heißt zwei."
+ "scheduleNote": "Der Upload läuft alle {hours} h. Frisch heißt innerhalb eines Laufs, fällig heißt ein Lauf blieb ohne Ergebnis, veraltet heißt zwei.",
+ "lifecycleConfigured": "Der Bucket löscht Kopien nach {days} Tagen über seine eigene Lifecycle-Regel.",
+ "lifecycleMissing": "Der Bucket hat keine Lifecycle-Regel, jede nächtliche Kopie bleibt also für immer liegen. Lege eine Regel an, die Objekte nach BACKUP_RETENTION_DAYS löscht.",
+ "lifecycleUnknown": "Ob der Bucket alte Kopien löscht, ließ sich nicht lesen: Die Zugangsdaten dürfen seine Lifecycle-Regel nicht lesen. Prüfe, dass eine Regel Objekte nach BACKUP_RETENTION_DAYS löscht.",
+ "pendingDeletions": "Kopien von {count} gelöschten oder geleerten Konten liegen noch im Bucket und werden entfernt.",
+ "pendingDeletionsFailing": "Kopien von {count} gelöschten oder geleerten Konten liegen noch im Bucket, weil er das Löschen verweigert hat ({error}). Gib den Zugangsdaten DeleteObject; das Löschen wird jede Nacht wiederholt."
},
"restoreStarted": "Wiederherstellung gestartet. Der Fortschritt steht über der Liste.",
"restoreActive": "Für dieses Konto läuft schon eine Wiederherstellung. Warte, bis sie fertig ist.",
@@ -7309,7 +7378,10 @@
"restoreFailureNotStarted": "Der Hintergrunddienst hat die Wiederherstellung nicht übernommen. Prüfe, ob er läuft.",
"restoreFailureEnqueue": "Die Wiederherstellung konnte nicht an den Hintergrunddienst übergeben werden. Prüfe, ob er läuft.",
"restoreFailureAfterCommit": "Die Daten wurden wiederhergestellt, aber ein Schritt danach ist fehlgeschlagen. Diagramme holen das womöglich erst beim nächsten nächtlichen Lauf nach.",
- "restoreFailureUnexpected": "Die Wiederherstellung ist an einem unerwarteten Fehler gescheitert und wurde zurückgerollt. Das Server-Log nennt den Fehler."
+ "restoreFailureUnexpected": "Die Wiederherstellung ist an einem unerwarteten Fehler gescheitert und wurde zurückgerollt. Das Server-Log nennt den Fehler.",
+ "restoreFailureKeyMissing": "Die Sicherung braucht den Verschlüsselungsschlüssel {keys}, mit dem dieser Server sie nicht öffnen kann. Nimm den Schlüssel wieder in ENCRYPTION_KEYS auf, starte neu und starte die Wiederherstellung erneut.",
+ "restoreFailureForeignReference": "Die Datei verweist auf Einträge, die zu einem anderen Konto gehören. Es wurde nichts geschrieben.",
+ "previewKeyMissing": "Diese Sicherung lässt sich auf diesem Server nicht wiederherstellen: Sie braucht den Verschlüsselungsschlüssel {keys}. Nimm den Schlüssel zuerst wieder in ENCRYPTION_KEYS auf und starte neu."
},
"danger-zone": {
"title": "Gefahrenzone",
@@ -7348,7 +7420,7 @@
"rotateEnqueued": "Rotation gestartet.",
"rotateFailed": "Rotation konnte nicht gestartet werden.",
"stepUpNeeded": "Ein frischer zweiter Faktor ist erforderlich. Bestätige deinen zweiten Faktor oder nutze das Kommandozeilen-Werkzeug, falls kein zweiter Faktor eingerichtet ist.",
- "cliNote": "Für diesen Knopf ist ein zweiter Faktor nötig. Betreiber ohne zweiten Faktor rotieren mit scripts/rotate-encryption-key.ts.",
+ "cliNote": "Für diesen Knopf ist ein zweiter Faktor nötig. Ohne ihn führen Betreiber scripts/rotate-encryption-key.ts aus einem Quellcode-Checkout dieser Version aus; die Schritte stehen in docs/ops/encryption-key-rotation.md.",
"columnsTitle": "Abdeckung pro Spalte",
"columnsDescription": "Jede verschlüsselte Spalte mit ihren Zeilenzahlen auf dem aktiven Schlüssel gegenüber älteren Schlüsseln.",
"colColumn": "Spalte",
@@ -7361,7 +7433,17 @@
"completed": "Abgeschlossen",
"failed": "Fehlgeschlagen"
},
- "coverageDetail": "Es werden nur Schlüssel-IDs und Anzahlen gezeigt — niemals Schlüsselmaterial."
+ "coverageDetail": "Es werden nur Schlüssel-IDs und Anzahlen gezeigt — niemals Schlüsselmaterial.",
+ "backupsTitle": "Schlüssel, die die Sicherungen noch brauchen",
+ "backupsDescription": "Ein alter Schlüssel bleibt nötig, solange eine Sicherung unten ihn noch aufführt.",
+ "backupsStoredLine": "Schlüssel {key}: {count} gespeicherte Kopien, die älteste vom {when}",
+ "backupsUnrecordedLine": "{count} gespeicherte Kopien stammen aus der Zeit, bevor Sicherungen ihre Schlüssel festhielten (die älteste vom {when}). Sie können jeden damals vorhandenen Schlüssel brauchen: Behalte alle alten Schlüssel, bis sie ersetzt oder gelöscht sind.",
+ "backupsOffhostLine": "Schlüssel {key}: Die letzte externe Kopie, die ihn braucht, kam am {when} in den Bucket; dort gebraucht bis {until}",
+ "backupsOffhostLineUnknown": "Schlüssel {key}: Die letzte externe Kopie, die ihn braucht, kam am {when} in den Bucket. Wie lange der Bucket Kopien behält, ist unbekannt; behalte den Schlüssel, bis diese Kopie sicher weg ist.",
+ "backupsNone": "Noch keine Sicherung hat die Schlüssel festgehalten, die sie braucht.",
+ "backupsStillNeed": "Alle Zeilen sind rotiert, aber Sicherungen brauchen noch {keys}",
+ "backupsUnrecordedBadge": "Alle Zeilen sind rotiert, aber ältere Sicherungen können noch einen ausgemusterten Schlüssel brauchen",
+ "backupsDetail": "Die Rotation verschlüsselt jede Zeile neu, aber eine Sicherung behält den Schlüssel, unter dem ihr Inhalt geschrieben wurde, und ihre Wiederherstellung braucht ihn. Behalte einen ausgemusterten Schlüssel in ENCRYPTION_KEYS, bis er unten nicht mehr aufgeführt ist."
}
},
"carrier": "Netzbetreiber",
@@ -7408,7 +7490,11 @@
"countOther": "{count} Fehlschläge",
"lastFailed": "Letzter Fehlschlag {when}"
},
- "deleteAllDetail": "Nutzerkonten bleiben bestehen."
+ "deleteAllDetail": "Nutzerkonten bleiben bestehen.",
+ "cardError": {
+ "title": "Dieser Teil der Seite konnte nicht angezeigt werden",
+ "description": "Der Rest der Seite funktioniert weiter; versuche es erneut oder lade neu, wenn es bleibt."
+ }
},
"achievements": {
"title": "Erfolge",
@@ -8016,7 +8102,8 @@
"bodyToday": "Dein Vorrat an {medName} reicht keinen ganzen Tag mehr ({units} Einheiten übrig).",
"bodyReorder": "Dein Vorrat an {medName} ({units} Einheiten) ist um den {runsOutOn} aufgebraucht — bestelle bis zum {reorderBy} nach.",
"bodyLastDose": "Dein Vorrat an {medName} ({units} Einheiten) reicht noch etwa für die letzte Dosis. Aufgebraucht um den {runsOutOn}.",
- "bodyExpired": "{medName}: kein nutzbarer Bestand mehr. {units} Einheiten sind als abgelaufen markiert."
+ "bodyExpired": "{medName}: kein nutzbarer Bestand mehr. {units} Einheiten sind als abgelaufen markiert.",
+ "bodyAsNeeded": "Von {medName} sind nur noch {units} Einheiten übrig. Bestelle nach, bevor du die nächste Dosis brauchst."
},
"moodReminders": {
"dailyTitle": "Stimmung erfassen",
@@ -10147,10 +10234,74 @@
"body": "Beim Lesen mit KI geht dies an deinen KI-Dienst außerhalb dieses Servers. Darf HealthLog Dokumente, Laborbefunde und Medikamentenbeschreibungen zum Lesen senden?",
"grant": "Lesen erlauben",
"error": "Die Einwilligung konnte nicht gespeichert werden. Bitte versuch es erneut."
+ },
+ "sourcePicker": {
+ "open": "Aus Archiv importieren",
+ "openNamed": "Aus {name} importieren",
+ "title": "Aus {name} importieren",
+ "description": "Nach Namen suchen, dann die Dokumente anhaken, die HealthLog übernehmen soll.",
+ "sourceLabel": "Archiv",
+ "searchLabel": "Dokumentname",
+ "searchPlaceholder": "Nach Namen suchen",
+ "tagLabel": "Tag",
+ "tagAny": "Alle Tags",
+ "fromLabel": "Von",
+ "toLabel": "Bis",
+ "clearFilters": "Tag und Datum zurücksetzen",
+ "rangeInvalid": "Das Enddatum liegt vor dem Startdatum.",
+ "empty": "{name} hat keine Dokumente zum Anzeigen.",
+ "noMatch": "Keine passenden Dokumente.",
+ "loadMore": "Mehr laden",
+ "stateImported": "In HealthLog",
+ "stateDeleted": "In HealthLog gelöscht",
+ "selectedCount": "{count} von höchstens {max} ausgewählt",
+ "importAction": "{count} importieren",
+ "importing": "Import läuft, ein Dokument nach dem anderen …",
+ "done": "Fertig",
+ "summary": "Importiert: {imported}. Schon vorhanden: {present}. Nicht importiert: {failed}.",
+ "stopped": "Abgebrochen: {reason}",
+ "result": {
+ "waiting": "Wartet",
+ "working": "Wird importiert …",
+ "imported": "Importiert",
+ "importedLinked": "Importiert und verknüpft",
+ "duplicate": "Schon in HealthLog",
+ "duplicateLinked": "Schon in HealthLog, jetzt verknüpft",
+ "deleted": "In HealthLog gelöscht, bleibt gelöscht"
+ },
+ "errors": {
+ "unavailable": "Dokumentarchive sind auf diesem Server nicht freigeschaltet.",
+ "notConnected": "Dieses Archiv ist nicht verbunden. Verbinde es unter Einstellungen, Integrationen.",
+ "originNotAllowed": "Diese Adresse steht nicht auf der Liste der erlaubten Archive dieses Servers. Bitte die Administration, sie aufzunehmen.",
+ "unreachable": "Das Archiv war nicht erreichbar. Prüfe, ob es läuft und die Adresse stimmt.",
+ "redirected": "Das Archiv hat die Anfrage umgeleitet. Trage die Adresse ein, auf die es umleitet.",
+ "authRefused": "Das Archiv hat das Token abgelehnt. Prüfe das Token und seine Berechtigungen.",
+ "versionTooOld": "Das Archiv ist zu alt. Nötig ist Paperless-ngx 2.16 oder neuer.",
+ "badResponse": "Die Antwort des Archivs ließ sich nicht lesen. Prüfe, ob die Adresse auf das Archiv selbst zeigt.",
+ "notFound": "Das Archiv kennt dieses Dokument nicht, oder die Organisations-ID ist falsch oder nicht deine.",
+ "linkTargetNotFound": "Der Eintrag, mit dem verknüpft werden sollte, existiert nicht mehr.",
+ "rateLimited": "Zu viele Anfragen. Warte eine Minute und versuche es erneut.",
+ "browserOnly": "Dokumentarchive lassen sich nur im Browser verwenden.",
+ "invalidAddress": "Gib die vollständige Adresse ein, beginnend mit https:// oder http://.",
+ "organizationRequired": "Gib die Organisations-ID aus Papra ein.",
+ "tokenRequired": "Gib das API-Token ein. Eine neue Adresse braucht es erneut.",
+ "fileTooLarge": "Die Datei ist größer, als HealthLog annimmt.",
+ "quotaExceeded": "Dein Dokumentenspeicher ist voll.",
+ "fileType": "HealthLog nimmt diesen Dateityp nicht an.",
+ "uploadLimit": "Das stündliche Upload-Limit ist erreicht. Versuche es später erneut.",
+ "uploadBusy": "Andere Uploads laufen noch. Versuche es gleich noch einmal.",
+ "moduleDisabled": "Dokumente sind in den Einstellungen ausgeschaltet.",
+ "generic": "Etwas ist schiefgelaufen. Versuche es erneut.",
+ "permissionMissing": "Der Papra-Schlüssel darf keine Tags lesen. Gib ihm die Berechtigung tags:read."
+ },
+ "pickMore": "Weitere auswählen",
+ "finishedHint": "Suche erneut oder ändere einen Filter, um weitere Dokumente auszuwählen.",
+ "maxReached": "Du kannst höchstens {max} Dokumente auf einmal importieren.",
+ "noMatchHint": "Versuche einen Teil des Namens oder setze Tag und Datum zurück."
}
},
"selfDescription": {
- "dataPosture": "HealthLog wird selbst gehostet: Deine Gesundheitsdaten bleiben auf deiner eigenen Instanz und sind im Ruhezustand verschlüsselt. KI-Funktionen nutzen einen von dir mitgebrachten Anbieter-Schlüssel oder ein lokales Modell — nichts verlässt deine Instanz über den von dir konfigurierten Anbieter hinaus, und es gibt keine Drittanbieter-Analyse deiner Gesundheitsdaten."
+ "dataPosture": "HealthLog wird selbst gehostet: Deine Gesundheitsdaten liegen in der Datenbank deiner eigenen Instanz. Notizen und andere Freitexte, Dokumente, Fragebogen-Antworten, von der KI geschriebene Texte und Zugangs-Tokens sind im Ruhezustand verschlüsselt; Messwerte, Laborwerte, Medikamentennamen und Stimmungswerte werden unverschlüsselt gespeichert und sind so sicher wie dein Server. KI-Funktionen senden Daten nur an den KI-Anbieter, der für dein Konto eingerichtet ist, und es gibt keine Drittanbieter-Analyse deiner Gesundheitsdaten."
},
"customMetrics": {
"sectionTitle": "Eigene Metriken",
diff --git a/messages/en.json b/messages/en.json
index fa1fb5458..ff67a71e9 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -126,7 +126,14 @@
"nothingSaved": "Nothing was saved. Check the highlighted field and try again.",
"invalidBody": "The profile update could not be read.",
"emailInUse": "This email address is already in use.",
- "emailRateLimited": "Too many email-address changes. Try again later."
+ "emailRateLimited": "Too many email-address changes. Try again later.",
+ "emailSecondFactorRequired": "Confirm with your second factor or a passkey to change the email address."
+ }
+ },
+ "auth": {
+ "reproof": {
+ "required": "Confirm your current password to change this.",
+ "failed": "That did not verify. Nothing was changed."
}
}
},
@@ -440,7 +447,8 @@
"errorEmailUnverified": "Your identity provider did not mark your email address as verified.",
"errorIdentityConflict": "That email address belongs to an account that is already linked to a different SSO identity.",
"errorRegistrationDisabled": "No account matches that identity, and new sign-ups are currently closed.",
- "errorFailed": "Single sign-on failed. Please try again."
+ "errorFailed": "Single sign-on failed. Please try again.",
+ "errorLinkRequired": "An account with this email address already exists here. Sign in to it once with its password or passkey to connect it to single sign-on."
},
"native": {
"signingInToApp": "Signing in to the HealthLog app"
@@ -464,6 +472,13 @@
"title": "Set up a second factor",
"body": "Your administrator requires a second factor on this account. Add an authenticator app or a security key to continue.",
"signOut": "Sign out"
+ },
+ "confirmAccess": {
+ "title": "Confirm it is you",
+ "body": "An assistant is asking to connect to your HealthLog data. Confirm it is you, then you will be taken back to decide whether to allow it.",
+ "action": "Confirm",
+ "failed": "That did not work. Please try again.",
+ "nothingToConfirm": "There is nothing to confirm here. Start the connection again from your assistant."
}
},
"dashboard": {
@@ -4591,7 +4606,7 @@
"sampleBody": "Weight down 1.2 kg over two weeks. Resting pulse steady at 58 bpm. Blood pressure in range on 6 of 7 days — the one high reading followed a late, salty dinner.",
"sampleCaption": "Example only — not your own data. This is what a briefing reads like once insights are on.",
"ladderTitle": "Three ways to turn it on",
- "ladderLocalTitle": "Local model — nothing leaves your network",
+ "ladderLocalTitle": "Local model — AI requests stay on your network",
"ladderLocalBody": "Point HealthLog at Ollama or LM Studio on your own machine. Free and fully private — the calm default.",
"ladderByokTitle": "Bring your own key",
"ladderByokBody": "Use your own OpenAI or Anthropic key. It runs under your account and billing, and the API tier keeps your data out of training by default.",
@@ -5173,7 +5188,6 @@
"passphrasePlaceholder": "Passphrase (min. 12 characters)",
"passphraseHelp": "Derives the archive key with Argon2id and seals it with AES-256-GCM. Save the file with a .hlx extension.",
"noRecoveryWarning": "There is no recovery — if you lose this passphrase, the archive cannot be opened. Keep it somewhere safe.",
- "stepUpNeeded": "Confirm your second factor, then try again.",
"detail": "Same shape as the weekly auto-backup. Some record types are not carried yet — the file's manifest names what is in it.",
"contents": "Your measurements, medications, mood, cycle, labs, illness episodes, allergies, family history, nutrients, workouts, health profile and custom metrics in one JSON file."
}
@@ -5553,7 +5567,10 @@
"invalidFormat": "That is not written in a form this field can read.",
"invalidType": "That value did not arrive in a form this field can store.",
"rateLimited": "Changed too often. Wait a little and try again.",
- "other": "This value was not accepted. Check it and try again."
+ "other": "This value was not accepted. Check it and try again.",
+ "reproofRequired": "Enter your current password to change the email address.",
+ "reproofFailed": "That password is not correct, so the email address was not changed.",
+ "secondFactorRequired": "Confirm with your second factor or a passkey to change the email address."
},
"avatar": {
"title": "Profile photo",
@@ -5594,7 +5611,9 @@
"currentPassword": "Current password",
"authenticatorCode": "Authenticator code",
"authenticatorPlaceholder": "6-digit authenticator code",
- "verifyAndAdd": "Verify and add"
+ "verifyAndAdd": "Verify and add",
+ "factorDescription": "Verify an existing sign-in method before adding a second factor.",
+ "verifyAndContinue": "Verify and continue"
},
"passkeyName": "Name",
"noPasskeys": "No passkeys registered.",
@@ -5655,10 +5674,10 @@
"codexConnected": "ChatGPT connected successfully! Insights are now active.",
"codexDisconnected": "ChatGPT connection removed.",
"codexConnectionFailed": "ChatGPT connection failed. Please try again.",
- "rawData": "Send raw data",
- "rawDataOnDescription": "Aggregated metrics plus anonymized individual points from the last 30 days with time context are sent — never your name, email, or direct account identifiers.",
- "rawDataOffDescription": "Only summarized metrics are sent (for example averages, trends, minimum/maximum) — no individual points and no exact time context.",
- "rawDataWarning": "Raw mode enabled: the provider receives additional anonymized points from the last 30 days for higher analysis accuracy.",
+ "rawData": "Send detailed history",
+ "rawDataOnDescription": "Besides the summaries, the provider receives averages of your readings: per day for the last 90 days, per week for the year before, and per month back to 5 years, each with its date and number of readings. Single readings, your name and your email are not sent.",
+ "rawDataOffDescription": "Only summaries are sent: the latest value, averages, trends, minimum and maximum over fixed windows. No series of readings.",
+ "rawDataWarning": "Detailed history is on: every analysis also sends daily averages for the last 90 days, weekly averages for the year before, and monthly averages back to 5 years.",
"regenerateInsights": "Regenerate reports",
"regenerateSuccess": "Reports regenerated successfully",
"regenerateRateLimit": "Please wait — you've hit the hourly limit for analyses.",
@@ -6545,7 +6564,12 @@
"revokeSessionConfirmTitle": "Sign this device out?",
"revokeSessionConfirmBody": "{device} will need to sign in again. Nothing recorded on it is affected.",
"signOutEverywhereConfirmTitle": "Sign out every other device?",
- "signOutEverywhereConfirmBody": "Every device except this one will need to sign in again. This device stays signed in. Nothing you have recorded is affected."
+ "signOutEverywhereConfirmBody": "Every other device, every connected AI assistant and every API token will need to sign in or be set up again. This device stays signed in. Nothing you have recorded is affected.",
+ "signOutEverywhereShareLinks": "Also end the doctor share links",
+ "signOutEverywhereInvites": "Invitations to your record that nobody has accepted yet are withdrawn. People who already accepted keep their access; you can end it next.",
+ "signOutEverywhereInvitesDone": "Withdrew {count} invitation(s) nobody had accepted.",
+ "grantsKeptTitle": "These people can still see your record",
+ "grantsKeptBody": "Signing out does not end access you gave someone. End it here if you did not give it, or no longer want them to have it."
},
"privacy": {
"encryption": {
@@ -6579,7 +6603,7 @@
"delete": {
"title": "Delete or reset",
"description": "Remove your account entirely, or wipe your health records while keeping the account.",
- "backupLag": "Deletion removes your data immediately. Encrypted off-host backups still contain it until they roll off the operator's backup retention window.",
+ "backupLag": "Deletion removes your data immediately. If this server keeps encrypted off-host backups, the copies of your data are deleted from that storage right after, or on the next nightly run if the storage is unreachable.",
"openAdvanced": "Delete or reset"
},
"posture": {
@@ -6698,6 +6722,41 @@
"showChecklist": "Show the checklist again",
"checklistShown": "The checklist is back on the dashboard.",
"checklistNothingHidden": "The checklist was not hidden; it opens on the dashboard."
+ },
+ "documentSources": {
+ "title": "Document archives",
+ "description": "Search Paperless-ngx or Papra from HealthLog and import the documents you pick.",
+ "explainer": "An import button appears wherever you link documents: on a visit, a vaccination and a condition, and in Documents. HealthLog keeps its own encrypted copy of each document you import, and asks your archive only when you search or import.",
+ "systemLabel": "Archive",
+ "statusConnected": "Connected",
+ "statusNotConnected": "Not connected",
+ "connectedSince": "Connected, last checked {date}",
+ "originRemoved": "This address is no longer on the server's list of allowed archives. Ask the administrator, or save another address.",
+ "baseUrl": "Address",
+ "allowedOrigins": "Allowed on this server: {origins}",
+ "organizationId": "Organization id",
+ "organizationHelp": "The part after /organizations/ in the Papra address bar. Papra organization ids look like org_ followed by 24 letters and digits.",
+ "token": "API token",
+ "tokenSaved": "Saved for this address. Leave empty to keep it.",
+ "tokenHelpPaperless": "In Paperless-ngx: My Profile, API token.",
+ "tokenHelpPapra": "In Papra: an API key with documents:read and tags:read.",
+ "save": "Save and test",
+ "saving": "Testing…",
+ "saved": "Connected. The archive answered.",
+ "test": "Test",
+ "testOk": "The archive answered in {latency} ms.",
+ "disconnect": "Disconnect",
+ "disconnectTitle": "Disconnect {name}?",
+ "disconnectDescription": "HealthLog forgets the address and the token. Documents you already imported stay in HealthLog.",
+ "unavailableExplainer": "This server no longer allows document archives, so the connection below cannot be used. You can still remove it.",
+ "moduleOffExplainer": "Documents are switched off, so the connection below is not used. You can still remove it.",
+ "tokenNewAddress": "A new address needs the API token again."
+ },
+ "emailChangePassword": "Current password (needed to change the email address)",
+ "reproof": {
+ "description": "This shares or exports your health data. Confirm it's you to continue.",
+ "signInAgain": "Sign in again, then try once more. Your account has no password or passkey to confirm here.",
+ "failed": "That did not work. Check what you entered and try again."
}
},
"admin": {
@@ -6746,7 +6805,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",
@@ -7063,6 +7126,7 @@
"versionTileBuiltAt": "Built at",
"versionTileUpdateAvailable": "Update available: {tag}",
"versionTileUpToDate": "Up to date",
+ "versionTileCheckOff": "Update check off",
"snapshotLoadError": "Failed to load system snapshot.",
"snapshotLoading": "Loading snapshot…",
"auditTitle": "Recent activity",
@@ -7267,7 +7331,7 @@
"description": "When this host last put each account's encrypted copy in your bucket.",
"loadError": "Could not load the off-host status.",
"notConfiguredTitle": "Off-host backup is not configured",
- "notConfiguredDescription": "No bucket and no key are set, so nothing leaves this host. Set BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY and BACKUP_ENCRYPTION_KEY to turn the nightly upload on.",
+ "notConfiguredDescription": "No bucket and no key are set, so no backup leaves this host. Set BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY and BACKUP_ENCRYPTION_KEY to turn the nightly upload on.",
"stateFresh": "Fresh",
"stateDue": "Due",
"stateStale": "Stale",
@@ -7276,7 +7340,12 @@
"neverDetail": "Nothing has landed for this account. Last checked {when}.",
"unknownDetail": "No nightly run has recorded this account yet.",
"age": "{hours} h ago",
- "scheduleNote": "The upload runs every {hours} h. Fresh means inside one run, due means one run produced nothing, stale means two did not."
+ "scheduleNote": "The upload runs every {hours} h. Fresh means inside one run, due means one run produced nothing, stale means two did not.",
+ "lifecycleConfigured": "The bucket deletes copies after {days} days by its own lifecycle rule.",
+ "lifecycleMissing": "The bucket has no lifecycle rule, so every nightly copy is kept forever. Add a rule that expires objects after BACKUP_RETENTION_DAYS.",
+ "lifecycleUnknown": "Whether the bucket deletes old copies could not be read: the credential may not read its lifecycle rule. Check that a rule expires objects after BACKUP_RETENTION_DAYS.",
+ "pendingDeletions": "Copies of {count} deleted or wiped accounts are still in the bucket and are being removed.",
+ "pendingDeletionsFailing": "Copies of {count} deleted or wiped accounts are still in the bucket because it refused to delete them ({error}). Give the credential DeleteObject; the deletion is retried every night."
},
"restoreStarted": "Restore started. Its progress shows above the list.",
"restoreActive": "A restore of this account is already running. Wait for it to finish.",
@@ -7309,7 +7378,10 @@
"restoreFailureNotStarted": "The background worker did not pick the restore up. Check that the worker is running.",
"restoreFailureEnqueue": "The restore could not be handed to the background worker. Check that the worker is running.",
"restoreFailureAfterCommit": "The data was restored, but a step after it failed. Charts may take until the next nightly run to catch up.",
- "restoreFailureUnexpected": "The restore stopped on an unexpected error and was rolled back. The server log names the error."
+ "restoreFailureUnexpected": "The restore stopped on an unexpected error and was rolled back. The server log names the error.",
+ "restoreFailureKeyMissing": "The backup needs encryption key {keys}, which this server cannot open it with. Add the key back to ENCRYPTION_KEYS, restart, and start the restore again.",
+ "restoreFailureForeignReference": "The file points at records that belong to another account. Nothing was written.",
+ "previewKeyMissing": "This backup cannot be restored on this server: it needs encryption key {keys}. Add the key back to ENCRYPTION_KEYS and restart first."
},
"danger-zone": {
"title": "Danger Zone",
@@ -7348,7 +7420,7 @@
"rotateEnqueued": "Rotation started.",
"rotateFailed": "Could not start the rotation.",
"stepUpNeeded": "A fresh second factor is required. Confirm your second factor, or use the command-line tool if you have no second factor enrolled.",
- "cliNote": "A second factor is required to use this button. Operators without one rotate with scripts/rotate-encryption-key.ts.",
+ "cliNote": "A second factor is required to use this button. Without one, run scripts/rotate-encryption-key.ts from a source checkout of this release; the steps are in docs/ops/encryption-key-rotation.md.",
"columnsTitle": "Per-column coverage",
"columnsDescription": "Each encrypted column with its row counts on the active key versus older keys.",
"colColumn": "Column",
@@ -7361,7 +7433,17 @@
"completed": "Completed",
"failed": "Failed"
},
- "coverageDetail": "Only key ids and counts are shown — never key material."
+ "coverageDetail": "Only key ids and counts are shown — never key material.",
+ "backupsTitle": "Keys the backups still need",
+ "backupsDescription": "A retired key stays needed while any backup below still lists it.",
+ "backupsStoredLine": "Key {key}: {count} stored copies, the oldest from {when}",
+ "backupsUnrecordedLine": "{count} stored copies were written before backups recorded their keys (the oldest on {when}). They may need any key that existed then: keep every old key until they are replaced or deleted.",
+ "backupsOffhostLine": "Key {key}: the last off-host copy that needs it went into the bucket on {when}; needed there until {until}",
+ "backupsOffhostLineUnknown": "Key {key}: the last off-host copy that needs it went into the bucket on {when}. The bucket's expiry is unknown, so keep the key until you know that copy is gone.",
+ "backupsNone": "No backup has recorded the keys it needs yet.",
+ "backupsStillNeed": "Every row is rotated, but backups still need {keys}",
+ "backupsUnrecordedBadge": "Every row is rotated, but older backups may still need a retired key",
+ "backupsDetail": "Rotation re-encrypts every row, but a backup keeps the key its content was written under, and restoring it needs that key. Keep a retired key in ENCRYPTION_KEYS until nothing below lists it."
}
},
"carrier": "Carrier",
@@ -7408,7 +7490,11 @@
"countOther": "{count} failures",
"lastFailed": "Last failure {when}"
},
- "deleteAllDetail": "User accounts will be preserved."
+ "deleteAllDetail": "User accounts will be preserved.",
+ "cardError": {
+ "title": "This part of the page could not be shown",
+ "description": "The rest of the page still works; try again or reload if it keeps failing."
+ }
},
"achievements": {
"title": "Achievements",
@@ -8016,7 +8102,8 @@
"bodyToday": "{medName}: {units} units left — less than one day of supply.",
"bodyReorder": "{medName}: {units} units left. Supply runs out around {runsOutOn} — reorder by {reorderBy}.",
"bodyLastDose": "{medName}: {units} units left — about your final dose. Supply runs out around {runsOutOn}.",
- "bodyExpired": "{medName}: no usable supply left. {units} units are marked expired."
+ "bodyExpired": "{medName}: no usable supply left. {units} units are marked expired.",
+ "bodyAsNeeded": "{medName}: only {units} units left. Reorder before you need the next dose."
},
"moodReminders": {
"dailyTitle": "Log your mood",
@@ -10147,10 +10234,74 @@
"body": "Reading with AI sends this to your AI service outside this server. Allow HealthLog to send documents, lab reports and medication descriptions for reading?",
"grant": "Allow reading",
"error": "The consent could not be saved. Please try again."
+ },
+ "sourcePicker": {
+ "open": "Import from archive",
+ "openNamed": "Import from {name}",
+ "title": "Import from {name}",
+ "description": "Search by name, then tick the documents HealthLog should copy.",
+ "sourceLabel": "Archive",
+ "searchLabel": "Document name",
+ "searchPlaceholder": "Search by name",
+ "tagLabel": "Tag",
+ "tagAny": "Any tag",
+ "fromLabel": "From",
+ "toLabel": "To",
+ "clearFilters": "Clear tag and dates",
+ "rangeInvalid": "The end date is before the start date.",
+ "empty": "{name} has no documents to show.",
+ "noMatch": "No documents match.",
+ "loadMore": "Load more",
+ "stateImported": "In HealthLog",
+ "stateDeleted": "Deleted in HealthLog",
+ "selectedCount": "{count} of up to {max} selected",
+ "importAction": "Import {count}",
+ "importing": "Importing, one document at a time…",
+ "done": "Done",
+ "summary": "Imported: {imported}. Already there: {present}. Not imported: {failed}.",
+ "stopped": "Stopped: {reason}",
+ "result": {
+ "waiting": "Waiting",
+ "working": "Importing…",
+ "imported": "Imported",
+ "importedLinked": "Imported and linked",
+ "duplicate": "Already in HealthLog",
+ "duplicateLinked": "Already in HealthLog, now linked",
+ "deleted": "Deleted in HealthLog, left alone"
+ },
+ "errors": {
+ "unavailable": "Document archives are not enabled on this server.",
+ "notConnected": "This archive is not connected. Connect it under Settings, Integrations.",
+ "originNotAllowed": "This address is not on the server's list of allowed archives. Ask the administrator to add it.",
+ "unreachable": "The archive could not be reached. Check that it is running and that the address is right.",
+ "redirected": "The archive redirected the request. Enter the address it redirects to.",
+ "authRefused": "The archive refused the token. Check the token and its permissions.",
+ "versionTooOld": "The archive is too old. Paperless-ngx 2.16 or later is needed.",
+ "badResponse": "The archive's answer could not be read. Check that the address points at the archive itself.",
+ "notFound": "The archive has no such document, or the organization id is wrong or not yours.",
+ "linkTargetNotFound": "The record to link to no longer exists.",
+ "rateLimited": "Too many requests. Wait a minute and try again.",
+ "browserOnly": "Document archives can only be used in the browser.",
+ "invalidAddress": "Enter the full address, starting with https:// or http://.",
+ "organizationRequired": "Enter the Papra organization id.",
+ "tokenRequired": "Enter the API token. A new address needs it again.",
+ "fileTooLarge": "The file is larger than HealthLog accepts.",
+ "quotaExceeded": "Your document storage is full.",
+ "fileType": "HealthLog does not accept this file type.",
+ "uploadLimit": "The hourly upload limit is reached. Try again later.",
+ "uploadBusy": "Other uploads are still running. Try again in a moment.",
+ "moduleDisabled": "Documents are switched off in Settings.",
+ "generic": "Something went wrong. Try again.",
+ "permissionMissing": "The Papra key cannot read tags. Give it the tags:read permission."
+ },
+ "pickMore": "Pick more",
+ "finishedHint": "Search again or change a filter to pick more documents.",
+ "maxReached": "You can import up to {max} documents at a time.",
+ "noMatchHint": "Try part of the name, or clear the tag and dates."
}
},
"selfDescription": {
- "dataPosture": "HealthLog is self-hosted: your health data stays on your own instance, encrypted at rest. AI features use a provider key you bring or a local model, so nothing leaves your instance beyond the provider you configure, and there is no third-party analytics on your health data."
+ "dataPosture": "HealthLog is self-hosted: your health data lives in your own instance's database. Notes and other free text, documents, questionnaire answers, AI-written text and access tokens are encrypted at rest; measurement values, lab values, medication names and mood scores are stored unencrypted and rely on the security of your server. AI features send data only to the AI provider set up for your account, and there is no third-party analytics on your health data."
},
"customMetrics": {
"sectionTitle": "Custom metrics",
diff --git a/messages/es.json b/messages/es.json
index 46f903102..241ebd154 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -126,7 +126,14 @@
"nothingSaved": "No se guardó nada. Revisa el campo marcado e inténtalo de nuevo.",
"invalidBody": "No se pudo leer la actualización del perfil.",
"emailInUse": "Esta dirección de correo ya está en uso.",
- "emailRateLimited": "Demasiados cambios de dirección de correo. Inténtalo más tarde."
+ "emailRateLimited": "Demasiados cambios de dirección de correo. Inténtalo más tarde.",
+ "emailSecondFactorRequired": "Confirma con tu segundo factor o una clave de acceso para cambiar el correo electrónico."
+ }
+ },
+ "auth": {
+ "reproof": {
+ "required": "Confirma tu contraseña actual para cambiar esto.",
+ "failed": "La verificación ha fallado. No se ha cambiado nada."
}
}
},
@@ -440,7 +447,8 @@
"errorEmailUnverified": "Tu proveedor de identidad no marcó tu correo electrónico como verificado.",
"errorIdentityConflict": "Ese correo electrónico pertenece a una cuenta que ya está vinculada a otra identidad SSO.",
"errorRegistrationDisabled": "Ninguna cuenta coincide con esa identidad y los nuevos registros están cerrados actualmente.",
- "errorFailed": "El inicio de sesión único falló. Inténtalo de nuevo."
+ "errorFailed": "El inicio de sesión único falló. Inténtalo de nuevo.",
+ "errorLinkRequired": "Ya existe aquí una cuenta con esta dirección de correo. Inicia sesión en ella una vez con su contraseña o llave de acceso para conectarla al inicio de sesión único."
},
"native": {
"signingInToApp": "Iniciando sesión en la app de HealthLog"
@@ -464,6 +472,13 @@
"title": "Configurar un segundo factor",
"body": "Tu administrador exige un segundo factor en esta cuenta. Añade una app de autenticación o una llave de seguridad para continuar.",
"signOut": "Cerrar sesión"
+ },
+ "confirmAccess": {
+ "title": "Confirma que eres tú",
+ "body": "Un asistente quiere conectarse a tus datos de HealthLog. Confirma que eres tú y después decidirás si lo permites.",
+ "action": "Confirmar",
+ "failed": "No ha funcionado. Inténtalo de nuevo.",
+ "nothingToConfirm": "Aquí no hay nada que confirmar. Vuelve a iniciar la conexión desde tu asistente."
}
},
"dashboard": {
@@ -4591,7 +4606,7 @@
"sampleBody": "Peso 1,2 kg menos en dos semanas. Pulso en reposo estable en 58 lpm. Tensión dentro del rango 6 de 7 días — la única lectura alta llegó tras una cena tardía y salada.",
"sampleCaption": "Solo un ejemplo — no son tus datos. Así se lee un resumen cuando los insights están activos.",
"ladderTitle": "Tres formas de activarlo",
- "ladderLocalTitle": "Modelo local — nada sale de tu red",
+ "ladderLocalTitle": "Modelo local — las solicitudes de IA no salen de tu red",
"ladderLocalBody": "Apunta HealthLog a Ollama o LM Studio en tu propia máquina. Gratis y totalmente privado — la opción tranquila por defecto.",
"ladderByokTitle": "Usa tu propia clave",
"ladderByokBody": "Usa tu propia clave de OpenAI o Anthropic. Funciona con tu cuenta y facturación, y el nivel de API mantiene tus datos fuera del entrenamiento por defecto.",
@@ -5173,7 +5188,6 @@
"passphrasePlaceholder": "Contraseña (mín. 12 caracteres)",
"passphraseHelp": "Deriva la clave del archivo con Argon2id y lo sella con AES-256-GCM. Guarda el archivo con la extensión .hlx.",
"noRecoveryWarning": "No hay recuperación: si pierdes esta contraseña, el archivo no se podrá abrir. Guárdala en un lugar seguro.",
- "stepUpNeeded": "Confirma tu segundo factor y vuelve a intentarlo.",
"detail": "El mismo formato que la copia automática semanal. Algunos tipos de registro aún no se incluyen: el manifiesto del archivo indica qué contiene.",
"contents": "Tus mediciones, medicamentos, estado de ánimo, ciclo, analíticas, episodios de enfermedad, alergias, antecedentes familiares, nutrientes, entrenamientos, tu perfil de salud y tus métricas personalizadas en un archivo JSON."
}
@@ -5553,7 +5567,10 @@
"invalidFormat": "Eso no está escrito en un formato que este campo pueda leer.",
"invalidType": "El valor no llegó en un formato que este campo pueda guardar.",
"rateLimited": "Se ha cambiado demasiadas veces. Espera un poco e inténtalo de nuevo.",
- "other": "No se aceptó este valor. Revísalo e inténtalo de nuevo."
+ "other": "No se aceptó este valor. Revísalo e inténtalo de nuevo.",
+ "reproofRequired": "Introduce tu contraseña actual para cambiar la dirección de correo.",
+ "reproofFailed": "La contraseña no es correcta, así que la dirección de correo no se ha cambiado.",
+ "secondFactorRequired": "Confirma con tu segundo factor o una clave de acceso para cambiar el correo electrónico."
},
"avatar": {
"title": "Foto de perfil",
@@ -5594,7 +5611,9 @@
"currentPassword": "Contraseña actual",
"authenticatorCode": "Código del autenticador",
"authenticatorPlaceholder": "Código de seis dígitos del autenticador",
- "verifyAndAdd": "Verificar y añadir"
+ "verifyAndAdd": "Verificar y añadir",
+ "factorDescription": "Verifica un método de inicio de sesión existente antes de añadir un segundo factor.",
+ "verifyAndContinue": "Verificar y continuar"
},
"passkeyName": "Nombre",
"noPasskeys": "No hay claves de acceso registradas.",
@@ -5655,10 +5674,10 @@
"codexConnected": "¡ChatGPT conectado correctamente! Insights ya está activo.",
"codexDisconnected": "Conexión con ChatGPT eliminada.",
"codexConnectionFailed": "Falló la conexión con ChatGPT. Inténtalo de nuevo.",
- "rawData": "Enviar datos en bruto",
- "rawDataOnDescription": "Se envían métricas agregadas más puntos individuales anonimizados de los últimos 30 días con contexto temporal — nunca el nombre, el correo electrónico ni los identificadores directos de la cuenta.",
- "rawDataOffDescription": "Solo se envían métricas resumidas (por ejemplo, promedios, tendencias, mínimo/máximo) — sin puntos individuales ni contexto temporal exacto.",
- "rawDataWarning": "Modo en bruto activado: el proveedor recibe puntos anonimizados adicionales de los últimos 30 días para un análisis más preciso.",
+ "rawData": "Enviar historial detallado",
+ "rawDataOnDescription": "Además de los resúmenes, el proveedor recibe promedios de tus mediciones: por día de los últimos 90 días, por semana del año anterior y por mes hasta 5 años atrás, cada uno con su fecha y número de mediciones. No se envían mediciones sueltas, tu nombre ni tu correo electrónico.",
+ "rawDataOffDescription": "Solo se envían resúmenes: el último valor, promedios, tendencias, mínimo y máximo en ventanas fijas. Ninguna serie de mediciones.",
+ "rawDataWarning": "Historial detallado activado: cada análisis envía además promedios diarios de los últimos 90 días, promedios semanales del año anterior y promedios mensuales hasta 5 años atrás.",
"regenerateInsights": "Regenerar informes",
"regenerateSuccess": "Informes regenerados correctamente",
"regenerateRateLimit": "Espera un momento — has alcanzado el límite por hora de análisis.",
@@ -6545,7 +6564,12 @@
"revokeSessionConfirmTitle": "¿Cerrar la sesión de este dispositivo?",
"revokeSessionConfirmBody": "{device} tendrá que iniciar sesión de nuevo. Lo registrado en él no se ve afectado.",
"signOutEverywhereConfirmTitle": "¿Cerrar la sesión en los demás dispositivos?",
- "signOutEverywhereConfirmBody": "Todos los dispositivos salvo este tendrán que iniciar sesión de nuevo. Este seguirá con la sesión abierta. Nada de lo que has registrado se ve afectado."
+ "signOutEverywhereConfirmBody": "Todos los demás dispositivos, cada asistente de IA conectado y cada token de API tendrán que volver a iniciar sesión o configurarse de nuevo. Este dispositivo sigue con la sesión iniciada. Nada de lo que has registrado se ve afectado.",
+ "signOutEverywhereShareLinks": "Terminar también los enlaces compartidos con profesionales médicos",
+ "signOutEverywhereInvites": "Se retiran las invitaciones a tu historial que nadie ha aceptado todavía. Quien ya aceptó conserva el acceso; puedes quitárselo a continuación.",
+ "signOutEverywhereInvitesDone": "Se retiraron {count} invitación(es) que nadie había aceptado.",
+ "grantsKeptTitle": "Estas personas todavía pueden ver tu historial",
+ "grantsKeptBody": "Cerrar sesión no quita el acceso que diste a alguien. Quítalo aquí si no lo diste o si ya no quieres que lo tenga."
},
"privacy": {
"encryption": {
@@ -6579,7 +6603,7 @@
"delete": {
"title": "Eliminar o restablecer",
"description": "Elimina tu cuenta por completo o borra tus registros de salud conservando la cuenta.",
- "backupLag": "La eliminación borra tus datos de inmediato. Las copias de seguridad cifradas externas aún los contienen hasta que caduquen según la ventana de retención del operador.",
+ "backupLag": "La eliminación borra tus datos de inmediato. Si este servidor guarda copias de seguridad cifradas externas, las copias de tus datos se borran de ese almacenamiento justo después, o en la siguiente ejecución nocturna si no está disponible.",
"openAdvanced": "Eliminar o restablecer"
},
"posture": {
@@ -6698,6 +6722,41 @@
"showChecklist": "Mostrar la lista de nuevo",
"checklistShown": "La lista vuelve a estar en el panel.",
"checklistNothingHidden": "La lista no estaba oculta; se abre en el panel."
+ },
+ "documentSources": {
+ "title": "Archivos de documentos",
+ "description": "Busca en Paperless-ngx o Papra desde HealthLog e importa los documentos que elijas.",
+ "explainer": "Aparece un botón de importar allí donde vinculas documentos: en visitas, vacunas y afecciones, y en Documentos. HealthLog guarda su propia copia cifrada de cada documento importado y solo consulta tu archivo cuando buscas o importas.",
+ "systemLabel": "Archivo",
+ "statusConnected": "Conectado",
+ "statusNotConnected": "No conectado",
+ "connectedSince": "Conectado, última comprobación el {date}",
+ "originRemoved": "Esta dirección ya no está en la lista de archivos permitidos del servidor. Consulta a la administración o guarda otra dirección.",
+ "baseUrl": "Dirección",
+ "allowedOrigins": "Permitido en este servidor: {origins}",
+ "organizationId": "Id de organización",
+ "organizationHelp": "La parte que sigue a /organizations/ en la barra de direcciones de Papra. Los id de organización de Papra tienen la forma org_ seguida de 24 letras y cifras.",
+ "token": "Token de la API",
+ "tokenSaved": "Guardado para esta dirección. Déjalo vacío para conservarlo.",
+ "tokenHelpPaperless": "En Paperless-ngx: Mi perfil, token de la API.",
+ "tokenHelpPapra": "En Papra: una clave de API con documents:read y tags:read.",
+ "save": "Guardar y probar",
+ "saving": "Probando…",
+ "saved": "Conectado. El archivo respondió.",
+ "test": "Probar",
+ "testOk": "El archivo respondió en {latency} ms.",
+ "disconnect": "Desconectar",
+ "disconnectTitle": "¿Desconectar {name}?",
+ "disconnectDescription": "HealthLog olvida la dirección y el token. Los documentos ya importados se quedan en HealthLog.",
+ "unavailableExplainer": "Este servidor ya no permite archivos de documentos, así que la conexión de abajo no se puede usar. Aún puedes eliminarla.",
+ "moduleOffExplainer": "Los documentos están desactivados, así que la conexión de abajo no se usa. Aún puedes eliminarla.",
+ "tokenNewAddress": "Una dirección nueva necesita de nuevo el token de la API."
+ },
+ "emailChangePassword": "Contraseña actual (necesaria para cambiar la dirección de correo)",
+ "reproof": {
+ "description": "Esto comparte o exporta tus datos de salud. Confirma que eres tú para continuar.",
+ "signInAgain": "Vuelve a iniciar sesión y inténtalo de nuevo. Tu cuenta no tiene contraseña ni llave de acceso que confirmar aquí.",
+ "failed": "No ha funcionado. Revisa lo que has introducido e inténtalo de nuevo."
}
},
"admin": {
@@ -6746,7 +6805,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",
@@ -7063,6 +7126,7 @@
"versionTileBuiltAt": "Compilado el",
"versionTileUpdateAvailable": "Actualización disponible: {tag}",
"versionTileUpToDate": "Actualizado",
+ "versionTileCheckOff": "Comprobación de actualizaciones desactivada",
"snapshotLoadError": "No se pudo cargar la instantánea del sistema.",
"snapshotLoading": "Cargando instantánea...",
"auditTitle": "Actividad reciente",
@@ -7267,7 +7331,7 @@
"description": "Cuándo puso este host por última vez la copia cifrada de cada cuenta en tu bucket.",
"loadError": "No se pudo cargar el estado fuera del host.",
"notConfiguredTitle": "La copia fuera del host no está configurada",
- "notConfiguredDescription": "No hay bucket ni clave definidos, así que nada sale de este host. Define BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY y BACKUP_ENCRYPTION_KEY para activar la subida nocturna.",
+ "notConfiguredDescription": "No hay bucket ni clave definidos, así que ninguna copia de seguridad sale de este host. Define BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY y BACKUP_ENCRYPTION_KEY para activar la subida nocturna.",
"stateFresh": "Reciente",
"stateDue": "Pendiente",
"stateStale": "Obsoleta",
@@ -7276,7 +7340,12 @@
"neverDetail": "No ha llegado nada para esta cuenta. Última comprobación {when}.",
"unknownDetail": "Ninguna ejecución nocturna ha registrado esta cuenta todavía.",
"age": "hace {hours} h",
- "scheduleNote": "La subida se ejecuta cada {hours} h. Reciente significa dentro de una ejecución, pendiente que una no produjo nada, obsoleta que fueron dos."
+ "scheduleNote": "La subida se ejecuta cada {hours} h. Reciente significa dentro de una ejecución, pendiente que una no produjo nada, obsoleta que fueron dos.",
+ "lifecycleConfigured": "El bucket borra las copias a los {days} días con su propia regla de ciclo de vida.",
+ "lifecycleMissing": "El bucket no tiene regla de ciclo de vida, así que cada copia nocturna se guarda para siempre. Añade una regla que caduque los objetos tras BACKUP_RETENTION_DAYS.",
+ "lifecycleUnknown": "No se pudo leer si el bucket borra las copias antiguas: la credencial no puede leer su regla de ciclo de vida. Comprueba que una regla caduque los objetos tras BACKUP_RETENTION_DAYS.",
+ "pendingDeletions": "Aún quedan en el bucket copias de {count} cuentas eliminadas o vaciadas, y se están borrando.",
+ "pendingDeletionsFailing": "Aún quedan en el bucket copias de {count} cuentas eliminadas o vaciadas porque rechazó borrarlas ({error}). Da a la credencial el permiso DeleteObject; el borrado se reintenta cada noche."
},
"restoreStarted": "Restauración iniciada. Su progreso aparece encima de la lista.",
"restoreActive": "Ya hay una restauración en curso para esta cuenta. Espera a que termine.",
@@ -7309,7 +7378,10 @@
"restoreFailureNotStarted": "El proceso en segundo plano no recogió la restauración. Comprueba que está en marcha.",
"restoreFailureEnqueue": "No se pudo entregar la restauración al proceso en segundo plano. Comprueba que está en marcha.",
"restoreFailureAfterCommit": "Los datos se restauraron, pero falló un paso posterior. Puede que los gráficos no se pongan al día hasta la próxima ejecución nocturna.",
- "restoreFailureUnexpected": "La restauración se detuvo por un error inesperado y se deshizo. El registro del servidor indica el error."
+ "restoreFailureUnexpected": "La restauración se detuvo por un error inesperado y se deshizo. El registro del servidor indica el error.",
+ "restoreFailureKeyMissing": "La copia necesita la clave de cifrado {keys}, con la que este servidor no puede abrirla. Vuelve a añadir la clave a ENCRYPTION_KEYS, reinicia e inicia de nuevo la restauración.",
+ "restoreFailureForeignReference": "El archivo apunta a registros que pertenecen a otra cuenta. No se escribió nada.",
+ "previewKeyMissing": "Esta copia no se puede restaurar en este servidor: necesita la clave de cifrado {keys}. Vuelve a añadir la clave a ENCRYPTION_KEYS y reinicia primero."
},
"danger-zone": {
"title": "Zona de peligro",
@@ -7348,7 +7420,7 @@
"rotateEnqueued": "Rotación iniciada.",
"rotateFailed": "No se pudo iniciar la rotación.",
"stepUpNeeded": "Se requiere un segundo factor reciente. Confirma tu segundo factor o usa la herramienta de línea de comandos si no tienes ninguno configurado.",
- "cliNote": "Se necesita un segundo factor para usar este botón. Los operadores sin él rotan con scripts/rotate-encryption-key.ts.",
+ "cliNote": "Se necesita un segundo factor para usar este botón. Sin él, ejecuta scripts/rotate-encryption-key.ts desde una copia del código fuente de esta versión; los pasos están en docs/ops/encryption-key-rotation.md.",
"columnsTitle": "Cobertura por columna",
"columnsDescription": "Cada columna cifrada con sus recuentos de filas en la clave activa frente a claves anteriores.",
"colColumn": "Columna",
@@ -7361,7 +7433,17 @@
"completed": "Completado",
"failed": "Fallido"
},
- "coverageDetail": "Solo se muestran los id de clave y los recuentos, nunca el material de la clave."
+ "coverageDetail": "Solo se muestran los id de clave y los recuentos, nunca el material de la clave.",
+ "backupsTitle": "Claves que las copias aún necesitan",
+ "backupsDescription": "Una clave retirada sigue siendo necesaria mientras alguna copia de abajo la indique.",
+ "backupsStoredLine": "Clave {key}: {count} copias guardadas, la más antigua del {when}",
+ "backupsUnrecordedLine": "{count} copias guardadas se escribieron antes de que las copias registraran sus claves (la más antigua el {when}). Pueden necesitar cualquier clave que existiera entonces: conserva todas las claves antiguas hasta que se sustituyan o borren.",
+ "backupsOffhostLine": "Clave {key}: la última copia externa que la necesita entró en el bucket el {when}; necesaria allí hasta el {until}",
+ "backupsOffhostLineUnknown": "Clave {key}: la última copia externa que la necesita entró en el bucket el {when}. No se sabe cuándo caducan las copias en el bucket, así que conserva la clave hasta saber que esa copia ya no está.",
+ "backupsNone": "Ninguna copia ha registrado todavía las claves que necesita.",
+ "backupsStillNeed": "Todas las filas están rotadas, pero las copias aún necesitan {keys}",
+ "backupsUnrecordedBadge": "Todas las filas están rotadas, pero copias antiguas pueden necesitar aún una clave retirada",
+ "backupsDetail": "La rotación vuelve a cifrar cada fila, pero una copia conserva la clave con la que se escribió su contenido, y restaurarla necesita esa clave. Mantén una clave retirada en ENCRYPTION_KEYS hasta que no aparezca abajo."
}
},
"carrier": "Operador",
@@ -7408,7 +7490,11 @@
"countOther": "{count} fallos",
"lastFailed": "Último fallo {when}"
},
- "deleteAllDetail": "Las cuentas de usuario se conservarán."
+ "deleteAllDetail": "Las cuentas de usuario se conservarán.",
+ "cardError": {
+ "title": "Esta parte de la página no se pudo mostrar",
+ "description": "El resto de la página sigue funcionando; inténtalo de nuevo o recarga si el fallo continúa."
+ }
},
"achievements": {
"title": "Logros",
@@ -8016,7 +8102,8 @@
"bodyToday": "{medName}: quedan {units} unidades — menos de un día de suministro.",
"bodyReorder": "{medName}: quedan {units} unidades. Las existencias se agotan hacia el {runsOutOn} — vuelve a pedir antes del {reorderBy}.",
"bodyLastDose": "{medName}: quedan {units} unidades — alrededor de tu última dosis. Las existencias se agotan hacia el {runsOutOn}.",
- "bodyExpired": "{medName}: no quedan existencias utilizables. {units} unidades están marcadas como caducadas."
+ "bodyExpired": "{medName}: no quedan existencias utilizables. {units} unidades están marcadas como caducadas.",
+ "bodyAsNeeded": "{medName}: solo quedan {units} unidades. Vuelve a pedir antes de necesitar la próxima dosis."
},
"moodReminders": {
"dailyTitle": "Registrar el ánimo",
@@ -10147,10 +10234,74 @@
"body": "Leer con IA envía esto a tu servicio de IA fuera de este servidor. ¿Permitir que HealthLog envíe documentos, informes de laboratorio y descripciones de medicamentos para leerlos?",
"grant": "Permitir lectura",
"error": "No se pudo guardar el consentimiento. Inténtalo de nuevo."
+ },
+ "sourcePicker": {
+ "open": "Importar desde archivo",
+ "openNamed": "Importar desde {name}",
+ "title": "Importar desde {name}",
+ "description": "Busca por nombre y marca los documentos que HealthLog debe copiar.",
+ "sourceLabel": "Archivo",
+ "searchLabel": "Nombre del documento",
+ "searchPlaceholder": "Buscar por nombre",
+ "tagLabel": "Etiqueta",
+ "tagAny": "Cualquier etiqueta",
+ "fromLabel": "Desde",
+ "toLabel": "Hasta",
+ "clearFilters": "Quitar etiqueta y fechas",
+ "rangeInvalid": "La fecha final es anterior a la inicial.",
+ "empty": "{name} no tiene documentos que mostrar.",
+ "noMatch": "Ningún documento coincide.",
+ "loadMore": "Cargar más",
+ "stateImported": "En HealthLog",
+ "stateDeleted": "Eliminado en HealthLog",
+ "selectedCount": "{count} de un máximo de {max} seleccionados",
+ "importAction": "Importar {count}",
+ "importing": "Importando, un documento tras otro…",
+ "done": "Hecho",
+ "summary": "Importados: {imported}. Ya estaban: {present}. No importados: {failed}.",
+ "stopped": "Detenido: {reason}",
+ "result": {
+ "waiting": "En espera",
+ "working": "Importando…",
+ "imported": "Importado",
+ "importedLinked": "Importado y vinculado",
+ "duplicate": "Ya está en HealthLog",
+ "duplicateLinked": "Ya está en HealthLog, ahora vinculado",
+ "deleted": "Eliminado en HealthLog, se deja así"
+ },
+ "errors": {
+ "unavailable": "Los archivos de documentos no están habilitados en este servidor.",
+ "notConnected": "Este archivo no está conectado. Conéctalo en Ajustes, Integraciones.",
+ "originNotAllowed": "Esta dirección no está en la lista de archivos permitidos del servidor. Pide a la administración que la añada.",
+ "unreachable": "No se pudo acceder al archivo. Comprueba que está en marcha y que la dirección es correcta.",
+ "redirected": "El archivo redirigió la solicitud. Introduce la dirección a la que redirige.",
+ "authRefused": "El archivo rechazó el token. Comprueba el token y sus permisos.",
+ "versionTooOld": "El archivo es demasiado antiguo. Se necesita Paperless-ngx 2.16 o posterior.",
+ "badResponse": "No se pudo leer la respuesta del archivo. Comprueba que la dirección apunta al propio archivo.",
+ "notFound": "El archivo no tiene ese documento, o el id de organización es incorrecto o no es tuyo.",
+ "linkTargetNotFound": "El registro que se iba a vincular ya no existe.",
+ "rateLimited": "Demasiadas solicitudes. Espera un minuto y vuelve a intentarlo.",
+ "browserOnly": "Los archivos de documentos solo se pueden usar en el navegador.",
+ "invalidAddress": "Introduce la dirección completa, empezando por https:// o http://.",
+ "organizationRequired": "Introduce el id de organización de Papra.",
+ "tokenRequired": "Introduce el token de la API. Una dirección nueva lo necesita de nuevo.",
+ "fileTooLarge": "El archivo supera el tamaño que acepta HealthLog.",
+ "quotaExceeded": "Tu almacenamiento de documentos está lleno.",
+ "fileType": "HealthLog no acepta este tipo de archivo.",
+ "uploadLimit": "Se alcanzó el límite de subidas por hora. Inténtalo más tarde.",
+ "uploadBusy": "Aún hay otras subidas en curso. Inténtalo en un momento.",
+ "moduleDisabled": "Los documentos están desactivados en Ajustes.",
+ "generic": "Algo salió mal. Inténtalo de nuevo.",
+ "permissionMissing": "La clave de Papra no puede leer etiquetas. Dale el permiso tags:read."
+ },
+ "pickMore": "Elegir más",
+ "finishedHint": "Busca de nuevo o cambia un filtro para elegir más documentos.",
+ "maxReached": "Puedes importar como máximo {max} documentos a la vez.",
+ "noMatchHint": "Prueba con parte del nombre o quita la etiqueta y las fechas."
}
},
"selfDescription": {
- "dataPosture": "HealthLog es autoalojado: tus datos de salud permanecen en tu propia instancia, cifrados en reposo. Las funciones de IA usan una clave de proveedor que tú aportas o un modelo local, así que nada sale de tu instancia más allá del proveedor que configures, y no hay analítica de terceros sobre tus datos de salud."
+ "dataPosture": "HealthLog es autoalojado: tus datos de salud están en la base de datos de tu propia instancia. Las notas y otros textos libres, los documentos, las respuestas a cuestionarios, los textos escritos por la IA y los tokens de acceso se cifran en reposo; los valores de mediciones, los valores de laboratorio, los nombres de medicamentos y las puntuaciones de ánimo se guardan sin cifrar y dependen de la seguridad de tu servidor. Las funciones de IA envían datos solo al proveedor de IA configurado para tu cuenta, y no hay analítica de terceros sobre tus datos de salud."
},
"customMetrics": {
"sectionTitle": "Métricas personalizadas",
diff --git a/messages/fr.json b/messages/fr.json
index 722b77a24..64cbe69f1 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -126,7 +126,14 @@
"nothingSaved": "Rien n'a été enregistré. Corrigez le champ signalé et réessayez.",
"invalidBody": "La mise à jour du profil n'a pas pu être lue.",
"emailInUse": "Cette adresse e-mail est déjà utilisée.",
- "emailRateLimited": "Trop de changements d'adresse e-mail. Réessayez plus tard."
+ "emailRateLimited": "Trop de changements d'adresse e-mail. Réessayez plus tard.",
+ "emailSecondFactorRequired": "Confirmez avec votre second facteur ou une clé d'accès pour changer l'adresse e-mail."
+ }
+ },
+ "auth": {
+ "reproof": {
+ "required": "Confirmez votre mot de passe actuel pour modifier ceci.",
+ "failed": "La vérification a échoué. Rien n'a été modifié."
}
}
},
@@ -440,7 +447,8 @@
"errorEmailUnverified": "Votre fournisseur d'identité n'a pas marqué votre adresse e-mail comme vérifiée.",
"errorIdentityConflict": "Cette adresse e-mail appartient à un compte déjà lié à une autre identité SSO.",
"errorRegistrationDisabled": "Aucun compte ne correspond à cette identité et les nouvelles inscriptions sont actuellement fermées.",
- "errorFailed": "L'authentification unique a échoué. Veuillez réessayer."
+ "errorFailed": "L'authentification unique a échoué. Veuillez réessayer.",
+ "errorLinkRequired": "Un compte avec cette adresse e-mail existe déjà ici. Connectez-vous-y une fois avec son mot de passe ou sa clé d'accès pour le relier à l'authentification unique."
},
"native": {
"signingInToApp": "Connexion à l'application HealthLog"
@@ -464,6 +472,13 @@
"title": "Configurer un second facteur",
"body": "Votre administrateur exige un second facteur sur ce compte. Ajoutez une application d'authentification ou une clé de sécurité pour continuer.",
"signOut": "Se déconnecter"
+ },
+ "confirmAccess": {
+ "title": "Confirmez que c'est bien vous",
+ "body": "Un assistant demande à se connecter à vos données HealthLog. Confirmez que c'est bien vous, puis vous déciderez si vous l'autorisez.",
+ "action": "Confirmer",
+ "failed": "Cela n'a pas fonctionné. Veuillez réessayer.",
+ "nothingToConfirm": "Il n'y a rien à confirmer ici. Relancez la connexion depuis votre assistant."
}
},
"dashboard": {
@@ -4591,7 +4606,7 @@
"sampleBody": "Poids en baisse de 1,2 kg en deux semaines. Pouls au repos stable à 58 bpm. Tension dans la plage 6 jours sur 7 — la seule valeur élevée a suivi un dîner tardif et salé.",
"sampleCaption": "Exemple uniquement — pas vos données. Voilà à quoi ressemble un briefing une fois les insights activés.",
"ladderTitle": "Trois façons de l'activer",
- "ladderLocalTitle": "Modèle local — rien ne quitte votre réseau",
+ "ladderLocalTitle": "Modèle local — les requêtes d’IA restent sur votre réseau",
"ladderLocalBody": "Pointez HealthLog vers Ollama ou LM Studio sur votre propre machine. Gratuit et totalement privé — le choix calme par défaut.",
"ladderByokTitle": "Apportez votre clé",
"ladderByokBody": "Utilisez votre propre clé OpenAI ou Anthropic. Elle fonctionne avec votre compte et votre facturation, et le niveau API exclut vos données de l'entraînement par défaut.",
@@ -5173,7 +5188,6 @@
"passphrasePlaceholder": "Phrase secrète (12 caractères min.)",
"passphraseHelp": "Dérive la clé de l'archive avec Argon2id et la scelle avec AES-256-GCM. Enregistrez le fichier avec l'extension .hlx.",
"noRecoveryWarning": "Aucune récupération possible : si vous perdez cette phrase secrète, l'archive ne pourra pas être ouverte. Conservez-la en lieu sûr.",
- "stepUpNeeded": "Confirmez votre second facteur, puis réessayez.",
"detail": "Même format que la sauvegarde automatique hebdomadaire. Certains types d'enregistrement ne sont pas encore inclus : le manifeste du fichier indique ce qu'il contient.",
"contents": "Vos mesures, médicaments, humeur, cycle, analyses, épisodes de maladie, allergies, antécédents familiaux, nutriments, séances, votre profil de santé et vos mesures personnalisées dans un seul fichier JSON."
}
@@ -5553,7 +5567,10 @@
"invalidFormat": "Ce n'est pas écrit dans un format que ce champ peut lire.",
"invalidType": "La valeur n'est pas arrivée dans un format que ce champ peut enregistrer.",
"rateLimited": "Modifié trop souvent. Patientez un peu et réessayez.",
- "other": "Cette valeur n'a pas été acceptée. Vérifiez-la et réessayez."
+ "other": "Cette valeur n'a pas été acceptée. Vérifiez-la et réessayez.",
+ "reproofRequired": "Saisissez votre mot de passe actuel pour changer l'adresse e-mail.",
+ "reproofFailed": "Ce mot de passe est incorrect, l'adresse e-mail n'a donc pas été modifiée.",
+ "secondFactorRequired": "Confirmez avec votre second facteur ou une clé d'accès pour changer l'adresse e-mail."
},
"avatar": {
"title": "Photo de profil",
@@ -5594,7 +5611,9 @@
"currentPassword": "Mot de passe actuel",
"authenticatorCode": "Code d’authentification",
"authenticatorPlaceholder": "Code d’authentification à 6 chiffres",
- "verifyAndAdd": "Vérifier et ajouter"
+ "verifyAndAdd": "Vérifier et ajouter",
+ "factorDescription": "Vérifiez une méthode de connexion existante avant d'ajouter un second facteur.",
+ "verifyAndContinue": "Vérifier et continuer"
},
"passkeyName": "Nom",
"noPasskeys": "Aucune clé d’accès enregistrée.",
@@ -5655,10 +5674,10 @@
"codexConnected": "ChatGPT connecté ! Insights est maintenant actif.",
"codexDisconnected": "Connexion à ChatGPT supprimée.",
"codexConnectionFailed": "Échec de la connexion à ChatGPT. Veuillez réessayer.",
- "rawData": "Envoyer les données brutes",
- "rawDataOnDescription": "Des métriques agrégées et des points individuels anonymisés des 30 derniers jours avec contexte temporel sont envoyés — jamais le nom, l’e-mail ni les identifiants de compte directs.",
- "rawDataOffDescription": "Seules des métriques résumées sont envoyées (par exemple moyennes, tendances, minimum/maximum) — aucun point individuel ni contexte temporel exact.",
- "rawDataWarning": "Mode brut activé : le fournisseur reçoit des points anonymisés supplémentaires des 30 derniers jours pour une analyse plus précise.",
+ "rawData": "Envoyer l’historique détaillé",
+ "rawDataOnDescription": "En plus des résumés, le fournisseur reçoit des moyennes de vos mesures : par jour sur les 90 derniers jours, par semaine sur l’année précédente et par mois jusqu’à 5 ans en arrière, chacune avec sa date et son nombre de mesures. Les mesures individuelles, votre nom et votre e-mail ne sont pas envoyés.",
+ "rawDataOffDescription": "Seuls des résumés sont envoyés : la dernière valeur, des moyennes, des tendances, le minimum et le maximum sur des fenêtres fixes. Aucune série de mesures.",
+ "rawDataWarning": "Historique détaillé activé : chaque analyse envoie aussi les moyennes journalières des 90 derniers jours, les moyennes hebdomadaires de l’année précédente et les moyennes mensuelles jusqu’à 5 ans en arrière.",
"regenerateInsights": "Régénérer les rapports",
"regenerateSuccess": "Rapports régénérés",
"regenerateRateLimit": "Veuillez patienter — vous avez atteint la limite horaire d’analyses.",
@@ -6545,7 +6564,12 @@
"revokeSessionConfirmTitle": "Déconnecter cet appareil ?",
"revokeSessionConfirmBody": "{device} devra se reconnecter. Ce qui y est enregistré n'est pas affecté.",
"signOutEverywhereConfirmTitle": "Déconnecter tous les autres appareils ?",
- "signOutEverywhereConfirmBody": "Chaque appareil sauf celui-ci devra se reconnecter. Celui-ci reste connecté. Rien de ce que tu as enregistré n'est affecté."
+ "signOutEverywhereConfirmBody": "Tous les autres appareils, chaque assistant IA connecté et chaque jeton d'API devront se reconnecter ou être reconfigurés. Cet appareil reste connecté. Rien de ce que vous avez enregistré n'est touché.",
+ "signOutEverywhereShareLinks": "Mettre fin aussi aux liens de partage avec les médecins",
+ "signOutEverywhereInvites": "Les invitations à votre dossier que personne n'a encore acceptées sont retirées. Les personnes qui ont déjà accepté gardent leur accès ; vous pourrez y mettre fin ensuite.",
+ "signOutEverywhereInvitesDone": "{count} invitation(s) non acceptée(s) retirée(s).",
+ "grantsKeptTitle": "Ces personnes peuvent toujours voir votre dossier",
+ "grantsKeptBody": "Se déconnecter ne met pas fin à l'accès que vous avez donné à quelqu'un. Mettez-y fin ici si vous ne l'avez pas donné ou si vous ne le souhaitez plus."
},
"privacy": {
"encryption": {
@@ -6579,7 +6603,7 @@
"delete": {
"title": "Supprimer ou réinitialiser",
"description": "Supprimez complètement votre compte, ou effacez vos données de santé tout en conservant le compte.",
- "backupLag": "La suppression efface vos données immédiatement. Les sauvegardes chiffrées hors site les contiennent encore jusqu'à leur expiration selon la fenêtre de conservation de l'opérateur.",
+ "backupLag": "La suppression efface vos données immédiatement. Si ce serveur conserve des sauvegardes externes chiffrées, les copies de vos données sont supprimées de ce stockage juste après, ou lors du prochain passage nocturne s'il est injoignable.",
"openAdvanced": "Supprimer ou réinitialiser"
},
"posture": {
@@ -6698,6 +6722,41 @@
"showChecklist": "Réafficher la liste de départ",
"checklistShown": "La liste de départ est de retour sur le tableau de bord.",
"checklistNothingHidden": "La liste n'était pas masquée ; elle s'ouvre sur le tableau de bord."
+ },
+ "documentSources": {
+ "title": "Archives de documents",
+ "description": "Cherchez dans Paperless-ngx ou Papra depuis HealthLog et importez les documents choisis.",
+ "explainer": "Un bouton d'import apparaît partout où vous liez des documents : consultations, vaccinations, affections et Documents. HealthLog garde sa propre copie chiffrée de chaque document importé et n'interroge votre archive que lorsque vous cherchez ou importez.",
+ "systemLabel": "Archive",
+ "statusConnected": "Connecté",
+ "statusNotConnected": "Non connecté",
+ "connectedSince": "Connecté, dernière vérification le {date}",
+ "originRemoved": "Cette adresse ne figure plus dans la liste des archives autorisées du serveur. Voyez avec l'administration ou enregistrez une autre adresse.",
+ "baseUrl": "Adresse",
+ "allowedOrigins": "Autorisé sur ce serveur : {origins}",
+ "organizationId": "Identifiant d'organisation",
+ "organizationHelp": "La partie après /organizations/ dans la barre d'adresse de Papra. Les identifiants d'organisation Papra ont la forme org_ suivi de 24 lettres et chiffres.",
+ "token": "Jeton d'API",
+ "tokenSaved": "Enregistré pour cette adresse. Laissez vide pour le conserver.",
+ "tokenHelpPaperless": "Dans Paperless-ngx : Mon profil, jeton d'API.",
+ "tokenHelpPapra": "Dans Papra : une clé d'API avec documents:read et tags:read.",
+ "save": "Enregistrer et tester",
+ "saving": "Test en cours…",
+ "saved": "Connecté. L'archive a répondu.",
+ "test": "Tester",
+ "testOk": "L'archive a répondu en {latency} ms.",
+ "disconnect": "Déconnecter",
+ "disconnectTitle": "Déconnecter {name} ?",
+ "disconnectDescription": "HealthLog oublie l'adresse et le jeton. Les documents déjà importés restent dans HealthLog.",
+ "unavailableExplainer": "Ce serveur n'autorise plus les archives de documents ; la connexion ci-dessous ne peut donc pas servir. Vous pouvez toujours la supprimer.",
+ "moduleOffExplainer": "Les documents sont désactivés ; la connexion ci-dessous n'est donc pas utilisée. Vous pouvez toujours la supprimer.",
+ "tokenNewAddress": "Une nouvelle adresse demande de saisir à nouveau le jeton d'API."
+ },
+ "emailChangePassword": "Mot de passe actuel (nécessaire pour changer l'adresse e-mail)",
+ "reproof": {
+ "description": "Cette action partage ou exporte vos données de santé. Confirmez qu'il s'agit bien de vous pour continuer.",
+ "signInAgain": "Reconnectez-vous, puis réessayez. Votre compte n'a ni mot de passe ni clé d'accès à confirmer ici.",
+ "failed": "Cela n'a pas fonctionné. Vérifiez votre saisie et réessayez."
}
},
"admin": {
@@ -6746,7 +6805,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",
@@ -7063,6 +7126,7 @@
"versionTileBuiltAt": "Compilé le",
"versionTileUpdateAvailable": "Mise à jour disponible : {tag}",
"versionTileUpToDate": "À jour",
+ "versionTileCheckOff": "Vérification des mises à jour désactivée",
"snapshotLoadError": "Échec du chargement de l'instantané du système.",
"snapshotLoading": "Chargement de l'instantané...",
"auditTitle": "Activité récente",
@@ -7267,7 +7331,7 @@
"description": "Quand cet hôte a déposé pour la dernière fois la copie chiffrée de chaque compte dans votre bucket.",
"loadError": "Impossible de charger l'état hors hôte.",
"notConfiguredTitle": "La sauvegarde hors hôte n'est pas configurée",
- "notConfiguredDescription": "Aucun bucket ni clé ne sont définis, donc rien ne quitte cet hôte. Définissez BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY et BACKUP_ENCRYPTION_KEY pour activer l'envoi nocturne.",
+ "notConfiguredDescription": "Aucun bucket ni clé ne sont définis, donc aucune sauvegarde ne quitte cet hôte. Définissez BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY et BACKUP_ENCRYPTION_KEY pour activer l'envoi nocturne.",
"stateFresh": "Récente",
"stateDue": "Attendue",
"stateStale": "Périmée",
@@ -7276,7 +7340,12 @@
"neverDetail": "Rien n'est arrivé pour ce compte. Dernière vérification {when}.",
"unknownDetail": "Aucun cycle nocturne n'a encore enregistré ce compte.",
"age": "il y a {hours} h",
- "scheduleNote": "L'envoi tourne toutes les {hours} h. Récente signifie dans un cycle, attendue qu'un cycle n'a rien produit, périmée que deux n'ont rien produit."
+ "scheduleNote": "L'envoi tourne toutes les {hours} h. Récente signifie dans un cycle, attendue qu'un cycle n'a rien produit, périmée que deux n'ont rien produit.",
+ "lifecycleConfigured": "Le bucket supprime les copies après {days} jours grâce à sa propre règle de cycle de vie.",
+ "lifecycleMissing": "Le bucket n'a aucune règle de cycle de vie : chaque copie nocturne est donc conservée indéfiniment. Ajoutez une règle qui fait expirer les objets après BACKUP_RETENTION_DAYS.",
+ "lifecycleUnknown": "Impossible de savoir si le bucket supprime les anciennes copies : l'identifiant ne peut pas lire sa règle de cycle de vie. Vérifiez qu'une règle fait expirer les objets après BACKUP_RETENTION_DAYS.",
+ "pendingDeletions": "Des copies de {count} comptes supprimés ou vidés sont encore dans le bucket et sont en cours de suppression.",
+ "pendingDeletionsFailing": "Des copies de {count} comptes supprimés ou vidés sont encore dans le bucket, qui a refusé de les supprimer ({error}). Accordez DeleteObject à l'identifiant ; la suppression est retentée chaque nuit."
},
"restoreStarted": "Restauration lancée. Sa progression s'affiche au-dessus de la liste.",
"restoreActive": "Une restauration de ce compte est déjà en cours. Attends qu'elle se termine.",
@@ -7309,7 +7378,10 @@
"restoreFailureNotStarted": "Le processus en arrière-plan n'a pas pris la restauration en charge. Vérifie qu'il tourne.",
"restoreFailureEnqueue": "La restauration n'a pas pu être transmise au processus en arrière-plan. Vérifie qu'il tourne.",
"restoreFailureAfterCommit": "Les données ont été restaurées, mais une étape suivante a échoué. Les graphiques ne seront peut-être à jour qu'après la prochaine exécution nocturne.",
- "restoreFailureUnexpected": "La restauration s'est arrêtée sur une erreur inattendue et a été annulée. Le journal du serveur indique l'erreur."
+ "restoreFailureUnexpected": "La restauration s'est arrêtée sur une erreur inattendue et a été annulée. Le journal du serveur indique l'erreur.",
+ "restoreFailureKeyMissing": "La sauvegarde a besoin de la clé de chiffrement {keys}, avec laquelle ce serveur ne peut pas l'ouvrir. Remettez la clé dans ENCRYPTION_KEYS, redémarrez, puis relancez la restauration.",
+ "restoreFailureForeignReference": "Le fichier renvoie à des éléments qui appartiennent à un autre compte. Rien n'a été écrit.",
+ "previewKeyMissing": "Cette sauvegarde ne peut pas être restaurée sur ce serveur : elle a besoin de la clé de chiffrement {keys}. Remettez d'abord la clé dans ENCRYPTION_KEYS et redémarrez."
},
"danger-zone": {
"title": "Zone de danger",
@@ -7348,7 +7420,7 @@
"rotateEnqueued": "Rotation démarrée.",
"rotateFailed": "Impossible de démarrer la rotation.",
"stepUpNeeded": "Un second facteur récent est requis. Confirmez votre second facteur, ou utilisez l'outil en ligne de commande si aucun n'est configuré.",
- "cliNote": "Un second facteur est requis pour ce bouton. Les opérateurs sans second facteur utilisent scripts/rotate-encryption-key.ts.",
+ "cliNote": "Un second facteur est requis pour ce bouton. Sans lui, lancez scripts/rotate-encryption-key.ts depuis une copie des sources de cette version ; les étapes sont dans docs/ops/encryption-key-rotation.md.",
"columnsTitle": "Couverture par colonne",
"columnsDescription": "Chaque colonne chiffrée avec ses décomptes de lignes sur la clé active par rapport aux clés plus anciennes.",
"colColumn": "Colonne",
@@ -7361,7 +7433,17 @@
"completed": "Terminé",
"failed": "Échoué"
},
- "coverageDetail": "Seuls les id de clé et les décomptes sont affichés, jamais le matériel de clé."
+ "coverageDetail": "Seuls les id de clé et les décomptes sont affichés, jamais le matériel de clé.",
+ "backupsTitle": "Clés dont les sauvegardes ont encore besoin",
+ "backupsDescription": "Une clé retirée reste nécessaire tant qu'une sauvegarde ci-dessous la mentionne.",
+ "backupsStoredLine": "Clé {key} : {count} copies stockées, la plus ancienne du {when}",
+ "backupsUnrecordedLine": "{count} copies stockées ont été écrites avant que les sauvegardes n'enregistrent leurs clés (la plus ancienne le {when}). Elles peuvent avoir besoin de n'importe quelle clé de l'époque : gardez toutes les anciennes clés jusqu'à leur remplacement ou suppression.",
+ "backupsOffhostLine": "Clé {key} : la dernière copie externe qui en a besoin est arrivée dans le bucket le {when} ; nécessaire jusqu'au {until}",
+ "backupsOffhostLineUnknown": "Clé {key} : la dernière copie externe qui en a besoin est arrivée dans le bucket le {when}. La durée de conservation du bucket est inconnue : gardez la clé jusqu'à être sûr que cette copie a disparu.",
+ "backupsNone": "Aucune sauvegarde n'a encore enregistré les clés dont elle a besoin.",
+ "backupsStillNeed": "Toutes les lignes sont renouvelées, mais des sauvegardes ont encore besoin de {keys}",
+ "backupsUnrecordedBadge": "Toutes les lignes sont renouvelées, mais d'anciennes sauvegardes peuvent encore avoir besoin d'une clé retirée",
+ "backupsDetail": "La rotation rechiffre chaque ligne, mais une sauvegarde garde la clé sous laquelle son contenu a été écrit, et sa restauration en a besoin. Gardez une clé retirée dans ENCRYPTION_KEYS tant qu'elle figure ci-dessous."
}
},
"carrier": "Opérateur",
@@ -7408,7 +7490,11 @@
"countOther": "{count} échecs",
"lastFailed": "Dernier échec {when}"
},
- "deleteAllDetail": "Les comptes utilisateurs sont conservés."
+ "deleteAllDetail": "Les comptes utilisateurs sont conservés.",
+ "cardError": {
+ "title": "Cette partie de la page n’a pas pu s’afficher",
+ "description": "Le reste de la page fonctionne ; réessayez ou rechargez si le problème persiste."
+ }
},
"achievements": {
"title": "Réussites",
@@ -8016,7 +8102,8 @@
"bodyToday": "{medName} : {units} unités restantes — moins d’un jour de réserve.",
"bodyReorder": "{medName} : {units} unités restantes. Le stock sera épuisé vers le {runsOutOn} — recommandez avant le {reorderBy}.",
"bodyLastDose": "{medName} : {units} unités restantes — environ votre dernière dose. Le stock sera épuisé vers le {runsOutOn}.",
- "bodyExpired": "{medName} : plus de stock utilisable. {units} unités sont marquées périmées."
+ "bodyExpired": "{medName} : plus de stock utilisable. {units} unités sont marquées périmées.",
+ "bodyAsNeeded": "{medName} : plus que {units} unités. Recommandez avant d’avoir besoin de la prochaine dose."
},
"moodReminders": {
"dailyTitle": "Enregistrer votre humeur",
@@ -10147,10 +10234,74 @@
"body": "La lecture par IA envoie ceci à votre service d'IA hors de ce serveur. Autoriser HealthLog à envoyer des documents, des résultats d'analyses et des descriptions de médicaments pour lecture ?",
"grant": "Autoriser la lecture",
"error": "Le consentement n'a pas pu être enregistré. Veuillez réessayer."
+ },
+ "sourcePicker": {
+ "open": "Importer depuis une archive",
+ "openNamed": "Importer depuis {name}",
+ "title": "Importer depuis {name}",
+ "description": "Cherchez par nom, puis cochez les documents que HealthLog doit copier.",
+ "sourceLabel": "Archive",
+ "searchLabel": "Nom du document",
+ "searchPlaceholder": "Chercher par nom",
+ "tagLabel": "Étiquette",
+ "tagAny": "Toutes les étiquettes",
+ "fromLabel": "Du",
+ "toLabel": "Au",
+ "clearFilters": "Effacer l'étiquette et les dates",
+ "rangeInvalid": "La date de fin précède la date de début.",
+ "empty": "{name} n'a aucun document à afficher.",
+ "noMatch": "Aucun document ne correspond.",
+ "loadMore": "Charger plus",
+ "stateImported": "Dans HealthLog",
+ "stateDeleted": "Supprimé dans HealthLog",
+ "selectedCount": "{count} sélectionnés sur {max} au plus",
+ "importAction": "Importer {count}",
+ "importing": "Import en cours, un document après l'autre…",
+ "done": "Terminé",
+ "summary": "Importés : {imported}. Déjà présents : {present}. Non importés : {failed}.",
+ "stopped": "Arrêté : {reason}",
+ "result": {
+ "waiting": "En attente",
+ "working": "Import en cours…",
+ "imported": "Importé",
+ "importedLinked": "Importé et lié",
+ "duplicate": "Déjà dans HealthLog",
+ "duplicateLinked": "Déjà dans HealthLog, désormais lié",
+ "deleted": "Supprimé dans HealthLog, laissé tel quel"
+ },
+ "errors": {
+ "unavailable": "Les archives de documents ne sont pas activées sur ce serveur.",
+ "notConnected": "Cette archive n'est pas connectée. Connectez-la dans Réglages, Intégrations.",
+ "originNotAllowed": "Cette adresse ne figure pas dans la liste des archives autorisées du serveur. Demandez à l'administration de l'ajouter.",
+ "unreachable": "L'archive est injoignable. Vérifiez qu'elle fonctionne et que l'adresse est juste.",
+ "redirected": "L'archive a redirigé la requête. Saisissez l'adresse vers laquelle elle redirige.",
+ "authRefused": "L'archive a refusé le jeton. Vérifiez le jeton et ses droits.",
+ "versionTooOld": "L'archive est trop ancienne. Paperless-ngx 2.16 ou plus récent est nécessaire.",
+ "badResponse": "La réponse de l'archive est illisible. Vérifiez que l'adresse pointe vers l'archive elle-même.",
+ "notFound": "L'archive ne connaît pas ce document, ou l'identifiant d'organisation est faux ou n'est pas le vôtre.",
+ "linkTargetNotFound": "L'élément auquel lier le document n'existe plus.",
+ "rateLimited": "Trop de requêtes. Patientez une minute et réessayez.",
+ "browserOnly": "Les archives de documents ne s'utilisent que dans le navigateur.",
+ "invalidAddress": "Saisissez l'adresse complète, commençant par https:// ou http://.",
+ "organizationRequired": "Saisissez l'identifiant d'organisation Papra.",
+ "tokenRequired": "Saisissez le jeton d'API. Une nouvelle adresse le demande à nouveau.",
+ "fileTooLarge": "Le fichier dépasse la taille acceptée par HealthLog.",
+ "quotaExceeded": "Votre espace de documents est plein.",
+ "fileType": "HealthLog n'accepte pas ce type de fichier.",
+ "uploadLimit": "La limite horaire d'envois est atteinte. Réessayez plus tard.",
+ "uploadBusy": "D'autres envois sont en cours. Réessayez dans un instant.",
+ "moduleDisabled": "Les documents sont désactivés dans les réglages.",
+ "generic": "Un problème est survenu. Réessayez.",
+ "permissionMissing": "La clé Papra ne peut pas lire les étiquettes. Donnez-lui le droit tags:read."
+ },
+ "pickMore": "En choisir d'autres",
+ "finishedHint": "Cherchez à nouveau ou changez un filtre pour choisir d'autres documents.",
+ "maxReached": "Vous pouvez importer au plus {max} documents à la fois.",
+ "noMatchHint": "Essayez une partie du nom, ou effacez l'étiquette et les dates."
}
},
"selfDescription": {
- "dataPosture": "HealthLog est auto-hébergé : vos données de santé restent sur votre propre instance, chiffrées au repos. Les fonctions d'IA utilisent une clé de fournisseur que vous apportez ou un modèle local, donc rien ne quitte votre instance au-delà du fournisseur que vous configurez, et il n'y a aucune analyse tierce de vos données de santé."
+ "dataPosture": "HealthLog est auto-hébergé : vos données de santé se trouvent dans la base de données de votre propre instance. Les notes et autres textes libres, les documents, les réponses aux questionnaires, les textes rédigés par l'IA et les jetons d'accès sont chiffrés au repos ; les valeurs de mesure, les résultats de laboratoire, les noms de médicaments et les scores d'humeur sont stockés en clair et dépendent de la sécurité de votre serveur. Les fonctions d'IA n'envoient des données qu'au fournisseur d'IA configuré pour votre compte, et il n'y a aucune analyse tierce de vos données de santé."
},
"customMetrics": {
"sectionTitle": "Mesures personnalisées",
diff --git a/messages/it.json b/messages/it.json
index ae8310f2d..404c0ba2d 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -126,7 +126,14 @@
"nothingSaved": "Non è stato salvato nulla. Controlla il campo evidenziato e riprova.",
"invalidBody": "Impossibile leggere l'aggiornamento del profilo.",
"emailInUse": "Questo indirizzo email è già in uso.",
- "emailRateLimited": "Troppi cambi di indirizzo email. Riprova più tardi."
+ "emailRateLimited": "Troppi cambi di indirizzo email. Riprova più tardi.",
+ "emailSecondFactorRequired": "Conferma con il tuo secondo fattore o una passkey per cambiare l'indirizzo email."
+ }
+ },
+ "auth": {
+ "reproof": {
+ "required": "Conferma la password attuale per modificarlo.",
+ "failed": "La verifica non è riuscita. Non è stato modificato nulla."
}
}
},
@@ -440,7 +447,8 @@
"errorEmailUnverified": "Il tuo provider di identità non ha contrassegnato il tuo indirizzo email come verificato.",
"errorIdentityConflict": "Questo indirizzo email appartiene a un account già collegato a un'altra identità SSO.",
"errorRegistrationDisabled": "Nessun account corrisponde a questa identità e le nuove registrazioni sono attualmente chiuse.",
- "errorFailed": "L'accesso Single Sign-On non è riuscito. Riprova."
+ "errorFailed": "L'accesso Single Sign-On non è riuscito. Riprova.",
+ "errorLinkRequired": "Esiste già un account con questo indirizzo email. Accedi una volta con la sua password o passkey per collegarlo al single sign-on."
},
"native": {
"signingInToApp": "Accesso all'app HealthLog"
@@ -464,6 +472,13 @@
"title": "Configura un secondo fattore",
"body": "Il tuo amministratore richiede un secondo fattore su questo account. Aggiungi un'app di autenticazione o una chiave di sicurezza per continuare.",
"signOut": "Esci"
+ },
+ "confirmAccess": {
+ "title": "Conferma che sei tu",
+ "body": "Un assistente chiede di collegarsi ai tuoi dati HealthLog. Conferma che sei tu, poi deciderai se consentirlo.",
+ "action": "Conferma",
+ "failed": "Non ha funzionato. Riprova.",
+ "nothingToConfirm": "Qui non c'è nulla da confermare. Avvia di nuovo la connessione dal tuo assistente."
}
},
"dashboard": {
@@ -4591,7 +4606,7 @@
"sampleBody": "Peso in calo di 1,2 kg in due settimane. Battito a riposo stabile a 58 bpm. Pressione nel range in 6 giorni su 7 — l'unico valore alto è arrivato dopo una cena tardiva e salata.",
"sampleCaption": "Solo un esempio — non i tuoi dati. Ecco come si legge un briefing quando gli insight sono attivi.",
"ladderTitle": "Tre modi per attivarlo",
- "ladderLocalTitle": "Modello locale — nulla lascia la tua rete",
+ "ladderLocalTitle": "Modello locale — le richieste all’IA restano nella tua rete",
"ladderLocalBody": "Punta HealthLog su Ollama o LM Studio sulla tua macchina. Gratis e del tutto privato — la scelta tranquilla di default.",
"ladderByokTitle": "Usa la tua chiave",
"ladderByokBody": "Usa la tua chiave OpenAI o Anthropic. Gira sul tuo account e sulla tua fatturazione, e il piano API tiene i tuoi dati fuori dall'addestramento in modo predefinito.",
@@ -5173,7 +5188,6 @@
"passphrasePlaceholder": "Passphrase (min. 12 caratteri)",
"passphraseHelp": "Deriva la chiave dell'archivio con Argon2id e lo sigilla con AES-256-GCM. Salva il file con estensione .hlx.",
"noRecoveryWarning": "Non c'è recupero: se perdi questa passphrase, l'archivio non potrà essere aperto. Conservala in un luogo sicuro.",
- "stepUpNeeded": "Conferma il secondo fattore e riprova.",
"detail": "Stesso formato del backup automatico settimanale. Alcuni tipi di dato non sono ancora inclusi: il manifest del file indica cosa contiene.",
"contents": "Le tue misurazioni, i farmaci, l'umore, il ciclo, le analisi, gli episodi di malattia, le allergie, l'anamnesi familiare, i nutrienti, gli allenamenti, il tuo profilo di salute e le tue metriche personalizzate in un unico file JSON."
}
@@ -5553,7 +5567,10 @@
"invalidFormat": "Non è scritto in un formato che questo campo possa leggere.",
"invalidType": "Il valore non è arrivato in un formato che questo campo possa salvare.",
"rateLimited": "Modificato troppe volte. Aspetta un momento e riprova.",
- "other": "Questo valore non è stato accettato. Controllalo e riprova."
+ "other": "Questo valore non è stato accettato. Controllalo e riprova.",
+ "reproofRequired": "Inserisci la password attuale per cambiare l'indirizzo email.",
+ "reproofFailed": "La password non è corretta, quindi l'indirizzo email non è stato cambiato.",
+ "secondFactorRequired": "Conferma con il tuo secondo fattore o una passkey per cambiare l'indirizzo email."
},
"avatar": {
"title": "Foto del profilo",
@@ -5594,7 +5611,9 @@
"currentPassword": "Password attuale",
"authenticatorCode": "Codice dell’app di autenticazione",
"authenticatorPlaceholder": "Codice a 6 cifre dell’app di autenticazione",
- "verifyAndAdd": "Verifica e aggiungi"
+ "verifyAndAdd": "Verifica e aggiungi",
+ "factorDescription": "Verifica un metodo di accesso esistente prima di aggiungere un secondo fattore.",
+ "verifyAndContinue": "Verifica e continua"
},
"passkeyName": "Nome",
"noPasskeys": "Nessuna passkey registrata.",
@@ -5655,10 +5674,10 @@
"codexConnected": "ChatGPT connesso correttamente! Insights è ora attivo.",
"codexDisconnected": "Connessione a ChatGPT rimossa.",
"codexConnectionFailed": "Connessione a ChatGPT non riuscita. Riprova.",
- "rawData": "Invia dati grezzi",
- "rawDataOnDescription": "Vengono inviate metriche aggregate più punti individuali anonimizzati degli ultimi 30 giorni con contesto temporale — mai nome, e-mail o identificatori diretti dell’account.",
- "rawDataOffDescription": "Vengono inviate solo metriche riassuntive (ad esempio medie, tendenze, minimo/massimo) — nessun punto individuale e nessun contesto temporale preciso.",
- "rawDataWarning": "Modalità grezza attiva: il provider riceve ulteriori punti anonimizzati degli ultimi 30 giorni per un’analisi più precisa.",
+ "rawData": "Invia cronologia dettagliata",
+ "rawDataOnDescription": "Oltre ai riepiloghi, il provider riceve le medie delle tue misurazioni: per giorno negli ultimi 90 giorni, per settimana nell’anno precedente e per mese fino a 5 anni fa, ciascuna con data e numero di misurazioni. Le singole misurazioni, il tuo nome e la tua e-mail non vengono inviati.",
+ "rawDataOffDescription": "Vengono inviati solo riepiloghi: l’ultimo valore, medie, tendenze, minimo e massimo su finestre fisse. Nessuna serie di misurazioni.",
+ "rawDataWarning": "Cronologia dettagliata attiva: ogni analisi invia anche le medie giornaliere degli ultimi 90 giorni, le medie settimanali dell’anno precedente e le medie mensili fino a 5 anni fa.",
"regenerateInsights": "Rigenera i report",
"regenerateSuccess": "Report rigenerati correttamente",
"regenerateRateLimit": "Attendi — hai raggiunto il limite orario di analisi.",
@@ -6545,7 +6564,12 @@
"revokeSessionConfirmTitle": "Disconnettere questo dispositivo?",
"revokeSessionConfirmBody": "{device} dovrà accedere di nuovo. Quanto registrato su di esso non viene toccato.",
"signOutEverywhereConfirmTitle": "Disconnettere tutti gli altri dispositivi?",
- "signOutEverywhereConfirmBody": "Ogni dispositivo tranne questo dovrà accedere di nuovo. Questo resta connesso. Nulla di ciò che hai registrato viene toccato."
+ "signOutEverywhereConfirmBody": "Tutti gli altri dispositivi, ogni assistente IA collegato e ogni token API dovranno accedere di nuovo o essere configurati di nuovo. Questo dispositivo resta connesso. Nulla di ciò che hai registrato viene toccato.",
+ "signOutEverywhereShareLinks": "Termina anche i link di condivisione con i medici",
+ "signOutEverywhereInvites": "Gli inviti alla tua cartella che nessuno ha ancora accettato vengono ritirati. Chi ha già accettato mantiene l'accesso; puoi revocarlo subito dopo.",
+ "signOutEverywhereInvitesDone": "Ritirati {count} inviti non accettati.",
+ "grantsKeptTitle": "Queste persone possono ancora vedere la tua cartella",
+ "grantsKeptBody": "Disconnettersi non revoca l'accesso che hai dato a qualcuno. Revocalo qui se non l'hai dato tu o se non vuoi più che lo abbia."
},
"privacy": {
"encryption": {
@@ -6579,7 +6603,7 @@
"delete": {
"title": "Elimina o reimposta",
"description": "Elimina completamente il tuo account oppure cancella i tuoi dati sanitari mantenendo l'account.",
- "backupLag": "L'eliminazione rimuove subito i tuoi dati. I backup cifrati esterni li contengono ancora finché non scadono secondo la finestra di conservazione dell'operatore.",
+ "backupLag": "L'eliminazione rimuove subito i tuoi dati. Se questo server conserva backup esterni cifrati, le copie dei tuoi dati vengono eliminate da quello spazio subito dopo, o alla successiva esecuzione notturna se non è raggiungibile.",
"openAdvanced": "Elimina o reimposta"
},
"posture": {
@@ -6698,6 +6722,41 @@
"showChecklist": "Mostra di nuovo la lista",
"checklistShown": "La lista è di nuovo nella dashboard.",
"checklistNothingHidden": "La lista non era nascosta; si apre nella dashboard."
+ },
+ "documentSources": {
+ "title": "Archivi di documenti",
+ "description": "Cerca in Paperless-ngx o Papra da HealthLog e importa i documenti che scegli.",
+ "explainer": "Un pulsante di importazione compare ovunque colleghi documenti: nelle visite, nelle vaccinazioni, nelle condizioni e in Documenti. HealthLog conserva una propria copia cifrata di ogni documento importato e interroga il tuo archivio solo quando cerchi o importi.",
+ "systemLabel": "Archivio",
+ "statusConnected": "Collegato",
+ "statusNotConnected": "Non collegato",
+ "connectedSince": "Collegato, ultimo controllo il {date}",
+ "originRemoved": "Questo indirizzo non è più nell'elenco degli archivi consentiti dal server. Chiedi all'amministrazione o salva un altro indirizzo.",
+ "baseUrl": "Indirizzo",
+ "allowedOrigins": "Consentito su questo server: {origins}",
+ "organizationId": "Id organizzazione",
+ "organizationHelp": "La parte dopo /organizations/ nella barra degli indirizzi di Papra. Gli id organizzazione di Papra hanno la forma org_ seguita da 24 lettere e cifre.",
+ "token": "Token API",
+ "tokenSaved": "Salvato per questo indirizzo. Lascia vuoto per mantenerlo.",
+ "tokenHelpPaperless": "In Paperless-ngx: Il mio profilo, token API.",
+ "tokenHelpPapra": "In Papra: una chiave API con documents:read e tags:read.",
+ "save": "Salva e prova",
+ "saving": "Prova in corso…",
+ "saved": "Collegato. L'archivio ha risposto.",
+ "test": "Prova",
+ "testOk": "L'archivio ha risposto in {latency} ms.",
+ "disconnect": "Scollega",
+ "disconnectTitle": "Scollegare {name}?",
+ "disconnectDescription": "HealthLog dimentica indirizzo e token. I documenti già importati restano in HealthLog.",
+ "unavailableExplainer": "Questo server non consente più gli archivi di documenti, quindi il collegamento qui sotto non si può usare. Puoi comunque rimuoverlo.",
+ "moduleOffExplainer": "I documenti sono disattivati, quindi il collegamento qui sotto non viene usato. Puoi comunque rimuoverlo.",
+ "tokenNewAddress": "Un nuovo indirizzo richiede di nuovo il token API."
+ },
+ "emailChangePassword": "Password attuale (necessaria per cambiare l'indirizzo email)",
+ "reproof": {
+ "description": "Questa azione condivide o esporta i tuoi dati sanitari. Conferma che sei tu per continuare.",
+ "signInAgain": "Accedi di nuovo, poi riprova. Il tuo account non ha una password o una passkey da confermare qui.",
+ "failed": "Non ha funzionato. Controlla quanto inserito e riprova."
}
},
"admin": {
@@ -6746,7 +6805,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",
@@ -7063,6 +7126,7 @@
"versionTileBuiltAt": "Compilato il",
"versionTileUpdateAvailable": "Aggiornamento disponibile: {tag}",
"versionTileUpToDate": "Aggiornato",
+ "versionTileCheckOff": "Controllo aggiornamenti disattivato",
"snapshotLoadError": "Impossibile caricare l'istantanea del sistema.",
"snapshotLoading": "Caricamento istantanea...",
"auditTitle": "Attività recente",
@@ -7267,7 +7331,7 @@
"description": "Quando questo host ha messo per l'ultima volta la copia cifrata di ogni account nel tuo bucket.",
"loadError": "Impossibile caricare lo stato fuori host.",
"notConfiguredTitle": "Il backup fuori host non è configurato",
- "notConfiguredDescription": "Non sono impostati né bucket né chiave, quindi nulla lascia questo host. Imposta BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY e BACKUP_ENCRYPTION_KEY per attivare il caricamento notturno.",
+ "notConfiguredDescription": "Non sono impostati né bucket né chiave, quindi nessun backup lascia questo host. Imposta BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY e BACKUP_ENCRYPTION_KEY per attivare il caricamento notturno.",
"stateFresh": "Recente",
"stateDue": "Attesa",
"stateStale": "Obsoleta",
@@ -7276,7 +7340,12 @@
"neverDetail": "Per questo account non è arrivato nulla. Ultimo controllo {when}.",
"unknownDetail": "Nessun giro notturno ha ancora registrato questo account.",
"age": "{hours} h fa",
- "scheduleNote": "Il caricamento gira ogni {hours} h. Recente significa entro un giro, attesa che un giro non ha prodotto nulla, obsoleta che non lo hanno fatto due."
+ "scheduleNote": "Il caricamento gira ogni {hours} h. Recente significa entro un giro, attesa che un giro non ha prodotto nulla, obsoleta che non lo hanno fatto due.",
+ "lifecycleConfigured": "Il bucket elimina le copie dopo {days} giorni con la propria regola di ciclo di vita.",
+ "lifecycleMissing": "Il bucket non ha una regola di ciclo di vita, quindi ogni copia notturna viene conservata per sempre. Aggiungi una regola che faccia scadere gli oggetti dopo BACKUP_RETENTION_DAYS.",
+ "lifecycleUnknown": "Non è stato possibile leggere se il bucket elimina le copie vecchie: la credenziale non può leggere la sua regola di ciclo di vita. Verifica che una regola faccia scadere gli oggetti dopo BACKUP_RETENTION_DAYS.",
+ "pendingDeletions": "Copie di {count} account eliminati o svuotati sono ancora nel bucket e vengono rimosse.",
+ "pendingDeletionsFailing": "Copie di {count} account eliminati o svuotati sono ancora nel bucket perché ha rifiutato di eliminarle ({error}). Concedi DeleteObject alla credenziale; l'eliminazione viene ritentata ogni notte."
},
"restoreStarted": "Ripristino avviato. L'avanzamento compare sopra l'elenco.",
"restoreActive": "Per questo account è già in corso un ripristino. Aspetta che finisca.",
@@ -7309,7 +7378,10 @@
"restoreFailureNotStarted": "Il processo in background non ha preso in carico il ripristino. Controlla che sia in esecuzione.",
"restoreFailureEnqueue": "Non è stato possibile passare il ripristino al processo in background. Controlla che sia in esecuzione.",
"restoreFailureAfterCommit": "I dati sono stati ripristinati, ma un passaggio successivo non è riuscito. I grafici potrebbero aggiornarsi solo con la prossima esecuzione notturna.",
- "restoreFailureUnexpected": "Il ripristino si è fermato per un errore imprevisto ed è stato annullato. Il log del server indica l'errore."
+ "restoreFailureUnexpected": "Il ripristino si è fermato per un errore imprevisto ed è stato annullato. Il log del server indica l'errore.",
+ "restoreFailureKeyMissing": "Il backup richiede la chiave di cifratura {keys}, con cui questo server non riesce ad aprirlo. Aggiungi di nuovo la chiave a ENCRYPTION_KEYS, riavvia e avvia di nuovo il ripristino.",
+ "restoreFailureForeignReference": "Il file rimanda a voci che appartengono a un altro account. Non è stato scritto nulla.",
+ "previewKeyMissing": "Questo backup non può essere ripristinato su questo server: richiede la chiave di cifratura {keys}. Aggiungi prima di nuovo la chiave a ENCRYPTION_KEYS e riavvia."
},
"danger-zone": {
"title": "Zona pericolosa",
@@ -7348,7 +7420,7 @@
"rotateEnqueued": "Rotazione avviata.",
"rotateFailed": "Impossibile avviare la rotazione.",
"stepUpNeeded": "È richiesto un secondo fattore recente. Conferma il secondo fattore oppure usa lo strumento da riga di comando se non ne hai configurato uno.",
- "cliNote": "Per questo pulsante serve un secondo fattore. Gli operatori senza secondo fattore ruotano con scripts/rotate-encryption-key.ts.",
+ "cliNote": "Per questo pulsante serve un secondo fattore. Senza, esegui scripts/rotate-encryption-key.ts da una copia dei sorgenti di questa versione; i passaggi sono in docs/ops/encryption-key-rotation.md.",
"columnsTitle": "Copertura per colonna",
"columnsDescription": "Ogni colonna cifrata con i conteggi di righe sulla chiave attiva rispetto alle chiavi più vecchie.",
"colColumn": "Colonna",
@@ -7361,7 +7433,17 @@
"completed": "Completato",
"failed": "Fallito"
},
- "coverageDetail": "Vengono mostrati solo gli id di chiave e i conteggi, mai il materiale della chiave."
+ "coverageDetail": "Vengono mostrati solo gli id di chiave e i conteggi, mai il materiale della chiave.",
+ "backupsTitle": "Chiavi di cui i backup hanno ancora bisogno",
+ "backupsDescription": "Una chiave ritirata resta necessaria finché un backup qui sotto la elenca ancora.",
+ "backupsStoredLine": "Chiave {key}: {count} copie salvate, la più vecchia del {when}",
+ "backupsUnrecordedLine": "{count} copie salvate sono state scritte prima che i backup registrassero le loro chiavi (la più vecchia il {when}). Possono richiedere qualsiasi chiave esistente allora: conserva tutte le vecchie chiavi finché non vengono sostituite o eliminate.",
+ "backupsOffhostLine": "Chiave {key}: l'ultima copia esterna che la richiede è entrata nel bucket il {when}; necessaria fino al {until}",
+ "backupsOffhostLineUnknown": "Chiave {key}: l'ultima copia esterna che la richiede è entrata nel bucket il {when}. La scadenza del bucket è sconosciuta, quindi conserva la chiave finché non sai che quella copia non c'è più.",
+ "backupsNone": "Nessun backup ha ancora registrato le chiavi di cui ha bisogno.",
+ "backupsStillNeed": "Tutte le righe sono ruotate, ma i backup hanno ancora bisogno di {keys}",
+ "backupsUnrecordedBadge": "Tutte le righe sono ruotate, ma backup più vecchi potrebbero ancora richiedere una chiave ritirata",
+ "backupsDetail": "La rotazione cifra di nuovo ogni riga, ma un backup conserva la chiave con cui è stato scritto il suo contenuto, e ripristinarlo richiede quella chiave. Tieni una chiave ritirata in ENCRYPTION_KEYS finché compare qui sotto."
}
},
"carrier": "Operatore",
@@ -7408,7 +7490,11 @@
"countOther": "{count} errori",
"lastFailed": "Ultimo errore {when}"
},
- "deleteAllDetail": "Gli account utente vengono conservati."
+ "deleteAllDetail": "Gli account utente vengono conservati.",
+ "cardError": {
+ "title": "Questa parte della pagina non è stata mostrata",
+ "description": "Il resto della pagina funziona; riprova o ricarica se il problema continua."
+ }
},
"achievements": {
"title": "Obiettivi",
@@ -8016,7 +8102,8 @@
"bodyToday": "{medName}: {units} unità rimaste — meno di un giorno di scorta.",
"bodyReorder": "{medName}: {units} unità rimaste. Le scorte finiscono intorno al {runsOutOn} — riordina entro il {reorderBy}.",
"bodyLastDose": "{medName}: {units} unità rimaste — circa la tua ultima dose. Le scorte finiscono intorno al {runsOutOn}.",
- "bodyExpired": "{medName}: nessuna scorta utilizzabile. {units} unità sono contrassegnate come scadute."
+ "bodyExpired": "{medName}: nessuna scorta utilizzabile. {units} unità sono contrassegnate come scadute.",
+ "bodyAsNeeded": "{medName}: restano solo {units} unità. Riordina prima di aver bisogno della prossima dose."
},
"moodReminders": {
"dailyTitle": "Registra il tuo umore",
@@ -10147,10 +10234,74 @@
"body": "La lettura con IA invia questo contenuto al tuo servizio di IA fuori da questo server. Consentire a HealthLog di inviare documenti, referti di laboratorio e descrizioni di farmaci per la lettura?",
"grant": "Consenti lettura",
"error": "Non è stato possibile salvare il consenso. Riprova."
+ },
+ "sourcePicker": {
+ "open": "Importa da archivio",
+ "openNamed": "Importa da {name}",
+ "title": "Importa da {name}",
+ "description": "Cerca per nome, poi spunta i documenti che HealthLog deve copiare.",
+ "sourceLabel": "Archivio",
+ "searchLabel": "Nome del documento",
+ "searchPlaceholder": "Cerca per nome",
+ "tagLabel": "Etichetta",
+ "tagAny": "Qualsiasi etichetta",
+ "fromLabel": "Dal",
+ "toLabel": "Al",
+ "clearFilters": "Azzera etichetta e date",
+ "rangeInvalid": "La data finale è precedente a quella iniziale.",
+ "empty": "{name} non ha documenti da mostrare.",
+ "noMatch": "Nessun documento corrisponde.",
+ "loadMore": "Carica altri",
+ "stateImported": "Già in HealthLog",
+ "stateDeleted": "Eliminato in HealthLog",
+ "selectedCount": "{count} selezionati su un massimo di {max}",
+ "importAction": "Importa {count}",
+ "importing": "Importazione in corso, un documento alla volta…",
+ "done": "Fatto",
+ "summary": "Importati: {imported}. Già presenti: {present}. Non importati: {failed}.",
+ "stopped": "Interrotto: {reason}",
+ "result": {
+ "waiting": "In attesa",
+ "working": "Importazione…",
+ "imported": "Importato",
+ "importedLinked": "Importato e collegato",
+ "duplicate": "Già in HealthLog",
+ "duplicateLinked": "Già in HealthLog, ora collegato",
+ "deleted": "Eliminato in HealthLog, lasciato così"
+ },
+ "errors": {
+ "unavailable": "Gli archivi di documenti non sono abilitati su questo server.",
+ "notConnected": "Questo archivio non è collegato. Collegalo in Impostazioni, Integrazioni.",
+ "originNotAllowed": "Questo indirizzo non è nell'elenco degli archivi consentiti dal server. Chiedi all'amministrazione di aggiungerlo.",
+ "unreachable": "L'archivio non è raggiungibile. Verifica che sia in funzione e che l'indirizzo sia corretto.",
+ "redirected": "L'archivio ha reindirizzato la richiesta. Inserisci l'indirizzo verso cui reindirizza.",
+ "authRefused": "L'archivio ha rifiutato il token. Controlla il token e i suoi permessi.",
+ "versionTooOld": "L'archivio è troppo vecchio. Serve Paperless-ngx 2.16 o successivo.",
+ "badResponse": "Non è stato possibile leggere la risposta dell'archivio. Verifica che l'indirizzo punti all'archivio stesso.",
+ "notFound": "L'archivio non ha questo documento, oppure l'id dell'organizzazione è sbagliato o non è tuo.",
+ "linkTargetNotFound": "L'elemento da collegare non esiste più.",
+ "rateLimited": "Troppe richieste. Attendi un minuto e riprova.",
+ "browserOnly": "Gli archivi di documenti si usano solo nel browser.",
+ "invalidAddress": "Inserisci l'indirizzo completo, che inizia con https:// o http://.",
+ "organizationRequired": "Inserisci l'id dell'organizzazione Papra.",
+ "tokenRequired": "Inserisci il token API. Un nuovo indirizzo lo richiede di nuovo.",
+ "fileTooLarge": "Il file è più grande di quanto HealthLog accetti.",
+ "quotaExceeded": "Lo spazio per i documenti è pieno.",
+ "fileType": "HealthLog non accetta questo tipo di file.",
+ "uploadLimit": "È stato raggiunto il limite orario di caricamenti. Riprova più tardi.",
+ "uploadBusy": "Altri caricamenti sono ancora in corso. Riprova tra un momento.",
+ "moduleDisabled": "I documenti sono disattivati nelle impostazioni.",
+ "generic": "Qualcosa è andato storto. Riprova.",
+ "permissionMissing": "La chiave Papra non può leggere le etichette. Dalle il permesso tags:read."
+ },
+ "pickMore": "Scegline altri",
+ "finishedHint": "Cerca di nuovo o cambia un filtro per scegliere altri documenti.",
+ "maxReached": "Puoi importare al massimo {max} documenti alla volta.",
+ "noMatchHint": "Prova con parte del nome, oppure azzera etichetta e date."
}
},
"selfDescription": {
- "dataPosture": "HealthLog è self-hosted: i tuoi dati di salute restano sulla tua istanza, cifrati a riposo. Le funzioni di IA usano una chiave del provider che fornisci tu o un modello locale, quindi nulla lascia la tua istanza oltre il provider che configuri, e non c'è alcuna analisi di terze parti sui tuoi dati di salute."
+ "dataPosture": "HealthLog è self-hosted: i tuoi dati di salute si trovano nel database della tua istanza. Note e altri testi liberi, documenti, risposte ai questionari, testi scritti dall'IA e token di accesso sono cifrati a riposo; valori delle misurazioni, valori di laboratorio, nomi dei farmaci e punteggi dell'umore sono salvati in chiaro e dipendono dalla sicurezza del tuo server. Le funzioni di IA inviano dati solo al provider di IA configurato per il tuo account, e non c'è alcuna analisi di terze parti sui tuoi dati di salute."
},
"customMetrics": {
"sectionTitle": "Metriche personalizzate",
diff --git a/messages/ko.json b/messages/ko.json
index 674b6c9a3..8ce36f817 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -126,7 +126,14 @@
"nothingSaved": "저장된 내용이 없어요. 표시된 항목을 확인하고 다시 시도해 주세요.",
"invalidBody": "프로필 변경 내용을 읽지 못했어요.",
"emailInUse": "이미 사용 중인 이메일 주소예요.",
- "emailRateLimited": "이메일 주소를 너무 자주 변경했어요. 잠시 후 다시 시도해 주세요."
+ "emailRateLimited": "이메일 주소를 너무 자주 변경했어요. 잠시 후 다시 시도해 주세요.",
+ "emailSecondFactorRequired": "이메일 주소를 바꾸려면 2단계 인증 수단이나 패스키로 확인해 주세요."
+ }
+ },
+ "auth": {
+ "reproof": {
+ "required": "변경하려면 현재 비밀번호를 확인하세요.",
+ "failed": "확인에 실패했습니다. 아무것도 변경되지 않았습니다."
}
}
},
@@ -440,7 +447,8 @@
"errorEmailUnverified": "제공자가 이메일 주소를 인증됨으로 표시하지 않았어요.",
"errorIdentityConflict": "그 이메일 주소는 다른 SSO 계정에 이미 연결돼 있어요.",
"errorRegistrationDisabled": "일치하는 계정이 없고, 지금은 회원가입이 닫혀 있어요.",
- "errorFailed": "싱글 사인온에 실패했어요. 다시 시도해 주세요."
+ "errorFailed": "싱글 사인온에 실패했어요. 다시 시도해 주세요.",
+ "errorLinkRequired": "이 이메일 주소를 사용하는 계정이 이미 있습니다. 싱글 사인온에 연결하려면 해당 계정의 비밀번호나 패스키로 한 번 로그인하세요."
},
"native": {
"signingInToApp": "HealthLog 앱에 로그인하는 중"
@@ -464,6 +472,13 @@
"title": "2차 인증 수단 설정",
"body": "관리자가 이 계정에 2차 인증 수단을 요구해요. 인증 앱이나 보안 키를 추가하고 계속해 주세요.",
"signOut": "로그아웃"
+ },
+ "confirmAccess": {
+ "title": "본인인지 확인해 주세요",
+ "body": "어시스턴트가 HealthLog 데이터에 연결하려고 해요. 본인인지 확인하면 허용할지 정하는 화면으로 돌아가요.",
+ "action": "확인",
+ "failed": "확인하지 못했어요. 다시 시도해 주세요.",
+ "nothingToConfirm": "여기에서 확인할 것이 없어요. 어시스턴트에서 연결을 다시 시작해 주세요."
}
},
"dashboard": {
@@ -4591,7 +4606,7 @@
"sampleBody": "2주 동안 체중 1.2kg 감소. 안정 시 맥박은 58bpm으로 꾸준해요. 혈압은 7일 중 6일 범위 안 — 높았던 하루는 늦은 시간의 짠 저녁 식사 다음이었어요.",
"sampleCaption": "예시일 뿐 내 데이터가 아니에요. 인사이트를 켜면 브리핑이 이런 식으로 읽혀요.",
"ladderTitle": "켜는 방법 세 가지",
- "ladderLocalTitle": "로컬 모델 — 내 네트워크 밖으로 나가지 않아요",
+ "ladderLocalTitle": "로컬 모델 — AI 요청이 내 네트워크 안에 머물러요",
"ladderLocalBody": "내 컴퓨터의 Ollama나 LM Studio를 HealthLog에 연결해요. 무료이고 완전히 비공개 — 가장 마음 편한 기본값이에요.",
"ladderByokTitle": "내 API 키 사용",
"ladderByokBody": "본인의 OpenAI나 Anthropic 키를 쓰면 돼요. 내 계정과 결제로 돌아가고, API 등급은 기본적으로 데이터를 학습에 쓰지 않아요.",
@@ -5173,7 +5188,6 @@
"passphrasePlaceholder": "암호문구 (12자 이상)",
"passphraseHelp": "Argon2id로 보관 파일 키를 만들고 AES-256-GCM으로 봉인해요. 파일은 .hlx 확장자로 저장해 주세요.",
"noRecoveryWarning": "복구 수단이 없어요 — 이 암호문구를 잃어버리면 보관 파일을 열 수 없어요. 안전한 곳에 보관해 주세요.",
- "stepUpNeeded": "2단계 인증을 확인한 뒤 다시 시도해 주세요.",
"detail": "주간 자동 백업과 같은 형식이에요. 아직 담기지 않는 기록 유형이 있고, 파일의 매니페스트에 무엇이 들었는지 적혀 있어요.",
"contents": "측정, 복약, 기분, 생리 주기, 검사, 아픈 날 에피소드, 알레르기, 가족력, 영양, 운동, 건강 프로필, 사용자 지표를 JSON 파일 하나에."
}
@@ -5553,7 +5567,10 @@
"invalidFormat": "이 항목이 읽을 수 있는 형식이 아니에요.",
"invalidType": "이 항목이 저장할 수 있는 형식으로 오지 않았어요.",
"rateLimited": "너무 자주 바꿨어요. 잠시 후 다시 시도해 주세요.",
- "other": "이 값은 저장하지 못했어요. 확인한 뒤 다시 시도해 주세요."
+ "other": "이 값은 저장하지 못했어요. 확인한 뒤 다시 시도해 주세요.",
+ "reproofRequired": "이메일 주소를 변경하려면 현재 비밀번호를 입력하세요.",
+ "reproofFailed": "비밀번호가 올바르지 않아 이메일 주소가 변경되지 않았습니다.",
+ "secondFactorRequired": "이메일 주소를 바꾸려면 2단계 인증 수단이나 패스키로 확인해 주세요."
},
"avatar": {
"title": "프로필 사진",
@@ -5594,7 +5611,9 @@
"currentPassword": "현재 비밀번호",
"authenticatorCode": "인증 앱 코드",
"authenticatorPlaceholder": "6자리 인증 앱 코드",
- "verifyAndAdd": "확인 후 추가"
+ "verifyAndAdd": "확인 후 추가",
+ "factorDescription": "두 번째 인증 수단을 추가하기 전에 기존 로그인 방법을 확인하세요.",
+ "verifyAndContinue": "확인하고 계속"
},
"passkeyName": "이름",
"noPasskeys": "등록된 패스키가 없어요.",
@@ -5655,10 +5674,10 @@
"codexConnected": "ChatGPT를 연결했어요! 이제 인사이트를 쓸 수 있어요.",
"codexDisconnected": "ChatGPT 연결을 해제했어요.",
"codexConnectionFailed": "ChatGPT 연결에 실패했어요. 다시 시도해 주세요.",
- "rawData": "원본 데이터 보내기",
- "rawDataOnDescription": "집계 지표와 함께 최근 30일의 익명화된 개별 값이 시간 맥락과 함께 전송돼요 — 이름, 이메일, 계정 식별자는 절대 보내지 않아요.",
- "rawDataOffDescription": "요약 지표만 전송돼요(예: 평균, 추이, 최소/최대) — 개별 값이나 정확한 시간 맥락은 보내지 않아요.",
- "rawDataWarning": "원본 모드가 켜져 있어요. 분석 정확도를 높이려고 제공자에게 최근 30일의 익명화된 값이 추가로 전달돼요.",
+ "rawData": "자세한 기록 보내기",
+ "rawDataOnDescription": "요약과 함께 측정값의 평균이 제공자에게 전송돼요. 최근 90일은 하루 단위, 그 이전 1년은 주 단위, 최대 5년 전까지는 월 단위이며 각각 날짜와 측정 횟수가 포함돼요. 개별 측정값, 이름, 이메일은 보내지 않아요.",
+ "rawDataOffDescription": "요약만 전송돼요. 최근 값, 평균, 추이, 정해진 기간의 최소/최대가 포함되며 측정값 시계열은 보내지 않아요.",
+ "rawDataWarning": "자세한 기록이 켜져 있어요. 분석할 때마다 최근 90일의 일별 평균, 그 이전 1년의 주별 평균, 최대 5년 전까지의 월별 평균이 추가로 전송돼요.",
"regenerateInsights": "리포트 다시 생성",
"regenerateSuccess": "리포트를 다시 생성했어요",
"regenerateRateLimit": "잠시만 기다려 주세요 — 시간당 분석 한도에 도달했어요.",
@@ -6545,7 +6564,12 @@
"revokeSessionConfirmTitle": "이 기기를 로그아웃할까요?",
"revokeSessionConfirmBody": "{device} — 다시 로그인해야 해요. 기기에 기록된 내용은 영향받지 않아요.",
"signOutEverywhereConfirmTitle": "다른 기기를 모두 로그아웃할까요?",
- "signOutEverywhereConfirmBody": "이 기기를 뺀 모든 기기가 다시 로그인해야 해요. 이 기기는 로그인 상태로 남아요. 기록한 내용은 영향받지 않아요."
+ "signOutEverywhereConfirmBody": "다른 모든 기기, 연결된 모든 AI 어시스턴트와 모든 API 토큰은 다시 로그인하거나 다시 설정해야 합니다. 이 기기는 로그인 상태로 유지됩니다. 기록한 내용에는 영향이 없습니다.",
+ "signOutEverywhereShareLinks": "의료진 공유 링크도 종료",
+ "signOutEverywhereInvites": "아직 아무도 수락하지 않은 기록 공유 초대는 철회됩니다. 이미 수락한 사람은 접근 권한을 유지하며, 이어서 끝낼 수 있어요.",
+ "signOutEverywhereInvitesDone": "수락되지 않은 초대 {count}개를 철회했어요.",
+ "grantsKeptTitle": "이 사람들은 여전히 내 기록을 볼 수 있어요",
+ "grantsKeptBody": "로그아웃해도 다른 사람에게 준 접근 권한은 끝나지 않아요. 직접 준 적이 없거나 더 이상 원하지 않으면 여기에서 끝내세요."
},
"privacy": {
"encryption": {
@@ -6579,7 +6603,7 @@
"delete": {
"title": "삭제 또는 초기화",
"description": "계정을 완전히 없애거나, 계정은 두고 건강 기록만 지워요.",
- "backupLag": "삭제하면 데이터는 즉시 지워져요. 외부에 보관된 암호화 백업에는 운영자의 백업 보관 기간이 끝날 때까지 남아 있어요.",
+ "backupLag": "삭제하면 데이터는 즉시 지워져요. 이 서버가 암호화된 외부 백업을 보관한다면 그 저장소에 있는 데이터 사본도 곧바로 삭제되고, 저장소에 연결할 수 없으면 다음 야간 실행 때 삭제돼요.",
"openAdvanced": "삭제 또는 초기화"
},
"posture": {
@@ -6698,6 +6722,41 @@
"showChecklist": "체크리스트 다시 표시",
"checklistShown": "체크리스트가 대시보드에 다시 표시됩니다.",
"checklistNothingHidden": "체크리스트는 숨겨져 있지 않았습니다. 대시보드에서 열립니다."
+ },
+ "documentSources": {
+ "title": "문서 보관함",
+ "description": "HealthLog에서 Paperless-ngx나 Papra를 검색하고 선택한 문서를 가져옵니다.",
+ "explainer": "문서를 연결하는 곳마다 가져오기 버튼이 나타납니다: 진료, 예방접종, 질환, 그리고 문서 페이지. HealthLog는 가져온 문서마다 자체 암호화 사본을 보관하며, 검색하거나 가져올 때만 보관함에 요청합니다.",
+ "systemLabel": "보관함",
+ "statusConnected": "연결됨",
+ "statusNotConnected": "연결 안 됨",
+ "connectedSince": "연결됨, 마지막 확인 {date}",
+ "originRemoved": "이 주소는 더 이상 서버의 허용된 보관함 목록에 없습니다. 관리자에게 문의하거나 다른 주소를 저장하세요.",
+ "baseUrl": "주소",
+ "allowedOrigins": "이 서버에서 허용됨: {origins}",
+ "organizationId": "조직 ID",
+ "organizationHelp": "Papra 주소 표시줄에서 /organizations/ 다음 부분입니다. Papra 조직 ID는 org_ 뒤에 영문자와 숫자 24자가 오는 형식입니다.",
+ "token": "API 토큰",
+ "tokenSaved": "이 주소에 저장됨. 유지하려면 비워 두세요.",
+ "tokenHelpPaperless": "Paperless-ngx: 내 프로필의 API 토큰.",
+ "tokenHelpPapra": "Papra: documents:read와 tags:read 권한이 있는 API 키.",
+ "save": "저장하고 테스트",
+ "saving": "테스트 중…",
+ "saved": "연결되었습니다. 보관함이 응답했습니다.",
+ "test": "테스트",
+ "testOk": "보관함이 {latency}ms 만에 응답했습니다.",
+ "disconnect": "연결 해제",
+ "disconnectTitle": "{name} 연결을 해제할까요?",
+ "disconnectDescription": "HealthLog가 주소와 토큰을 잊습니다. 이미 가져온 문서는 HealthLog에 남습니다.",
+ "unavailableExplainer": "이 서버는 더 이상 문서 보관함을 허용하지 않아 아래 연결을 사용할 수 없습니다. 연결은 여전히 삭제할 수 있습니다.",
+ "moduleOffExplainer": "문서 기능이 꺼져 있어 아래 연결은 사용되지 않습니다. 연결은 여전히 삭제할 수 있습니다.",
+ "tokenNewAddress": "새 주소에는 API 토큰을 다시 입력해야 합니다."
+ },
+ "emailChangePassword": "현재 비밀번호 (이메일 주소를 변경하려면 필요)",
+ "reproof": {
+ "description": "건강 데이터를 공유하거나 내보내는 작업입니다. 계속하려면 본인임을 확인하세요.",
+ "signInAgain": "다시 로그인한 후 한 번 더 시도하세요. 이 계정에는 여기서 확인할 비밀번호나 패스키가 없습니다.",
+ "failed": "확인하지 못했습니다. 입력한 내용을 확인하고 다시 시도하세요."
}
},
"admin": {
@@ -6746,7 +6805,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": "인사이트 품질",
@@ -7042,6 +7105,7 @@
"versionTileBuiltAt": "빌드 시각",
"versionTileUpdateAvailable": "업데이트 있음: {tag}",
"versionTileUpToDate": "최신 상태",
+ "versionTileCheckOff": "업데이트 확인 꺼짐",
"snapshotLoadError": "시스템 스냅샷을 불러오지 못했어요.",
"snapshotLoading": "스냅샷 불러오는 중…",
"auditTitle": "최근 활동",
@@ -7246,7 +7310,7 @@
"description": "이 호스트가 각 계정의 암호화 사본을 버킷에 마지막으로 올린 시점입니다.",
"loadError": "외부 보관 상태를 불러오지 못했습니다.",
"notConfiguredTitle": "외부 보관 백업이 설정되지 않았습니다",
- "notConfiguredDescription": "버킷과 키가 모두 설정되지 않아 이 호스트를 벗어나는 것이 없습니다. 야간 업로드를 켜려면 BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY, BACKUP_ENCRYPTION_KEY를 설정하세요.",
+ "notConfiguredDescription": "버킷과 키가 모두 설정되지 않아 이 호스트를 벗어나는 백업이 없습니다. 야간 업로드를 켜려면 BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY, BACKUP_ENCRYPTION_KEY를 설정하세요.",
"stateFresh": "최신",
"stateDue": "지연",
"stateStale": "오래됨",
@@ -7255,7 +7319,12 @@
"neverDetail": "이 계정으로 도착한 것이 없습니다. 마지막 확인 {when}.",
"unknownDetail": "아직 어떤 야간 실행도 이 계정을 기록하지 않았습니다.",
"age": "{hours}시간 전",
- "scheduleNote": "업로드는 {hours}시간마다 실행됩니다. 최신은 한 번의 실행 이내, 지연은 한 번이 아무것도 만들지 못한 상태, 오래됨은 두 번이 그런 상태입니다."
+ "scheduleNote": "업로드는 {hours}시간마다 실행됩니다. 최신은 한 번의 실행 이내, 지연은 한 번이 아무것도 만들지 못한 상태, 오래됨은 두 번이 그런 상태입니다.",
+ "lifecycleConfigured": "버킷의 수명 주기 규칙에 따라 복사본이 {days}일 후 삭제됩니다.",
+ "lifecycleMissing": "버킷에 수명 주기 규칙이 없어 매일 밤의 복사본이 영구히 보관됩니다. BACKUP_RETENTION_DAYS 후 객체를 만료시키는 규칙을 추가하세요.",
+ "lifecycleUnknown": "버킷이 오래된 복사본을 삭제하는지 읽을 수 없었습니다. 자격 증명이 수명 주기 규칙을 읽지 못할 수 있습니다. BACKUP_RETENTION_DAYS 후 객체를 만료시키는 규칙이 있는지 확인하세요.",
+ "pendingDeletions": "삭제되었거나 비워진 계정 {count}개의 복사본이 아직 버킷에 있으며 제거 중입니다.",
+ "pendingDeletionsFailing": "버킷이 삭제를 거부하여 삭제되었거나 비워진 계정 {count}개의 복사본이 아직 남아 있습니다 ({error}). 자격 증명에 DeleteObject 권한을 부여하세요. 삭제는 매일 밤 다시 시도됩니다."
},
"restoreStarted": "복원을 시작했어요. 진행 상황은 목록 위에 표시돼요.",
"restoreActive": "이 계정의 복원이 이미 진행 중이에요. 끝날 때까지 기다려 주세요.",
@@ -7288,7 +7357,10 @@
"restoreFailureNotStarted": "백그라운드 작업자가 복원을 가져가지 않았어요. 작업자가 실행 중인지 확인해 주세요.",
"restoreFailureEnqueue": "복원을 백그라운드 작업자에게 넘기지 못했어요. 작업자가 실행 중인지 확인해 주세요.",
"restoreFailureAfterCommit": "데이터는 복원됐지만 그다음 단계가 실패했어요. 차트는 다음 야간 실행 때 따라잡을 수 있어요.",
- "restoreFailureUnexpected": "예상하지 못한 오류로 복원이 멈췄고 되돌려졌어요. 서버 로그에 오류가 나와 있어요."
+ "restoreFailureUnexpected": "예상하지 못한 오류로 복원이 멈췄고 되돌려졌어요. 서버 로그에 오류가 나와 있어요.",
+ "restoreFailureKeyMissing": "이 백업에는 암호화 키 {keys}가 필요하지만 이 서버는 그 키로 백업을 열 수 없습니다. 키를 ENCRYPTION_KEYS에 다시 추가하고 재시작한 뒤 복원을 다시 시작하세요.",
+ "restoreFailureForeignReference": "파일이 다른 계정에 속한 기록을 가리키고 있습니다. 아무것도 기록되지 않았습니다.",
+ "previewKeyMissing": "이 서버에서는 이 백업을 복원할 수 없습니다. 암호화 키 {keys}가 필요합니다. 먼저 키를 ENCRYPTION_KEYS에 다시 추가하고 재시작하세요."
},
"danger-zone": {
"title": "위험 구역",
@@ -7327,7 +7399,7 @@
"rotateEnqueued": "교체를 시작했어요.",
"rotateFailed": "교체를 시작하지 못했어요.",
"stepUpNeeded": "2단계 인증을 다시 확인해야 해요. 2단계 인증을 확인하거나, 등록한 수단이 없으면 명령줄 도구를 써 주세요.",
- "cliNote": "이 버튼을 쓰려면 2단계 인증이 필요해요. 등록하지 않은 운영자는 scripts/rotate-encryption-key.ts로 교체해 주세요.",
+ "cliNote": "이 버튼을 쓰려면 2단계 인증이 필요해요. 등록하지 않았다면 이 버전의 소스 코드 체크아웃에서 scripts/rotate-encryption-key.ts를 실행해 주세요. 절차는 docs/ops/encryption-key-rotation.md에 있어요.",
"columnsTitle": "열별 적용 범위",
"columnsDescription": "암호화된 열마다 활성 키와 이전 키의 행 수를 보여줘요.",
"colColumn": "열",
@@ -7340,7 +7412,17 @@
"completed": "완료",
"failed": "실패"
},
- "coverageDetail": "키 id와 개수만 보여줘요 — 키 자체는 절대 표시하지 않아요."
+ "coverageDetail": "키 id와 개수만 보여줘요 — 키 자체는 절대 표시하지 않아요.",
+ "backupsTitle": "백업에 아직 필요한 키",
+ "backupsDescription": "아래 백업 중 하나라도 표시하는 동안 폐기한 키는 계속 필요합니다.",
+ "backupsStoredLine": "키 {key}: 저장된 복사본 {count}개, 가장 오래된 것은 {when}",
+ "backupsUnrecordedLine": "저장된 복사본 {count}개는 백업이 키를 기록하기 전에 작성되었습니다(가장 오래된 것은 {when}). 당시 존재하던 어떤 키든 필요할 수 있으니, 교체되거나 삭제될 때까지 이전 키를 모두 유지하세요.",
+ "backupsOffhostLine": "키 {key}: 이 키가 필요한 마지막 외부 복사본이 {when}에 버킷에 저장되었습니다. {until}까지 필요합니다",
+ "backupsOffhostLineUnknown": "키 {key}: 이 키가 필요한 마지막 외부 복사본이 {when}에 버킷에 저장되었습니다. 버킷의 만료 기간을 알 수 없으므로 그 복사본이 사라진 것을 확인할 때까지 키를 유지하세요.",
+ "backupsNone": "아직 필요한 키를 기록한 백업이 없습니다.",
+ "backupsStillNeed": "모든 행이 회전되었지만 백업에 아직 {keys}가 필요합니다",
+ "backupsUnrecordedBadge": "모든 행이 회전되었지만 이전 백업에는 아직 폐기한 키가 필요할 수 있습니다",
+ "backupsDetail": "회전은 모든 행을 다시 암호화하지만, 백업은 내용이 기록된 키를 그대로 유지하며 복원에는 그 키가 필요합니다. 아래에 더 이상 표시되지 않을 때까지 폐기한 키를 ENCRYPTION_KEYS에 유지하세요."
}
},
"carrier": "통신사",
@@ -7408,6 +7490,10 @@
"typeLocal": "로컬 모델 (개인)",
"typeGateway": "OpenAI 호환 게이트웨이 (개인)",
"spendToday": "오늘({date}): 토큰 {total}개, 그중 {operator}개는 운영자 키에서 사용되었습니다."
+ },
+ "cardError": {
+ "title": "페이지의 이 부분을 표시할 수 없습니다",
+ "description": "페이지의 나머지 부분은 계속 작동합니다. 다시 시도하거나 계속 실패하면 새로고침하세요."
}
},
"achievements": {
@@ -8016,7 +8102,8 @@
"bodyToday": "{medName}: {units}개 남았어요 — 하루치도 안 돼요.",
"bodyReorder": "{medName}: {units}개 남았어요. {runsOutOn}쯤 소진돼요 — {reorderBy}까지 재주문하세요.",
"bodyLastDose": "{medName}: {units}개 남았어요 — 마지막 복용분이에요. {runsOutOn}쯤 소진돼요.",
- "bodyExpired": "{medName}: 쓸 수 있는 재고가 없어요. {units}개가 유효기간 만료로 표시돼 있어요."
+ "bodyExpired": "{medName}: 쓸 수 있는 재고가 없어요. {units}개가 유효기간 만료로 표시돼 있어요.",
+ "bodyAsNeeded": "{medName}: {units}개만 남았어요. 다음에 필요하기 전에 재주문하세요."
},
"moodReminders": {
"dailyTitle": "기분 기록하기",
@@ -10147,10 +10234,74 @@
"body": "AI로 읽으면 이 내용이 이 서버 밖의 AI 서비스로 전송됩니다. HealthLog가 문서, 검사 결과지, 약 설명을 읽기용으로 보내도록 허용하시겠습니까?",
"grant": "읽기 허용",
"error": "동의를 저장하지 못했습니다. 다시 시도해 주세요."
+ },
+ "sourcePicker": {
+ "open": "보관함에서 가져오기",
+ "openNamed": "{name}에서 가져오기",
+ "title": "{name}에서 가져오기",
+ "description": "이름으로 검색한 뒤 HealthLog로 복사할 문서를 선택하세요.",
+ "sourceLabel": "보관함",
+ "searchLabel": "문서 이름",
+ "searchPlaceholder": "이름으로 검색",
+ "tagLabel": "태그",
+ "tagAny": "모든 태그",
+ "fromLabel": "시작일",
+ "toLabel": "종료일",
+ "clearFilters": "태그와 날짜 지우기",
+ "rangeInvalid": "종료일이 시작일보다 앞섭니다.",
+ "empty": "{name}에 표시할 문서가 없습니다.",
+ "noMatch": "일치하는 문서가 없습니다.",
+ "loadMore": "더 불러오기",
+ "stateImported": "HealthLog에 있음",
+ "stateDeleted": "HealthLog에서 삭제됨",
+ "selectedCount": "최대 {max}개 중 {count}개 선택",
+ "importAction": "{count}개 가져오기",
+ "importing": "문서를 하나씩 가져오는 중…",
+ "done": "완료",
+ "summary": "가져옴: {imported}. 이미 있음: {present}. 가져오지 못함: {failed}.",
+ "stopped": "중단됨: {reason}",
+ "result": {
+ "waiting": "대기 중",
+ "working": "가져오는 중…",
+ "imported": "가져옴",
+ "importedLinked": "가져오고 연결함",
+ "duplicate": "이미 HealthLog에 있음",
+ "duplicateLinked": "이미 HealthLog에 있음, 이제 연결됨",
+ "deleted": "HealthLog에서 삭제됨, 그대로 둠"
+ },
+ "errors": {
+ "unavailable": "이 서버에서는 문서 보관함이 활성화되어 있지 않습니다.",
+ "notConnected": "이 보관함은 연결되어 있지 않습니다. 설정의 통합에서 연결하세요.",
+ "originNotAllowed": "이 주소는 서버의 허용된 보관함 목록에 없습니다. 관리자에게 추가를 요청하세요.",
+ "unreachable": "보관함에 연결할 수 없습니다. 실행 중인지, 주소가 맞는지 확인하세요.",
+ "redirected": "보관함이 요청을 리디렉션했습니다. 리디렉션되는 주소를 입력하세요.",
+ "authRefused": "보관함이 토큰을 거부했습니다. 토큰과 권한을 확인하세요.",
+ "versionTooOld": "보관함 버전이 너무 오래되었습니다. Paperless-ngx 2.16 이상이 필요합니다.",
+ "badResponse": "보관함의 응답을 읽을 수 없습니다. 주소가 보관함 자체를 가리키는지 확인하세요.",
+ "notFound": "보관함에 해당 문서가 없거나, 조직 ID가 잘못되었거나 본인의 조직이 아닙니다.",
+ "linkTargetNotFound": "연결할 기록이 더 이상 존재하지 않습니다.",
+ "rateLimited": "요청이 너무 많습니다. 1분 후에 다시 시도하세요.",
+ "browserOnly": "문서 보관함은 브라우저에서만 사용할 수 있습니다.",
+ "invalidAddress": "https:// 또는 http://로 시작하는 전체 주소를 입력하세요.",
+ "organizationRequired": "Papra 조직 ID를 입력하세요.",
+ "tokenRequired": "API 토큰을 입력하세요. 새 주소에는 다시 필요합니다.",
+ "fileTooLarge": "파일이 HealthLog가 허용하는 크기보다 큽니다.",
+ "quotaExceeded": "문서 저장 공간이 가득 찼습니다.",
+ "fileType": "HealthLog는 이 파일 형식을 받지 않습니다.",
+ "uploadLimit": "시간당 업로드 한도에 도달했습니다. 나중에 다시 시도하세요.",
+ "uploadBusy": "다른 업로드가 아직 진행 중입니다. 잠시 후 다시 시도하세요.",
+ "moduleDisabled": "설정에서 문서 기능이 꺼져 있습니다.",
+ "generic": "문제가 발생했습니다. 다시 시도하세요.",
+ "permissionMissing": "Papra 키가 태그를 읽을 수 없습니다. tags:read 권한을 부여하세요."
+ },
+ "pickMore": "더 고르기",
+ "finishedHint": "다시 검색하거나 필터를 바꿔 문서를 더 고르세요.",
+ "maxReached": "한 번에 최대 {max}개 문서를 가져올 수 있습니다.",
+ "noMatchHint": "이름의 일부로 검색하거나 태그와 날짜를 지워 보세요."
}
},
"selfDescription": {
- "dataPosture": "HealthLog는 셀프 호스팅이에요. 건강 데이터는 본인 인스턴스에 암호화되어 저장돼요. AI 기능은 직접 준비한 제공자 키나 로컬 모델을 쓰기 때문에 설정한 제공자 외에는 아무것도 인스턴스 밖으로 나가지 않고, 건강 데이터에 대한 서드파티 분석도 없어요."
+ "dataPosture": "HealthLog는 셀프 호스팅이에요. 건강 데이터는 본인 인스턴스의 데이터베이스에 저장돼요. 메모와 기타 자유 입력 텍스트, 문서, 설문 응답, AI가 작성한 텍스트, 접근 토큰은 저장 시 암호화돼요. 측정값, 검사 수치, 약 이름, 기분 점수는 암호화되지 않은 채 저장되며 서버 자체의 보안에 의존해요. AI 기능은 계정에 설정된 AI 제공자에게만 데이터를 보내고, 건강 데이터에 대한 서드파티 분석은 없어요."
},
"customMetrics": {
"sectionTitle": "사용자 지표",
diff --git a/messages/pl.json b/messages/pl.json
index a2c23d8b3..fccc01aca 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -126,7 +126,14 @@
"nothingSaved": "Nic nie zostało zapisane. Sprawdź oznaczone pole i spróbuj ponownie.",
"invalidBody": "Nie udało się odczytać aktualizacji profilu.",
"emailInUse": "Ten adres e-mail jest już używany.",
- "emailRateLimited": "Zbyt wiele zmian adresu e-mail. Spróbuj ponownie później."
+ "emailRateLimited": "Zbyt wiele zmian adresu e-mail. Spróbuj ponownie później.",
+ "emailSecondFactorRequired": "Potwierdź drugim składnikiem lub kluczem dostępu, aby zmienić adres e-mail."
+ }
+ },
+ "auth": {
+ "reproof": {
+ "required": "Potwierdź obecne hasło, aby to zmienić.",
+ "failed": "Weryfikacja nie powiodła się. Nic nie zostało zmienione."
}
}
},
@@ -440,7 +447,8 @@
"errorEmailUnverified": "Twój dostawca tożsamości nie oznaczył Twojego adresu e-mail jako zweryfikowanego.",
"errorIdentityConflict": "Ten adres e-mail należy do konta powiązanego już z inną tożsamością SSO.",
"errorRegistrationDisabled": "Żadne konto nie pasuje do tej tożsamości, a nowe rejestracje są obecnie zamknięte.",
- "errorFailed": "Logowanie jednokrotne nie powiodło się. Spróbuj ponownie."
+ "errorFailed": "Logowanie jednokrotne nie powiodło się. Spróbuj ponownie.",
+ "errorLinkRequired": "Konto z tym adresem e-mail już tu istnieje. Zaloguj się do niego raz hasłem lub kluczem dostępu, aby połączyć je z logowaniem jednokrotnym."
},
"native": {
"signingInToApp": "Logowanie do aplikacji HealthLog"
@@ -464,6 +472,13 @@
"title": "Skonfiguruj drugi składnik",
"body": "Administrator wymaga drugiego składnika na tym koncie. Dodaj aplikację uwierzytelniającą lub klucz bezpieczeństwa, aby kontynuować.",
"signOut": "Wyloguj się"
+ },
+ "confirmAccess": {
+ "title": "Potwierdź, że to Ty",
+ "body": "Asystent chce połączyć się z Twoimi danymi w HealthLog. Potwierdź, że to Ty, a potem zdecydujesz, czy na to pozwolić.",
+ "action": "Potwierdź",
+ "failed": "Nie udało się. Spróbuj ponownie.",
+ "nothingToConfirm": "Nie ma tu nic do potwierdzenia. Rozpocznij połączenie ponownie w swoim asystencie."
}
},
"dashboard": {
@@ -4591,7 +4606,7 @@
"sampleBody": "Waga niższa o 1,2 kg w ciągu dwóch tygodni. Tętno spoczynkowe stabilne na 58 bpm. Ciśnienie w normie przez 6 z 7 dni — jedyny wysoki odczyt nastąpił po późnej, słonej kolacji.",
"sampleCaption": "Tylko przykład — to nie są Twoje dane. Tak czyta się briefing, gdy insighty są włączone.",
"ladderTitle": "Trzy sposoby, by to włączyć",
- "ladderLocalTitle": "Model lokalny — nic nie opuszcza Twojej sieci",
+ "ladderLocalTitle": "Model lokalny — zapytania do AI zostają w Twojej sieci",
"ladderLocalBody": "Skieruj HealthLog na Ollama lub LM Studio na własnej maszynie. Za darmo i w pełni prywatnie — spokojny wybór domyślny.",
"ladderByokTitle": "Użyj własnego klucza",
"ladderByokBody": "Użyj własnego klucza OpenAI lub Anthropic. Działa na Twoim koncie i rozliczeniu, a poziom API domyślnie wyklucza Twoje dane z treningu.",
@@ -5173,7 +5188,6 @@
"passphrasePlaceholder": "Hasło-fraza (min. 12 znaków)",
"passphraseHelp": "Wyprowadza klucz archiwum za pomocą Argon2id i pieczętuje je AES-256-GCM. Zapisz plik z rozszerzeniem .hlx.",
"noRecoveryWarning": "Nie ma odzyskiwania — jeśli zgubisz tę frazę, archiwum nie da się otworzyć. Przechowuj ją bezpiecznie.",
- "stepUpNeeded": "Potwierdź drugi składnik i spróbuj ponownie.",
"detail": "Ten sam kształt co cotygodniowa kopia automatyczna. Niektóre rodzaje wpisów nie są jeszcze przenoszone — manifest pliku wskazuje, co zawiera.",
"contents": "Twoje pomiary, leki, nastrój, cykl, wyniki badań, epizody chorobowe, alergie, wywiad rodzinny, składniki odżywcze, treningi, Twój profil zdrowia i własne wskaźniki w jednym pliku JSON."
}
@@ -5553,7 +5567,10 @@
"invalidFormat": "To nie jest zapisane w formacie, który to pole może odczytać.",
"invalidType": "Wartość nie dotarła w formacie, który to pole może zapisać.",
"rateLimited": "Zmieniane zbyt często. Odczekaj chwilę i spróbuj ponownie.",
- "other": "Ta wartość nie została przyjęta. Sprawdź ją i spróbuj ponownie."
+ "other": "Ta wartość nie została przyjęta. Sprawdź ją i spróbuj ponownie.",
+ "reproofRequired": "Wpisz obecne hasło, aby zmienić adres e-mail.",
+ "reproofFailed": "Hasło jest nieprawidłowe, więc adres e-mail nie został zmieniony.",
+ "secondFactorRequired": "Potwierdź drugim składnikiem lub kluczem dostępu, aby zmienić adres e-mail."
},
"avatar": {
"title": "Zdjęcie profilowe",
@@ -5594,7 +5611,9 @@
"currentPassword": "Aktualne hasło",
"authenticatorCode": "Kod z aplikacji uwierzytelniającej",
"authenticatorPlaceholder": "6-cyfrowy kod z aplikacji uwierzytelniającej",
- "verifyAndAdd": "Zweryfikuj i dodaj"
+ "verifyAndAdd": "Zweryfikuj i dodaj",
+ "factorDescription": "Potwierdź istniejącą metodę logowania, zanim dodasz drugi składnik.",
+ "verifyAndContinue": "Potwierdź i kontynuuj"
},
"passkeyName": "Nazwa",
"noPasskeys": "Brak zarejestrowanych kluczy dostępu.",
@@ -5655,10 +5674,10 @@
"codexConnected": "ChatGPT połączony pomyślnie! Insights jest teraz aktywny.",
"codexDisconnected": "Połączenie z ChatGPT usunięte.",
"codexConnectionFailed": "Połączenie z ChatGPT nie powiodło się. Spróbuj ponownie.",
- "rawData": "Wysyłaj surowe dane",
- "rawDataOnDescription": "Wysyłane są zagregowane metryki oraz zanonimizowane pojedyncze punkty z ostatnich 30 dni z kontekstem czasowym — nigdy imię, adres e-mail ani bezpośrednie identyfikatory konta.",
- "rawDataOffDescription": "Wysyłane są tylko podsumowane metryki (np. średnie, trendy, minimum/maksimum) — bez pojedynczych punktów i bez dokładnego kontekstu czasowego.",
- "rawDataWarning": "Tryb surowych danych włączony: dostawca otrzymuje dodatkowe zanonimizowane punkty z ostatnich 30 dni dla dokładniejszej analizy.",
+ "rawData": "Wysyłaj szczegółową historię",
+ "rawDataOnDescription": "Oprócz podsumowań dostawca otrzymuje średnie z Twoich pomiarów: dzienne z ostatnich 90 dni, tygodniowe z roku wcześniej i miesięczne do 5 lat wstecz, każdą z datą i liczbą pomiarów. Pojedyncze pomiary, Twoje imię i adres e-mail nie są wysyłane.",
+ "rawDataOffDescription": "Wysyłane są tylko podsumowania: ostatnia wartość, średnie, trendy, minimum i maksimum w stałych okresach. Bez serii pomiarów.",
+ "rawDataWarning": "Szczegółowa historia jest włączona: każda analiza wysyła dodatkowo średnie dzienne z ostatnich 90 dni, średnie tygodniowe z roku wcześniej i średnie miesięczne do 5 lat wstecz.",
"regenerateInsights": "Wygeneruj raporty ponownie",
"regenerateSuccess": "Raporty wygenerowane ponownie",
"regenerateRateLimit": "Poczekaj — osiągnięto godzinny limit analiz.",
@@ -6545,7 +6564,12 @@
"revokeSessionConfirmTitle": "Wylogować to urządzenie?",
"revokeSessionConfirmBody": "{device} będzie musiało zalogować się ponownie. Zapisane na nim dane pozostają bez zmian.",
"signOutEverywhereConfirmTitle": "Wylogować wszystkie pozostałe urządzenia?",
- "signOutEverywhereConfirmBody": "Każde urządzenie poza tym będzie musiało zalogować się ponownie. To urządzenie pozostaje zalogowane. Zapisane dane pozostają bez zmian."
+ "signOutEverywhereConfirmBody": "Wszystkie inne urządzenia, każdy połączony asystent AI i każdy token API będą musiały zalogować się ponownie lub zostać skonfigurowane od nowa. To urządzenie pozostaje zalogowane. Zapisane dane pozostają bez zmian.",
+ "signOutEverywhereShareLinks": "Zakończ także linki udostępniania dla lekarzy",
+ "signOutEverywhereInvites": "Zaproszenia do Twojej karty, których nikt jeszcze nie przyjął, zostaną wycofane. Osoby, które już je przyjęły, zachowują dostęp; możesz go zakończyć zaraz potem.",
+ "signOutEverywhereInvitesDone": "Wycofano nieprzyjęte zaproszenia: {count}.",
+ "grantsKeptTitle": "Te osoby nadal widzą Twoją kartę",
+ "grantsKeptBody": "Wylogowanie nie kończy dostępu, który komuś dałeś. Zakończ go tutaj, jeśli go nie dawałeś albo już nie chcesz, żeby go miał."
},
"privacy": {
"encryption": {
@@ -6579,7 +6603,7 @@
"delete": {
"title": "Usuń lub zresetuj",
"description": "Usuń całe konto albo wyczyść dane zdrowotne, zachowując konto.",
- "backupLag": "Usunięcie natychmiast usuwa Twoje dane. Zaszyfrowane kopie zapasowe poza serwerem nadal je zawierają, dopóki nie wygasną zgodnie z oknem retencji operatora.",
+ "backupLag": "Usunięcie natychmiast usuwa Twoje dane. Jeśli ten serwer przechowuje zaszyfrowane kopie zapasowe poza serwerem, kopie Twoich danych są usuwane z tego magazynu zaraz potem, a jeśli jest niedostępny, przy następnym nocnym przebiegu.",
"openAdvanced": "Usuń lub zresetuj"
},
"posture": {
@@ -6698,6 +6722,41 @@
"showChecklist": "Pokaż listę ponownie",
"checklistShown": "Lista wróciła na panel.",
"checklistNothingHidden": "Lista nie była ukryta; otwiera się na panelu."
+ },
+ "documentSources": {
+ "title": "Archiwa dokumentów",
+ "description": "Przeszukuj Paperless-ngx lub Papra z poziomu HealthLog i importuj wybrane dokumenty.",
+ "explainer": "Przycisk importu pojawia się wszędzie tam, gdzie wiążesz dokumenty: przy wizytach, szczepieniach i schorzeniach oraz w Dokumentach. HealthLog przechowuje własną zaszyfrowaną kopię każdego zaimportowanego dokumentu i pyta archiwum tylko wtedy, gdy szukasz lub importujesz.",
+ "systemLabel": "Archiwum",
+ "statusConnected": "Połączono",
+ "statusNotConnected": "Nie połączono",
+ "connectedSince": "Połączono, ostatnio sprawdzono {date}",
+ "originRemoved": "Tego adresu nie ma już na liście dozwolonych archiwów serwera. Zapytaj administrację albo zapisz inny adres.",
+ "baseUrl": "Adres",
+ "allowedOrigins": "Dozwolone na tym serwerze: {origins}",
+ "organizationId": "Identyfikator organizacji",
+ "organizationHelp": "Część po /organizations/ w pasku adresu Papra. Identyfikatory organizacji Papra mają postać org_ i 24 litery oraz cyfry.",
+ "token": "Token API",
+ "tokenSaved": "Zapisano dla tego adresu. Zostaw puste, aby go zachować.",
+ "tokenHelpPaperless": "W Paperless-ngx: Mój profil, token API.",
+ "tokenHelpPapra": "W Papra: klucz API z uprawnieniami documents:read i tags:read.",
+ "save": "Zapisz i sprawdź",
+ "saving": "Sprawdzanie…",
+ "saved": "Połączono. Archiwum odpowiedziało.",
+ "test": "Sprawdź",
+ "testOk": "Archiwum odpowiedziało w {latency} ms.",
+ "disconnect": "Rozłącz",
+ "disconnectTitle": "Rozłączyć {name}?",
+ "disconnectDescription": "HealthLog zapomina adres i token. Zaimportowane już dokumenty zostają w HealthLog.",
+ "unavailableExplainer": "Ten serwer nie pozwala już na archiwa dokumentów, więc poniższego połączenia nie da się użyć. Nadal możesz je usunąć.",
+ "moduleOffExplainer": "Dokumenty są wyłączone, więc poniższe połączenie nie jest używane. Nadal możesz je usunąć.",
+ "tokenNewAddress": "Nowy adres wymaga ponownego podania tokenu API."
+ },
+ "emailChangePassword": "Obecne hasło (potrzebne do zmiany adresu e-mail)",
+ "reproof": {
+ "description": "Ta czynność udostępnia lub eksportuje Twoje dane zdrowotne. Potwierdź, że to Ty, aby kontynuować.",
+ "signInAgain": "Zaloguj się ponownie, a potem spróbuj jeszcze raz. Twoje konto nie ma hasła ani klucza dostępu do potwierdzenia tutaj.",
+ "failed": "To się nie udało. Sprawdź wprowadzone dane i spróbuj ponownie."
}
},
"admin": {
@@ -6746,7 +6805,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",
@@ -7063,6 +7126,7 @@
"versionTileBuiltAt": "Skompilowano",
"versionTileUpdateAvailable": "Dostępna aktualizacja: {tag}",
"versionTileUpToDate": "Aktualne",
+ "versionTileCheckOff": "Sprawdzanie aktualizacji wyłączone",
"snapshotLoadError": "Nie udało się załadować migawki systemu.",
"snapshotLoading": "Ładowanie migawki...",
"auditTitle": "Ostatnia aktywność",
@@ -7267,7 +7331,7 @@
"description": "Kiedy ten host ostatnio umieścił zaszyfrowaną kopię każdego konta w Twoim buckecie.",
"loadError": "Nie udało się wczytać stanu poza hostem.",
"notConfiguredTitle": "Kopia poza hostem nie jest skonfigurowana",
- "notConfiguredDescription": "Nie ustawiono ani bucketu, ani klucza, więc nic nie opuszcza tego hosta. Ustaw BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY i BACKUP_ENCRYPTION_KEY, aby włączyć nocne wysyłanie.",
+ "notConfiguredDescription": "Nie ustawiono ani bucketu, ani klucza, więc żadna kopia zapasowa nie opuszcza tego hosta. Ustaw BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY i BACKUP_ENCRYPTION_KEY, aby włączyć nocne wysyłanie.",
"stateFresh": "Świeża",
"stateDue": "Zaległa",
"stateStale": "Przeterminowana",
@@ -7276,7 +7340,12 @@
"neverDetail": "Dla tego konta nic nie dotarło. Ostatnie sprawdzenie {when}.",
"unknownDetail": "Żaden nocny przebieg nie zarejestrował jeszcze tego konta.",
"age": "{hours} h temu",
- "scheduleNote": "Wysyłka działa co {hours} h. Świeża znaczy w obrębie jednego przebiegu, zaległa że jeden nic nie dał, przeterminowana że dwa."
+ "scheduleNote": "Wysyłka działa co {hours} h. Świeża znaczy w obrębie jednego przebiegu, zaległa że jeden nic nie dał, przeterminowana że dwa.",
+ "lifecycleConfigured": "Bucket usuwa kopie po {days} dniach zgodnie z własną regułą cyklu życia.",
+ "lifecycleMissing": "Bucket nie ma reguły cyklu życia, więc każda nocna kopia zostaje na zawsze. Dodaj regułę, która usuwa obiekty po BACKUP_RETENTION_DAYS.",
+ "lifecycleUnknown": "Nie udało się odczytać, czy bucket usuwa stare kopie: poświadczenia nie mogą odczytać jego reguły cyklu życia. Sprawdź, czy reguła usuwa obiekty po BACKUP_RETENTION_DAYS.",
+ "pendingDeletions": "Kopie {count} usuniętych lub wyczyszczonych kont są jeszcze w buckecie i są usuwane.",
+ "pendingDeletionsFailing": "Kopie {count} usuniętych lub wyczyszczonych kont są jeszcze w buckecie, bo odmówił ich usunięcia ({error}). Nadaj poświadczeniom uprawnienie DeleteObject; usuwanie jest ponawiane co noc."
},
"restoreStarted": "Przywracanie rozpoczęte. Postęp widać nad listą.",
"restoreActive": "Dla tego konta trwa już przywracanie. Poczekaj, aż się skończy.",
@@ -7309,7 +7378,10 @@
"restoreFailureNotStarted": "Proces w tle nie przejął przywracania. Sprawdź, czy działa.",
"restoreFailureEnqueue": "Nie udało się przekazać przywracania do procesu w tle. Sprawdź, czy działa.",
"restoreFailureAfterCommit": "Dane zostały przywrócone, ale krok po nich się nie powiódł. Wykresy mogą nadrobić to dopiero przy następnym nocnym przebiegu.",
- "restoreFailureUnexpected": "Przywracanie zatrzymało się na nieoczekiwanym błędzie i zostało wycofane. Dziennik serwera podaje błąd."
+ "restoreFailureUnexpected": "Przywracanie zatrzymało się na nieoczekiwanym błędzie i zostało wycofane. Dziennik serwera podaje błąd.",
+ "restoreFailureKeyMissing": "Kopia wymaga klucza szyfrowania {keys}, którym ten serwer nie może jej otworzyć. Dodaj klucz z powrotem do ENCRYPTION_KEYS, uruchom ponownie i rozpocznij przywracanie jeszcze raz.",
+ "restoreFailureForeignReference": "Plik wskazuje na wpisy należące do innego konta. Nic nie zostało zapisane.",
+ "previewKeyMissing": "Tej kopii nie da się przywrócić na tym serwerze: wymaga klucza szyfrowania {keys}. Najpierw dodaj klucz z powrotem do ENCRYPTION_KEYS i uruchom ponownie."
},
"danger-zone": {
"title": "Strefa zagrożenia",
@@ -7348,7 +7420,7 @@
"rotateEnqueued": "Rotacja rozpoczęta.",
"rotateFailed": "Nie udało się rozpocząć rotacji.",
"stepUpNeeded": "Wymagany jest świeży drugi składnik. Potwierdź drugi składnik lub użyj narzędzia wiersza poleceń, jeśli nie masz skonfigurowanego żadnego.",
- "cliNote": "Do tego przycisku wymagany jest drugi składnik. Operatorzy bez niego rotują przez scripts/rotate-encryption-key.ts.",
+ "cliNote": "Do tego przycisku wymagany jest drugi składnik. Bez niego uruchom scripts/rotate-encryption-key.ts z kopii kodu źródłowego tej wersji; kroki opisuje docs/ops/encryption-key-rotation.md.",
"columnsTitle": "Pokrycie wg kolumn",
"columnsDescription": "Każda zaszyfrowana kolumna z liczbą wierszy na aktywnym kluczu względem starszych kluczy.",
"colColumn": "Kolumna",
@@ -7361,7 +7433,17 @@
"completed": "Zakończono",
"failed": "Niepowodzenie"
},
- "coverageDetail": "Pokazywane są tylko identyfikatory kluczy i liczby — nigdy materiał klucza."
+ "coverageDetail": "Pokazywane są tylko identyfikatory kluczy i liczby — nigdy materiał klucza.",
+ "backupsTitle": "Klucze, których kopie nadal potrzebują",
+ "backupsDescription": "Wycofany klucz jest potrzebny, dopóki którakolwiek kopia poniżej go wymienia.",
+ "backupsStoredLine": "Klucz {key}: {count} zapisanych kopii, najstarsza z {when}",
+ "backupsUnrecordedLine": "{count} zapisanych kopii powstało, zanim kopie zaczęły zapisywać swoje klucze (najstarsza {when}). Mogą potrzebować każdego klucza, który wtedy istniał: zachowaj wszystkie stare klucze, dopóki nie zostaną zastąpione lub usunięte.",
+ "backupsOffhostLine": "Klucz {key}: ostatnia kopia zewnętrzna, która go potrzebuje, trafiła do bucketu {when}; potrzebny tam do {until}",
+ "backupsOffhostLineUnknown": "Klucz {key}: ostatnia kopia zewnętrzna, która go potrzebuje, trafiła do bucketu {when}. Nie wiadomo, jak długo bucket przechowuje kopie, więc zachowaj klucz, dopóki nie masz pewności, że tej kopii już nie ma.",
+ "backupsNone": "Żadna kopia nie zapisała jeszcze kluczy, których potrzebuje.",
+ "backupsStillNeed": "Wszystkie wiersze są zrotowane, ale kopie nadal potrzebują {keys}",
+ "backupsUnrecordedBadge": "Wszystkie wiersze są zrotowane, ale starsze kopie mogą nadal potrzebować wycofanego klucza",
+ "backupsDetail": "Rotacja ponownie szyfruje każdy wiersz, ale kopia zachowuje klucz, którym zapisano jej zawartość, a jej przywrócenie wymaga tego klucza. Trzymaj wycofany klucz w ENCRYPTION_KEYS, dopóki widnieje poniżej."
}
},
"carrier": "Operator",
@@ -7408,7 +7490,11 @@
"countOther": "{count} niepowodzeń",
"lastFailed": "Ostatnie niepowodzenie {when}"
},
- "deleteAllDetail": "Konta użytkowników zostaną zachowane."
+ "deleteAllDetail": "Konta użytkowników zostaną zachowane.",
+ "cardError": {
+ "title": "Nie udało się wyświetlić tej części strony",
+ "description": "Reszta strony działa; spróbuj ponownie lub odśwież, jeśli błąd się powtarza."
+ }
},
"achievements": {
"title": "Osiągnięcia",
@@ -8016,7 +8102,8 @@
"bodyToday": "{medName}: pozostało {units} jednostek — mniej niż jeden dzień zapasu.",
"bodyReorder": "{medName}: pozostało {units} jednostek. Zapas skończy się około {runsOutOn} — zamów ponownie do {reorderBy}.",
"bodyLastDose": "{medName}: pozostało {units} jednostek — to mniej więcej Twoja ostatnia dawka. Zapas skończy się około {runsOutOn}.",
- "bodyExpired": "{medName}: brak zapasu do użycia. {units} jednostek oznaczono jako przeterminowane."
+ "bodyExpired": "{medName}: brak zapasu do użycia. {units} jednostek oznaczono jako przeterminowane.",
+ "bodyAsNeeded": "{medName}: zostało tylko {units} jednostek. Zamów ponownie, zanim będziesz potrzebować kolejnej dawki."
},
"moodReminders": {
"dailyTitle": "Zapisz nastrój",
@@ -10147,10 +10234,74 @@
"body": "Odczyt przez AI wysyła to do Twojej usługi AI poza tym serwerem. Czy HealthLog może wysyłać dokumenty, wyniki badań i opisy leków do odczytu?",
"grant": "Zezwól na odczyt",
"error": "Nie udało się zapisać zgody. Spróbuj ponownie."
+ },
+ "sourcePicker": {
+ "open": "Importuj z archiwum",
+ "openNamed": "Importuj z {name}",
+ "title": "Import z {name}",
+ "description": "Szukaj po nazwie, a potem zaznacz dokumenty, które HealthLog ma skopiować.",
+ "sourceLabel": "Archiwum",
+ "searchLabel": "Nazwa dokumentu",
+ "searchPlaceholder": "Szukaj po nazwie",
+ "tagLabel": "Tag",
+ "tagAny": "Dowolny tag",
+ "fromLabel": "Od",
+ "toLabel": "Do",
+ "clearFilters": "Wyczyść tag i daty",
+ "rangeInvalid": "Data końcowa jest wcześniejsza niż początkowa.",
+ "empty": "{name} nie ma dokumentów do pokazania.",
+ "noMatch": "Brak pasujących dokumentów.",
+ "loadMore": "Wczytaj więcej",
+ "stateImported": "W HealthLog",
+ "stateDeleted": "Usunięty w HealthLog",
+ "selectedCount": "Wybrano {count} z maksymalnie {max}",
+ "importAction": "Importuj ({count})",
+ "importing": "Importowanie, dokument po dokumencie…",
+ "done": "Gotowe",
+ "summary": "Zaimportowane: {imported}. Już były: {present}. Niezaimportowane: {failed}.",
+ "stopped": "Zatrzymano: {reason}",
+ "result": {
+ "waiting": "Czeka",
+ "working": "Importowanie…",
+ "imported": "Zaimportowano",
+ "importedLinked": "Zaimportowano i powiązano",
+ "duplicate": "Już jest w HealthLog",
+ "duplicateLinked": "Już jest w HealthLog, teraz powiązany",
+ "deleted": "Usunięty w HealthLog, pozostaje usunięty"
+ },
+ "errors": {
+ "unavailable": "Archiwa dokumentów nie są włączone na tym serwerze.",
+ "notConnected": "To archiwum nie jest połączone. Połącz je w Ustawieniach, Integracje.",
+ "originNotAllowed": "Tego adresu nie ma na liście dozwolonych archiwów serwera. Poproś administrację o jego dodanie.",
+ "unreachable": "Nie udało się połączyć z archiwum. Sprawdź, czy działa i czy adres jest poprawny.",
+ "redirected": "Archiwum przekierowało żądanie. Wpisz adres, na który przekierowuje.",
+ "authRefused": "Archiwum odrzuciło token. Sprawdź token i jego uprawnienia.",
+ "versionTooOld": "Archiwum jest za stare. Potrzebny jest Paperless-ngx 2.16 lub nowszy.",
+ "badResponse": "Nie udało się odczytać odpowiedzi archiwum. Sprawdź, czy adres wskazuje na samo archiwum.",
+ "notFound": "Archiwum nie ma takiego dokumentu albo identyfikator organizacji jest błędny lub nie należy do ciebie.",
+ "linkTargetNotFound": "Wpis, z którym miało nastąpić powiązanie, już nie istnieje.",
+ "rateLimited": "Za dużo żądań. Odczekaj minutę i spróbuj ponownie.",
+ "browserOnly": "Z archiwów dokumentów można korzystać tylko w przeglądarce.",
+ "invalidAddress": "Wpisz pełny adres, zaczynający się od https:// lub http://.",
+ "organizationRequired": "Wpisz identyfikator organizacji Papra.",
+ "tokenRequired": "Wpisz token API. Nowy adres wymaga go ponownie.",
+ "fileTooLarge": "Plik jest większy, niż przyjmuje HealthLog.",
+ "quotaExceeded": "Miejsce na dokumenty jest pełne.",
+ "fileType": "HealthLog nie przyjmuje tego typu pliku.",
+ "uploadLimit": "Osiągnięto godzinny limit przesyłania. Spróbuj później.",
+ "uploadBusy": "Trwają jeszcze inne przesyłania. Spróbuj za chwilę.",
+ "moduleDisabled": "Dokumenty są wyłączone w ustawieniach.",
+ "generic": "Coś poszło nie tak. Spróbuj ponownie.",
+ "permissionMissing": "Klucz Papra nie może odczytać tagów. Nadaj mu uprawnienie tags:read."
+ },
+ "pickMore": "Wybierz więcej",
+ "finishedHint": "Wyszukaj ponownie lub zmień filtr, aby wybrać więcej dokumentów.",
+ "maxReached": "Możesz zaimportować naraz najwyżej {max} dokumentów.",
+ "noMatchHint": "Spróbuj części nazwy albo wyczyść tag i daty."
}
},
"selfDescription": {
- "dataPosture": "HealthLog jest hostowany samodzielnie: Twoje dane zdrowotne pozostają na Twojej instancji, zaszyfrowane w spoczynku. Funkcje AI korzystają z dostarczonego przez Ciebie klucza dostawcy lub modelu lokalnego, więc nic nie opuszcza Twojej instancji poza skonfigurowanym dostawcą i nie ma analityki firm trzecich na Twoich danych zdrowotnych."
+ "dataPosture": "HealthLog jest hostowany samodzielnie: Twoje dane zdrowotne znajdują się w bazie danych Twojej instancji. Notatki i inne teksty swobodne, dokumenty, odpowiedzi w kwestionariuszach, teksty napisane przez AI i tokeny dostępu są zaszyfrowane w spoczynku; wartości pomiarów, wyniki badań laboratoryjnych, nazwy leków i oceny nastroju są przechowywane bez szyfrowania i zależą od bezpieczeństwa Twojego serwera. Funkcje AI wysyłają dane tylko do dostawcy AI skonfigurowanego dla Twojego konta i nie ma analityki firm trzecich na Twoich danych zdrowotnych."
},
"customMetrics": {
"sectionTitle": "Metryki własne",
diff --git a/next.config.ts b/next.config.ts
index 3e3a554fa..2edfcfba3 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -295,18 +295,19 @@ const nextConfig: NextConfig = {
outputFileTracingExcludes: {
"*": ["./next.config.ts"],
},
- // Next 16 caps the request body that passes THROUGH middleware (our
- // `src/proxy.ts`) at ~10MB by default, silently truncating larger bodies
- // before the route handler sees them. The Apple Health `export.zip` importer
- // (`/api/import/apple-health-export`) streams multi-MB archives to disk; the
- // 10MB cap truncated them so the ZIP end-of-central-directory was lost and
- // the parser failed (GitHub #281). Raise the ceiling so real exports pass
- // intact. The value is a ceiling, not a buffer — only the actual upload size
- // is held — so this is safe; very large exports also want a matching
- // reverse-proxy body limit (see docs/self-hosting/reverse-proxy.md).
+ // Next reads the body of every non-GET request the proxy (`src/proxy.ts`)
+ // matches into memory, so both the proxy and the route can read it, and it
+ // does so BEFORE the route runs — before any authentication. Everything past
+ // this ceiling is dropped, not refused. It used to be 512 MB so the Apple
+ // Health importer could receive whole archives (GitHub #281), which let any
+ // anonymous POST make the server hold that much. It is 1 MB now: the routes
+ // whose real bodies are larger are left out of the proxy matcher and read
+ // their own body after authenticating (`src/lib/http/proxy-bypass-routes.ts`),
+ // and every other route's own cap is at or below this. Large exports still
+ // want a matching reverse-proxy body limit (docs/self-hosting/reverse-proxy.md).
experimental: {
optimizePackageImports: ["recharts", "lucide-react"],
- middlewareClientMaxBodySize: "512mb",
+ proxyClientMaxBodySize: "1mb",
},
};
diff --git a/package.json b/package.json
index 131d68ec7..cbb99830e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "healthlog",
- "version": "1.39.2",
+ "version": "1.39.3",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -26,8 +26,8 @@
"scripts": {
"prepare": "node scripts/prepare.mjs",
"dev": "next dev",
- "predev": "node scripts/generate-i18n-boot.mjs",
- "prebuild": "node scripts/generate-sw-version.mjs && node scripts/generate-i18n-boot.mjs",
+ "predev": "node scripts/generate-i18n-boot.mjs && node scripts/stage-tesseract-assets.mjs",
+ "prebuild": "node scripts/generate-sw-version.mjs && node scripts/generate-i18n-boot.mjs && node scripts/stage-tesseract-assets.mjs",
"build": "NODE_OPTIONS=--max-old-space-size=8192 next build",
"analyze": "ANALYZE=1 next build",
"bundle-report": "node scripts/check-bundle-budget.mjs",
@@ -112,6 +112,8 @@
"@next/bundle-analyzer": "^16.3.3",
"@playwright/test": "^1.63.0",
"@tailwindcss/postcss": "^4",
+ "@tesseract.js-data/deu": "1.0.0",
+ "@tesseract.js-data/eng": "1.0.0",
"@testcontainers/postgresql": "^12.1.0",
"@types/node": "26.5.1",
"@types/pg": "^8.23.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 134544a01..723cc806e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -204,6 +204,12 @@ importers:
'@tailwindcss/postcss':
specifier: ^4
version: 4.3.3
+ '@tesseract.js-data/deu':
+ specifier: 1.0.0
+ version: 1.0.0
+ '@tesseract.js-data/eng':
+ specifier: 1.0.0
+ version: 1.0.0
'@testcontainers/postgresql':
specifier: ^12.1.0
version: 12.1.0(supports-color@7.2.0)
@@ -2819,6 +2825,12 @@ packages:
'@tanstack/virtual-core@3.17.10':
resolution: {integrity: sha512-eMorKWIi2nekElu+8DgSMJR4iR+sTDeh+W8VjKiMH79yUfEDJ3W9aL/96/wwbxNjyV6EfN39HKszSxjd90zAqQ==}
+ '@tesseract.js-data/deu@1.0.0':
+ resolution: {integrity: sha512-3ter1p2gFAiCd5CMC+PodZ9YY2UDomkKj+ytlKeeZqOUmhLx1SDnYPf5zog0g5MBbxYveW+8omI3PUflqHfZ4Q==}
+
+ '@tesseract.js-data/eng@1.0.0':
+ resolution: {integrity: sha512-mbTumm6KQPUHyzTPQaF3ObXYnx0SqqfV2nabqFVQBwD6Kl7PhGSLSzOlfFTWy0P3BjghaSKA2W9GB19Jk+ZcTg==}
+
'@testcontainers/postgresql@12.1.0':
resolution: {integrity: sha512-Pjf2VSVNirEPfz36nidyrVAnZvc2YhajOznY4VgyEsvfTd5qiMNOuPq96drREvxAUtXl5SFLX7vXj7sSq4aTcA==}
@@ -9508,6 +9520,10 @@ snapshots:
'@tanstack/virtual-core@3.17.10': {}
+ '@tesseract.js-data/deu@1.0.0': {}
+
+ '@tesseract.js-data/eng@1.0.0': {}
+
'@testcontainers/postgresql@12.1.0(supports-color@7.2.0)':
dependencies:
testcontainers: 12.1.0(supports-color@7.2.0)
diff --git a/prisma/migrations/0356_document_source_connections/migration.sql b/prisma/migrations/0356_document_source_connections/migration.sql
new file mode 100644
index 000000000..f50be788c
--- /dev/null
+++ b/prisma/migrations/0356_document_source_connections/migration.sql
@@ -0,0 +1,46 @@
+-- Document picker (#1038). One connection per person per system (Paperless-ngx
+-- or Papra) with the API token encrypted at rest. The picker searches the
+-- source and imports picked documents on request only; nothing polls.
+CREATE TABLE "document_source_connections" (
+ "id" TEXT NOT NULL,
+ "user_id" TEXT NOT NULL,
+ "system" VARCHAR(16) NOT NULL,
+ "base_url" VARCHAR(2048) NOT NULL,
+ "organization_id" VARCHAR(128),
+ "token_encrypted" TEXT NOT NULL,
+ "last_verified_at" TIMESTAMP(3),
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMP(3) NOT NULL,
+ CONSTRAINT "document_source_connections_pkey" PRIMARY KEY ("id")
+);
+
+CREATE UNIQUE INDEX "document_source_connections_user_id_system_key"
+ ON "document_source_connections" ("user_id", "system");
+
+ALTER TABLE "document_source_connections"
+ ADD CONSTRAINT "document_source_connections_user_id_fkey"
+ FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- Source instance (#1038 review): the same id in two Paperless-ngx (or two
+-- Papra) instances is two documents. A nullable origin joins every source key;
+-- keys stored without one (v1.39.2 imports) keep matching any instance, which
+-- the application decides at lookup time. NULLS NOT DISTINCT keeps two keys
+-- without an instance colliding exactly as before (PG15+; the stack runs 16).
+ALTER TABLE "inbound_documents" ADD COLUMN "source_instance" VARCHAR(512);
+DROP INDEX IF EXISTS "inbound_documents_user_source_key";
+CREATE UNIQUE INDEX "inbound_documents_user_source_key"
+ ON "inbound_documents" ("user_id", "source_system", "source_instance", "source_id")
+ NULLS NOT DISTINCT
+ WHERE "source_id" IS NOT NULL;
+
+ALTER TABLE "document_source_aliases" ADD COLUMN "source_instance" VARCHAR(512);
+DROP INDEX IF EXISTS "document_source_aliases_user_id_source_system_source_id_key";
+CREATE UNIQUE INDEX "document_source_aliases_user_id_source_system_source_instance_source_id_key"
+ ON "document_source_aliases" ("user_id", "source_system", "source_instance", "source_id")
+ NULLS NOT DISTINCT;
+
+ALTER TABLE "document_import_keys" ADD COLUMN "source_instance" VARCHAR(512);
+DROP INDEX IF EXISTS "document_import_keys_user_id_source_system_source_id_key";
+CREATE UNIQUE INDEX "document_import_keys_user_id_source_system_source_instance_source_id_key"
+ ON "document_import_keys" ("user_id", "source_system", "source_instance", "source_id")
+ NULLS NOT DISTINCT;
diff --git a/prisma/migrations/0357_encrypt_coach_title_and_metric_note/migration.sql b/prisma/migrations/0357_encrypt_coach_title_and_metric_note/migration.sql
new file mode 100644
index 000000000..dfc375a40
--- /dev/null
+++ b/prisma/migrations/0357_encrypt_coach_title_and_metric_note/migration.sql
@@ -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;
diff --git a/prisma/migrations/0358_backup_key_ids_and_offhost_purge/migration.sql b/prisma/migrations/0358_backup_key_ids_and_offhost_purge/migration.sql
new file mode 100644
index 000000000..09a577e69
--- /dev/null
+++ b/prisma/migrations/0358_backup_key_ids_and_offhost_purge/migration.sql
@@ -0,0 +1,37 @@
+-- Backups: which keys their content needs, and off-host copies that leave
+-- with the account.
+--
+-- 1. A stored backup carries encrypted columns as the stored ciphertext, and
+-- key rotation cannot reach inside a copy. Each copy now records the key
+-- ids its content was written under, so the admin encryption view can say
+-- which retired key is still needed and by how old a copy. Copies written
+-- before this release are marked unrecorded rather than assumed clean.
+ALTER TABLE "data_backups"
+ ADD COLUMN "inner_key_ids" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
+ ADD COLUMN "inner_key_ids_recorded" BOOLEAN NOT NULL DEFAULT false;
+
+-- 2. The same fact for the off-host bucket, per key rather than per object:
+-- when the nightly run last wrote an object that needed each key.
+CREATE TABLE "offhost_backup_key_use" (
+ "key_id" TEXT NOT NULL,
+ "first_written_at" TIMESTAMP(3) NOT NULL,
+ "last_written_at" TIMESTAMP(3) NOT NULL,
+ CONSTRAINT "offhost_backup_key_use_pkey" PRIMARY KEY ("key_id")
+);
+
+-- 3. Accounts whose off-host objects have to be deleted: written with the
+-- account deletion or the data wipe, removed once the bucket holds nothing
+-- for the account. No foreign key, because a deleted account's row is gone
+-- by the time the job runs.
+CREATE TABLE "offhost_purge_requests" (
+ "id" TEXT NOT NULL,
+ "subject_id" TEXT NOT NULL,
+ "reason" TEXT NOT NULL,
+ "requested_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "attempts" INTEGER NOT NULL DEFAULT 0,
+ "last_attempt_at" TIMESTAMP(3),
+ "last_failure" TEXT,
+ CONSTRAINT "offhost_purge_requests_pkey" PRIMARY KEY ("id")
+);
+CREATE INDEX "offhost_purge_requests_requested_at_idx"
+ ON "offhost_purge_requests"("requested_at");
diff --git a/prisma/migrations/0359_session_reproof_stamp/migration.sql b/prisma/migrations/0359_session_reproof_stamp/migration.sql
new file mode 100644
index 000000000..1eb53749d
--- /dev/null
+++ b/prisma/migrations/0359_session_reproof_stamp/migration.sql
@@ -0,0 +1,9 @@
+-- When a browser session last re-proved a credential the account holds.
+--
+-- Exporting the whole record, making a share link, minting a token and the
+-- admin backup / reset / wipe actions now ask for a fresh proof, not just a
+-- live session. A second-factor proof keeps stamping `mfa_verified_at`, as it
+-- always has. A password proof on an account without a second factor stamps
+-- this column instead, so a password never satisfies a gate that asks for a
+-- second factor. Nullable, no default: a session that never re-proved has none.
+ALTER TABLE "sessions" ADD COLUMN "reproof_at" TIMESTAMP(3);
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 7d725e8d3..e24bf462a 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -928,6 +928,9 @@ model User {
// v1.39.2 — further source keys an import answered with an existing
// document (same bytes under another key). See DocumentSourceAlias.
documentSourceAliases DocumentSourceAlias[]
+ // v1.39.3 — the person's own connection to a Paperless-ngx or Papra
+ // instance for the document picker. See DocumentSourceConnection.
+ documentSourceConnections DocumentSourceConnection[]
extractedFacts ExtractedFact[]
documentConditionLinks DocumentConditionLink[]
documentContentIndexes DocumentContentIndex[]
@@ -1168,6 +1171,13 @@ model Session {
// v1.23 — step-up freshness stamp. Set when the session completes a second
// factor (or re-confirms it); `requireFreshMfa` (a later wave) reads it.
mfaVerifiedAt DateTime? @map("mfa_verified_at")
+ /// When this session last re-proved a credential through `POST
+ /// /api/auth/reproof` (any method). Read by `requireRecentProof`, which on an
+ /// account without a second factor accepts it as the fresh proof in front of
+ /// exports, share links, token minting and the admin data actions. A
+ /// second-factor proof also stamps `mfaVerifiedAt`; a password proof stamps
+ /// only this, so it never satisfies a gate that asks for a second factor.
+ reproofAt DateTime? @map("reproof_at")
// v1.23 — sliding "last seen" for the user-facing active-session list.
lastActiveAt DateTime? @map("last_active_at")
@@ -4970,19 +4980,29 @@ model PushSubscription {
// ─── Data Backups ────────────────────────────────────────────
model DataBackup {
- id String @id @default(cuid())
- userId String @map("user_id")
- type String @default("WEEKLY_AUTO") // WEEKLY_AUTO, MANUAL_UPLOAD_
+ id String @id @default(cuid())
+ userId String @map("user_id")
+ type String @default("WEEKLY_AUTO") // WEEKLY_AUTO, MANUAL_UPLOAD_
/// The whole encrypted copy as one value: how every backup was stored up
/// to v1.39.1. NULL for a copy stored in pieces (`chunks`), which is how
/// every backup is written from v1.39.2 on. A row carries one or the other.
- data String?
+ data String?
/// How many pieces a chunked copy has, and the random id sealed into every
/// one of them (`src/lib/export/backup-chunks.ts`). The reader checks both
/// against the pieces before it releases a byte; NULL on a single-value copy.
- chunkCount Int? @map("chunk_count")
- chunkStreamId String? @map("chunk_stream_id")
- createdAt DateTime @default(now()) @map("created_at")
+ chunkCount Int? @map("chunk_count")
+ chunkStreamId String? @map("chunk_stream_id")
+ /// The application key ids the ciphertext INSIDE this copy was written
+ /// under, recorded as the copy is written (`createBackupKeyIdTextScanner`).
+ /// Key rotation re-seals the copy's envelope but not what is inside it, so
+ /// a key stays needed while any copy lists it. NULL for a copy written
+ /// before v1.39.3, which the admin encryption view counts as unrecorded.
+ /// Consumer: `GET /api/admin/encryption/status` (`backups.stored`).
+ innerKeyIds String[] @default([]) @map("inner_key_ids")
+ /// Whether `innerKeyIds` was recorded; false on every copy written before
+ /// v1.39.3. Consumer: the same status read (`backups.unrecorded`).
+ innerKeyIdsRecorded Boolean @default(false) @map("inner_key_ids_recorded")
+ createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
chunks DataBackupChunk[]
@@ -5148,6 +5168,43 @@ model OffhostBackupState {
@@map("offhost_backup_state")
}
+/// Per application key id, when the nightly off-host run last put an object
+/// in the bucket whose inner ciphertext needed that key. The bucket's
+/// lifecycle rule keeps each object for its retention, so a retired key is
+/// needed until that long after this date. Not per account: the question it
+/// answers is about keys. Written by `runOffhostBackup`; consumer:
+/// `GET /api/admin/encryption/status` (`backups.offhost`).
+model OffhostBackupKeyUse {
+ keyId String @id @map("key_id")
+ firstWrittenAt DateTime @map("first_written_at")
+ lastWrittenAt DateTime @map("last_written_at")
+
+ @@map("offhost_backup_key_use")
+}
+
+/// An account whose off-host copies have to leave the bucket: it was deleted,
+/// or its data was wiped. Written in the same transaction as the deletion, so
+/// the request cannot be lost between the two, and deleted once every object
+/// for the account is gone. `subjectId` is deliberately not a relation: for a
+/// deleted account the user row is already gone. Written by
+/// `requestOffhostPurge`; consumer: `processOffhostPurges` (the
+/// `offhost-backup-purge` job) and the off-host card's pending count behind
+/// `GET /api/admin/backups`.
+model OffhostPurgeRequest {
+ id String @id @default(cuid())
+ subjectId String @map("subject_id")
+ /// `account_deleted`, `data_wiped` or `managed_profile_deleted`.
+ reason String
+ requestedAt DateTime @default(now()) @map("requested_at")
+ attempts Int @default(0)
+ lastAttemptAt DateTime? @map("last_attempt_at")
+ /// The bucket's last refusal, trimmed; shown to the operator.
+ lastFailure String? @map("last_failure")
+
+ @@index([requestedAt])
+ @@map("offhost_purge_requests")
+}
+
// ─── Apple Health export.zip import (v1.4.34) ───────────────
//
// Mirrors a `pg-boss` `apple-health-import` job's lifecycle so the
@@ -5789,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
@@ -5800,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")
}
@@ -6050,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")
@@ -6084,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")
}
@@ -8008,15 +8089,24 @@ model InboundDocument {
/// and the Paperless workflow recipe; shown on the detail sheet.
sourceSystem String? @map("source_system") @db.VarChar(16)
/// v1.39.2 (#1038) — the document's id in `sourceSystem`. Together with
- /// `userId` + `sourceSystem` it is unique across live AND tombstoned rows
- /// (raw SQL in migration 0353, not expressible in Prisma):
- /// CREATE UNIQUE INDEX inbound_documents_user_source_key
- /// ON inbound_documents (user_id, source_system, source_id)
- /// WHERE source_id IS NOT NULL;
+ /// `userId`, `sourceSystem` and `sourceInstance` it is unique across live
+ /// AND tombstoned rows (raw SQL in migrations 0353 and 0356, not expressible
+ /// in Prisma; see `sourceInstance` for the current definition).
/// Including tombstones is the point: re-running an importer must not bring
/// back a document the person deleted. The purge carries the key over to
/// `DocumentImportKey` when it removes the row.
sourceId String? @map("source_id") @db.VarChar(128)
+ /// v1.39.3 (#1038) — which instance of `sourceSystem` the document came
+ /// from: the normalised origin (`scheme://host[:port]`) of the Paperless-ngx
+ /// or Papra it was fetched from, so id 123 on one Paperless and id 123 on
+ /// another are two keys. Plaintext; an address, not health data. Null for a
+ /// key sent without one (every v1.39.2 import, an older script, a workflow
+ /// that does not send it): such a key matches any instance of its system,
+ /// and a lookup without an instance matches any stored instance. Part of the
+ /// source-key unique index since migration 0356, which rebuilt it as
+ /// (user_id, source_system, source_instance, source_id) NULLS NOT DISTINCT
+ /// WHERE source_id IS NOT NULL.
+ sourceInstance String? @map("source_instance") @db.VarChar(512)
/// v1.39.2 (#1038) — the upload asked for `aiRead=defer`: nothing may send
/// this document to an AI provider on its own (the summary catch-up, the
/// summary job, lab auto-staging all skip it). Cleared when the person
@@ -8078,18 +8168,24 @@ model InboundDocument {
/// own, so deleting the document keeps every key that ever reached it deleted.
/// Cascades with the document; the purge copies it first.
model DocumentSourceAlias {
- id String @id @default(cuid())
- userId String @map("user_id")
- documentId String @map("document_id")
+ id String @id @default(cuid())
+ userId String @map("user_id")
+ documentId String @map("document_id")
/// Same closed vocabulary as `InboundDocument.sourceSystem`.
- sourceSystem String @map("source_system") @db.VarChar(16)
- sourceId String @map("source_id") @db.VarChar(128)
- createdAt DateTime @default(now()) @map("created_at")
+ sourceSystem String @map("source_system") @db.VarChar(16)
+ sourceId String @map("source_id") @db.VarChar(128)
+ /// v1.39.3 — the source instance, as on `InboundDocument.sourceInstance`
+ /// (null matches any instance of the system).
+ sourceInstance String? @map("source_instance") @db.VarChar(512)
+ createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
- @@unique([userId, sourceSystem, sourceId])
+ // NULLS NOT DISTINCT in migration 0356 (hand-written; Prisma would
+ // regenerate it NULLS DISTINCT): a key stored without an instance still
+ // collides with the same key without one.
+ @@unique([userId, sourceSystem, sourceInstance, sourceId])
@@index([documentId])
@@map("document_source_aliases")
}
@@ -8101,19 +8197,62 @@ model DocumentSourceAlias {
/// "deleted" instead of storing the document again. Written by the purge only;
/// read by the upload route's source-key check. Holds no document content.
model DocumentImportKey {
- id String @id @default(cuid())
- userId String @map("user_id")
+ id String @id @default(cuid())
+ userId String @map("user_id")
/// Same closed vocabulary as `InboundDocument.sourceSystem`.
- sourceSystem String @map("source_system") @db.VarChar(16)
- sourceId String @map("source_id") @db.VarChar(128)
- createdAt DateTime @default(now()) @map("created_at")
+ sourceSystem String @map("source_system") @db.VarChar(16)
+ sourceId String @map("source_id") @db.VarChar(128)
+ /// v1.39.3 — the source instance, as on `InboundDocument.sourceInstance`
+ /// (null matches any instance of the system).
+ sourceInstance String? @map("source_instance") @db.VarChar(512)
+ createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- @@unique([userId, sourceSystem, sourceId])
+ // NULLS NOT DISTINCT in migration 0356 (hand-written; Prisma would
+ // regenerate it NULLS DISTINCT): a key stored without an instance still
+ // collides with the same key without one.
+ @@unique([userId, sourceSystem, sourceInstance, sourceId])
@@map("document_import_keys")
}
+/// v1.39.3 (#1038) — one person's connection to a Paperless-ngx or Papra
+/// instance, used by the document picker to search that system by name, tag
+/// and date and import the documents the person picks. One per system per
+/// person. Only reachable when the operator lists the instance's origin in
+/// `DOCUMENT_SOURCE_ORIGINS`; the origin is re-checked on every request, so
+/// removing it from the list cuts the connection off without touching the
+/// row. No background job reads it: every request to the source is one the
+/// person started. Not carried in backups (a third-party credential for a
+/// service on the operator's network; see `NOT_IN_BACKUP_MODELS`); removed by
+/// Delete all data and with the account.
+model DocumentSourceConnection {
+ id String @id @default(cuid())
+ userId String @map("user_id")
+ /// `PAPERLESS` | `PAPRA` (`DOCUMENT_PICKER_SYSTEMS` in
+ /// `src/lib/documents/sources/types.ts`).
+ system String @map("system") @db.VarChar(16)
+ /// Base address of the instance, origin plus an optional path. Plaintext:
+ /// its origin is compared with the operator's list on every use, and the
+ /// settings card shows it. Not health data.
+ baseUrl String @map("base_url") @db.VarChar(2048)
+ /// Papra organization id; null for Paperless-ngx.
+ organizationId String? @map("organization_id") @db.VarChar(128)
+ /// The API token (Paperless-ngx) or API key (Papra), AES-256-GCM via
+ /// `encrypt()`. No route returns it, in whole or in part.
+ tokenEncrypted String @map("token_encrypted")
+ /// Last time the source answered a test with this token: the save's live
+ /// check or the settings card's Test button. Shown on the card.
+ lastVerifiedAt DateTime? @map("last_verified_at")
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@unique([userId, system])
+ @@map("document_source_connections")
+}
+
model ExtractedFact {
id String @id @default(cuid())
documentId String @map("document_id")
diff --git a/public/sw.js b/public/sw.js
index 15ecbd8d2..6d754253d 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
- /* @sw-version-fallback */ "v1.39.2";
+ /* @sw-version-fallback */ "v1.39.3";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
diff --git a/scripts/__tests__/import-documents.test.ts b/scripts/__tests__/import-documents.test.ts
index 6c5865fa4..754a41e38 100644
--- a/scripts/__tests__/import-documents.test.ts
+++ b/scripts/__tests__/import-documents.test.ts
@@ -343,7 +343,7 @@ async function papra() {
return {
status: 200,
json: {
- documentsCount: 3,
+ documentsCount: 4,
documents: [
{
id: "doc_a",
@@ -365,6 +365,15 @@ async function papra() {
{ id: "tag_l", name: "Lab" },
],
},
+ {
+ id: "doc_huge",
+ name: "Scan.pdf",
+ originalName: "scan.pdf",
+ originalSize: 40_000_000,
+ createdAt: "2025-03-10T12:00:00.000Z",
+ documentDate: "2020-03-10",
+ tags: [{ id: "tag_h", name: "Health" }],
+ },
{
id: "doc_old",
name: "Old.pdf",
@@ -424,6 +433,7 @@ describe("import-documents.mjs — Paperless-ngx", () => {
kind: "LAB_RESULT",
sourceSystem: "PAPERLESS",
sourceId: "1",
+ sourceInstance: new URL(pl.url).origin,
aiRead: "defer",
},
});
@@ -664,6 +674,7 @@ describe("import-documents.mjs — Papra", () => {
documentDate: "2019-06-01",
sourceSystem: "PAPRA",
sourceId: "doc_a",
+ sourceInstance: new URL(pp.url).origin,
});
// --ai-read: no deferral field at all.
expect(sent[0].aiRead).toBeUndefined();
@@ -675,7 +686,31 @@ describe("import-documents.mjs — Papra", () => {
);
expect(pp.seen[0].auth).toBe("Bearer pp_test");
expect(res.stdout).toMatch(/Imported: 1/);
- expect(res.stdout).toMatch(/Skipped: 1/);
+ // doc_b answered 413 with the cap; doc_huge is known to be larger than
+ // that and is skipped without being downloaded or sent.
+ expect(res.stdout).toMatch(/Skipped: 2/);
expect(res.stdout).toMatch(/larger than HealthLog accepts/);
+ expect(pp.seen.some((s) => s.path.includes("doc_huge"))).toBe(false);
+ });
+
+ it("shows small sizes in KB on a dry run", async () => {
+ const pp = await papra();
+ const res = await runScript(
+ [
+ "papra",
+ "--papra-url",
+ pp.url,
+ "--papra-org",
+ "org_1",
+ "--tag",
+ "Health",
+ "--since",
+ "2016-01-01",
+ "--dry-run",
+ ],
+ { PAPRA_TOKEN: "pp_test" },
+ );
+ expect(res.code).toBe(0);
+ expect(res.stdout).toMatch(/2\.0 KB\s+Papra doc_a/);
});
});
diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json
index 11fcfadb5..3cec8225f 100644
--- a/scripts/env-manifest.json
+++ b/scripts/env-manifest.json
@@ -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",
@@ -163,6 +167,25 @@
}
]
},
+ {
+ "name": "Outbound requests",
+ "description": "Switches for the requests HealthLog makes to third parties on its own. All default to on; set any of them to 1, true or yes to turn that request off.",
+ "required": false,
+ "variables": [
+ {
+ "name": "IP_GEO_LOOKUP_DISABLED",
+ "purpose": "Stops the online IP-to-location lookup for login events and the monthly MaxMind download. Only the local GeoLite2 databases are used."
+ },
+ {
+ "name": "PASSWORD_BREACH_CHECK_DISABLED",
+ "purpose": "Stops the HaveIBeenPwned range query made when a password is set. The query carries the first five hex characters of the password's SHA-1, never the password or the full hash."
+ },
+ {
+ "name": "UPDATE_CHECK_DISABLED",
+ "purpose": "Stops the daily request to the GitHub releases API behind the About page and the admin version tile."
+ }
+ ]
+ },
{
"name": "APNs (iOS push)",
"description": "All four core APNS_* vars together. Missing any disables APNs silently \u2014 the v1.4.40 AP-2 gap that motivated this manifest. allOrNone trips the synthetic required row whenever 1-3 of the 4 are set.",
@@ -245,6 +268,17 @@
}
]
},
+ {
+ "name": "Document picker",
+ "description": "Operator grant for the document picker, which lets a person search their own Paperless-ngx or Papra instance and import picked documents. Unset, the picker is off. Optional.",
+ "required": false,
+ "variables": [
+ {
+ "name": "DOCUMENT_SOURCE_ORIGINS",
+ "purpose": "Comma-separated exact origins (scheme://host[:port], no path, no wildcard, no CIDR), e.g. \"https://paperless.example.com,http://papra.lan:1221\". Only listed origins are contacted, public or private; redirects are not followed; link-local, metadata and unspecified addresses stay refused even when listed; a malformed entry is logged once and grants nothing."
+ }
+ ]
+ },
{
"name": "Off-host backups",
"description": "Required ALL-OR-NONE: any of these without the rest disables backups silently.",
@@ -293,6 +327,11 @@
"purpose": "Trusted reverse-proxy hop count; the client IP is read that many entries from the right of X-Forwarded-For.",
"example": "1"
},
+ {
+ "name": "BACKUP_ENCRYPTION_PREVIOUS_KEYS",
+ "purpose": "Comma-separated off-host backup keys that are no longer written with but may still be needed to read older objects. Set during a rotation of BACKUP_ENCRYPTION_KEY until the bucket's lifecycle rule has retired every object written under the old key.",
+ "example": ""
+ },
{
"name": "BACKUP_MAX_STORED_MB",
"purpose": "Largest stored backup of one account the app keeps, in megabytes of encrypted copy (2048 by default). A storage limit, independent of the app's memory: a backup past it fails with a message naming this variable, and the previous copy stays.",
diff --git a/scripts/import-documents.mjs b/scripts/import-documents.mjs
index 1394136b3..194af9d24 100644
--- a/scripts/import-documents.mjs
+++ b/scripts/import-documents.mjs
@@ -301,6 +301,7 @@ function paperless(options) {
return {
system: "PAPERLESS",
+ instance: new URL(base).origin,
label,
async *documents() {
const query = new URLSearchParams({
@@ -324,7 +325,8 @@ function paperless(options) {
tagId = found[0].id;
query.set("tags__id__all", String(tagId));
}
- if (options.since) query.set("created__date__gte", options.since);
+ // API version 9 made `created` a plain date: filter on it directly.
+ if (options.since) query.set("created__gte", options.since);
let typeNames = new Map();
if (options.kindMap.size > 0) {
@@ -399,6 +401,7 @@ function papra(options) {
return {
system: "PAPRA",
+ instance: new URL(options.baseUrl).origin,
label,
async *documents() {
let tagId = null;
@@ -526,6 +529,7 @@ function keyQuery(source, doc) {
return new URLSearchParams({
sourceSystem: source.system,
sourceId: doc.sourceId.slice(0, SOURCE_ID_MAX),
+ sourceInstance: source.instance,
});
}
@@ -617,6 +621,9 @@ async function upload(options, source, doc, file) {
if (!options.aiRead) form.append("aiRead", "defer");
form.append("sourceSystem", source.system);
form.append("sourceId", doc.sourceId.slice(0, SOURCE_ID_MAX));
+ // Which Paperless-ngx or Papra this id belongs to: the same id in two
+ // instances is two documents. HealthLog 1.39.2 ignores the field.
+ form.append("sourceInstance", source.instance);
return form;
},
);
@@ -640,6 +647,8 @@ async function upload(options, source, doc, file) {
return {
outcome: "skipped",
reason: `larger than HealthLog accepts${meta.maxFileBytes ? ` (${mib(meta.maxFileBytes)})` : ""}`,
+ maxFileBytes:
+ typeof meta.maxFileBytes === "number" ? meta.maxFileBytes : undefined,
};
case 415:
return {
@@ -661,9 +670,10 @@ async function upload(options, source, doc, file) {
function mib(bytes) {
if (typeof bytes !== "number") return "?";
- return bytes >= 1024 * 1024 * 1024
- ? `${(bytes / 1024 ** 3).toFixed(1)} GB`
- : `${(bytes / 1024 ** 2).toFixed(1)} MB`;
+ if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
+ // Under 0.1 MB a size in MB reads as 0.0; say it in KB.
+ if (bytes < 0.1 * 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
}
// ── Run ────────────────────────────────────────────────────────────────────
@@ -698,6 +708,8 @@ async function run(argv, env = process.env) {
};
let listed = 0;
let totalBytes = 0;
+ /** HealthLog's per-file limit, once a 413 has said it. */
+ let maxFileBytes = null;
let unknownSizes = 0;
out(
@@ -736,6 +748,20 @@ async function run(argv, env = process.env) {
continue;
}
+ // HealthLog says its per-file limit only when it refuses a file. Once
+ // it has, a document the source already says is larger is skipped
+ // without downloading it.
+ if (
+ maxFileBytes !== null &&
+ typeof doc.size === "number" &&
+ doc.size > maxFileBytes
+ ) {
+ const reason = `larger than HealthLog accepts (${mib(maxFileBytes)})`;
+ tally.skipped.push({ where, reason });
+ out(` skipped ${where}: ${reason}`);
+ continue;
+ }
+
const file = await source.download(doc);
if (file.skip) {
tally.skipped.push({ where, reason: file.skip });
@@ -743,6 +769,9 @@ async function run(argv, env = process.env) {
continue;
}
const result = await upload(options, source, doc, file);
+ if (typeof result.maxFileBytes === "number") {
+ maxFileBytes = result.maxFileBytes;
+ }
if (result.outcome === "imported") {
tally.imported++;
out(` imported ${where}`);
diff --git a/scripts/restore-backup.ts b/scripts/restore-backup.ts
index c6f4a51fe..15cc8e80a 100644
--- a/scripts/restore-backup.ts
+++ b/scripts/restore-backup.ts
@@ -32,6 +32,7 @@ import {
loadOffhostConfig,
getS3Client,
openBackupObject,
+ offhostKeyRing,
OffhostBackupNotConfiguredError,
} from "@/lib/jobs/offhost-backup";
@@ -109,7 +110,7 @@ async function main() {
`Downloading s3://${cfg.bucket}/${key} from ${cfg.endpoint} (region=${cfg.region})`,
);
const ciphertext = await s3.getObject(key);
- const source = openBackupObject(ciphertext, cfg.encryptionKey);
+ const source = openBackupObject(ciphertext, offhostKeyRing(cfg), key);
// Checked first, in one streamed pass that keeps only the small sections,
// so nothing is written for an object that is not a backup.
diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts
index 0bac08bc9..da64e9060 100644
--- a/scripts/rotate-encryption-key.ts
+++ b/scripts/rotate-encryption-key.ts
@@ -328,6 +328,16 @@ async function main() {
);
}
+ // ───── Document picker (String) ─────
+ // Column: "tokenEncrypted" — a person's Paperless-ngx / Papra API token.
+ results.push(
+ await rotateStringColumn(
+ "DocumentSourceConnection",
+ "tokenEncrypted",
+ prisma.documentSourceConnection,
+ ),
+ );
+
// ───── AppSettings — operator credentials (String) ─────
// Columns: "adminAiKeyEncrypted" "webPushVapidPrivateKeyEncrypted"
// "adminCodexAccessTokenEncrypted" "adminCodexRefreshTokenEncrypted"
@@ -408,7 +418,7 @@ async function main() {
}
// ───── Coach (Bytes columns) ─────
- // "encryptedContent" "summaryEncrypted" "factEncrypted"
+ // "encryptedContent" "summaryEncrypted" "titleEncrypted" "factEncrypted"
results.push(
await rotateBytesColumn(
"CoachMessage",
@@ -423,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),
);
@@ -681,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.
diff --git a/scripts/stage-tesseract-assets.mjs b/scripts/stage-tesseract-assets.mjs
new file mode 100644
index 000000000..7787c1a0f
--- /dev/null
+++ b/scripts/stage-tesseract-assets.mjs
@@ -0,0 +1,68 @@
+#!/usr/bin/env node
+/**
+ * Stage the in-browser OCR engine under `public/tesseract/`.
+ *
+ * `src/lib/labs/local-ocr.ts` reads a lab-report photo on the device with
+ * tesseract.js. Left at its defaults, tesseract.js fetches its worker, its
+ * WebAssembly core and the language data from cdn.jsdelivr.net: the browser
+ * of every user who scans a report contacts a third-party CDN, and the
+ * production CSP (`worker-src 'self'`, `connect-src 'self'`) refuses those
+ * requests, so the feature could not load at all. Serving the same files
+ * from the app's own origin removes both problems.
+ *
+ * What is copied, all from pinned packages in node_modules:
+ * - tesseract.js `dist/worker.min.js`
+ * - the three LSTM-only core builds of tesseract.js-core (plain, SIMD,
+ * relaxed SIMD); the worker picks one by feature detection. LSTM-only
+ * because `createWorker` defaults to OEM 1.
+ * - `deu` and `eng` `4.0.0_best_int` language data, gzipped, which is what
+ * OEM 1 loads.
+ *
+ * Runs as a `prebuild`/`predev` step; the output is gitignored.
+ */
+import { copyFileSync, mkdirSync, rmSync } from "node:fs";
+import { createRequire } from "node:module";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const require = createRequire(import.meta.url);
+const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
+const outDir = join(repoRoot, "public", "tesseract");
+
+const pkgDir = (name) => dirname(require.resolve(`${name}/package.json`));
+
+// tesseract.js-core is a dependency of tesseract.js, not of this project;
+// resolve it from tesseract.js's own location so pnpm's strict layout finds it.
+const tesseractDir = pkgDir("tesseract.js");
+const coreDir = dirname(
+ createRequire(join(tesseractDir, "package.json")).resolve(
+ "tesseract.js-core/package.json",
+ ),
+);
+
+const files = [
+ [join(tesseractDir, "dist", "worker.min.js"), "worker.min.js"],
+ ...[
+ "tesseract-core-lstm.wasm.js",
+ "tesseract-core-simd-lstm.wasm.js",
+ "tesseract-core-relaxedsimd-lstm.wasm.js",
+ ].map((file) => [join(coreDir, file), join("core", file)]),
+ // Literal specifiers, so the dependency is visible to static analysis.
+ ...[
+ ["deu", require.resolve("@tesseract.js-data/deu/package.json")],
+ ["eng", require.resolve("@tesseract.js-data/eng/package.json")],
+ ].map(([lang, manifest]) => [
+ join(dirname(manifest), "4.0.0_best_int", `${lang}.traineddata.gz`),
+ join("lang", `${lang}.traineddata.gz`),
+ ]),
+];
+
+rmSync(outDir, { recursive: true, force: true });
+for (const [from, to] of files) {
+ const target = join(outDir, to);
+ mkdirSync(dirname(target), { recursive: true });
+ copyFileSync(from, target);
+}
+console.log(
+ `stage-tesseract-assets: ${files.length} files -> public/tesseract`,
+);
diff --git a/src/__tests__/ai-consent-enforcement-guard.test.ts b/src/__tests__/ai-consent-enforcement-guard.test.ts
index adfa10b47..16e41710c 100644
--- a/src/__tests__/ai-consent-enforcement-guard.test.ts
+++ b/src/__tests__/ai-consent-enforcement-guard.test.ts
@@ -183,18 +183,22 @@ describe("every provider-resolving module carries a consent helper", () => {
});
describe("the consent guard itself stays the single source of the policy", () => {
- it("keeps the server-managed provider set defined in exactly one place", () => {
- // Both operator-credential tags must be classified in consent-guard.ts and
- // nowhere else — a second copy of this list is how the policy drifts.
- const guard = read("lib/ai/consent-guard.ts");
- expect(guard).toContain('"admin-openai"');
- expect(guard).toContain('"admin-codex"');
+ it("keeps the operator-held provider set defined in exactly one place", () => {
+ // The two operator-credential tags are listed in provider-egress.ts and
+ // nowhere else, and the consent guard reads them from there. A second copy
+ // is how the policy drifts. This guard used to match one variable NAME, so
+ // a copy under another name in the capability resolver went unseen for as
+ // long as it existed; it now matches the list itself, in either order.
+ const pair =
+ /\[\s*"admin-(?:openai|codex)"\s*,\s*"admin-(?:openai|codex)"\s*,?\s*\]/;
+ const holders = sourceFiles().filter((rel) => pair.test(read(rel)));
+ expect(holders).toEqual(["lib/ai/provider-egress.ts"]);
- const copies = sourceFiles().filter(
- (rel) =>
- rel !== "lib/ai/consent-guard.ts" &&
- /SERVER_MANAGED_PROVIDER_TYPES/.test(read(rel)),
+ expect(read("lib/ai/consent-guard.ts")).toMatch(
+ /from "@\/lib\/ai\/provider-egress"/,
+ );
+ expect(read("lib/ai/capabilities/resolve.ts")).toMatch(
+ /from "@\/lib\/ai\/provider-egress"/,
);
- expect(copies).toEqual([]);
});
});
diff --git a/src/__tests__/api-handler-bypass-route.test.ts b/src/__tests__/api-handler-bypass-route.test.ts
new file mode 100644
index 000000000..5c5410cce
--- /dev/null
+++ b/src/__tests__/api-handler-bypass-route.test.ts
@@ -0,0 +1,71 @@
+/**
+ * The large-upload routes are left out of the proxy matcher, so `apiHandler`
+ * performs the proxy's duties for them: the worker-only and demo-mode
+ * refusals, the transport security headers and a CSP. Every other route still
+ * gets those from the proxy and is left untouched here.
+ */
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { NextRequest, NextResponse } from "next/server";
+
+const runWeb = vi.hoisted(() => ({ value: true }));
+vi.mock("@/lib/process-type", () => ({
+ shouldRunWeb: () => runWeb.value,
+ shouldRunWorker: () => true,
+ getProcessType: () => "all",
+}));
+
+import { apiHandler } from "@/lib/api-handler";
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+ runWeb.value = true;
+});
+
+function call(path: string, method = "POST") {
+ const handler = vi.fn(async (_req: NextRequest) =>
+ NextResponse.json({ data: "ok" }),
+ );
+ const wrapped = apiHandler(handler);
+ return {
+ handler,
+ res: wrapped(new NextRequest(`http://localhost${path}`, { method })),
+ };
+}
+
+describe("apiHandler on a route the proxy does not see", () => {
+ it("adds the proxy's security headers and a CSP", async () => {
+ const { res } = call("/api/measurements/batch");
+ const r = await res;
+ expect(r.status).toBe(200);
+ expect(r.headers.get("x-content-type-options")).toBe("nosniff");
+ expect(r.headers.get("x-frame-options")).toBe("DENY");
+ expect(r.headers.get("content-security-policy")).toContain(
+ "default-src 'none'",
+ );
+ expect(r.headers.get("x-request-id")).toBeTruthy();
+ });
+
+ it("refuses a mutation on a demo instance before the handler runs", async () => {
+ vi.stubEnv("DEMO_MODE", "true");
+ const { res, handler } = call("/api/import/csv");
+ const r = await res;
+ expect(r.status).toBe(403);
+ expect(handler).not.toHaveBeenCalled();
+ expect(r.headers.get("x-frame-options")).toBe("DENY");
+ });
+
+ it("refuses in a worker-only container", async () => {
+ runWeb.value = false;
+ const { res, handler } = call("/api/admin/backups/upload");
+ expect((await res).status).toBe(503);
+ expect(handler).not.toHaveBeenCalled();
+ });
+
+ it("leaves a proxied route to the proxy", async () => {
+ vi.stubEnv("DEMO_MODE", "true");
+ const { res, handler } = call("/api/auth/me/timezone", "PUT");
+ const r = await res;
+ expect(handler).toHaveBeenCalled();
+ expect(r.headers.get("content-security-policy")).toBeNull();
+ });
+});
diff --git a/src/__tests__/api/auth/login/mfa-branch.test.ts b/src/__tests__/api/auth/login/mfa-branch.test.ts
index 79de4e666..1b9a644e1 100644
--- a/src/__tests__/api/auth/login/mfa-branch.test.ts
+++ b/src/__tests__/api/auth/login/mfa-branch.test.ts
@@ -9,6 +9,11 @@ vi.mock("@/lib/db", () => ({
prisma: {
user: { findFirst: vi.fn() },
webauthnMfaCredential: { count: vi.fn().mockResolvedValue(0) },
+ // The per-account throttle's reads: no known place, no wait running.
+ trustedDevice: { findUnique: vi.fn(async () => null) },
+ refreshToken: { findFirst: vi.fn(async () => null) },
+ session: { findFirst: vi.fn(async () => null) },
+ rateLimit: { findUnique: vi.fn(async () => null), upsert: vi.fn() },
},
}));
vi.mock("@/lib/auth/password", () => ({
@@ -29,6 +34,8 @@ vi.mock("@/lib/rate-limit", () => ({
resetAt: Date.now() + 1e6,
ip: "203.0.113.7",
}),
+ checkRateLimit: vi.fn(),
+ refundRateLimit: vi.fn(),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/auth/login-response", () => ({
@@ -61,6 +68,7 @@ import { POST } from "@/app/api/auth/login/route";
import { prisma } from "@/lib/db";
import { finishLogin } from "@/lib/auth/login-response";
import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
+import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
function loginRequest() {
return new Request("http://localhost/api/auth/login", {
@@ -85,6 +93,12 @@ beforeEach(() => {
vi.clearAllMocks();
// Default: no registered security key. Individual tests override as needed.
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
+ vi.mocked(checkRateLimit).mockResolvedValue({
+ allowed: true,
+ limit: 10,
+ remaining: 9,
+ resetAt: Date.now() + 1e6,
+ });
});
describe("login MFA branch", () => {
@@ -155,3 +169,51 @@ describe("login MFA branch", () => {
expect(finishLogin).not.toHaveBeenCalled();
});
});
+
+describe("per-account login throttle", () => {
+ it("refuses a throttled account before any password is verified", async () => {
+ const { verifyPasswordOrDummy } = await import("@/lib/auth/password");
+ vi.mocked(prisma.user.findFirst).mockResolvedValue({
+ ...BASE_USER,
+ totpConfirmedAt: null,
+ } as never);
+ // A wait is running for this account, set by earlier failures.
+ vi.mocked(prisma.rateLimit.findUnique).mockResolvedValueOnce({
+ resetAt: new Date(Date.now() + 60_000),
+ } as never);
+
+ const res = await POST(loginRequest());
+ expect(res.status).toBe(429);
+ expect(verifyPasswordOrDummy).not.toHaveBeenCalled();
+ expect(finishLogin).not.toHaveBeenCalled();
+ expect(vi.mocked(prisma.rateLimit.findUnique).mock.calls[0][0]).toEqual({
+ where: { key: "auth:login:account:u:user-1:wait" },
+ select: { resetAt: true },
+ });
+ });
+
+ it("keys an unknown identifier on its hash, so a lock says nothing about existence", async () => {
+ vi.mocked(prisma.user.findFirst).mockResolvedValue(null as never);
+ const res = await POST(loginRequest());
+ expect(res.status).toBe(401);
+ expect(vi.mocked(checkRateLimit).mock.calls[0][0]).toMatch(
+ /^auth:login:account:i:/,
+ );
+ });
+
+ it("refunds a successful sign-in and keeps a failed one counted", async () => {
+ const { verifyPasswordOrDummy } = await import("@/lib/auth/password");
+ vi.mocked(prisma.user.findFirst).mockResolvedValue({
+ ...BASE_USER,
+ totpConfirmedAt: null,
+ } as never);
+
+ await POST(loginRequest());
+ expect(refundRateLimit).toHaveBeenCalledWith("auth:login:account:u:user-1");
+
+ vi.mocked(refundRateLimit).mockClear();
+ vi.mocked(verifyPasswordOrDummy).mockResolvedValueOnce(false);
+ await POST(loginRequest());
+ expect(refundRateLimit).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/__tests__/api/auth/mfa/verify.test.ts b/src/__tests__/api/auth/mfa/verify.test.ts
index b16904be3..094614336 100644
--- a/src/__tests__/api/auth/mfa/verify.test.ts
+++ b/src/__tests__/api/auth/mfa/verify.test.ts
@@ -31,6 +31,7 @@ vi.mock("@/lib/auth/login-response", () => ({
}));
vi.mock("@/lib/auth/mfa/challenge", () => ({
loadActiveChallenge: vi.fn(),
+ reserveChallengeAttempt: vi.fn().mockResolvedValue(true),
recordChallengeFailure: vi
.fn()
.mockResolvedValue({ exhausted: false, attempts: 1 }),
@@ -53,6 +54,7 @@ import { prisma } from "@/lib/db";
import { finishLogin } from "@/lib/auth/login-response";
import {
loadActiveChallenge,
+ reserveChallengeAttempt,
recordChallengeFailure,
claimChallenge,
} from "@/lib/auth/mfa/challenge";
@@ -105,6 +107,16 @@ describe("POST /api/auth/mfa/verify", () => {
expect(arg.source).toBe("mfa.verify");
});
+ it("no attempt left to reserve → 401 before the factor is evaluated", async () => {
+ vi.mocked(loadActiveChallenge).mockResolvedValue(ACTIVE_CHALLENGE as never);
+ vi.mocked(reserveChallengeAttempt).mockResolvedValueOnce(false);
+
+ const res = await POST(verifyRequest(GOOD_BODY));
+ expect(res.status).toBe(401);
+ expect(verifyMfaFactor).not.toHaveBeenCalled();
+ expect(finishLogin).not.toHaveBeenCalled();
+ });
+
it("invalid ticket → 401, no session", async () => {
vi.mocked(loadActiveChallenge).mockResolvedValue(null);
const res = await POST(verifyRequest(GOOD_BODY));
diff --git a/src/__tests__/compose-env-whitelist-guard.test.ts b/src/__tests__/compose-env-whitelist-guard.test.ts
index 20c3ad68e..ba8cfb12a 100644
--- a/src/__tests__/compose-env-whitelist-guard.test.ts
+++ b/src/__tests__/compose-env-whitelist-guard.test.ts
@@ -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.
*
@@ -46,6 +47,13 @@ const SRC = join(ROOT, "src");
const DIRECT_READ =
/process\.env(?:\.([A-Za-z_][A-Za-z0-9_]*)|\[\s*["'`]([A-Za-z_][A-Za-z0-9_]*)["'`]\s*\])/g;
+/**
+ * `envValue("NAME")`, `envOr("NAME", …)` and `envFlag("NAME")` from
+ * `src/lib/env.ts` — the one helper every read with a fallback goes through,
+ * so its call shape is a read wherever it appears. Asserted non-empty in T2.
+ */
+const HELPER_READ = /\benv(?:Value|Or|Flag)\(\s*["'`]([A-Z][A-Z0-9_]*)["'`]/g;
+
/**
* Modules that index `process.env` through a variable rather than a literal.
* The direct matcher cannot see their reads at all, so each names the exact
@@ -72,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,
@@ -154,6 +156,7 @@ function runtimeReads(): Set {
for (const rel of sourceFiles()) {
const src = read(rel);
for (const m of src.matchAll(DIRECT_READ)) names.add(m[1] ?? m[2]);
+ for (const m of src.matchAll(HELPER_READ)) names.add(m[1]);
}
for (const reader of INDIRECT_READERS) {
for (const m of read(reader.file).matchAll(reader.pattern)) names.add(m[1]);
@@ -191,6 +194,13 @@ describe("compose env whitelist", () => {
});
it("T2 — every indirect reader still matches its call shape", () => {
+ const helperReads = sourceFiles().flatMap((rel) =>
+ [...read(rel).matchAll(HELPER_READ)].map((m) => m[1]),
+ );
+ // The env helper is the common path for reads with a fallback; a matcher
+ // that stopped seeing it would drop those names from the read set.
+ expect(helperReads.length).toBeGreaterThan(10);
+ expect(helperReads).toContain("IP_GEO_LOOKUP_URL");
for (const reader of INDIRECT_READERS) {
const found = [...read(reader.file).matchAll(reader.pattern)].map(
(m) => m[1],
diff --git a/src/__tests__/container-scripts-ship-guard.test.ts b/src/__tests__/container-scripts-ship-guard.test.ts
new file mode 100644
index 000000000..83e2e928f
--- /dev/null
+++ b/src/__tests__/container-scripts-ship-guard.test.ts
@@ -0,0 +1,111 @@
+/**
+ * Structural guard: a script a runbook tells the operator to run inside the
+ * container is actually in the image, together with every local file it
+ * imports.
+ *
+ * ## The failure this freezes
+ *
+ * `docs/ops/password-reset.md` told operators to run
+ * `docker compose exec app node scripts/reset-password.mjs`, and the runner
+ * stage of the Dockerfile copied nothing from `scripts/`: the standalone
+ * output does not carry it. The recovery path for a locked-out admin failed
+ * with "Cannot find module" at the moment it was needed. The intake-repair
+ * runbook had the same gap under the `healthlog-tsx` launcher.
+ *
+ * ## What it proves and what it does not
+ *
+ * Every `docker compose exec … node scripts/…` or `healthlog-tsx scripts/…`
+ * command in `docs/` names
+ * a file the runner stage copies, and every relative import of that file is
+ * copied too. Whether the package imports resolve is proved at image build by
+ * the `RUN` checks next to the copies; this file cannot see the traced tree.
+ * A command written in some other form (a different launcher) is outside the
+ * matcher.
+ */
+import { describe, expect, it } from "vitest";
+import { readFileSync, readdirSync } from "node:fs";
+import { dirname, join, normalize } from "node:path";
+
+const ROOT = process.cwd();
+const DOCKERFILE = readFileSync(join(ROOT, "Dockerfile"), "utf8");
+
+/**
+ * In-container command forms: `docker compose exec node scripts/…`
+ * or the image's own `healthlog-tsx scripts/…` launcher. A bare
+ * `node scripts/…` is a workstation or source-checkout command and is out of
+ * scope.
+ */
+const CONTAINER_COMMAND =
+ /(?:docker compose exec\s+(?:-\S+\s+)*\S+\s+node|\bhealthlog-tsx)\s+(scripts\/[\w.-]+)/g;
+
+function markdownFiles(dir: string): string[] {
+ return readdirSync(dir, { recursive: true, encoding: "utf8" })
+ .filter((rel) => rel.endsWith(".md"))
+ .map((rel) => join(dir, rel));
+}
+
+function runnerStage(): string {
+ const start = DOCKERFILE.search(/^FROM .* AS runner$/m);
+ expect(start, "the Dockerfile has a runner stage").toBeGreaterThan(0);
+ return DOCKERFILE.slice(start);
+}
+
+function copiedIntoRunner(path: string): boolean {
+ const escaped = path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ return new RegExp(
+ `^COPY --from=builder (?:--chown=\\S+ )?/app/${escaped} \\./${escaped}$`,
+ "m",
+ ).test(runnerStage());
+}
+
+/** Scripts named by a container command anywhere under docs/. */
+function documentedContainerScripts(): string[] {
+ const found = new Set();
+ for (const file of markdownFiles(join(ROOT, "docs"))) {
+ for (const m of readFileSync(file, "utf8").matchAll(CONTAINER_COMMAND)) {
+ found.add(m[1]);
+ }
+ }
+ return [...found].sort();
+}
+
+/** Relative import specifiers of a script, resolved against its location. */
+function localImports(script: string): string[] {
+ const src = readFileSync(join(ROOT, script), "utf8");
+ const specs = [
+ ...src.matchAll(/^\s*import\s[^;]*?from\s+["'](\.{1,2}\/[^"']+)["']/gm),
+ ].map((m) => m[1]);
+ return specs.map((spec) => normalize(join(dirname(script), spec)));
+}
+
+describe("scripts the runbooks run inside the container ship in the image", () => {
+ it("finds the commands it is meant to check", () => {
+ const scripts = documentedContainerScripts();
+ // A matcher that found nothing would agree with any Dockerfile.
+ expect(scripts).toContain("scripts/reset-password.mjs");
+ expect(scripts).toContain("scripts/repair-intake-anomalies.ts");
+ expect(localImports("scripts/reset-password.mjs")).toContain(
+ "src/lib/auth/argon2-params.mjs",
+ );
+ expect(copiedIntoRunner("scripts/not-a-real-script.mjs")).toBe(false);
+ });
+
+ it("copies every documented script into the runner stage", () => {
+ const missing = documentedContainerScripts().filter(
+ (script) => !copiedIntoRunner(script),
+ );
+ expect(
+ missing,
+ "A runbook runs these inside the container, but the Dockerfile runner " +
+ "stage does not COPY them. Add the COPY (and its local imports), or " +
+ "change the runbook to a source-checkout command.",
+ ).toEqual([]);
+ });
+
+ it("copies every local file those scripts import", () => {
+ const missing = documentedContainerScripts()
+ .flatMap((script) => localImports(script))
+ .filter((path) => !copiedIntoRunner(path));
+ expect(missing).toEqual([]);
+ });
+});
diff --git a/src/__tests__/date-order-explicit-formatter-guard.test.ts b/src/__tests__/date-order-explicit-formatter-guard.test.ts
index faec9324c..cf89630c0 100644
--- a/src/__tests__/date-order-explicit-formatter-guard.test.ts
+++ b/src/__tests__/date-order-explicit-formatter-guard.test.ts
@@ -259,18 +259,10 @@ const RAW_ALLOWLIST: ReadonlyArray<{ file: string; why: string }> = [
file: "src/components/measurement-reminders/vorsorge-section.tsx",
why: "The ISO day the postpone request writes to the API. A wire value, and the wire format is ISO whatever the reader prefers.",
},
- {
- file: "src/lib/gamification/achievements.ts",
- why: "The day key streak arithmetic counts on.",
- },
{
file: "src/lib/charts/bucket-time-series.ts",
why: "Bucket boundary arithmetic. The bucket LABELS are a separate step and go through `makeBucketLabelFormatters`.",
},
- {
- file: "src/lib/ai/prompts/insight-generator.ts",
- why: "An ISO day inside a model prompt. The model reads ISO; the user never sees this string.",
- },
{
file: "src/lib/jobs/measurement-reminder.ts",
why: "The local day in the notification dedup key. Changing it with a display preference would re-send a claimed reminder.",
@@ -283,10 +275,6 @@ const RAW_ALLOWLIST: ReadonlyArray<{ file: string; why: string }> = [
file: "src/lib/i18n/relative-time.ts",
why: "The en-CA day keys that decide today / yesterday. A comparison between two keys, never printed.",
},
- {
- file: "src/lib/tz/resolver.ts",
- why: "The Berlin day formatter the zone arithmetic itself is built on.",
- },
];
/**
diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts
index 27a745581..fcf209cfe 100644
--- a/src/__tests__/destructive-control-guard.test.ts
+++ b/src/__tests__/destructive-control-guard.test.ts
@@ -579,6 +579,13 @@ const REGISTRY: DestructiveEntry[] = [
recovery: "permanent",
confirm: ["AlertDialog"],
})),
+ {
+ file: "components/settings/integrations/document-sources-card.tsx",
+ destroys:
+ "the stored Paperless-ngx or Papra address and API token; documents already imported stay in the vault — reconnecting means typing the token again",
+ recovery: "permanent",
+ confirm: ["AlertDialog"],
+ },
];
// ── Detectors ─────────────────────────────────────────────────────────────
diff --git a/src/__tests__/document-sources-owner-only-guard.test.ts b/src/__tests__/document-sources-owner-only-guard.test.ts
new file mode 100644
index 000000000..a61741026
--- /dev/null
+++ b/src/__tests__/document-sources-owner-only-guard.test.ts
@@ -0,0 +1,106 @@
+/**
+ * The document picker's routes (#1038) are owner-only, cookie-only, and stay
+ * that way.
+ *
+ * A connection is the owner's credential to a system outside HealthLog. A
+ * delegate or guardian driving it would read the owner's whole Paperless-ngx or
+ * Papra archive, which no grant covers; a Bearer (the native client, a script)
+ * has no business managing it either. The rule is in two halves, and this file
+ * freezes both at every route module under `src/app/api/documents/sources`:
+ *
+ * 1. the module calls a bare `requireAuth()` itself (no scope, so a narrow
+ * token is refused, and a request acting on another record is refused
+ * 403 `sharing.not_permitted`), and names none of the resolvers that
+ * would admit a delegate, a guardian, or an actor surface;
+ * 2. every handler passes the result through `admitDocumentSourceCaller`,
+ * which refuses any transport but the cookie.
+ *
+ * The scan asserts it found the modules it expects, so an empty directory or a
+ * moved tree fails rather than passing on nothing.
+ *
+ * Mutation checks: swap one route's `requireAuth()` for
+ * `requireRecordAuth("write", "documents")`, or drop its
+ * `admitDocumentSourceCaller` call, and this file fails naming that module.
+ */
+import { readFileSync, readdirSync } from "node:fs";
+import { join, relative } from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+import { stripComments } from "./helpers/source-files";
+
+const ROOT = join(process.cwd(), "src/app/api/documents/sources");
+
+function routeModules(): string[] {
+ const out: string[] = [];
+ const walk = (dir: string) => {
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ const full = join(dir, entry.name);
+ if (entry.isDirectory()) walk(full);
+ else if (entry.name === "route.ts") out.push(full);
+ }
+ };
+ walk(ROOT);
+ return out.map((file) => relative(ROOT, file)).sort();
+}
+
+const EXPECTED = [
+ "[system]/import/route.ts",
+ "[system]/route.ts",
+ "[system]/search/route.ts",
+ "[system]/tags/route.ts",
+ "[system]/test/route.ts",
+ "route.ts",
+];
+
+describe("document-source routes are owner-only and cookie-only", () => {
+ const modules = routeModules();
+
+ it("finds exactly the route modules it freezes", () => {
+ expect(modules).toEqual(EXPECTED);
+ });
+
+ it.each(EXPECTED)(
+ "%s resolves a bare requireAuth and no wider resolver",
+ (rel) => {
+ const source = stripComments(readFileSync(join(ROOT, rel), "utf8"));
+ expect(source).toMatch(/requireAuth\(\s*\)/);
+ expect(source).not.toMatch(/requireAuth\(\s*[^)\s]/);
+ for (const wider of [
+ "requireRecordAuth",
+ "requireGuardianAuth",
+ "requireActorAuth",
+ "requireCookieAuth",
+ "requireBearerAuth",
+ ]) {
+ expect(source, `${rel} names ${wider}`).not.toContain(wider);
+ }
+ },
+ );
+
+ it.each(EXPECTED)(
+ "%s admits every handler through the cookie check",
+ (rel) => {
+ const source = stripComments(readFileSync(join(ROOT, rel), "utf8"));
+ const handlers = source.match(
+ /export const (GET|POST|PUT|PATCH|DELETE)\b/g,
+ );
+ expect(handlers?.length ?? 0).toBeGreaterThan(0);
+ const auths = source.match(/await requireAuth\(\s*\)/g) ?? [];
+ const admits = source.match(/await admitDocumentSourceCaller\(/g) ?? [];
+ expect(auths.length).toBe(handlers!.length);
+ expect(admits.length).toBe(handlers!.length);
+ },
+ );
+
+ it("the cookie check refuses every transport but the cookie", () => {
+ const support = stripComments(
+ readFileSync(
+ join(process.cwd(), "src/lib/documents/sources/route-support.ts"),
+ "utf8",
+ ),
+ );
+ expect(support).toContain('auth.authMethod !== "cookie"');
+ expect(support).toContain('"documents.sources.browserOnly"');
+ });
+});
diff --git a/src/__tests__/env-empty-is-unset-guard.test.ts b/src/__tests__/env-empty-is-unset-guard.test.ts
new file mode 100644
index 000000000..a50b87bbc
--- /dev/null
+++ b/src/__tests__/env-empty-is-unset-guard.test.ts
@@ -0,0 +1,107 @@
+/**
+ * Structural guard: an environment variable with a fallback is read through
+ * `src/lib/env.ts`, never as `process.env.NAME ?? fallback`.
+ *
+ * ## The failure this freezes
+ *
+ * `docker-compose.yml` forwards its whitelist as `"${NAME:-}"`, so every
+ * listed variable the operator left unset arrives as the empty string. `??`
+ * only replaces `null` and `undefined`, so the empty string wins and the
+ * fallback never applies. On a stock compose stack that sent Withings an
+ * OAuth `redirect_uri=""`, handed the weather client an empty base URL
+ * (`ERR_INVALID_URL` on every fetch) and pointed the IP geo lookup at a
+ * relative path. The same shape with a method in between,
+ * `process.env.NAME?.replace(...) ?? fallback`, fails the same way.
+ *
+ * ## What it proves and what it does not
+ *
+ * It proves no source file under `src/` writes a nullish fallback directly
+ * against a `process.env` read. It does not see a read stored in a local
+ * first and defaulted later (`const x = process.env.NAME; x ?? y`); those
+ * stay a review question. `||` is allowed: it already treats the empty
+ * string as unset, and client components need a literal
+ * `process.env.NEXT_PUBLIC_X` for build-time inlining.
+ */
+import { describe, expect, it } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { walkSourceFiles } from "./helpers/source-files";
+
+const SRC = join(process.cwd(), "src");
+
+/**
+ * `process.env.NAME` or `process.env["NAME"]`, optionally followed by
+ * optional-chained calls such as `?.trim()` or `?.replace(/x/, "")`, then
+ * `??` across any whitespace including a line break.
+ */
+const NULLISH_ENV_FALLBACK =
+ /process\.env(?:\.[A-Za-z_][A-Za-z0-9_]*|\[\s*["'`][A-Za-z_][A-Za-z0-9_]*["'`]\s*\])(?:\?\.[A-Za-z_]+\((?:[^()]|\([^()]*\))*\))*\s*\?\?/g;
+
+function sourceFiles(): string[] {
+ return walkSourceFiles(SRC, { floor: 3000 })
+ .filter((rel) => !rel.startsWith("generated/"))
+ .filter((rel) => !rel.includes("__tests__"))
+ .filter((rel) => !rel.endsWith(".test.ts") && !rel.endsWith(".test.tsx"))
+ .filter((rel) => rel !== "lib/env.ts");
+}
+
+function offenders(): string[] {
+ return sourceFiles().filter((rel) => {
+ const src = readFileSync(join(SRC, rel), "utf8");
+ return [...src.matchAll(NULLISH_ENV_FALLBACK)].length > 0;
+ });
+}
+
+describe("an empty environment variable counts as unset", () => {
+ it("T1 — the matcher catches every shape it claims to", () => {
+ const shapes = [
+ `const a = process.env.FOO ?? "x";`,
+ `const b = process.env.FOO\n ?? "x";`,
+ `const c = process.env["FOO"] ?? "x";`,
+ `const d = process.env.FOO?.replace(/\\/$/, "") ?? "x";`,
+ `const e = process.env.FOO?.trim()?.toLowerCase() ?? "x";`,
+ ];
+ for (const shape of shapes) {
+ expect(
+ [...shape.matchAll(NULLISH_ENV_FALLBACK)].length,
+ shape,
+ ).toBeGreaterThan(0);
+ }
+ for (const allowed of [
+ `const f = process.env.FOO || "x";`,
+ `const g = envOr("FOO", "x");`,
+ `const h = process.env.FOO === "1" ? a ?? b : c;`,
+ ]) {
+ expect([...allowed.matchAll(NULLISH_ENV_FALLBACK)], allowed).toEqual([]);
+ }
+ });
+
+ it("T2 — no source file defaults a process.env read with ??", () => {
+ expect(
+ offenders(),
+ "Read these through envValue/envOr/envFlag from @/lib/env. Compose " +
+ "passes an unset variable as the empty string, and ?? keeps it.",
+ ).toEqual([]);
+ });
+
+ it("T3 — the sweep read the tree and the matcher fires on real source", () => {
+ // An empty offender list proves nothing if the narrowed walk found no
+ // files, or if the matcher no longer fires on what the tree actually
+ // holds. Floor the narrowed set, then put the pre-fix read back into a
+ // real client and require the matcher to find it there.
+ const files = sourceFiles();
+ expect(files.length).toBeGreaterThan(2400);
+ expect(files).toContain("lib/withings/client.ts");
+ const real = readFileSync(join(SRC, "lib/withings/client.ts"), "utf8");
+ expect([...real.matchAll(NULLISH_ENV_FALLBACK)]).toEqual([]);
+ const reverted = real.replace(
+ /envOr\(\s*"WITHINGS_REDIRECT_URI",/,
+ "process.env.WITHINGS_REDIRECT_URI ?? (",
+ );
+ expect(reverted).not.toBe(real);
+ expect([...reverted.matchAll(NULLISH_ENV_FALLBACK)].length).toBeGreaterThan(
+ 0,
+ );
+ });
+});
diff --git a/src/__tests__/factor-verification-surface-guard.test.ts b/src/__tests__/factor-verification-surface-guard.test.ts
new file mode 100644
index 000000000..23cb148e4
--- /dev/null
+++ b/src/__tests__/factor-verification-surface-guard.test.ts
@@ -0,0 +1,77 @@
+/**
+ * Every route that checks a password or a second factor is a guessing oracle,
+ * and every one must be throttled and must record a refused guess.
+ *
+ * The passkey-enrollment re-proof verified a password and a TOTP code with
+ * neither: a stolen session could guess there as fast as it could send
+ * requests, and nothing in the audit log would show it. Behavioural tests pin
+ * each route that was fixed; this freezes the SET, so a new route that starts
+ * verifying a credential has to be listed here, and the listing asks the two
+ * questions at the moment it is added.
+ *
+ * Matching is on the verifier's name anywhere in the file, not on an import
+ * shape, so an aliased import still counts. It remains a tripwire: it proves a
+ * throttle and an audit call are present in the file, not that they sit on the
+ * right branch — the per-route tests do that.
+ */
+import { describe, expect, it } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { walkSourceFiles } from "./helpers/source-files";
+
+const APP = join(process.cwd(), "src", "app");
+
+const VERIFIER =
+ /\b(verifyPassword|verifyPasswordOrDummy|verifyMfaFactor|verifyTotp|verifyMfaAuthentication|verifyAuthentication|verifyExistingFactorProof|checkCookieEnrollmentProof)\b/;
+
+const THROTTLE =
+ /\b(checkRateLimit|checkAuthSurfaceRateLimit|throttleReproof|checkCookieEnrollmentProof|reserveChallengeAttempt)\s*\(/;
+
+const AUDIT =
+ /\b(auditLog|recordReproofFailure|checkCookieEnrollmentProof)\s*\(/;
+
+/**
+ * Every file under src/app that verifies a credential. Adding one means
+ * answering, in its own tests, how a wrong guess is limited and recorded.
+ */
+const EXPECTED = [
+ "api/auth/login/route.ts",
+ "api/auth/me/mfa/disable/route.ts",
+ "api/auth/me/mfa/totp/confirm/route.ts",
+ "api/auth/me/mfa/totp/setup/route.ts",
+ "api/auth/me/mfa/webauthn/register/options/route.ts",
+ "api/auth/mfa/verify/route.ts",
+ "api/auth/mfa/webauthn/verify/route.ts",
+ "api/auth/passkey/login-verify/route.ts",
+ "api/auth/passkey/register-options/route.ts",
+ "api/auth/password/route.ts",
+ "api/auth/reproof/route.ts",
+ "api/auth/step-up/route.ts",
+];
+
+function verifyingFiles(): string[] {
+ return walkSourceFiles(APP, { floor: 500 })
+ .filter((p) => !p.includes("__tests__"))
+ .filter((p) => !/\.test\.tsx?$/.test(p))
+ .filter((p) => VERIFIER.test(readFileSync(join(APP, p), "utf8")));
+}
+
+describe("credential-verifying routes", () => {
+ const found = verifyingFiles();
+
+ it("finds the verifying routes at all", () => {
+ // An empty match agrees with nothing and would pass on a tree never read.
+ expect(found.length).toBeGreaterThanOrEqual(EXPECTED.length);
+ });
+
+ it("is exactly the listed set", () => {
+ expect(found).toEqual(EXPECTED);
+ });
+
+ it.each(EXPECTED)("%s is throttled and audits refusals", (rel) => {
+ const src = readFileSync(join(APP, rel), "utf8");
+ expect(src).toMatch(THROTTLE);
+ expect(src).toMatch(AUDIT);
+ });
+});
diff --git a/src/__tests__/measurement-write-tail-guard.test.ts b/src/__tests__/measurement-write-tail-guard.test.ts
new file mode 100644
index 000000000..dc56b62f8
--- /dev/null
+++ b/src/__tests__/measurement-write-tail-guard.test.ts
@@ -0,0 +1,171 @@
+/**
+ * Every place that writes `Measurement` rows either runs the shared
+ * post-write tail or says here why it does not.
+ *
+ * Two readers hang off every measurement write: the rollup buckets the write
+ * dirtied, and the cached status assessments of the types it touched. The
+ * shared tail (`afterMeasurementMutation`) runs both. Several write paths
+ * refreshed the rollups by hand and never re-warmed the assessments (the MCP
+ * writes, the Telegram reply, both importers), so a reading logged there kept
+ * the day's old assessment until the nightly pass. This guard freezes the set
+ * of write sites: a new one fails until it is listed with its tail.
+ *
+ * A tripwire, not a proof: it cannot tell whether a listed reason is still
+ * true, only that the set did not change without someone editing this file.
+ */
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { walkSourceFiles } from "./helpers/source-files";
+
+const SRC = join(process.cwd(), "src");
+
+/**
+ * `prisma.measurement.create(`, `tx.measurement.deleteMany(`, … Whitespace
+ * and line breaks between the parts are tolerated so a formatter reflow does
+ * not hide a site.
+ */
+const WRITE_RE =
+ /\.\s*measurement\s*\.\s*(?:create|createMany|createManyAndReturn|upsert|update|updateMany|delete|deleteMany)\s*\(/g;
+
+/** Runs `afterMeasurementMutation` on the rows it wrote. */
+const TAIL = "runs afterMeasurementMutation";
+
+/**
+ * Every file with a measurement write, and how it keeps the two readers
+ * current. A file listed here must still contain a write (a stale entry
+ * fails too).
+ */
+const WRITE_SITES: Record = {
+ "app/api/import/csv/route.ts": TAIL,
+ "app/api/import/route.ts": TAIL,
+ "app/api/measurements/[id]/route.ts": TAIL,
+ "app/api/measurements/bulk-delete/route.ts": TAIL,
+ "app/api/measurements/by-external-ids/route.ts": TAIL,
+ "app/api/measurements/restore/route.ts": TAIL,
+ "app/api/measurements/route.ts": TAIL,
+ "app/api/mental-health/assessments/route.ts": TAIL,
+ "lib/insights/score-row.ts": TAIL,
+ "lib/mcp/writes.ts": TAIL,
+ "lib/measurements/create-from-telegram.ts": TAIL,
+ "lib/whoop/sync-body.ts": TAIL,
+ "lib/whoop/webhook-handler.ts": TAIL,
+ // Integration syncs: each collects the (type, day) pairs it touched and
+ // runs both legs itself at the end of the sync.
+ "lib/fitbit/sync-core.ts":
+ "refolds the touched days and re-warms the touched types at the end of the upsert",
+ "lib/google-health/sync-core.ts":
+ "refolds the touched days and re-warms the touched types at the end of the upsert",
+ "lib/nightscout/sync.ts":
+ "refolds the touched days and re-warms the touched types at the end of the sync",
+ "lib/withings/sync.ts":
+ "refolds the touched days and re-warms the touched types at the end of the sync",
+ "lib/withings/sync-activity.ts":
+ "refolds the touched days (and the day a moved row left) and re-warms the touched types",
+ "lib/withings/sync-ecg.ts":
+ "refolds the touched days and re-warms the touched types at the end of the sync",
+ // The reconcile primitive writes one row inside the caller's transaction;
+ // its callers (Polar, Oura, WHOOP, the Apple export import) collect the
+ // verdicts' dirty identities and run the tail once per batch.
+ "lib/measurements/reconcile-external-measurement.ts":
+ "a primitive; every caller runs the tail over the verdicts' dirty identities",
+ "lib/measurements/import-apple-health-export.ts":
+ "the import worker refolds the import's span and re-warms every imported type",
+ "lib/export/restore-backup.ts":
+ "a restore replaces the whole record: it refolds the whole window and drops every per-user cache",
+ // Value-preserving reshapes: the same readings folded into daily or hourly
+ // rows. The rollups are refolded; the figures an assessment reads do not
+ // change, so re-warming would only re-bill the same text.
+ "lib/measurements/consolidate-daily-mean.ts":
+ "value-preserving consolidation; refolds the touched days",
+ "lib/measurements/consolidate-legacy-steps.ts":
+ "value-preserving consolidation; refolds the touched days",
+ "lib/measurements/drain-per-sample-cumulative.ts":
+ "value-preserving consolidation; refolds the touched days",
+ "lib/measurements/dense-intraday-hourly-rebuild.ts":
+ "value-preserving hourly fold; refolds the touched spans",
+ "lib/measurements/dense-intraday-retention.ts":
+ "value-preserving retention fold; refolds the touched days",
+ "lib/jobs/sleep-timeline-backfill.ts":
+ "one-shot re-stamp of stored sleep rows onto their timeline; refolds the touched days",
+ "lib/jobs/step-consolidation-repair.ts":
+ "one-shot restore of step rows an old consolidation removed; refolds the touched days",
+ "lib/sleep/sweep-stale-segments.ts":
+ "tombstones re-scored sleep segments inside a sync; the calling sync refolds the night",
+ // Writes that do not change a reading.
+ "lib/jobs/note-encryption-backfill.ts":
+ "encrypts the note column in place; no value, instant or type changes",
+ "lib/jobs/measurement-tombstone-cleanup.ts":
+ "hard-deletes rows that were already soft-deleted and read nowhere",
+};
+
+function sourceFiles(): string[] {
+ return walkSourceFiles(SRC, { floor: 3000 })
+ .filter((p) => !p.startsWith("generated/"))
+ .filter((p) => !p.includes("__tests__"))
+ .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
+ .sort();
+}
+
+function writeCounts(): Map {
+ const out = new Map();
+ for (const rel of sourceFiles()) {
+ const matches = readFileSync(join(SRC, rel), "utf8").match(WRITE_RE);
+ if (matches && matches.length > 0) out.set(rel, matches.length);
+ }
+ return out;
+}
+
+describe("measurement write tail", () => {
+ const counts = writeCounts();
+
+ it("finds the write sites at all", () => {
+ // An empty match set would agree with any allowlist.
+ expect(counts.size).toBeGreaterThanOrEqual(30);
+ });
+
+ it("lists every file that writes measurements", () => {
+ const unlisted = [...counts.keys()].filter((f) => !(f in WRITE_SITES));
+ expect(
+ unlisted,
+ "a new measurement write site: run afterMeasurementMutation on the rows it writes, or list it here with the reason it does not",
+ ).toEqual([]);
+ });
+
+ it("has no stale entries", () => {
+ const stale = Object.keys(WRITE_SITES).filter((f) => !counts.has(f));
+ expect(stale).toEqual([]);
+ });
+
+ it("the files that claim the shared tail call it", () => {
+ const missing = Object.entries(WRITE_SITES)
+ .filter(([, reason]) => reason === TAIL)
+ .map(([file]) => file)
+ .filter(
+ (file) =>
+ !/afterMeasurementMutation\s*\(/.test(
+ readFileSync(join(SRC, file), "utf8"),
+ ),
+ );
+ expect(missing).toEqual([]);
+ });
+
+ it("the syncs that run both legs by hand call both", () => {
+ const handRolled = Object.entries(WRITE_SITES)
+ .filter(([, reason]) => reason.includes("re-warms"))
+ .map(([file]) => file)
+ .filter(
+ (file) => file !== "lib/measurements/import-apple-health-export.ts",
+ );
+ expect(handRolled.length).toBeGreaterThan(0);
+ const missing = handRolled.filter((file) => {
+ const src = readFileSync(join(SRC, file), "utf8");
+ return !(
+ /recomputeBucketsForMeasurement|afterMeasurementMutation/.test(src) &&
+ /invalidateStatusInsightsForTypes|afterMeasurementMutation/.test(src)
+ );
+ });
+ expect(missing).toEqual([]);
+ });
+});
diff --git a/src/__tests__/oidc-native-error-reasons-documented.test.ts b/src/__tests__/oidc-native-error-reasons-documented.test.ts
new file mode 100644
index 000000000..b5b7ec7c9
--- /dev/null
+++ b/src/__tests__/oidc-native-error-reasons-documented.test.ts
@@ -0,0 +1,71 @@
+/**
+ * The native OIDC leg ends at `healthlog://oidc-callback?error=`, 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 {
+ const found = new Set();
+ 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=") &&
+ 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([]);
+ });
+});
diff --git a/src/__tests__/password-breach-check-switch.test.ts b/src/__tests__/password-breach-check-switch.test.ts
new file mode 100644
index 000000000..622c5d92d
--- /dev/null
+++ b/src/__tests__/password-breach-check-switch.test.ts
@@ -0,0 +1,66 @@
+/**
+ * The HaveIBeenPwned check sits behind one operator switch, and nothing
+ * reaches it except through that switch.
+ *
+ * `PASSWORD_BREACH_CHECK_DISABLED` is how an operator stops the one request
+ * a password change makes to a third party. A route that imported the raw
+ * check from `@/lib/auth/hibp` would keep sending it with the switch on, so
+ * the import itself is frozen: only `src/lib/password-breach-check.ts` (and
+ * tests) may name that module.
+ */
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { walkSourceFiles } from "./helpers/source-files";
+
+const checkPasswordBreach = vi.fn();
+vi.mock("@/lib/auth/hibp", () => ({
+ checkPasswordBreach: (password: string) => checkPasswordBreach(password),
+}));
+
+const SRC = join(process.cwd(), "src");
+const RAW_IMPORT = /from\s+["']@\/lib\/auth\/hibp["']|["']\.\/hibp["']/;
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+ checkPasswordBreach.mockReset();
+});
+
+describe("password breach check switch", () => {
+ it("calls the range API when the switch is unset or empty", async () => {
+ checkPasswordBreach.mockResolvedValue({ breached: true, count: 3 });
+ const { checkPasswordBreachIfEnabled } =
+ await import("@/lib/password-breach-check");
+ vi.stubEnv("PASSWORD_BREACH_CHECK_DISABLED", "");
+ await expect(
+ checkPasswordBreachIfEnabled("correct horse battery"),
+ ).resolves.toEqual({ breached: true, count: 3 });
+ expect(checkPasswordBreach).toHaveBeenCalledTimes(1);
+ });
+
+ it.each(["1", "true", "yes"])(
+ "makes no request when PASSWORD_BREACH_CHECK_DISABLED=%s",
+ async (value) => {
+ vi.stubEnv("PASSWORD_BREACH_CHECK_DISABLED", value);
+ const { checkPasswordBreachIfEnabled } =
+ await import("@/lib/password-breach-check");
+ await expect(
+ checkPasswordBreachIfEnabled("correct horse battery"),
+ ).resolves.toBeNull();
+ expect(checkPasswordBreach).not.toHaveBeenCalled();
+ },
+ );
+
+ it("is the only non-test importer of the raw check", () => {
+ const importers = walkSourceFiles(SRC, { floor: 3000 })
+ .filter((rel) => !rel.startsWith("generated/"))
+ .filter((rel) => !rel.includes("__tests__"))
+ .filter((rel) => !rel.endsWith(".test.ts"))
+ .filter((rel) => rel !== "lib/auth/hibp.ts")
+ .filter((rel) => RAW_IMPORT.test(readFileSync(join(SRC, rel), "utf8")));
+ // Non-empty by construction: the wrapper itself must match, or the
+ // matcher went stale and this assertion would pass on nothing.
+ expect(importers).toEqual(["lib/password-breach-check.ts"]);
+ });
+});
diff --git a/src/__tests__/proxy-body-limit.test.ts b/src/__tests__/proxy-body-limit.test.ts
new file mode 100644
index 000000000..a14e33dbe
--- /dev/null
+++ b/src/__tests__/proxy-body-limit.test.ts
@@ -0,0 +1,164 @@
+/**
+ * The proxy's body ceiling, and the routes that stay out of the proxy so their
+ * larger bodies are never read before authentication.
+ *
+ * Next reads the body of every non-GET request the proxy matcher covers into
+ * memory before the route runs. At 512 MB that let any anonymous POST make the
+ * server hold half a gigabyte. The ceiling is 1 MB; the routes whose bodies are
+ * legitimately larger are excluded from the matcher and bound their own body
+ * after the caller is known. Three things have to stay true together, and this
+ * pins each:
+ *
+ * 1. the ceiling in next.config.ts is 1 MB;
+ * 2. the matcher, compiled exactly as Next compiles it, skips precisely the
+ * bypass list and still covers every other API route (and the tesseract
+ * worker, which needs its CSP);
+ * 3. every route that reads a body larger than the ceiling is on the list.
+ */
+import { describe, expect, it, vi } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { walkSourceFiles } from "./helpers/source-files";
+import {
+ PROXY_BYPASS_ROUTES,
+ isProxyBypassRoute,
+} from "@/lib/http/proxy-bypass-routes";
+
+vi.mock("@/lib/process-type", () => ({ shouldRunWeb: () => true }));
+
+const ROOT = process.cwd();
+const ONE_MB = 1024 * 1024;
+
+async function compiledMatcher(): Promise {
+ const { config } = await import("../proxy");
+ // Next's own compiler for `config.matcher`, so the test sees the regex the
+ // server will actually run. Not part of Next's typed surface.
+ const analysis =
+ (await import("next/dist/build/analysis/get-page-static-info.js")) as unknown as {
+ getMiddlewareMatchers: (
+ m: unknown,
+ c: Record,
+ ) => Array<{ regexp: string }>;
+ };
+ const { getMiddlewareMatchers } = analysis;
+ const [m] = getMiddlewareMatchers(config.matcher, {});
+ return new RegExp(m.regexp);
+}
+
+/** Every API route path, with dynamic segments filled in. */
+function apiRoutePaths(): string[] {
+ const appDir = join(ROOT, "src", "app");
+ return walkSourceFiles(appDir, { floor: 500 })
+ .filter((p) => p.startsWith("api/") && p.endsWith("/route.ts"))
+ .filter((p) => !p.includes("__tests__"))
+ .map((p) =>
+ (
+ "/" + p.replace(/\/route\.ts$/, "").replace(/\[\.{0,3}[^\]]+\]/g, "x")
+ ).replace(/\/\([^)]+\)/g, ""),
+ )
+ .sort();
+}
+
+describe("proxy body ceiling", () => {
+ it("is 1 MB, under the current option name", () => {
+ const src = readFileSync(join(ROOT, "next.config.ts"), "utf8");
+ expect(src).toMatch(/proxyClientMaxBodySize:\s*"1mb"/);
+ expect(src).not.toMatch(/middlewareClientMaxBodySize/);
+ });
+});
+
+describe("proxy matcher", () => {
+ it("skips exactly the bypass routes and covers every other API route", async () => {
+ const re = await compiledMatcher();
+ const routes = apiRoutePaths();
+ expect(routes.length).toBeGreaterThan(300);
+
+ for (const path of PROXY_BYPASS_ROUTES) {
+ expect(routes, `${path} is not a route`).toContain(path);
+ expect(re.test(path), `${path} still passes the proxy`).toBe(false);
+ expect(re.test(`${path}/`), `${path}/ still passes the proxy`).toBe(
+ false,
+ );
+ }
+ const covered = routes.filter((p) => !isProxyBypassRoute(p));
+ const escaped = covered.filter((p) => !re.test(p));
+ expect(escaped).toEqual([]);
+ });
+
+ it("still covers pages, the tesseract worker and sub-paths of bypass routes", async () => {
+ const re = await compiledMatcher();
+ for (const path of [
+ "/",
+ "/dashboard",
+ "/labs",
+ "/documents",
+ "/tesseract/worker.min.js",
+ "/api/auth/login",
+ "/api/user/avatar/abc",
+ "/api/documents/inbound/abc/extract",
+ "/api/import/csv/preview",
+ ]) {
+ expect(re.test(path), path).toBe(true);
+ }
+ });
+});
+
+/** Byte caps a route file declares, from `maxBytes:` literals and `*_BYTES` constants. */
+function declaredCaps(src: string): number[] {
+ const consts = new Map();
+ for (const m of src.matchAll(
+ /const\s+([A-Z0-9_]*BYTES)\s*=\s*([\d.\s*_]+);/g,
+ )) {
+ const value = Function(`return (${m[2].replace(/_/g, "")})`)() as number;
+ consts.set(m[1], value);
+ }
+ const caps: number[] = [...consts.values()];
+ for (const m of src.matchAll(/maxBytes:\s*([\d\s*_]+)[,\s}]/g)) {
+ caps.push(Function(`return (${m[1].replace(/_/g, "")})`)() as number);
+ }
+ return caps;
+}
+
+describe("the bypass list is complete", () => {
+ const appDir = join(ROOT, "src", "app");
+ const files = walkSourceFiles(appDir, { floor: 500 }).filter(
+ (p) =>
+ p.startsWith("api/") &&
+ p.endsWith("/route.ts") &&
+ !p.includes("__tests__"),
+ );
+ const pathOf = (rel: string) =>
+ "/" + rel.replace(/\/route\.ts$/, "").replace(/\[\.{0,3}[^\]]+\]/g, "x");
+
+ it("lists every route whose declared body cap exceeds the ceiling", () => {
+ const over = files
+ .filter((rel) =>
+ declaredCaps(readFileSync(join(appDir, rel), "utf8")).some(
+ (n) => n > ONE_MB,
+ ),
+ )
+ .map(pathOf);
+ expect(over.length).toBeGreaterThan(5);
+ expect(over.filter((p) => !isProxyBypassRoute(p))).toEqual([]);
+ });
+
+ it("lists every route that streams or multipart-parses its body", () => {
+ // The OAuth forms are tiny url-encoded bodies with their own 16 KB cap.
+ const SMALL_FORMS = new Set([
+ "/api/mcp/oauth/token",
+ "/api/mcp/oauth/register",
+ "/api/mcp/oauth/authorize",
+ ]);
+ const streaming = files
+ .filter((rel) =>
+ /readBoundedBody\(|Readable\.fromWeb\(|\.formData\(\)|request\.body\b/.test(
+ readFileSync(join(appDir, rel), "utf8"),
+ ),
+ )
+ .map(pathOf)
+ .filter((p) => !SMALL_FORMS.has(p));
+ expect(streaming.length).toBeGreaterThan(3);
+ expect(streaming.filter((p) => !isProxyBypassRoute(p))).toEqual([]);
+ });
+});
diff --git a/src/__tests__/proxy-early-exit-headers.test.ts b/src/__tests__/proxy-early-exit-headers.test.ts
index 9095f4105..bfa02f8c5 100644
--- a/src/__tests__/proxy-early-exit-headers.test.ts
+++ b/src/__tests__/proxy-early-exit-headers.test.ts
@@ -23,6 +23,7 @@ vi.mock("@/lib/process-type", () => ({
}));
import { proxy } from "../proxy";
+import { applyBypassRouteHeaders } from "@/lib/http/proxy-bypass-routes";
function makeRequest(
pathname: string,
@@ -60,6 +61,10 @@ function expectBaselineHeaders(res: Response) {
);
expect(res.headers.get("Permissions-Policy")).toContain("camera=()");
expect(res.headers.get("Cross-Origin-Opener-Policy")).toBe("same-origin");
+ expect(res.headers.get("Cross-Origin-Resource-Policy")).toBe("same-origin");
+ expect(res.headers.get("Cross-Origin-Embedder-Policy")).toBe(
+ "credentialless",
+ );
expect(res.headers.get("X-Permitted-Cross-Domain-Policies")).toBe("none");
expect(res.headers.get("Strict-Transport-Security")).toContain(
"max-age=31536000",
@@ -117,3 +122,22 @@ describe("proxy early exits carry the baseline security headers", () => {
expect(res.headers.get("X-Frame-Options")).toBe("DENY");
});
});
+
+describe("the routes that bypass the proxy", () => {
+ it("carry the same cross-origin headers the proxy sets on a full response", () => {
+ const viaProxy = proxy(makeRequest("/api/health"));
+ const headers = new Headers();
+ applyBypassRouteHeaders(headers);
+ for (const name of [
+ "Cross-Origin-Opener-Policy",
+ "Cross-Origin-Resource-Policy",
+ "Cross-Origin-Embedder-Policy",
+ "X-Content-Type-Options",
+ "X-Frame-Options",
+ "X-Permitted-Cross-Domain-Policies",
+ "Strict-Transport-Security",
+ ]) {
+ expect(headers.get(name), name).toBe(viaProxy.headers.get(name));
+ }
+ });
+});
diff --git a/src/__tests__/proxy-local-ocr-csp.test.ts b/src/__tests__/proxy-local-ocr-csp.test.ts
new file mode 100644
index 000000000..33b28ac7e
--- /dev/null
+++ b/src/__tests__/proxy-local-ocr-csp.test.ts
@@ -0,0 +1,65 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { NextRequest } from "next/server";
+
+/**
+ * Local lab OCR runs tesseract's WebAssembly engine in a same-origin worker
+ * served from `/tesseract/`. Compiling WebAssembly needs `'wasm-unsafe-eval'`
+ * in `script-src`, and a worker takes its CSP from its own script's response.
+ * The grant is scoped the way the AI-host and Withings carve-outs are: the
+ * worker files and the two pages that start OCR, and nothing else.
+ */
+
+vi.mock("@/lib/process-type", () => ({
+ shouldRunWeb: () => true,
+}));
+
+import { proxy } from "../proxy";
+
+beforeEach(() => {
+ vi.stubEnv("NODE_ENV", "production");
+});
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+});
+
+function scriptSrc(pathname: string): string {
+ const res = proxy(
+ new NextRequest(`http://localhost${pathname}`, {
+ headers: { cookie: "healthlog_session=sess-1" },
+ }),
+ );
+ const csp = res.headers.get("content-security-policy") ?? "";
+ return csp.split(";").find((d) => d.trim().startsWith("script-src")) ?? "";
+}
+
+describe("'wasm-unsafe-eval' for local OCR", () => {
+ it.each([
+ "/tesseract/worker.min.js",
+ "/tesseract/tesseract-core-simd-lstm.wasm.js",
+ "/labs",
+ "/documents",
+ ])("is granted on %s", (path) => {
+ expect(scriptSrc(path)).toContain("'wasm-unsafe-eval'");
+ });
+
+ it.each([
+ "/dashboard",
+ "/",
+ "/labs/some-biomarker",
+ "/settings/labs",
+ "/settings/ai",
+ "/api/labs/ocr/commit",
+ "/tesseractish",
+ ])("is not granted on %s", (path) => {
+ const src = scriptSrc(path);
+ expect(src).toContain("script-src");
+ expect(src).not.toContain("wasm-unsafe-eval");
+ });
+
+ it("never widens script-src to plain eval", () => {
+ expect(scriptSrc("/tesseract/worker.min.js")).not.toContain(
+ "'unsafe-eval'",
+ );
+ });
+});
diff --git a/src/__tests__/recent-proof-surface-guard.test.ts b/src/__tests__/recent-proof-surface-guard.test.ts
new file mode 100644
index 000000000..bb334ff98
--- /dev/null
+++ b/src/__tests__/recent-proof-surface-guard.test.ts
@@ -0,0 +1,192 @@
+/**
+ * Structural guard on the recent-proof gate.
+ *
+ * Exporting the whole record, making a clinician share link, minting a token,
+ * and the admin backup, restore, wipe and reset actions ask for a fresh proof,
+ * not only a live session (`requireRecentProof` / `assertRecentCookieProof` in
+ * `src/lib/api-handler.ts`). This file pins which routes those are, so a route
+ * cannot drop the gate, and a new one cannot quietly join the list of routes
+ * whose Bearer arm still takes the token alone.
+ *
+ * Matching is on the IMPORT and then on calls of the local name, the way
+ * `step-up-elevation-guard.test.ts` does it, so an aliased import is followed.
+ * Every sweep asserts it matched something: a matcher that finds nothing agrees
+ * with every list.
+ */
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { walkSourceFiles } from "./helpers/source-files";
+
+const SRC = join(process.cwd(), "src");
+
+function read(rel: string): string {
+ return readFileSync(join(SRC, rel), "utf8");
+}
+
+function sourceFiles(): string[] {
+ return walkSourceFiles(SRC, { floor: 3000 })
+ .filter((p) => !p.startsWith("generated/"))
+ .filter((p) => !p.includes("__tests__"))
+ .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
+ .sort();
+}
+
+function importedAs(rel: string, exportName: string): string[] {
+ const names: string[] = [];
+ for (const m of read(rel).matchAll(
+ /import\s*\{([\s\S]*?)\}\s*from\s*["']([^"']+)["']/g,
+ )) {
+ if (!/api-handler$/.test(m[2])) continue;
+ for (const part of m[1].split(",")) {
+ const piece = part.trim().replace(/^type\s+/, "");
+ const aliased = piece.match(
+ /^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/,
+ );
+ if (aliased && aliased[1] === exportName) names.push(aliased[2]);
+ else if (piece === exportName) names.push(piece);
+ }
+ }
+ return names;
+}
+
+/** Every call of `exportName` in `rel`, as the source text of its argument. */
+function callArgs(rel: string, exportName: string): string[] {
+ const src = read(rel);
+ const args: string[] = [];
+ for (const local of importedAs(rel, exportName)) {
+ for (const m of src.matchAll(
+ new RegExp(`\\b${local}\\s*\\(([^)]*)\\)`, "g"),
+ )) {
+ args.push(m[1].replace(/\s+/g, " ").trim());
+ }
+ }
+ return args;
+}
+
+function callers(exportName: string): string[] {
+ return sourceFiles().filter(
+ (rel) => rel !== "lib/api-handler.ts" && callArgs(rel, exportName).length,
+ );
+}
+
+/** Routes that resolve their own caller and take the recent-proof gate. */
+const RECENT_PROOF_ROUTES: Record = {
+ // Offering access to the record. No shipped client calls it on a token.
+ "app/api/account/grants/route.ts": "elevation",
+ // `type=all` only; the single-type exports stay on `requireAuth`.
+ "app/api/export/route.ts": "elevation",
+ "app/api/export/full-backup/route.ts": "elevation",
+ "app/api/export/encrypted/route.ts": "elevation-if-enrolled",
+ // The shipped iOS app calls these two on its token without an elevation and
+ // reads a 401 outside its known paths as a dead session. Tightening them is
+ // an app release first; see the iOS note for v1.39.3.
+ "app/api/share-links/route.ts": "token",
+ "app/api/mcp/tokens/route.ts": "token",
+};
+
+/**
+ * Routes that resolve a cookie OR a token through their own resolver
+ * (`requireAuth` with a scope, `requireRecordAuth`) and add the cookie arm
+ * only. Their Bearer arm is the token, as with the `token` rule above: the
+ * shipped app calls both with its token and no elevation.
+ */
+const TOKEN_ARM_COOKIE_PROOF_ROUTES = [
+ "app/api/export/health-record/route.ts",
+ "app/api/fhir/Patient/$everything/route.ts",
+].sort();
+
+/**
+ * The connection consent. It resolves the session through `getSession`
+ * (OAuth answers in its own error format, not the envelope) and asks for the
+ * proof on the consent page and again on "allow".
+ */
+const CONSENT_PROOF_ROUTES = ["app/api/mcp/oauth/authorize/route.ts"];
+
+/** Cookie-only routes that add the gate after their own resolver. */
+const COOKIE_PROOF_ROUTES = [
+ "app/api/admin/backups/[id]/download/route.ts",
+ "app/api/admin/backups/[id]/restore/route.ts",
+ "app/api/admin/backups/upload/route.ts",
+ "app/api/admin/data/route.ts",
+ "app/api/admin/users/[id]/reset-password/route.ts",
+ "app/api/auth/reproof/route.ts",
+ "app/api/tokens/documents/route.ts",
+ "app/api/tokens/measurements/route.ts",
+].sort();
+
+describe("the recent-proof gate", () => {
+ it("reads the tree it claims to sweep", () => {
+ expect(sourceFiles().length).toBeGreaterThan(1500);
+ });
+
+ it("is taken by exactly the known routes, each with its Bearer rule", () => {
+ const found: Record = {};
+ for (const rel of callers("requireRecentProof")) {
+ const args = callArgs(rel, "requireRecentProof");
+ expect(args).toHaveLength(1);
+ const rule = args[0].match(/bearer:\s*"([^"]+)"/)?.[1];
+ expect(rule, rel).toBeDefined();
+ found[rel] = rule!;
+ }
+ expect(Object.keys(found).length).toBeGreaterThan(0);
+ expect(found).toEqual(RECENT_PROOF_ROUTES);
+ });
+
+ it("the cookie arm is added by exactly the known routes", () => {
+ const found = callers("assertRecentCookieProof");
+ expect(found.length).toBeGreaterThan(0);
+ expect(found).toEqual(
+ [
+ ...COOKIE_PROOF_ROUTES,
+ ...TOKEN_ARM_COOKIE_PROOF_ROUTES,
+ ...CONSENT_PROOF_ROUTES,
+ ].sort(),
+ );
+ });
+
+ it("a route that resolves either transport asks only on the cookie", () => {
+ for (const rel of TOKEN_ARM_COOKIE_PROOF_ROUTES) {
+ expect(read(rel), rel).toMatch(
+ /if \(auth\.authMethod === "cookie"\) \{\s*await assertRecentCookieProof\(/,
+ );
+ }
+ });
+
+ it("each cookie-only route resolves a cookie before it asks", () => {
+ for (const rel of COOKIE_PROOF_ROUTES) {
+ const resolvers = [
+ ...importedAs(rel, "requireAdmin"),
+ ...importedAs(rel, "requireCookieAuth"),
+ ];
+ expect(resolvers, rel).toHaveLength(1);
+ expect(importedAs(rel, "requireAuth"), rel).toEqual([]);
+ }
+ });
+
+ it("every route whose Bearer arm takes an elevation spends it", () => {
+ const withElevation = Object.entries(RECENT_PROOF_ROUTES).filter(
+ ([, rule]) => rule !== "token",
+ );
+ expect(withElevation.length).toBeGreaterThan(0);
+ for (const [rel] of withElevation) {
+ expect(read(rel), rel).toMatch(/\.commitElevation\(\)/);
+ }
+ });
+
+ it("key rotation keeps its stricter second-factor gate", () => {
+ const rel = "app/api/admin/encryption/rotate/route.ts";
+ expect(callArgs(rel, "requireFreshMfa").length).toBeGreaterThan(0);
+ });
+
+ it("the gate reads the session's own stamps, and only a second factor on an enrolled account", () => {
+ const src = read("lib/api-handler.ts");
+ const start = src.indexOf("export async function assertRecentCookieProof(");
+ expect(start).toBeGreaterThan(-1);
+ const body = src.slice(start, src.indexOf("\nexport ", start + 1));
+ // The enrolled arm names mfaVerifiedAt alone: a password re-proof
+ // (`reproofAt`) or a bare sign-in (`createdAt`) must not stand in for it.
+ expect(body).toMatch(/enrolled\s*\?\s*fresh\(row\?\.mfaVerifiedAt\)\s*:/);
+ });
+});
diff --git a/src/__tests__/share-view-leaf-render-guard.test.ts b/src/__tests__/share-view-leaf-render-guard.test.ts
index 5f073d4f5..50b591b21 100644
--- a/src/__tests__/share-view-leaf-render-guard.test.ts
+++ b/src/__tests__/share-view-leaf-render-guard.test.ts
@@ -225,7 +225,9 @@ const DISPOSITIONS: Record = {
MEDICATION_LIST: {
kind: "rendered",
data: {
- medications: [{ name: "Ramipril", dose: "5 mg", schedules: [] }],
+ medications: [
+ { id: "med-ramipril", name: "Ramipril", dose: "5 mg", schedules: [] },
+ ],
},
marker: "Ramipril",
},
@@ -252,7 +254,13 @@ const DISPOSITIONS: Record = {
kind: "rendered",
data: {
compliance: {
- Metformin: { total: 60, taken: 57, skipped: 1, missed: 2 },
+ "med-metformin": {
+ name: "Metformin",
+ total: 60,
+ taken: 57,
+ skipped: 1,
+ missed: 2,
+ },
},
},
marker: "Metformin",
diff --git a/src/__tests__/sharing-recoverable-errors.test.tsx b/src/__tests__/sharing-recoverable-errors.test.tsx
index 05e6618cf..67f84b3ff 100644
--- a/src/__tests__/sharing-recoverable-errors.test.tsx
+++ b/src/__tests__/sharing-recoverable-errors.test.tsx
@@ -345,18 +345,16 @@ describe("a refused invitation keeps what was typed", () => {
it("clears the form only where the invitation actually went", () => {
// A static render cannot press the button, so the claim is made about the
- // code path instead: every field reset lives inside `onSuccess`. A reset
- // that migrated into `onError` — or out of the callbacks entirely — would
- // wipe a typed identifier and eight ticked sections on a failure the
- // person is being asked to retry.
- const start = INVITE_CARD.indexOf("onSuccess:");
- const split = INVITE_CARD.indexOf("onError:", start);
- // The options object ends at the first ` },` after the error arm, so
- // the slice stops at the mutation rather than running into the JSX below,
- // where a `setNarrowed(false)` / `setNarrowed(true)` on a radio would
- // satisfy the matcher for the wrong reason. The success reset returns the
- // scope to unset (`null`), which the JSX never writes.
- const end = INVITE_CARD.indexOf("\n },", split);
+ // code path instead: every field reset sits after the awaited invitation
+ // inside `try`, and none in the `catch`. A reset that migrated into the
+ // failure arm would wipe a typed identifier and eight ticked sections on
+ // a failure the person is being asked to retry. The failure arm ends at
+ // the end of the submit handler, before the JSX below, where a
+ // `setNarrowed(false)` on a radio would satisfy the matcher for the wrong
+ // reason.
+ const start = INVITE_CARD.indexOf("await recentProof.run(");
+ const split = INVITE_CARD.indexOf("} catch (err) {", start);
+ const end = INVITE_CARD.indexOf("\n };", split);
const onSuccess = INVITE_CARD.slice(start, split);
const onError = INVITE_CARD.slice(split, end);
diff --git a/src/__tests__/step-up-elevation-guard.test.ts b/src/__tests__/step-up-elevation-guard.test.ts
index 1b8bb5fdd..366abc900 100644
--- a/src/__tests__/step-up-elevation-guard.test.ts
+++ b/src/__tests__/step-up-elevation-guard.test.ts
@@ -118,11 +118,14 @@ function callers(exportName: string, moduleRe: RegExp): string[] {
* consult this mechanism at all;
* - the trusted-device routes. "Remember this browser" is a browser concept
* with no native equivalent to manage;
- * - `requireFreshMfaIfEnrolled`'s callers (password change, encrypted
- * export, encryption-key rotation, the sharing invite). Those are
- * destructive account actions, not second-factor management, and letting an
- * elevation satisfy them would be exactly the silent widening this file
- * exists to prevent.
+ * - `requireFreshMfaIfEnrolled`'s callers (password change, encryption-key
+ * rotation, the sharing invite). Those are destructive account actions,
+ * not second-factor management, and letting an elevation satisfy them
+ * would be exactly the silent widening this file exists to prevent.
+ *
+ * The two whole-record exports reach the elevation through a third gate,
+ * `requireRecentProof`, whose route list and per-route Bearer rule are frozen
+ * in `recent-proof-surface-guard.test.ts`.
*
* Account deletion and the record wipe used to sit in that last bullet. They
* left it deliberately, and through a different gate: see `ERASURE_ROUTES`
diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts
index 8cd324ece..d8f561058 100644
--- a/src/__tests__/success-affordance-guard.test.ts
+++ b/src/__tests__/success-affordance-guard.test.ts
@@ -161,6 +161,13 @@ const PINNED_AFFORDANCES: Record<
"src/components/documents/document-card.tsx": { "text-success": 1 },
"src/components/documents/document-detail-sheet.tsx": { "toast.success": 2 },
"src/components/documents/documents-view.tsx": { "toast.success": 3 },
+ // The document picker's per-row result: tinted only for the `imported` /
+ // `duplicate` outcome the import route returned for that one document, i.e.
+ // a document the vault now holds. The run's headline goes through
+ // WrittenOutcomeLine; a row sits inside a button, where its cannot.
+ "src/components/documents/sources/document-source-picker.tsx": {
+ "text-success": 1,
+ },
"src/components/error-details.tsx": { CheckCircle2: 2, "text-success": 1 },
"src/components/illness/episode-menu.tsx": {
CheckCircle2: 2,
diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx
index 24ac6042a..cfb9fdc8e 100644
--- a/src/app/admin/[section]/renderer.tsx
+++ b/src/app/admin/[section]/renderer.tsx
@@ -12,10 +12,11 @@
* read `useAuth()` to gate rendering).
*/
-import type { JSX } from "react";
+import { Children, type JSX } from "react";
import { useAuth } from "@/hooks/use-auth";
import { AboutSection } from "@/components/settings/about-section";
+import { AdminCardBoundary } from "@/components/admin/admin-card-boundary";
import { AiQualitySection } from "@/components/admin/ai-quality-section";
import { AiServerKeySection } from "@/components/admin/ai-server-key-section";
import { ProviderHealthSection } from "@/components/admin/provider-health-section";
@@ -189,5 +190,13 @@ export function AdminSectionRenderer({
* believes, so it is gone.
*/
function SectionFrame({ children }: { children: React.ReactNode }) {
- return
{children}
;
+ // Each card fails alone: one that throws while rendering paints an error
+ // card in its place instead of taking its neighbours down with it.
+ return (
+
+ {Children.map(children, (card) => (
+
{card}
+ ))}
+
+ );
}
diff --git a/src/app/api/__tests__/ai-route-inventory.ts b/src/app/api/__tests__/ai-route-inventory.ts
index 2e49101b2..7e90462ed 100644
--- a/src/app/api/__tests__/ai-route-inventory.ts
+++ b/src/app/api/__tests__/ai-route-inventory.ts
@@ -67,6 +67,11 @@ export const AI_ROUTES: Readonly> = {
capabilities: ["documentAi"],
why: "The upload is data and always accepted; only the AI work queued after it follows the capability.",
},
+ "src/app/api/documents/sources/[system]/import/route.ts": {
+ kind: "mixed",
+ capabilities: ["documentAi"],
+ why: "A picked document is stored whatever AI says; only the summary queued after a fresh insert follows the capability.",
+ },
"src/app/api/auth/me/documents-auto-ai-read/route.ts": {
kind: "mixed",
capabilities: ["documentAi"],
diff --git a/src/app/api/account/grants/__tests__/invite-access-level.test.ts b/src/app/api/account/grants/__tests__/invite-access-level.test.ts
index 3798cc52f..431ad44e0 100644
--- a/src/app/api/account/grants/__tests__/invite-access-level.test.ts
+++ b/src/app/api/account/grants/__tests__/invite-access-level.test.ts
@@ -27,11 +27,19 @@ vi.mock("@/lib/api-handler", async () => {
await vi.importActual(
"@/lib/api-handler",
);
+ const requireAuth = vi.fn();
return {
...actual,
apiHandler: Promise>(h: T): T =>
h,
- requireAuth: vi.fn(),
+ requireAuth,
+ // The invitation takes the fresh-proof gate; here it resolves the caller
+ // the stubbed `requireAuth` names, so these cases stay about the row.
+ // The gate itself has its own case in `invite-recent-proof.test.ts`.
+ requireRecentProof: vi.fn(async () => ({
+ ...(await requireAuth()),
+ commitElevation: async () => {},
+ })),
};
});
diff --git a/src/app/api/account/grants/__tests__/invite-recent-proof.test.ts b/src/app/api/account/grants/__tests__/invite-recent-proof.test.ts
new file mode 100644
index 000000000..e4c987074
--- /dev/null
+++ b/src/app/api/account/grants/__tests__/invite-recent-proof.test.ts
@@ -0,0 +1,114 @@
+/**
+ * Offering access to the record asks for a fresh proof.
+ *
+ * An invitation outlives the session that sent it, so a live session alone
+ * was enough for a stolen one to leave a standing way into the record in the
+ * thief's own account. The real gate runs here; only the session, the
+ * database and the headers are stubbed.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { NextRequest } from "next/server";
+
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ session: { findUnique: vi.fn() },
+ webauthnMfaCredential: { count: vi.fn(async () => 0) },
+ passkey: { count: vi.fn(async () => 0) },
+ user: { findFirst: vi.fn() },
+ accountGrant: { create: vi.fn(), findFirst: vi.fn() },
+ },
+}));
+vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
+vi.mock("@/lib/rate-limit", () => ({
+ checkRateLimit: vi.fn(async () => ({ allowed: true })),
+}));
+vi.mock("@/lib/auth/audit", () => ({
+ auditLog: vi.fn().mockResolvedValue(undefined),
+ recordDelegatedAccess: vi.fn(),
+}));
+vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
+vi.mock("@/lib/sharing/grants", async (importOriginal) => ({
+ ...(await importOriginal()),
+ inviteGrant: vi.fn(),
+}));
+vi.mock("next/headers", () => ({
+ headers: vi.fn(async () => ({ get: () => null })),
+ cookies: vi.fn(async () => ({
+ get: () => undefined,
+ set: () => {},
+ delete: () => {},
+ })),
+}));
+
+import { POST } from "../route";
+import { getSession } from "@/lib/auth/session";
+import { prisma } from "@/lib/db";
+import { inviteGrant } from "@/lib/sharing/grants";
+
+const OWNER = {
+ id: "owner-1",
+ role: "USER",
+ username: "owner",
+ totpConfirmedAt: null,
+ passwordHash: "argon",
+};
+
+function invite(): NextRequest {
+ return new NextRequest("http://localhost/api/account/grants", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ identifier: "housemate", access: "READ" }),
+ });
+}
+
+function signedIn(createdAt: Date) {
+ vi.mocked(getSession).mockResolvedValue({
+ session: { id: "sess-1", expiresAt: new Date(Date.now() + 1e6) },
+ user: OWNER,
+ } as never);
+ vi.mocked(prisma.session.findUnique).mockResolvedValue({
+ createdAt,
+ mfaVerifiedAt: null,
+ reproofAt: null,
+ } as never);
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe("POST /api/account/grants — fresh proof", () => {
+ it("a session that signed in an hour ago is asked to confirm, and nothing is offered", async () => {
+ signedIn(new Date(Date.now() - 60 * 60 * 1000));
+ const res = await POST(invite());
+ expect(res.status).toBe(401);
+ const body = await res.json();
+ expect(body.meta.errorCode).toBe("auth.reproof.required");
+ expect(body.meta.methods).toEqual(["password"]);
+ expect(prisma.user.findFirst).not.toHaveBeenCalled();
+ expect(inviteGrant).not.toHaveBeenCalled();
+ });
+
+ it("a session that signed in a minute ago offers the invitation", async () => {
+ signedIn(new Date(Date.now() - 60 * 1000));
+ vi.mocked(prisma.user.findFirst).mockResolvedValue({
+ id: "invitee-1",
+ username: "housemate",
+ displayName: null,
+ } as never);
+ vi.mocked(inviteGrant).mockResolvedValue({
+ id: "grant-1",
+ access: "READ",
+ scopeJson: null,
+ invitedAt: new Date(),
+ acceptedAt: null,
+ expiresAt: null,
+ lastUsedAt: null,
+ revokedAt: null,
+ revokedBy: null,
+ } as never);
+ const res = await POST(invite());
+ expect(res.status).toBe(201);
+ expect(inviteGrant).toHaveBeenCalled();
+ });
+});
diff --git a/src/app/api/account/grants/__tests__/invite-scope-and-manage.test.ts b/src/app/api/account/grants/__tests__/invite-scope-and-manage.test.ts
index 265283687..1d79bad00 100644
--- a/src/app/api/account/grants/__tests__/invite-scope-and-manage.test.ts
+++ b/src/app/api/account/grants/__tests__/invite-scope-and-manage.test.ts
@@ -31,11 +31,19 @@ vi.mock("@/lib/api-handler", async () => {
await vi.importActual(
"@/lib/api-handler",
);
+ const requireAuth = vi.fn();
return {
...actual,
apiHandler: Promise>(h: T): T =>
h,
- requireAuth: vi.fn(),
+ requireAuth,
+ // The invitation takes the fresh-proof gate; here it resolves the caller
+ // the stubbed `requireAuth` names, so these cases stay about the row.
+ // The gate itself has its own case in `invite-recent-proof.test.ts`.
+ requireRecentProof: vi.fn(async () => ({
+ ...(await requireAuth()),
+ commitElevation: async () => {},
+ })),
requireFreshMfaIfEnrolled: vi.fn(),
};
});
diff --git a/src/app/api/account/grants/route.ts b/src/app/api/account/grants/route.ts
index 1a0184f55..0cae8e85d 100644
--- a/src/app/api/account/grants/route.ts
+++ b/src/app/api/account/grants/route.ts
@@ -21,9 +21,15 @@
* a code rather than leaving a token caller with an authentication error on a
* request that authenticated fine.
*
- * **Neither is delegable, and that is structural.** Both resolve through bare
- * `requireAuth()`, which refuses outright while the caller is acting on
- * another account. So a delegate cannot invite anybody into the record they
+ * v1.39.3 — offering any level also asks for a fresh proof
+ * (`requireRecentProof`), and "sign out everywhere" withdraws the invitations
+ * nobody has accepted yet. An invitation outlives the session that sent it, so
+ * one offered from a stolen session would otherwise stay live after the owner
+ * signed everything else out.
+ *
+ * **Neither is delegable, and that is structural.** Both resolve through
+ * `requireAuth()` (the invitation through `requireRecentProof`, which calls
+ * it), which refuses outright while the caller is acting on another account. So a delegate cannot invite anybody into the record they
* were given access to, cannot widen their own grant, and cannot pass it on:
* the route does not have a re-delegation check that somebody could soften, it
* has no code path that runs under a switch at all. The grantor is then taken
@@ -45,6 +51,7 @@ import {
apiHandler,
requireAuth,
requireFreshMfaIfEnrolled,
+ requireRecentProof,
MFA_STEP_UP_MAX_AGE_SECONDS,
} from "@/lib/api-handler";
import {
@@ -122,7 +129,12 @@ export const GET = apiHandler(async () => {
});
export const POST = apiHandler(async (request: NextRequest) => {
- const auth = await requireAuth();
+ // Offering access leaves a standing way into the record in somebody else's
+ // hands, one that outlives the session that offered it. So a live session is
+ // not enough: a sign-in or re-proof within five minutes on the browser, with
+ // the second factor on an account that has one, or a step-up elevation on a
+ // token. No shipped client offers access with a token.
+ const auth = await requireRecentProof({ bearer: "elevation" });
const user = auth.user;
const rl = await checkRateLimit(
@@ -194,6 +206,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("No account with that name or e-mail", 404);
}
+ await auth.commitElevation();
+
try {
// `inviteGrant` decides everything about the row except the level: the
// self-grant refusal, and the one-live-grant-per-pair rule the partial
diff --git a/src/app/api/admin/ai-settings/__tests__/route.test.ts b/src/app/api/admin/ai-settings/__tests__/route.test.ts
index 7f73335bf..f776098b5 100644
--- a/src/app/api/admin/ai-settings/__tests__/route.test.ts
+++ b/src/app/api/admin/ai-settings/__tests__/route.test.ts
@@ -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();
+ });
+});
diff --git a/src/app/api/admin/ai-settings/route.ts b/src/app/api/admin/ai-settings/route.ts
index 59c11fba1..aafbd9b7a 100644
--- a/src/app/api/admin/ai-settings/route.ts
+++ b/src/app/api/admin/ai-settings/route.ts
@@ -1,3 +1,4 @@
+import { envOr } from "@/lib/env";
import { NextRequest } from "next/server";
import { apiHandler, requireAdmin, HttpError } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
@@ -6,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";
@@ -28,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,
+ ),
});
});
@@ -95,7 +102,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
"api.groq.com",
"openrouter.ai",
]);
- for (const h of (process.env.ADMIN_AI_BASE_URL_ALLOWLIST ?? "")
+ for (const h of envOr("ADMIN_AI_BASE_URL_ALLOWLIST", "")
.split(",")
.map((h) => h.trim())
.filter(Boolean)) {
diff --git a/src/app/api/admin/backups/[id]/download/route.ts b/src/app/api/admin/backups/[id]/download/route.ts
index 4db7bd51b..31b703a7e 100644
--- a/src/app/api/admin/backups/[id]/download/route.ts
+++ b/src/app/api/admin/backups/[id]/download/route.ts
@@ -18,7 +18,12 @@ import { Readable } from "node:stream";
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
-import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
+import {
+ apiHandler,
+ HttpError,
+ requireAdmin,
+ assertRecentCookieProof,
+} from "@/lib/api-handler";
import { apiError, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -42,7 +47,10 @@ export const GET = apiHandler(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
- const { user: admin } = await requireAdmin();
+ const { user: admin, session } = await requireAdmin();
+ // A fresh proof on top of the admin session: this reaches every account's
+ // data, so a stolen admin session alone must not be enough.
+ await assertRecentCookieProof(admin, session.id);
const { id } = await params;
annotate({ action: { name: "admin.backups.download" }, meta: { id } });
diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts
index f9949a15b..476e47073 100644
--- a/src/app/api/admin/backups/[id]/restore/route.ts
+++ b/src/app/api/admin/backups/[id]/restore/route.ts
@@ -20,7 +20,12 @@
*/
import { NextRequest } from "next/server";
-import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
+import {
+ apiHandler,
+ HttpError,
+ requireAdmin,
+ assertRecentCookieProof,
+} from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -45,7 +50,10 @@ const handler = apiHandler(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
- const { user: admin } = await requireAdmin();
+ const { user: admin, session } = await requireAdmin();
+ // A fresh proof on top of the admin session: this reaches every account's
+ // data, so a stolen admin session alone must not be enough.
+ await assertRecentCookieProof(admin, session.id);
const { id } = await params;
annotate({ action: { name: "admin.backups.restore" }, meta: { id } });
diff --git a/src/app/api/admin/backups/[id]/summary/route.ts b/src/app/api/admin/backups/[id]/summary/route.ts
index d8f89eb6d..b1499a1ed 100644
--- a/src/app/api/admin/backups/[id]/summary/route.ts
+++ b/src/app/api/admin/backups/[id]/summary/route.ts
@@ -22,9 +22,15 @@ import {
storedBackupRefusal,
STORED_BACKUP_SELECT,
} from "@/lib/export/stored-backup";
+import {
+ assessBackupKeys,
+ BACKUP_KEY_MISSING_CODE,
+ describeBackupKeyProblem,
+} from "@/lib/export/backup-key-ids";
import {
readStreamedBackup,
type BackupSource,
+ type StreamedBackup,
} from "@/lib/export/streamed-backup";
import { annotate } from "@/lib/logging/context";
import {
@@ -72,8 +78,9 @@ export const GET = apiHandler(
let payload;
let measurementCount: number;
+ let streamed: StreamedBackup;
try {
- const streamed = await readStreamedBackup(source);
+ streamed = await readStreamedBackup(source);
measurementCount = streamed.measurementCount;
payload = parseBackupPayload(streamed.raw);
} catch (err) {
@@ -93,6 +100,22 @@ export const GET = apiHandler(
);
}
+ // The preview is where the operator decides, so it says what the restore
+ // would refuse: a key the file's inner ciphertext needs and this host
+ // does not have. The instance settings are left out here, as the restore
+ // leaves them out unless asked; the restore itself checks them when they
+ // are asked for.
+ const keyVerdict = assessBackupKeys(streamed.keys, {
+ ignoreSections: new Set(["appSettings"]),
+ });
+ const keyProblem = describeBackupKeyProblem(keyVerdict);
+ if (keyProblem) {
+ return apiError(keyProblem, 422, {
+ errorCode: BACKUP_KEY_MISSING_CODE,
+ keyIds: [...keyVerdict.missing, ...keyVerdict.unreadable],
+ });
+ }
+
const summary = {
...summarizeBackup(payload),
measurements: measurementCount,
diff --git a/src/app/api/admin/backups/__tests__/route.test.ts b/src/app/api/admin/backups/__tests__/route.test.ts
index 70b583451..350c4756b 100644
--- a/src/app/api/admin/backups/__tests__/route.test.ts
+++ b/src/app/api/admin/backups/__tests__/route.test.ts
@@ -29,12 +29,23 @@ vi.mock("@/lib/db", () => ({
prisma: {
$queryRaw: (...a: unknown[]) => queryRawMock(...a),
user: { findMany: (...a: unknown[]) => findManyMock(...a) },
+ offhostPurgeRequest: {
+ count: async () => 2,
+ findFirst: async () => ({
+ requestedAt: new Date("2026-09-20T10:00:00.000Z"),
+ lastFailure: "AccessDenied",
+ }),
+ },
},
}));
const configuredMock = vi.fn(() => true);
vi.mock("@/lib/jobs/offhost-backup", () => ({
offhostBackupConfigured: () => configuredMock(),
+ probeOffhostLifecycle: async () => ({
+ state: "missing",
+ expirationDays: null,
+ }),
}));
import { GET } from "../route";
@@ -139,10 +150,12 @@ describe("GET /api/admin/backups — off-host freshness", () => {
const body = await read();
- expect(body.offhost).toEqual({
+ expect(body.offhost).toMatchObject({
configured: false,
periodHours: 24,
rows: [],
+ // No bucket to ask on a host that is not configured.
+ lifecycle: { state: "unknown", expirationDays: null },
});
});
@@ -196,4 +209,20 @@ describe("GET /api/admin/backups — off-host freshness", () => {
]);
expect(JSON.stringify(body.offhost)).not.toMatch(/bucket|endpoint|secret/i);
});
+
+ it("carries the bucket's lifecycle verdict and the deletions it still owes", async () => {
+ configuredMock.mockReturnValue(true);
+ queryRawMock.mockResolvedValue([]);
+ findManyMock.mockResolvedValue([]);
+ const body = await read();
+ expect(body.offhost.lifecycle).toEqual({
+ state: "missing",
+ expirationDays: null,
+ });
+ expect(body.offhost.pendingDeletions).toEqual({
+ count: 2,
+ oldestRequestedAt: "2026-09-20T10:00:00.000Z",
+ lastFailure: "AccessDenied",
+ });
+ });
});
diff --git a/src/app/api/admin/backups/route.ts b/src/app/api/admin/backups/route.ts
index 09d9cc9f6..b24339aa7 100644
--- a/src/app/api/admin/backups/route.ts
+++ b/src/app/api/admin/backups/route.ts
@@ -27,7 +27,10 @@ import { annotate } from "@/lib/logging/context";
import { DATA_BACKUP_QUEUE } from "@/lib/jobs/data-backup-policy";
import { readLastQueueRun } from "@/lib/jobs/job-failures";
import { summariseBackupSchedule } from "@/lib/jobs/backup-schedule-status";
-import { offhostBackupConfigured } from "@/lib/jobs/offhost-backup";
+import {
+ offhostBackupConfigured,
+ probeOffhostLifecycle,
+} from "@/lib/jobs/offhost-backup";
import {
classifyOffhostBackup,
OFFHOST_BACKUP_PERIOD_HOURS,
@@ -164,6 +167,28 @@ export const GET = apiHandler(async () => {
a.username.localeCompare(b.username),
);
+ // A wipe whose copies are already gone waits for the next nightly run
+ // before its request is removed (see `offhost-purge.ts`); nothing of it is
+ // left in the bucket, so it is not counted as a pending deletion.
+ const stillInBucket = {
+ NOT: {
+ reason: "data_wiped",
+ lastFailure: null,
+ lastAttemptAt: { not: null },
+ },
+ };
+ const [purgeCount, oldestPurge, lifecycle] = await Promise.all([
+ prisma.offhostPurgeRequest.count({ where: stillInBucket }),
+ prisma.offhostPurgeRequest.findFirst({
+ where: stillInBucket,
+ orderBy: { requestedAt: "asc" },
+ select: { requestedAt: true, lastFailure: true },
+ }),
+ configured
+ ? probeOffhostLifecycle()
+ : Promise.resolve({ state: "unknown" as const, expirationDays: null }),
+ ]);
+
const payload: BackupsList = {
rows: list,
// Matches the retention window the backup-prune job enforces so the
@@ -182,6 +207,12 @@ export const GET = apiHandler(async () => {
// An unconfigured host has nothing to list, and the card says so rather
// than painting an empty table that reads as "no problems".
rows: configured ? offhostRows : [],
+ pendingDeletions: {
+ count: purgeCount,
+ oldestRequestedAt: oldestPurge?.requestedAt.toISOString() ?? null,
+ lastFailure: oldestPurge?.lastFailure ?? null,
+ },
+ lifecycle,
},
};
diff --git a/src/app/api/admin/backups/upload/route.ts b/src/app/api/admin/backups/upload/route.ts
index 50e74d36f..26fea6e9e 100644
--- a/src/app/api/admin/backups/upload/route.ts
+++ b/src/app/api/admin/backups/upload/route.ts
@@ -29,15 +29,30 @@
* Phase B1 / criterion 2 of the v1.4.15 backup-completeness work.
*/
import { Readable } from "node:stream";
-import { createGunzip } from "node:zlib";
+import { BodyTooLargeError, readBoundedBody } from "@/lib/labs/ocr-upload";
import { NextRequest } from "next/server";
import { ZodError } from "zod/v4";
import { prisma } from "@/lib/db";
-import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
+import {
+ apiHandler,
+ HttpError,
+ requireAdmin,
+ assertRecentCookieProof,
+} from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { BackupJsonError, scanBackupJson } from "@/lib/export/backup-json-scan";
+import {
+ BackupUploadDecodeError,
+ decodeBackupUpload,
+} from "@/lib/export/backup-upload-decode";
+import {
+ assessBackupKeys,
+ BACKUP_KEY_MISSING_CODE,
+ BackupKeyIdCollector,
+ describeBackupKeyProblem,
+} from "@/lib/export/backup-key-ids";
import {
BACKUP_UPLOAD_TOO_LARGE_CODE,
BackupBlobTooLargeError,
@@ -64,11 +79,13 @@ export const dynamic = "force-dynamic";
* is sent as the raw request body, which is read as a stream.
*/
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
+/** Room for the multipart boundaries and part headers around the file. */
+const MULTIPART_OVERHEAD_BYTES = 64 * 1024;
/**
- * Cap on a file sent as the raw request body, compressed or not. The same
- * ceiling the app sets for any request body (`middlewareClientMaxBodySize` in
- * `next.config.ts`), beyond which the body would arrive truncated. A
+ * Cap on a file sent as the raw request body, compressed or not, counted
+ * while the body streams. The route is left out of the proxy matcher
+ * (`src/lib/http/proxy-bypass-routes.ts`), so this is the only ceiling. A
* disaster-recovery file of 1.25 million measurements is 662 MB as plain
* JSON and 64 MB compressed, so large files go up compressed.
*/
@@ -93,54 +110,29 @@ class UploadRefused extends Error {
}
}
-/** Bytes → bytes, gunzipped when the file starts with the gzip magic. */
+/** The file's bytes, gunzipped and counted on both ends. */
async function* decodedBytes(
source: AsyncIterable,
limit: number,
): AsyncGenerator {
- let seen = 0;
- async function* counted() {
- for await (const chunk of source) {
- seen += chunk.byteLength;
- if (seen > limit) {
- throw new UploadRefused(
- 413,
- `Upload exceeds ${Math.round(limit / 1024 / 1024)} MB limit`,
- { reason: "file_size_exceeded", size: seen },
- );
- }
- yield chunk;
- }
- }
- const iterator = counted()[Symbol.asyncIterator]();
- const first = await iterator.next();
- if (first.done) return;
- const rest = (async function* () {
- yield first.value;
- for (;;) {
- const next = await iterator.next();
- if (next.done) return;
- yield next.value;
- }
- })();
- const head = first.value;
- if (head.byteLength >= 2 && head[0] === 0x1f && head[1] === 0x8b) {
- const gunzip = Readable.from(rest).pipe(createGunzip());
- try {
- for await (const chunk of gunzip) yield chunk as Buffer;
- } catch (err) {
- if (err instanceof UploadRefused) throw err;
- throw new UploadRefused(422, "Uploaded file is not valid gzip", {
- reason: "invalid_gzip",
+ try {
+ yield* decodeBackupUpload(source, { compressedLimit: limit });
+ } catch (err) {
+ if (err instanceof BackupUploadDecodeError) {
+ throw new UploadRefused(err.status, err.message, {
+ reason: err.reason,
+ ...(err.size !== undefined ? { size: err.size } : {}),
});
}
- return;
+ throw err;
}
- yield* rest;
}
export const POST = apiHandler(async (request: NextRequest) => {
- const { user: admin } = await requireAdmin();
+ const { user: admin, session } = await requireAdmin();
+ // A fresh proof on top of the admin session: this reaches every account's
+ // data, so a stolen admin session alone must not be enough.
+ await assertRecentCookieProof(admin, session.id);
annotate({ action: { name: "admin.backups.upload" } });
const ipAddress = getClientIp(request);
@@ -179,10 +171,23 @@ export const POST = apiHandler(async (request: NextRequest) => {
let source: AsyncIterable;
if (multipart) {
+ // The multipart parser holds the whole form and counts nothing, so the
+ // bytes are counted first and the form is parsed from what was kept. The
+ // route is outside the proxy matcher, so nothing else bounds this read.
let formData: FormData;
try {
- formData = await request.formData();
+ const bytes = await readBoundedBody(
+ request.body,
+ MAX_UPLOAD_BYTES + MULTIPART_OVERHEAD_BYTES,
+ );
+ formData = await new Response(new Blob([bytes]), {
+ headers: { "content-type": contentType },
+ }).formData();
} catch (err) {
+ if (err instanceof BodyTooLargeError) {
+ await denied("stream_size_exceeded");
+ return apiError("Upload exceeds 10 MB limit", 413);
+ }
return apiError(
`Invalid multipart body: ${err instanceof Error ? err.message : "unknown"}`,
400,
@@ -235,6 +240,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
}
let firstWithoutId: number | null = null;
+ const keys = new BackupKeyIdCollector();
let scanned;
try {
scanned = await scanBackupJson(storedAsRead(), {
@@ -251,6 +257,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (!parsed.data.id && firstWithoutId === null) {
firstWithoutId = index;
}
+ keys.visit(element, "measurements");
},
});
} catch (err) {
@@ -319,6 +326,27 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
}
+ // A file whose inner ciphertext needs a key this host does not have
+ // can be stored but never restored: the restore would refuse it, or
+ // worse, a restore on a host that had lost the key would write rows
+ // nobody can open. Refused here, where the operator is looking, with
+ // the key named. The instance settings are left to the restore, which
+ // checks them only when they are asked for back.
+ keys.visit(scanned.document);
+ const keyVerdict = assessBackupKeys(keys, {
+ ignoreSections: new Set(["appSettings"]),
+ });
+ const keyProblem = describeBackupKeyProblem(keyVerdict);
+ if (keyProblem) {
+ const keyIds = [...keyVerdict.missing, ...keyVerdict.unreadable];
+ throw new UploadRefused(
+ 422,
+ keyProblem,
+ { reason: "key_missing", keyIds },
+ { errorCode: BACKUP_KEY_MISSING_CODE, keyIds },
+ );
+ }
+
// Make sure the userId on the file points at a user that exists in
// this DB. Restore would fail later anyway, but failing here gives
// the admin a precise error before any side-effect.
diff --git a/src/app/api/admin/data/route.ts b/src/app/api/admin/data/route.ts
index 9fcbb9afe..68cdbc007 100644
--- a/src/app/api/admin/data/route.ts
+++ b/src/app/api/admin/data/route.ts
@@ -1,5 +1,9 @@
import { prisma } from "@/lib/db";
-import { apiHandler, requireAdmin } from "@/lib/api-handler";
+import {
+ apiHandler,
+ requireAdmin,
+ assertRecentCookieProof,
+} from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
@@ -47,7 +51,10 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
);
}
- const { user } = await requireAdmin();
+ const { user, session } = await requireAdmin();
+ // A fresh proof on top of the admin session: this reaches every account's
+ // data, so a stolen admin session alone must not be enough.
+ await assertRecentCookieProof(user, session.id);
annotate({ action: { name: "admin.data.delete" } });
// v1.18.1 — the documented convention buckets authenticated admin
diff --git a/src/app/api/admin/encryption/rotate/route.ts b/src/app/api/admin/encryption/rotate/route.ts
index 0b6c23bbc..0beacfe7f 100644
--- a/src/app/api/admin/encryption/rotate/route.ts
+++ b/src/app/api/admin/encryption/rotate/route.ts
@@ -3,9 +3,10 @@
*
* v1.23 — admin-triggered encryption-key rotation. Enqueues the
* `encryption-key-rotate` pg-boss job, which re-encrypts the whole encrypted-
- * column corpus to the configured ACTIVE key id. The CLI
- * (`scripts/rotate-encryption-key.ts`) stays the canonical path; this is a
- * convenience that reuses the same column registry and is safe by construction:
+ * column corpus to the configured ACTIVE key id. This is the primary path on
+ * the published image, which contains neither a package manager nor
+ * `scripts/rotate-encryption-key.ts`; the CLI runs from a source checkout and
+ * reuses the same column registry. Safe by construction:
*
* - ACTIVE-KEY-ONLY + NEVER ADDS / DROPS A KEY: the job only writes the active
* key and never touches `ENCRYPTION_KEYS`. Dropping a key remains an env +
@@ -16,8 +17,8 @@
* Auth: cookie-only `requireAdmin` (Bearer can never reach admin) PLUS
* `requireFreshMfa` step-up — re-encrypting the whole sensitive corpus is a
* high-blast-radius action. An admin without a second factor cannot use the
- * button and falls back to the CLI (this is intentional: step-up cannot be
- * softened, and the CLI is the documented canonical path).
+ * button and falls back to the CLI from a source checkout (intentional:
+ * step-up cannot be softened).
*/
import {
apiHandler,
diff --git a/src/app/api/admin/encryption/status/route.ts b/src/app/api/admin/encryption/status/route.ts
index 76cc2b02f..4bda28af8 100644
--- a/src/app/api/admin/encryption/status/route.ts
+++ b/src/app/api/admin/encryption/status/route.ts
@@ -19,6 +19,10 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { getConfiguredKeyIds } from "@/lib/crypto";
+import {
+ offhostBackupConfigured,
+ probeOffhostLifecycle,
+} from "@/lib/jobs/offhost-backup";
import {
scanCorpus,
type CorpusClient,
@@ -100,6 +104,100 @@ async function readRotationState(): Promise {
};
}
+/**
+ * Which keys the backups still need.
+ *
+ * Rotation re-encrypts every row in the database and re-seals every stored
+ * backup's envelope, but the content INSIDE a backup keeps the key it was
+ * written under: a restore writes it back verbatim. So "every row is on the
+ * active key" is not the same as "the old key can go". This answers the second
+ * question per key id, from what each copy recorded as it was written: the
+ * stored copies that need it and the oldest of them, and for the off-host
+ * bucket the last night an object needing it went in, plus how long the
+ * bucket's lifecycle rule keeps objects after that.
+ */
+export interface BackupKeyNeeds {
+ stored: Array<{ keyId: string; copies: number; oldestAt: string }>;
+ /** Copies written before v1.39.3, which recorded no key ids. */
+ unrecorded: { copies: number; oldestAt: string | null };
+ offhost: Array<{
+ keyId: string;
+ firstWrittenAt: string;
+ lastWrittenAt: string;
+ /** When the last object needing this key expires; null when unknown. */
+ neededUntil: string | null;
+ }>;
+ /** The bucket's expiry, when it has one the credential may read. */
+ offhostExpirationDays: number | null;
+ /** Retired key ids some backup still needs (never the active one). */
+ retiredKeysStillNeeded: string[];
+}
+
+async function readBackupKeyNeeds(
+ activeKeyId: string,
+ now: Date,
+): Promise {
+ const [stored, unrecorded, offhostRows] = await Promise.all([
+ prisma.$queryRaw<
+ Array<{ key_id: string; copies: number; oldest_at: Date }>
+ >`
+ SELECT k AS key_id, count(*)::int AS copies, min(created_at) AS oldest_at
+ FROM data_backups, unnest(inner_key_ids) AS k
+ WHERE inner_key_ids_recorded
+ GROUP BY k
+ ORDER BY k
+ `,
+ prisma.dataBackup.aggregate({
+ where: { innerKeyIdsRecorded: false },
+ _count: { _all: true },
+ _min: { createdAt: true },
+ }),
+ prisma.offhostBackupKeyUse.findMany({ orderBy: { keyId: "asc" } }),
+ ]);
+ const lifecycle =
+ offhostRows.length > 0 && offhostBackupConfigured()
+ ? await probeOffhostLifecycle()
+ : null;
+ const expirationDays =
+ lifecycle?.state === "configured" ? lifecycle.expirationDays : null;
+
+ const offhost = offhostRows.map((row) => ({
+ keyId: row.keyId,
+ firstWrittenAt: row.firstWrittenAt.toISOString(),
+ lastWrittenAt: row.lastWrittenAt.toISOString(),
+ neededUntil:
+ expirationDays === null
+ ? null
+ : new Date(
+ row.lastWrittenAt.getTime() + (expirationDays + 1) * 86_400_000,
+ ).toISOString(),
+ }));
+
+ const needed = new Set();
+ for (const row of stored) needed.add(row.key_id);
+ for (const row of offhost) {
+ if (row.neededUntil === null || Date.parse(row.neededUntil) > +now) {
+ needed.add(row.keyId);
+ }
+ }
+ needed.delete(activeKeyId);
+
+ return {
+ stored: stored.map((row) => ({
+ keyId: row.key_id,
+ copies: row.copies,
+ oldestAt: row.oldest_at.toISOString(),
+ })),
+ unrecorded: {
+ copies: unrecorded._count._all,
+ oldestAt: unrecorded._min.createdAt?.toISOString() ?? null,
+ },
+ offhost,
+ offhostExpirationDays: expirationDays,
+ retiredKeysStillNeeded: [...needed].sort(),
+ };
+}
+
export const GET = apiHandler(async () => {
await requireAdmin();
annotate({ action: { name: "admin.encryption.status" } });
@@ -114,6 +212,7 @@ export const GET = apiHandler(async () => {
}
const rotation = await readRotationState();
+ const backups = await readBackupKeyNeeds(scan.activeKeyId, new Date());
annotate({
meta: {
@@ -133,5 +232,14 @@ export const GET = apiHandler(async () => {
staleRows: scan.staleRows,
columns: scan.columns,
rotation,
+ backups,
+ // Every row rotated AND no backup still needs a retired key: the one
+ // state in which dropping an old key loses nothing. Copies that recorded
+ // no key ids count against it, because they may hold any key that
+ // existed when they were written.
+ safeToDropRetiredKeys:
+ scan.rotationComplete &&
+ backups.retiredKeysStillNeeded.length === 0 &&
+ backups.unrecorded.copies === 0,
});
});
diff --git a/src/app/api/admin/import-apple-health-export/__tests__/route.test.ts b/src/app/api/admin/import-apple-health-export/__tests__/route.test.ts
index 952f2f006..fcb453ed6 100644
--- a/src/app/api/admin/import-apple-health-export/__tests__/route.test.ts
+++ b/src/app/api/admin/import-apple-health-export/__tests__/route.test.ts
@@ -25,6 +25,7 @@ vi.mock("next/headers", () => ({
}));
const mocks = vi.hoisted(() => ({
+ runningImportFindFirst: vi.fn(async () => null),
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
bossSend: vi.fn().mockResolvedValue("boss-job-1"),
getGlobalBoss: vi.fn(),
@@ -60,6 +61,16 @@ vi.mock("@/lib/db", () => ({
findFirst: mocks.importJobFindFirst,
update: mocks.importJobUpdate,
},
+ // The one-running-import check and the new row, in one transaction.
+ // The check finds nothing running unless a test says otherwise.
+ $transaction: async (fn: (tx: unknown) => unknown) =>
+ fn({
+ $executeRaw: async () => 0,
+ importJob: {
+ findFirst: mocks.runningImportFindFirst,
+ create: mocks.importJobCreate,
+ },
+ }),
user: {
findUnique: mocks.userFindUnique,
},
diff --git a/src/app/api/admin/import-apple-health-export/route.ts b/src/app/api/admin/import-apple-health-export/route.ts
index 67e8ecd32..b3ea40528 100644
--- a/src/app/api/admin/import-apple-health-export/route.ts
+++ b/src/app/api/admin/import-apple-health-export/route.ts
@@ -27,6 +27,11 @@ import {
type AppleHealthImportPayload,
} from "@/lib/jobs/apple-health-import-worker";
import { streamMultipartToDisk } from "@/lib/multipart/stream-to-disk";
+import {
+ createImportJobUnlessBusy,
+ discardStagedUpload,
+ IMPORT_BUSY_CODE,
+} from "@/lib/import/apple-health-staging";
import { unlink } from "node:fs/promises";
export const dynamic = "force-dynamic";
@@ -96,6 +101,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
ipAddress: getClientIp(request),
details: { reason: "missing_user_id" },
});
+ await discardStagedUpload(uploaded.filePath);
return apiError("Multipart field 'userId' is required", 422);
}
@@ -109,6 +115,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
ipAddress: getClientIp(request),
details: { reason: "target_user_missing", targetUserId },
});
+ await discardStagedUpload(uploaded.filePath);
return apiError(`Target user '${targetUserId}' does not exist`, 422);
}
@@ -149,19 +156,27 @@ export const POST = apiHandler(async (request: NextRequest) => {
ipAddress: getClientIp(request),
details: { reason: "worker_not_running" },
});
+ await discardStagedUpload(uploaded.filePath);
throw new HttpError(503, "Background worker is not running");
}
- const importJob = await prisma.importJob.create({
- data: {
- userId: targetUser.id,
- triggeredByAdminId: admin.id,
- status: "queued",
- uploadBytes: uploaded.bytes,
- uploadSha256: uploaded.sha256,
- parserRevision: APPLE_HEALTH_IMPORT_PARSER_REVISION,
- },
+ const staged = await createImportJobUnlessBusy(prisma, {
+ userId: targetUser.id,
+ triggeredByAdminId: admin.id,
+ status: "queued",
+ uploadBytes: uploaded.bytes,
+ uploadSha256: uploaded.sha256,
+ parserRevision: APPLE_HEALTH_IMPORT_PARSER_REVISION,
});
+ if ("busy" in staged) {
+ await discardStagedUpload(uploaded.filePath);
+ return apiError(
+ "An Apple Health import is already running for this account. Wait for it to finish, then upload again.",
+ 409,
+ { errorCode: IMPORT_BUSY_CODE, jobId: staged.busy.id },
+ );
+ }
+ const importJob = staged.created;
const payload: AppleHealthImportPayload = {
userId: targetUser.id,
@@ -178,6 +193,19 @@ export const POST = apiHandler(async (request: NextRequest) => {
payload,
APPLE_HEALTH_IMPORT_SEND_OPTIONS,
);
+ if (!bossJobId) {
+ await prisma.importJob.update({
+ where: { id: importJob.id },
+ data: {
+ status: "failed",
+ failureReason:
+ "The import could not be queued. Upload the export again.",
+ completedAt: new Date(),
+ },
+ });
+ await discardStagedUpload(uploaded.filePath);
+ throw new HttpError(503, "The import could not be queued");
+ }
await prisma.importJob.update({
where: { id: importJob.id },
diff --git a/src/app/api/admin/monitoring/umami-test/route.ts b/src/app/api/admin/monitoring/umami-test/route.ts
index c030f2df1..e3a9d6fc1 100644
--- a/src/app/api/admin/monitoring/umami-test/route.ts
+++ b/src/app/api/admin/monitoring/umami-test/route.ts
@@ -1,3 +1,4 @@
+import { envValue } from "@/lib/env";
import { NextRequest } from "next/server";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
@@ -37,8 +38,8 @@ function resolveUmamiSendUrls(scriptUrl: string | null): string[] {
function resolveAppUrl(request: NextRequest): URL {
const configured =
- process.env.APP_URL ??
- process.env.NEXT_PUBLIC_APP_URL ??
+ envValue("APP_URL") ??
+ envValue("NEXT_PUBLIC_APP_URL") ??
`https://${request.headers.get("host") ?? "localhost:3000"}`;
try {
diff --git a/src/app/api/admin/notifications/reminder-check/route.ts b/src/app/api/admin/notifications/reminder-check/route.ts
index 6cb32ae31..78b6fc9c8 100644
--- a/src/app/api/admin/notifications/reminder-check/route.ts
+++ b/src/app/api/admin/notifications/reminder-check/route.ts
@@ -9,6 +9,7 @@ import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/tz/local-day";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
+import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
export const dynamic = "force-dynamic";
@@ -116,7 +117,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
let notificationsSent = 0;
for (const med of medications) {
- const userTz = med.user.timezone || "Europe/Berlin";
+ const userTz = med.user.timezone || DEFAULT_TIMEZONE;
const { start: todayStart, end: todayEnd } = getUserTodayBounds(
now,
userTz,
diff --git a/src/app/api/admin/provider-health/route.ts b/src/app/api/admin/provider-health/route.ts
index 78e90ca01..74579d8c3 100644
--- a/src/app/api/admin/provider-health/route.ts
+++ b/src/app/api/admin/provider-health/route.ts
@@ -3,6 +3,7 @@ import { buildDateKey } from "@/lib/ai/coach/budget";
import { prisma } from "@/lib/db";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
+import { OPERATOR_HELD_PROVIDER_TYPES } from "@/lib/ai/provider-egress";
export const dynamic = "force-dynamic";
@@ -43,8 +44,11 @@ interface ProviderHealthSummary {
lastFailureStatus: number | null;
}
-/** The two operator-managed tags sort first — they affect every user on the chain. */
-const CENTRAL_TYPES = ["admin-openai", "admin-codex"];
+/**
+ * The operator-managed tags sort first — they affect every user on the chain.
+ * Read from the one definition, in its order (the OpenAI key, then Codex).
+ */
+const CENTRAL_TYPES = [...OPERATOR_HELD_PROVIDER_TYPES];
export const GET = apiHandler(async () => {
await requireAdmin();
diff --git a/src/app/api/admin/status/route.ts b/src/app/api/admin/status/route.ts
index 055ad9678..d94de0dd4 100644
--- a/src/app/api/admin/status/route.ts
+++ b/src/app/api/admin/status/route.ts
@@ -1,3 +1,4 @@
+import { envOr } from "@/lib/env";
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
@@ -14,7 +15,7 @@ const BUILD_TIME = process.env.BUILD_TIMESTAMP || new Date().toISOString();
const START_TIME = new Date().toISOString();
// Try to read git commit info
-let gitCommit = process.env.GIT_COMMIT ?? "unknown";
+let gitCommit = envOr("GIT_COMMIT", "unknown");
try {
const head = readFileSync(".git/HEAD", "utf-8").trim();
if (head.startsWith("ref:")) {
@@ -69,7 +70,7 @@ export const GET = apiHandler(async () => {
const failingJobs = await readFailingQueues();
return apiSuccess({
- version: process.env.npm_package_version ?? "0.1.0",
+ version: process.env.npm_package_version || "0.1.0",
nodeVersion: process.version,
gitCommit,
buildTime: BUILD_TIME,
diff --git a/src/app/api/admin/users/[id]/reset-password/route.ts b/src/app/api/admin/users/[id]/reset-password/route.ts
index 024ccb29a..ae913dddc 100644
--- a/src/app/api/admin/users/[id]/reset-password/route.ts
+++ b/src/app/api/admin/users/[id]/reset-password/route.ts
@@ -1,5 +1,9 @@
import { prisma } from "@/lib/db";
-import { apiHandler, requireAdmin } from "@/lib/api-handler";
+import {
+ apiHandler,
+ requireAdmin,
+ assertRecentCookieProof,
+} from "@/lib/api-handler";
import { hashPassword, checkPasswordStrength } from "@/lib/auth/password";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -14,7 +18,7 @@ import { destroyAllSessions } from "@/lib/auth/session";
import { revokeStepUpElevations } from "@/lib/auth/step-up";
import { NextRequest, NextResponse } from "next/server";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
-import { checkPasswordBreach } from "@/lib/auth/hibp";
+import { checkPasswordBreachIfEnabled } from "@/lib/password-breach-check";
import { getServerTranslator } from "@/lib/i18n/server-translator";
export const POST = apiHandler(
@@ -31,7 +35,10 @@ export const POST = apiHandler(
);
}
- const { user } = await requireAdmin();
+ const { user, session } = await requireAdmin();
+ // A fresh proof on top of the admin session: this reaches every account's
+ // data, so a stolen admin session alone must not be enough.
+ await assertRecentCookieProof(user, session.id);
const { id } = await params;
annotate({
@@ -70,7 +77,7 @@ export const POST = apiHandler(
// v1.23 — reject an admin-set password found in a known breach corpus
// (HIBP k-anonymity). Fail-open on an unreachable HIBP.
- const breach = await checkPasswordBreach(password);
+ const breach = await checkPasswordBreachIfEnabled(password);
if (breach?.breached) {
return apiError(
getServerTranslator(locale).t("auth.passwordBreached"),
diff --git a/src/app/api/auth/login/__tests__/constant-work.test.ts b/src/app/api/auth/login/__tests__/constant-work.test.ts
index 87be3506b..6490b756a 100644
--- a/src/app/api/auth/login/__tests__/constant-work.test.ts
+++ b/src/app/api/auth/login/__tests__/constant-work.test.ts
@@ -21,6 +21,16 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
+// The per-account throttle has its own suite (login-throttle.test.ts); here
+// it lets every attempt through and counts nothing.
+vi.mock("@/lib/auth/login-throttle", () => ({
+ beginAccountLoginAttempt: vi.fn(async () => ({
+ waiting: null,
+ source: null,
+ failed: async () => {},
+ succeeded: async () => {},
+ })),
+}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findFirst: vi.fn() },
@@ -44,6 +54,8 @@ vi.mock("@/lib/rate-limit", () => ({
ip: "1.2.3.4",
}),
rateLimitHeaders: vi.fn(() => ({})),
+ checkRateLimit: vi.fn(async () => ({ allowed: true, remaining: 9 })),
+ refundRateLimit: vi.fn(async () => undefined),
}));
vi.mock("@/lib/db-compat", () => ({
diff --git a/src/app/api/auth/login/__tests__/native-token.test.ts b/src/app/api/auth/login/__tests__/native-token.test.ts
index 7089dbff9..231483378 100644
--- a/src/app/api/auth/login/__tests__/native-token.test.ts
+++ b/src/app/api/auth/login/__tests__/native-token.test.ts
@@ -3,6 +3,16 @@ import { NextRequest } from "next/server";
// --- Mocks must come before importing the route. ---
+// The per-account throttle has its own suite (login-throttle.test.ts); here
+// it lets every attempt through and counts nothing.
+vi.mock("@/lib/auth/login-throttle", () => ({
+ beginAccountLoginAttempt: vi.fn(async () => ({
+ waiting: null,
+ source: null,
+ failed: async () => {},
+ succeeded: async () => {},
+ })),
+}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findFirst: vi.fn() },
@@ -48,6 +58,7 @@ vi.mock("@/lib/rate-limit", () => ({
ip: "203.0.113.1",
}),
rateLimitHeaders: vi.fn(() => ({})),
+ refundRateLimit: vi.fn(async () => undefined),
}));
vi.mock("@/lib/db-compat", () => ({
diff --git a/src/app/api/auth/login/__tests__/route.test.ts b/src/app/api/auth/login/__tests__/route.test.ts
index fb2df339b..6a8d06c6a 100644
--- a/src/app/api/auth/login/__tests__/route.test.ts
+++ b/src/app/api/auth/login/__tests__/route.test.ts
@@ -8,6 +8,16 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
+// The per-account throttle has its own suite (login-throttle.test.ts); here
+// it lets every attempt through and counts nothing.
+vi.mock("@/lib/auth/login-throttle", () => ({
+ beginAccountLoginAttempt: vi.fn(async () => ({
+ waiting: null,
+ source: null,
+ failed: async () => {},
+ succeeded: async () => {},
+ })),
+}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findFirst: vi.fn() },
diff --git a/src/app/api/auth/login/__tests__/trusted-device.test.ts b/src/app/api/auth/login/__tests__/trusted-device.test.ts
index f8eab01a4..95b7a3492 100644
--- a/src/app/api/auth/login/__tests__/trusted-device.test.ts
+++ b/src/app/api/auth/login/__tests__/trusted-device.test.ts
@@ -1,6 +1,16 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
+// The per-account throttle has its own suite (login-throttle.test.ts); here
+// it lets every attempt through and counts nothing.
+vi.mock("@/lib/auth/login-throttle", () => ({
+ beginAccountLoginAttempt: vi.fn(async () => ({
+ waiting: null,
+ source: null,
+ failed: async () => {},
+ succeeded: async () => {},
+ })),
+}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findFirst: vi.fn() },
@@ -39,6 +49,7 @@ vi.mock("@/lib/rate-limit", () => ({
.fn()
.mockResolvedValue({ allowed: true, ip: "1.2.3.4" }),
rateLimitHeaders: vi.fn(() => ({})),
+ refundRateLimit: vi.fn(async () => undefined),
}));
vi.mock("@/lib/db-compat", () => ({
diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts
index 5c163c0dd..75dc974d6 100644
--- a/src/app/api/auth/login/route.ts
+++ b/src/app/api/auth/login/route.ts
@@ -10,6 +10,7 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
+import { beginAccountLoginAttempt } from "@/lib/auth/login-throttle";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
@@ -19,12 +20,19 @@ import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
import { consumeTrustedDevice } from "@/lib/auth/trusted-device";
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
import { isOidcOnly } from "@/lib/auth/oidc";
+import { readPendingLink } from "@/lib/auth/oidc-pending-link";
export const POST = apiHandler(async (request: NextRequest) => {
// OIDC_ONLY means password login must be a dead end, not just a hidden
// button — otherwise a pre-existing password (or a leaked/reused one)
// stays a live bypass of the operator's SSO-only policy.
- if (isOidcOnly()) {
+ //
+ // One exception, and only for one account: a browser the SSO callback sent
+ // here to confirm an existing account carries a sealed pending link naming
+ // that account. It may prove that account's password (checked again once
+ // the account is resolved) so the link can be made; nothing else opens.
+ const pendingLink = isOidcOnly() ? await readPendingLink() : null;
+ if (isOidcOnly() && !pendingLink) {
return apiError("Password login is disabled. Sign in with SSO.", 403, {
errorCode: "oidc_only",
});
@@ -84,6 +92,46 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
+ // Under an SSO-only policy the one password sign-in allowed is the one that
+ // confirms the account a pending single sign-on link names. Any other
+ // account is refused BEFORE its password is checked: refusing after the
+ // check answered "wrong password" and "right password, wrong account"
+ // differently, which made this exception an oracle for every other
+ // account's password.
+ if (pendingLink && pendingLink.userId !== user?.id) {
+ return apiError("Password login is disabled. Sign in with SSO.", 403, {
+ errorCode: "oidc_only",
+ });
+ }
+
+ // The per-account half of the throttle (`@/lib/auth/login-throttle`): a
+ // place this account has signed in from is neither counted nor held back,
+ // anywhere else waits longer after each failure past the first few, up to
+ // fifteen minutes. Decided before the Argon2id verification, so a waiting
+ // attempt costs no hashing.
+ const attempt = await beginAccountLoginAttempt({
+ user,
+ identifier,
+ request,
+ ip: rl.ip,
+ });
+ annotate({ meta: { login_known_source: attempt.source ?? "none" } });
+ if (attempt.waiting) {
+ await auditLog("auth.login.account_throttled", {
+ userId: user?.id,
+ ipAddress: ip,
+ details: { identifierHash },
+ });
+ annotate({ action: { name: "auth.login.account_throttled" } });
+ return NextResponse.json(
+ {
+ data: null,
+ error: "Too many login attempts. Please try again later.",
+ },
+ { status: 429, headers: rateLimitHeaders(attempt.waiting) },
+ );
+ }
+
// One verification for every outcome. An unknown identifier and an
// account carrying no password hash (a passkey-only account) used to
// return here before any hashing, so the three answers cost one indexed
@@ -97,6 +145,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const valid = await verifyPasswordOrDummy(user?.passwordHash, password);
if (!user || !user.passwordHash) {
+ await attempt.failed();
// v1.4.43 W3-SECURITY (H-1): never write the typed identifier into
// the audit row — `reason` already tells the operator what
// happened, and PII must not land in operator artefacts. The
@@ -111,6 +160,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
if (!valid) {
+ await attempt.failed();
await auditLog("auth.login.failed", {
userId: user.id,
ipAddress: ip,
@@ -119,6 +169,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Invalid credentials", 401);
}
+ // The password was right: this attempt was not a failed guess.
+ await attempt.succeeded();
+
const ua = request.headers.get("user-agent");
await auditLog("auth.login.password", {
diff --git a/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts b/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts
index 6639267e6..a819d6a08 100644
--- a/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts
+++ b/src/app/api/auth/me/mfa/disable/__tests__/route.test.ts
@@ -76,10 +76,14 @@ describe("POST /api/auth/me/mfa/disable", () => {
it("revokes other sessions + trusted devices (via destroyOtherSessions), keeping the current one", async () => {
const res = await POST(req());
expect(res.status).toBe(200);
- expect(destroyOtherSessions).toHaveBeenCalledWith("u1", {
- kind: "session",
- sessionId: "sess-current",
- });
+ expect(destroyOtherSessions).toHaveBeenCalledWith(
+ "u1",
+ {
+ kind: "session",
+ sessionId: "sess-current",
+ },
+ { reach: "sign-ins" },
+ );
});
it("spares the CALLER's own device login on the Bearer path", async () => {
@@ -99,10 +103,14 @@ describe("POST /api/auth/me/mfa/disable", () => {
const res = await POST(req());
expect(res.status).toBe(200);
- expect(destroyOtherSessions).toHaveBeenCalledWith("u1", {
- kind: "accessToken",
- accessTokenHash: "hash-of-caller-access-token",
- });
+ expect(destroyOtherSessions).toHaveBeenCalledWith(
+ "u1",
+ {
+ kind: "accessToken",
+ accessTokenHash: "hash-of-caller-access-token",
+ },
+ { reach: "sign-ins" },
+ );
});
it("spends the elevation before tearing the factor down", async () => {
diff --git a/src/app/api/auth/me/mfa/disable/route.ts b/src/app/api/auth/me/mfa/disable/route.ts
index d1ec12e0f..1493d61e4 100644
--- a/src/app/api/auth/me/mfa/disable/route.ts
+++ b/src/app/api/auth/me/mfa/disable/route.ts
@@ -118,6 +118,7 @@ export const POST = apiHandler(async (req: Request) => {
auth.transport === "cookie"
? { kind: "session", sessionId: auth.session.id }
: { kind: "accessToken", accessTokenHash: auth.accessTokenHash },
+ { reach: "sign-ins" },
);
await auditLog("auth.mfa.disabled", {
diff --git a/src/app/api/auth/me/mfa/totp/confirm/route.ts b/src/app/api/auth/me/mfa/totp/confirm/route.ts
index 2aac38635..6f30c6c6d 100644
--- a/src/app/api/auth/me/mfa/totp/confirm/route.ts
+++ b/src/app/api/auth/me/mfa/totp/confirm/route.ts
@@ -33,6 +33,7 @@ import {
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
+import { destroyOtherSessions } from "@/lib/auth/session";
import { decrypt } from "@/lib/crypto";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { verifyTotp } from "@/lib/auth/mfa/totp";
@@ -110,6 +111,12 @@ export const POST = apiHandler(async (req: Request) => {
}
const recoveryCodes = generateRecoveryCodes();
+ // A confirmed TOTP secret was refused above, so the only second factor the
+ // account can already hold is a security key.
+ const hadSecondFactor =
+ (await prisma.webauthnMfaCredential.count({
+ where: { userId: user.id },
+ })) > 0;
// The code verified and the activation is next — spend the elevation here so
// a wrong code above costs the caller nothing.
@@ -141,6 +148,19 @@ export const POST = apiHandler(async (req: Request) => {
// admin-enforced forced-enrollment redirect must clear immediately.
await setMfaEnrollCookie(false);
+ // Turning on the first second factor ends every other sign-in, as a password
+ // change does: a session opened before the factor existed, perhaps by
+ // whoever made the owner turn it on, must not keep going without it.
+ if (!hadSecondFactor) {
+ await destroyOtherSessions(
+ user.id,
+ auth.transport === "cookie"
+ ? { kind: "session", sessionId: auth.session.id }
+ : { kind: "accessToken", accessTokenHash: auth.accessTokenHash },
+ { reach: "sign-ins" },
+ );
+ }
+
await auditLog("auth.mfa.enabled", {
userId: user.id,
ipAddress: getClientIp(req),
diff --git a/src/app/api/auth/me/mfa/totp/setup/route.ts b/src/app/api/auth/me/mfa/totp/setup/route.ts
index a9ef6b5ed..9c94d919e 100644
--- a/src/app/api/auth/me/mfa/totp/setup/route.ts
+++ b/src/app/api/auth/me/mfa/totp/setup/route.ts
@@ -10,7 +10,16 @@
*
* Gated by `requireMfaManagementAuth`: a cookie session, or a Bearer token
* presenting a single-use step-up elevation minted against a re-proved factor.
- * A token on its own can still never enrol MFA. The recovery-code batch is
+ * A token on its own can still never enrol MFA.
+ *
+ * A cookie session on its own cannot either. Enrolling hands the caller a
+ * factor that later satisfies every step-up gate, so a stolen session must not
+ * be able to mint one: the cookie arm also passes `checkCookieEnrollmentProof`
+ * — a sign-in or second factor inside the last five minutes, or a password /
+ * factor proof in the body (the step-up mint's shapes); on an account that
+ * already has a second factor, only that factor or a passkey. The Bearer arm
+ * carries the proof in its elevation, under the same rule: on an account with
+ * a second factor the elevation must come from one. The recovery-code batch is
* issued at `/confirm` (after the factor is proven), not here, so an abandoned
* setup never persists codes.
*/
@@ -26,6 +35,7 @@ import { prisma } from "@/lib/db";
import { encrypt } from "@/lib/crypto";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { generateTotpSecret, buildOtpauthUri } from "@/lib/auth/mfa/totp";
+import { checkCookieEnrollmentProof } from "@/lib/auth/existing-factor-proof";
export const dynamic = "force-dynamic";
@@ -33,7 +43,7 @@ const SETUP_RATE_LIMIT = 5;
const SETUP_WINDOW_MS = 15 * 60 * 1000;
export const POST = apiHandler(async (req: Request) => {
- const auth = await requireMfaManagementAuth();
+ const auth = await requireMfaManagementAuth({ freshFactorIfEnrolled: true });
const { user } = auth;
const rl = await checkRateLimit(
@@ -57,6 +67,17 @@ export const POST = apiHandler(async (req: Request) => {
throw new HttpError(409, "A second factor is already active");
}
+ if (auth.transport === "cookie") {
+ const refusal = await checkCookieEnrollmentProof({
+ user,
+ sessionId: auth.session.id,
+ request: req,
+ ipAddress: getClientIp(req),
+ stage: "totp_enroll",
+ });
+ if (refusal) return refusal;
+ }
+
// Rate limit and the already-active check have passed; the write is next.
await auth.commitElevation();
diff --git a/src/app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts b/src/app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts
index e7704c9ca..996d75487 100644
--- a/src/app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts
+++ b/src/app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts
@@ -31,11 +31,16 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
+vi.mock("@/lib/auth/existing-factor-proof", () => ({
+ checkCookieEnrollmentProof: vi.fn(),
+}));
+
import { POST as REGISTER_OPTIONS } from "../options/route";
import { POST as REGISTER_VERIFY } from "../verify/route";
import { requireMfaManagementAuth } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
+import { checkCookieEnrollmentProof } from "@/lib/auth/existing-factor-proof";
import {
createMfaRegistrationOptions,
verifyMfaRegistration,
@@ -63,8 +68,16 @@ beforeEach(() => {
commitElevation: vi.fn().mockResolvedValue(undefined),
} as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
+ vi.mocked(checkCookieEnrollmentProof).mockResolvedValue(null);
});
+function optionsRequest(): NextRequest {
+ return new NextRequest(
+ "http://localhost/api/auth/me/mfa/webauthn/register/options",
+ { method: "POST" },
+ );
+}
+
describe("POST /api/auth/me/mfa/webauthn/register/options", () => {
it("returns ceremony options + challenge id", async () => {
vi.mocked(createMfaRegistrationOptions).mockResolvedValue({
@@ -72,7 +85,7 @@ describe("POST /api/auth/me/mfa/webauthn/register/options", () => {
challengeId: "ch-1",
} as never);
- const res = await REGISTER_OPTIONS();
+ const res = await REGISTER_OPTIONS(optionsRequest());
const body = (await res.json()) as {
data: { options: unknown; challengeId: string };
};
@@ -86,6 +99,40 @@ describe("POST /api/auth/me/mfa/webauthn/register/options", () => {
});
});
+describe("security-key enrollment needs a fresh proof on the cookie arm", () => {
+ it("stops before any challenge exists when the session cannot show one", async () => {
+ vi.mocked(checkCookieEnrollmentProof).mockResolvedValue(
+ new Response(null, { status: 401 }),
+ );
+ const res = await REGISTER_OPTIONS(optionsRequest());
+ expect(res.status).toBe(401);
+ expect(createMfaRegistrationOptions).not.toHaveBeenCalled();
+ expect(checkCookieEnrollmentProof).toHaveBeenCalledWith(
+ expect.objectContaining({
+ sessionId: "sess-1",
+ stage: "security_key_enroll",
+ }),
+ );
+ });
+
+ it("leaves the Bearer arm to its elevation", async () => {
+ vi.mocked(requireMfaManagementAuth).mockResolvedValue({
+ transport: "bearer",
+ user: USER,
+ apiTokenId: "tok-1",
+ accessTokenHash: "h",
+ commitElevation: vi.fn().mockResolvedValue(undefined),
+ } as never);
+ vi.mocked(createMfaRegistrationOptions).mockResolvedValue({
+ options: {},
+ challengeId: "ch-2",
+ } as never);
+ const res = await REGISTER_OPTIONS(optionsRequest());
+ expect(res.status).toBe(200);
+ expect(checkCookieEnrollmentProof).not.toHaveBeenCalled();
+ });
+});
+
describe("POST /api/auth/me/mfa/webauthn/register/verify", () => {
const credential = {
id: "cred-1",
diff --git a/src/app/api/auth/me/mfa/webauthn/register/options/route.ts b/src/app/api/auth/me/mfa/webauthn/register/options/route.ts
index a472d8d55..ff09e4f79 100644
--- a/src/app/api/auth/me/mfa/webauthn/register/options/route.ts
+++ b/src/app/api/auth/me/mfa/webauthn/register/options/route.ts
@@ -5,17 +5,25 @@
* session or a Bearer token presenting a single-use step-up elevation; a token
* on its own can never enrol MFA. Returns the SimpleWebAuthn creation options +
* the server-issued challenge id to present back at /register/verify.
+ *
+ * The cookie arm additionally passes `checkCookieEnrollmentProof`: a session
+ * alone cannot add a security key, for the same reason it cannot enroll TOTP —
+ * the new key would satisfy every step-up gate from then on. On an account that
+ * already has a second factor, both arms want that factor (or a passkey): a
+ * password, or a session that skipped the factor on a remembered browser, does
+ * not add a key.
*/
import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler";
-import { apiError, apiSuccess } from "@/lib/api-response";
+import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
+import { checkCookieEnrollmentProof } from "@/lib/auth/existing-factor-proof";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { createMfaRegistrationOptions } from "@/lib/auth/mfa/webauthn";
export const dynamic = "force-dynamic";
-export const POST = apiHandler(async () => {
- const auth = await requireMfaManagementAuth();
+export const POST = apiHandler(async (req: Request) => {
+ const auth = await requireMfaManagementAuth({ freshFactorIfEnrolled: true });
const { user } = auth;
const rl = await checkRateLimit(
@@ -31,6 +39,17 @@ export const POST = apiHandler(async () => {
return res;
}
+ if (auth.transport === "cookie") {
+ const refusal = await checkCookieEnrollmentProof({
+ user,
+ sessionId: auth.session.id,
+ request: req,
+ ipAddress: getClientIp(req),
+ stage: "security_key_enroll",
+ });
+ if (refusal) return refusal;
+ }
+
await auth.commitElevation();
const { options, challengeId } = await createMfaRegistrationOptions(
diff --git a/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts b/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts
index e96388a1f..975731d76 100644
--- a/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts
+++ b/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts
@@ -19,6 +19,7 @@ import {
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
+import { destroyOtherSessions } from "@/lib/auth/session";
import { verifyMfaRegistration } from "@/lib/auth/mfa/webauthn";
import { mfaWebauthnRegisterVerifySchema } from "@/lib/validations/mfa";
import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
@@ -57,6 +58,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
// failed ceremony above must not have cost the caller their elevation.
await auth.commitElevation();
+ const hadSecondFactor =
+ user.totpConfirmedAt !== null ||
+ (await prisma.webauthnMfaCredential.count({
+ where: { userId: user.id },
+ })) > 0;
+
const { registrationInfo } = verification;
const created = await prisma.webauthnMfaCredential.create({
data: {
@@ -76,6 +83,19 @@ export const POST = apiHandler(async (request: NextRequest) => {
// so clear any forced-enrollment redirect immediately.
await setMfaEnrollCookie(false);
+ // Turning on the first second factor ends every other sign-in, as a password
+ // change does: a session opened before the factor existed, perhaps by
+ // whoever made the owner turn it on, must not keep going without it.
+ if (!hadSecondFactor) {
+ await destroyOtherSessions(
+ user.id,
+ auth.transport === "cookie"
+ ? { kind: "session", sessionId: auth.session.id }
+ : { kind: "accessToken", accessTokenHash: auth.accessTokenHash },
+ { reach: "sign-ins" },
+ );
+ }
+
await auditLog("auth.mfa.webauthn.register", {
userId: user.id,
ipAddress: getClientIp(request),
diff --git a/src/app/api/auth/me/sessions/__tests__/route.test.ts b/src/app/api/auth/me/sessions/__tests__/route.test.ts
index 471e38b04..8f90a9de1 100644
--- a/src/app/api/auth/me/sessions/__tests__/route.test.ts
+++ b/src/app/api/auth/me/sessions/__tests__/route.test.ts
@@ -12,6 +12,7 @@ vi.mock("@/lib/db", () => ({
update: vi.fn(() => ({ catch: () => undefined })),
},
user: { findUnique: vi.fn() },
+ accountGrant: { findMany: vi.fn(async () => []) },
},
}));
@@ -163,16 +164,22 @@ describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => {
vi.mocked(destroyOtherSessions).mockResolvedValue({
sessionsRevoked: 3,
accessTokensRevoked: 0,
+ connectorsRevoked: 0,
+ shareLinksRevoked: 0,
+ pendingInvitesRevoked: 0,
});
const res = await DELETE(del());
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.sessionsRevoked).toBe(3);
- expect(destroyOtherSessions).toHaveBeenCalledWith("user-1", {
- kind: "session",
- sessionId: "sess-current",
- });
+ expect(destroyOtherSessions).toHaveBeenCalledWith(
+ "user-1",
+ { kind: "session", sessionId: "sess-current" },
+ // Everything, share links included, when the client says nothing: the
+ // shipped app sends no parameter and gets the safe default.
+ { reach: "everything", keepShareLinks: false },
+ );
});
it("names a Bearer caller by its access token so its own device login is spared", async () => {
@@ -198,19 +205,70 @@ describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => {
vi.mocked(destroyOtherSessions).mockResolvedValue({
sessionsRevoked: 1,
accessTokensRevoked: 1,
+ connectorsRevoked: 0,
+ shareLinksRevoked: 0,
+ pendingInvitesRevoked: 0,
});
const res = await DELETE(del());
expect(res.status).toBe(200);
- expect(destroyOtherSessions).toHaveBeenCalledWith("user-1", {
- kind: "accessToken",
- accessTokenHash: hashToken(raw),
- });
+ expect(destroyOtherSessions).toHaveBeenCalledWith(
+ "user-1",
+ { kind: "accessToken", accessTokenHash: hashToken(raw) },
+ { reach: "everything", keepShareLinks: false },
+ );
vi.mocked(headers).mockResolvedValue({ get: () => null } as never);
});
});
+describe("sign out everywhere and record sharing", () => {
+ it("reports withdrawn invitations and lists the accepted grants it kept", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ vi.mocked(destroyOtherSessions).mockResolvedValue({
+ sessionsRevoked: 1,
+ accessTokensRevoked: 0,
+ connectorsRevoked: 0,
+ shareLinksRevoked: 0,
+ pendingInvitesRevoked: 2,
+ });
+ const party = { id: "user-2", username: "carer", displayName: null };
+ const live = {
+ id: "grant-live",
+ access: "READ",
+ acceptedAt: new Date("2026-01-01"),
+ revokedAt: null,
+ expiresAt: null,
+ grantee: party,
+ };
+ const lapsed = {
+ ...live,
+ id: "grant-lapsed",
+ expiresAt: new Date("2026-02-01"),
+ };
+ vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([
+ live,
+ lapsed,
+ ] as never);
+
+ const res = await DELETE(del());
+ const body = await res.json();
+ expect(body.data.pendingInvitesRevoked).toBe(2);
+ // The lapsed one confers nothing any more and is not listed.
+ expect(body.data.grantsKept).toEqual([
+ { id: "grant-live", account: party, access: "READ" },
+ ]);
+ expect(prisma.accountGrant.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({
+ grantorId: "user-1",
+ revokedAt: null,
+ }),
+ }),
+ );
+ });
+});
+
describe("DELETE /api/auth/me/sessions/[id]", () => {
function ctx(id: string) {
return { params: Promise.resolve({ id }) };
diff --git a/src/app/api/auth/me/sessions/route.ts b/src/app/api/auth/me/sessions/route.ts
index ee4ade61c..9f256b4f4 100644
--- a/src/app/api/auth/me/sessions/route.ts
+++ b/src/app/api/auth/me/sessions/route.ts
@@ -1,8 +1,14 @@
/**
* GET /api/auth/me/sessions — list the user's active web sessions.
* DELETE /api/auth/me/sessions — "sign out everywhere": revoke every OTHER
- * session (and native refresh tokens), keeping
- * the caller's current session. Closes #64.
+ * session, device login, AI-assistant
+ * connection and token, and (unless
+ * `?keepShareLinks=1`) every clinician share
+ * link, keeping the caller's current
+ * credential. Closes #64. Also withdraws
+ * record-sharing invitations nobody has
+ * accepted yet, and lists the accepted
+ * grants it left standing (`grantsKept`).
*
* v1.23 — the user-facing session/device-management surface. Distinct from
* `/api/auth/me/devices`, which lists APNs / Web-Push notification devices;
@@ -31,6 +37,8 @@ import {
type CurrentCredential,
} from "@/lib/auth/session";
import { lookupIpLocation } from "@/lib/geo";
+import { GRANT_PARTY_SELECT } from "@/lib/sharing/grant-view";
+import { isGrantActive } from "@/lib/sharing/grants";
import { coarseDeviceLabel, maskIp } from "@/lib/auth/device-fingerprint";
export const dynamic = "force-dynamic";
@@ -114,15 +122,58 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
}
: { kind: "session", sessionId: auth.session.id };
- const { sessionsRevoked, accessTokensRevoked } = await destroyOtherSessions(
- user.id,
- current,
- );
+ // "Everywhere" reaches every credential that works without signing in: AI
+ // assistant connections, programmatic tokens and, unless the caller asks to
+ // keep them, clinician share links. The default is to revoke, because the
+ // person pressing this is often doing it after losing a device or a session,
+ // and a share link made by whoever held it would otherwise keep working. A
+ // client that sends nothing (the shipped app) gets the safe default.
+ const keepShareLinks =
+ new URL(request.url).searchParams.get("keepShareLinks") === "1";
+
+ const {
+ sessionsRevoked,
+ accessTokensRevoked,
+ connectorsRevoked,
+ shareLinksRevoked,
+ pendingInvitesRevoked,
+ } = await destroyOtherSessions(user.id, current, {
+ reach: "everything",
+ keepShareLinks,
+ });
+
+ // The accepted grants this account gave, still live. Not ended here: each is
+ // a person the owner chose, often a carer who still needs the record, and a
+ // button labelled "sign out" must not quietly cut them off. They are listed
+ // instead, so the person who just signed everything out can see who can
+ // still read the record and end any of them with one click.
+ const now = new Date();
+ const grantsKept = (
+ await prisma.accountGrant.findMany({
+ where: {
+ grantorId: user.id,
+ acceptedAt: { not: null },
+ revokedAt: null,
+ },
+ orderBy: { acceptedAt: "desc" },
+ include: { grantee: { select: GRANT_PARTY_SELECT } },
+ })
+ )
+ .filter((g) => isGrantActive(g, now))
+ .map((g) => ({ id: g.id, account: g.grantee, access: g.access }));
await auditLog("auth.session.revoke_others", {
userId: user.id,
ipAddress: getClientIp(request),
- details: { sessionsRevoked, accessTokensRevoked },
+ details: {
+ sessionsRevoked,
+ accessTokensRevoked,
+ connectorsRevoked,
+ shareLinksRevoked,
+ pendingInvitesRevoked,
+ grantsKept: grantsKept.length,
+ keepShareLinks,
+ },
});
annotate({
@@ -130,10 +181,21 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
meta: {
sessions_revoked: sessionsRevoked,
access_tokens_revoked: accessTokensRevoked,
+ connectors_revoked: connectorsRevoked,
+ share_links_revoked: shareLinksRevoked,
+ pending_invites_revoked: pendingInvitesRevoked,
+ grants_kept: grantsKept.length,
},
});
- return apiSuccess({ sessionsRevoked });
+ return apiSuccess({
+ sessionsRevoked,
+ accessTokensRevoked,
+ connectorsRevoked,
+ shareLinksRevoked,
+ pendingInvitesRevoked,
+ grantsKept,
+ });
});
/**
diff --git a/src/app/api/auth/mfa/verify/route.ts b/src/app/api/auth/mfa/verify/route.ts
index cdd7b03af..94bd30393 100644
--- a/src/app/api/auth/mfa/verify/route.ts
+++ b/src/app/api/auth/mfa/verify/route.ts
@@ -32,6 +32,7 @@ import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { finishLogin } from "@/lib/auth/login-response";
import {
loadActiveChallenge,
+ reserveChallengeAttempt,
recordChallengeFailure,
claimChallenge,
} from "@/lib/auth/mfa/challenge";
@@ -90,6 +91,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Invalid or expired challenge", 401);
}
+ // Count this attempt before anything is verified. The reservation is one
+ // guarded UPDATE, so concurrent guesses cannot all slip under the cap the
+ // way a read-then-increment let them.
+ if (!(await reserveChallengeAttempt(challenge.id))) {
+ annotate({ action: { name: "auth.mfa.verify.attempts_exhausted" } });
+ return apiError("Invalid or expired challenge", 401);
+ }
+
const user = await prisma.user.findUnique({
where: { id: challenge.userId },
select: {
diff --git a/src/app/api/auth/mfa/webauthn/verify/__tests__/route.test.ts b/src/app/api/auth/mfa/webauthn/verify/__tests__/route.test.ts
index 14815ff0c..fa86f7987 100644
--- a/src/app/api/auth/mfa/webauthn/verify/__tests__/route.test.ts
+++ b/src/app/api/auth/mfa/webauthn/verify/__tests__/route.test.ts
@@ -25,6 +25,7 @@ vi.mock("@/lib/rate-limit", () => ({
vi.mock("@/lib/auth/mfa/challenge", () => ({
loadActiveChallenge: vi.fn(),
+ reserveChallengeAttempt: vi.fn().mockResolvedValue(true),
recordChallengeFailure: vi.fn().mockResolvedValue({ exhausted: false }),
claimChallenge: vi.fn(),
}));
@@ -50,6 +51,7 @@ import { prisma } from "@/lib/db";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import {
loadActiveChallenge,
+ reserveChallengeAttempt,
claimChallenge,
recordChallengeFailure,
} from "@/lib/auth/mfa/challenge";
@@ -93,6 +95,7 @@ beforeEach(() => {
username: "u",
onboardingCompletedAt: new Date(),
} as never);
+ vi.mocked(reserveChallengeAttempt).mockResolvedValue(true);
vi.mocked(recordChallengeFailure).mockResolvedValue({
exhausted: false,
attempts: 1,
@@ -126,6 +129,14 @@ describe("POST /api/auth/mfa/webauthn/verify", () => {
);
});
+ it("refuses before the assertion runs when no attempt can be reserved", async () => {
+ vi.mocked(reserveChallengeAttempt).mockResolvedValue(false);
+ const res = await POST(makeRequest(VALID_BODY));
+ expect(res.status).toBe(401);
+ expect(verifyMfaAuthentication).not.toHaveBeenCalled();
+ expect(finishLogin).not.toHaveBeenCalled();
+ });
+
it("rejects with 401 and burns an attempt when the assertion fails", async () => {
vi.mocked(verifyMfaAuthentication).mockResolvedValue(false as never);
diff --git a/src/app/api/auth/mfa/webauthn/verify/route.ts b/src/app/api/auth/mfa/webauthn/verify/route.ts
index 00df78104..9df1e93d7 100644
--- a/src/app/api/auth/mfa/webauthn/verify/route.ts
+++ b/src/app/api/auth/mfa/webauthn/verify/route.ts
@@ -30,6 +30,7 @@ import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { finishLogin } from "@/lib/auth/login-response";
import {
loadActiveChallenge,
+ reserveChallengeAttempt,
recordChallengeFailure,
claimChallenge,
} from "@/lib/auth/mfa/challenge";
@@ -86,6 +87,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Invalid or expired challenge", 401);
}
+ // Count this attempt before anything is verified. The reservation is one
+ // guarded UPDATE, so concurrent guesses cannot all slip under the cap the
+ // way a read-then-increment let them.
+ if (!(await reserveChallengeAttempt(challenge.id))) {
+ annotate({ action: { name: "auth.mfa.verify.attempts_exhausted" } });
+ return apiError("Invalid or expired challenge", 401);
+ }
+
const user = await prisma.user.findUnique({
where: { id: challenge.userId },
select: { id: true, username: true, onboardingCompletedAt: true },
diff --git a/src/app/api/auth/oidc/callback/__tests__/route.test.ts b/src/app/api/auth/oidc/callback/__tests__/route.test.ts
index 769eeff35..370884fba 100644
--- a/src/app/api/auth/oidc/callback/__tests__/route.test.ts
+++ b/src/app/api/auth/oidc/callback/__tests__/route.test.ts
@@ -16,6 +16,7 @@ vi.mock("@/lib/rate-limit", () => ({
vi.mock("@/lib/crypto", () => ({
decrypt: vi.fn((s: string) => s.replace(/^enc\(/, "").replace(/\)$/, "")),
+ encrypt: vi.fn((s: string) => `enc(${s})`),
}));
vi.mock("@/lib/db", () => ({
@@ -321,26 +322,34 @@ describe("GET /api/auth/oidc/callback", () => {
expect(res.status).toBe(307);
});
- it("links an existing email-matched account ONCE, stamping (issuer, sub)", async () => {
+ // Inverted in v1.39.3. This used to pin the auto-link: an existing account
+ // with the same (unverified) local email was bound on the spot and signed
+ // in. Whoever set that address first owned the account the SSO user landed
+ // in, so the callback now links nothing and signs nobody in; it leaves a
+ // sealed pending link and sends the browser to confirm the account with its
+ // own credential (`oidc-pending-link.test.ts` covers the completion).
+ it("does not link an email-matched account on the email alone", async () => {
mockIdentity();
mockUserLookups({ byIdentity: null, byEmail: userRow() });
- vi.mocked(prisma.user.update).mockResolvedValue(
- userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }) as never,
- );
const res = await GET(validRequest("/dashboard"));
expect(prisma.user.create).not.toHaveBeenCalled();
- expect(prisma.user.update).toHaveBeenCalledWith({
- where: { id: "user-1" },
- data: { oidcIssuer: METADATA.issuer, oidcSub: "sub-1" },
- });
- expect(auditLog).toHaveBeenCalledWith(
+ expect(prisma.user.update).not.toHaveBeenCalled();
+ expect(createSession).not.toHaveBeenCalled();
+ expect(auditLog).not.toHaveBeenCalledWith(
"auth.oidc.linked",
+ expect.anything(),
+ );
+ expect(auditLog).toHaveBeenCalledWith(
+ "auth.oidc.link_pending",
expect.objectContaining({ userId: "user-1" }),
);
- expect(createSession).toHaveBeenCalled();
- expect(res.headers.get("location")).toContain("/dashboard");
+ expect(res.headers.get("location")).toContain("error=oidc_link_required");
+ const setCookie = res.headers.get("set-cookie") ?? "";
+ expect(setCookie).toContain("hl_oidc_link=");
+ expect(setCookie.toLowerCase()).toContain("httponly");
+ expect(setCookie).toContain("Path=/api/auth");
});
it("rejects a login whose email matches an account bound to a DIFFERENT identity", async () => {
@@ -505,10 +514,9 @@ describe("GET /api/auth/oidc/callback", () => {
it("never stamps the session step-up fresh — the 5th createSession arg is null", async () => {
mockIdentity();
- mockUserLookups({ byIdentity: null, byEmail: userRow() });
- vi.mocked(prisma.user.update).mockResolvedValue(
- userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }) as never,
- );
+ mockUserLookups({
+ byIdentity: userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }),
+ });
await GET(validRequest());
diff --git a/src/app/api/auth/oidc/callback/route.ts b/src/app/api/auth/oidc/callback/route.ts
index efa1221b5..0c090f4a0 100644
--- a/src/app/api/auth/oidc/callback/route.ts
+++ b/src/app/api/auth/oidc/callback/route.ts
@@ -37,6 +37,7 @@ import {
buildNativeCallbackUrl,
mintNativeHandoff,
} from "@/lib/auth/oidc-native-handoff";
+import { setPendingLinkCookie } from "@/lib/auth/oidc-pending-link";
const LOGIN_ERROR_URL = "/auth/login";
@@ -340,14 +341,29 @@ export const GET = apiHandler(async (req: NextRequest) => {
annotate({ meta: { reason: "identity_conflict" } });
return failRedirect("oidc_identity_conflict");
}
- user = await prisma.user.update({
- where: { id: byEmail.id },
- data: { oidcIssuer: metadata.issuer, oidcSub: identity.sub },
- });
- await auditLog("auth.oidc.linked", {
- userId: user.id,
+ // (2b) Never link on the email alone. The local address was never
+ // verified — anyone can register with it or type it into a profile —
+ // so matching it proves nothing about who owns the account. Record
+ // the link this login would make and send the browser to sign in to
+ // the account with its own credential; the link is made there
+ // (`completePendingOidcLink`), and only for this account.
+ await auditLog("auth.oidc.link_pending", {
+ userId: byEmail.id,
ipAddress: ip,
});
+ annotate({ meta: { reason: "link_confirmation_required" } });
+ if (isNative) {
+ // The native sign-in sheet cannot run the confirmation; the person
+ // signs in on the web once, after which the app's SSO works.
+ return failRedirect("oidc_link_required");
+ }
+ const response = errorRedirect("oidc_link_required");
+ setPendingLinkCookie(response, {
+ userId: byEmail.id,
+ issuer: metadata.issuer,
+ sub: identity.sub,
+ });
+ return response;
} else {
// (3) Provision.
const userCount = await prisma.user.count();
diff --git a/src/app/api/auth/passkey/login-verify/route.ts b/src/app/api/auth/passkey/login-verify/route.ts
index 9b2b36878..4032fdb15 100644
--- a/src/app/api/auth/passkey/login-verify/route.ts
+++ b/src/app/api/auth/passkey/login-verify/route.ts
@@ -18,6 +18,7 @@ import {
} from "@/lib/auth/native-client";
import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
import { isOidcOnly } from "@/lib/auth/oidc";
+import { completePendingOidcLink } from "@/lib/auth/oidc-pending-link";
export const POST = apiHandler(async (request: NextRequest) => {
// OIDC_ONLY must block passkey login too, not just password login — a
@@ -87,6 +88,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
// meets an MFA-enforcement policy AND passes `requireFreshMfa` step-up,
// exactly like a completed password+TOTP login. (The password+TOTP path is
// unchanged — it stamps the session at `/api/auth/mfa/verify`.)
+ // The passkey proved this account's own credential, so a single sign-on
+ // identity this browser was sent here to confirm may now be linked to it.
+ await completePendingOidcLink(user.id, ip ?? null);
+
const ua = request.headers.get("user-agent");
await createSession(
user.id,
diff --git a/src/app/api/auth/passkey/register-options/route.ts b/src/app/api/auth/passkey/register-options/route.ts
index a84bf7e8a..b72bfff13 100644
--- a/src/app/api/auth/passkey/register-options/route.ts
+++ b/src/app/api/auth/passkey/register-options/route.ts
@@ -2,16 +2,26 @@ import { NextRequest } from "next/server";
import {
createAuthenticationOptions,
createRegistrationOptions,
- verifyAuthentication,
} from "@/lib/auth/passkey";
+import { createMfaAuthenticationOptions } from "@/lib/auth/mfa/webauthn";
import {
- createMfaAuthenticationOptions,
- verifyMfaAuthentication,
-} from "@/lib/auth/mfa/webauthn";
-import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
-import { verifyPassword } from "@/lib/auth/password";
+ isSecondFactorProof,
+ recordReproofFailure,
+ refundReproof,
+ throttleReproof,
+ verifyExistingFactorProof,
+} from "@/lib/auth/existing-factor-proof";
+import {
+ hasSecondFactorEnrolled,
+ recentProofMethods,
+} from "@/lib/auth/second-factor";
import { prisma } from "@/lib/db";
-import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
+import {
+ apiError,
+ apiSuccess,
+ getClientIp,
+ safeJson,
+} from "@/lib/api-response";
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { stepUpMintSchema } from "@/lib/validations/step-up";
@@ -51,49 +61,44 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = stepUpMintSchema.safeParse(body);
if (!parsed.success) return proofRequired();
- let proved = false;
-
- if (parsed.data.method === "password") {
- proved = Boolean(
- user.passwordHash &&
- (await verifyPassword(user.passwordHash, parsed.data.password)),
- );
- } else if (parsed.data.method === "totp") {
- const result = await verifyMfaFactor(user, "totp", parsed.data.code);
- proved = result.ok;
- } else {
- const expectedType =
- parsed.data.method === "passkey"
- ? "authentication"
- : "mfa_authentication";
- const challenge = await prisma.authChallenge.findUnique({
- where: { id: parsed.data.challengeId },
- select: { userId: true, type: true },
+ // On an account with a second factor a password does not add a sign-in
+ // credential, exactly as it does not satisfy the second factor at sign-in:
+ // a passkey registered on the strength of the password alone would from then
+ // on sign in without the second factor at all. Refused before a guess is
+ // spent, with the proofs that would work.
+ if (
+ !isSecondFactorProof(parsed.data.method) &&
+ (await hasSecondFactorEnrolled(user))
+ ) {
+ annotate({
+ action: { name: "auth.reproof.method_refused" },
+ meta: { stage: "passkey_enroll", method: parsed.data.method },
+ });
+ return apiError("Confirm with your second factor or a passkey", 422, {
+ errorCode: "auth.reproof.too_weak",
+ methods: await recentProofMethods(user, true),
});
-
- if (challenge?.userId === user.id && challenge.type === expectedType) {
- try {
- if (parsed.data.method === "passkey") {
- const result = await verifyAuthentication(
- parsed.data.challengeId,
- parsed.data.credential,
- );
- proved =
- result.verification.verified && result.passkey.userId === user.id;
- } else {
- proved = await verifyMfaAuthentication(
- parsed.data.challengeId,
- user.id,
- parsed.data.credential,
- );
- }
- } catch {
- proved = false;
- }
- }
}
- if (!proved) return proofRequired();
+ // Every proof counts against the account's shared re-proof bucket and every
+ // refusal is audited. Without both, a stolen session could guess the account
+ // password or a TOTP code here as fast as it could send requests.
+ const ip = getClientIp(request);
+ const limited = await throttleReproof(user.id, ip, "passkey_enroll");
+ if (limited) return limited;
+
+ const outcome = await verifyExistingFactorProof(user, parsed.data);
+ if (!outcome.ok) {
+ await recordReproofFailure(
+ user.id,
+ ip,
+ "passkey_enroll",
+ parsed.data.method,
+ outcome.reason,
+ );
+ return proofRequired();
+ }
+ await refundReproof(user.id);
// A strong existing factor refreshes the session's MFA stamp. Password proof
// deliberately clears it: registration is authorized by this single-use,
diff --git a/src/app/api/auth/password/__tests__/route.test.ts b/src/app/api/auth/password/__tests__/route.test.ts
index e13ecff29..557ac34e8 100644
--- a/src/app/api/auth/password/__tests__/route.test.ts
+++ b/src/app/api/auth/password/__tests__/route.test.ts
@@ -50,6 +50,7 @@ vi.mock("@/lib/auth/password", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
+ refundRateLimit: vi.fn(),
}));
vi.mock("@/lib/i18n/server-locale", () => ({
@@ -77,7 +78,7 @@ vi.mock("next/headers", () => ({
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
-import { checkRateLimit } from "@/lib/rate-limit";
+import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
import { verifyPassword, checkPasswordStrength } from "@/lib/auth/password";
import {
requireFreshMfaIfEnrolled,
@@ -146,6 +147,9 @@ describe("POST /api/auth/password — MFA step-up (v1.25)", () => {
expect(res.status).toBe(200);
expect(requireFreshMfaIfEnrolled).toHaveBeenCalledWith(expect.any(Number));
expect(prisma.user.update).toHaveBeenCalled();
+ // The right current password was not a guess: the shared re-proof
+ // bucket gets its slot back.
+ expect(refundRateLimit).toHaveBeenCalledWith("auth:step-up:user-1");
});
});
diff --git a/src/app/api/auth/password/route.ts b/src/app/api/auth/password/route.ts
index ae97c2792..a8935d912 100644
--- a/src/app/api/auth/password/route.ts
+++ b/src/app/api/auth/password/route.ts
@@ -14,7 +14,11 @@ import {
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
-import { checkRateLimit } from "@/lib/rate-limit";
+import {
+ recordReproofFailure,
+ refundReproof,
+ throttleReproof,
+} from "@/lib/auth/existing-factor-proof";
import {
apiHandler,
requireFreshMfaIfEnrolled,
@@ -24,7 +28,7 @@ import { annotate } from "@/lib/logging/context";
import { destroyAllSessions, createSession } from "@/lib/auth/session";
import { revokeStepUpElevations } from "@/lib/auth/step-up";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
-import { checkPasswordBreach } from "@/lib/auth/hibp";
+import { checkPasswordBreachIfEnabled } from "@/lib/password-breach-check";
import { getServerTranslator } from "@/lib/i18n/server-translator";
export const POST = apiHandler(async (request: NextRequest) => {
@@ -35,14 +39,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
// contract. Mirrors the step-up on MFA-disable + recovery-code regen.
const { user } = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS);
- const rl = await checkRateLimit(
- `auth:password:${user.id}`,
- 5,
- 15 * 60 * 1000,
- );
- if (!rl.allowed) {
- return apiError("Too many attempts. Please wait 15 minutes.", 429);
- }
+ // The current-password check below is a guessing oracle, so it draws on the
+ // account's shared re-proof budget: a guess spent here is one the step-up
+ // mint and the enrollment proofs cannot spend again.
+ const ip = getClientIp(request);
+ const limited = await throttleReproof(user.id, ip, "password_change");
+ if (limited) return limited;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
@@ -62,8 +64,19 @@ export const POST = apiHandler(async (request: NextRequest) => {
const currentValid = await verifyPassword(user.passwordHash, currentPassword);
if (!currentValid) {
+ await recordReproofFailure(
+ user.id,
+ ip,
+ "password_change",
+ "password",
+ "bad_password",
+ );
return apiError("Current password is incorrect", 401);
}
+ // The current password was right, so this attempt was not a guess. Give the
+ // shared re-proof budget its slot back; otherwise a password change followed
+ // by a few confirmations elsewhere runs the owner out of attempts.
+ await refundReproof(user.id);
if (currentPassword === newPassword) {
return apiError("New password must differ from current password", 422);
@@ -89,7 +102,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// corpus (HIBP k-anonymity). Fail-open: a null result (HIBP unreachable)
// never blocks the change. Only the user's chosen NEW password is checked —
// existing credentials are never retroactively blocked at login.
- const breach = await checkPasswordBreach(newPassword);
+ const breach = await checkPasswordBreachIfEnabled(newPassword);
if (breach?.breached) {
return apiError(
getServerTranslator(locale).t("auth.passwordBreached"),
diff --git a/src/app/api/auth/profile/route.ts b/src/app/api/auth/profile/route.ts
index 515a3e272..3497f6ba8 100644
--- a/src/app/api/auth/profile/route.ts
+++ b/src/app/api/auth/profile/route.ts
@@ -9,16 +9,30 @@ import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { applyProfileUpdate } from "@/lib/auth/profile-update";
+import { authorizeSensitiveChange } from "@/lib/auth/existing-factor-proof";
export const PUT = apiHandler(async (request: NextRequest) => {
- const { user } = await requireAuth();
+ const auth = await requireAuth();
+ const { user } = auth;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
- const result = await applyProfileUpdate(user.id, body, getClientIp(request));
+ const ip = getClientIp(request);
+ const result = await applyProfileUpdate(user.id, body, ip, {
+ // A new email address needs a fresh proof: the address is what single
+ // sign-on links an existing account by.
+ authorizeEmailChange: (currentPassword) =>
+ authorizeSensitiveChange({
+ user,
+ cookieSessionId: auth.authMethod === "cookie" ? auth.session.id : null,
+ currentPassword,
+ ipAddress: ip,
+ stage: "email_change",
+ }),
+ });
if (!result.ok) {
const meta = result.errorCode ? { errorCode: result.errorCode } : undefined;
return result.issues
diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts
index d89662cbd..2c4b5623c 100644
--- a/src/app/api/auth/register/route.ts
+++ b/src/app/api/auth/register/route.ts
@@ -21,7 +21,7 @@ import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
-import { checkPasswordBreach } from "@/lib/auth/hibp";
+import { checkPasswordBreachIfEnabled } from "@/lib/password-breach-check";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import {
isValidTimezone,
@@ -173,7 +173,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// v1.23 — reject a registration password found in a known breach corpus
// (HIBP k-anonymity). Fail-open on an unreachable HIBP.
- const breach = await checkPasswordBreach(password);
+ const breach = await checkPasswordBreachIfEnabled(password);
if (breach?.breached) {
return apiError(
getServerTranslator(locale).t("auth.passwordBreached"),
diff --git a/src/app/api/auth/reproof/route.ts b/src/app/api/auth/reproof/route.ts
new file mode 100644
index 000000000..f01203d96
--- /dev/null
+++ b/src/app/api/auth/reproof/route.ts
@@ -0,0 +1,142 @@
+/**
+ * POST /api/auth/reproof
+ *
+ * A browser session re-proves a credential the account holds, so that for the
+ * next five minutes it may export the whole record, make a share link, mint a
+ * token, or run an admin backup, restore, wipe or reset. Those routes answer
+ * 401 `auth.reproof.required` with `meta.methods` when the session carries no
+ * recent proof; the web opens the re-proof dialog, posts the proof here, and
+ * retries.
+ *
+ * What a proof stamps on the session row:
+ * - `totp`, `webauthn` or `passkey`: `mfaVerifiedAt` and `reproofAt`. The
+ * same stamp a completed second factor or passkey sign-in writes, from the
+ * same factor, so it also clears the fresh-factor routes, exactly as
+ * signing in again would.
+ * - `password`: `reproofAt` only. Accepted only on an account without a
+ * second factor; an account with one is told which proofs it can give
+ * instead (422 `auth.reproof.too_weak`), because a password has never
+ * stood in for the second factor.
+ *
+ * Cookie-only. The Bearer transport has its own proof flow (`POST
+ * /api/auth/step-up`, token-bound single-use elevations), and a stamp on a
+ * session row means nothing to a token.
+ *
+ * GET asks the same question the gated routes ask, without acting: 200 when
+ * the session's proof is recent, otherwise the same 401 `auth.reproof.required`
+ * with `meta.methods`. The web calls it before a large upload, so the file is
+ * not sent twice around a re-proof.
+ *
+ * Proofs draw on the account's shared re-proof budget (five per fifteen
+ * minutes, the same bucket as the step-up mint and the enrollment routes) and
+ * every refusal is audited. Failures are indistinguishable on the wire.
+ */
+import { NextRequest } from "next/server";
+import {
+ apiHandler,
+ assertRecentCookieProof,
+ recentProofMethods,
+ requireCookieAuth,
+} from "@/lib/api-handler";
+import {
+ apiError,
+ apiSuccess,
+ apiValidationError,
+ getClientIp,
+ safeJson,
+ sanitiseZodIssues,
+} from "@/lib/api-response";
+import { prisma } from "@/lib/db";
+import { auditLog } from "@/lib/auth/audit";
+import { annotate } from "@/lib/logging/context";
+import {
+ REPROOF_FAILED_CODE,
+ recordReproofFailure,
+ refundReproof,
+ throttleReproof,
+ verifyExistingFactorProof,
+} from "@/lib/auth/existing-factor-proof";
+import { stepUpMintSchema } from "@/lib/validations/step-up";
+
+export const dynamic = "force-dynamic";
+
+export const POST = apiHandler(async (request: NextRequest) => {
+ const { user, session } = await requireCookieAuth();
+ const ip = getClientIp(request);
+
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
+ if (jsonError) return jsonError;
+
+ const parsed = stepUpMintSchema.safeParse(body);
+ if (!parsed.success) {
+ return apiValidationError(
+ "Invalid request",
+ sanitiseZodIssues(parsed.error.issues),
+ 422,
+ );
+ }
+ const proof = parsed.data;
+
+ // Refuse a proof that could not satisfy the gate BEFORE spending a guess on
+ // it: a password on an account with a second factor would verify and still
+ // leave every gated action refused.
+ const methods = await recentProofMethods(user);
+ if (!methods.includes(proof.method)) {
+ annotate({
+ action: { name: "auth.reproof.method_refused" },
+ meta: { method: proof.method },
+ });
+ return apiError("Use one of the listed ways to confirm it is you", 422, {
+ errorCode: "auth.reproof.too_weak",
+ methods,
+ });
+ }
+
+ const limited = await throttleReproof(user.id, ip, "reproof");
+ if (limited) return limited;
+
+ const outcome = await verifyExistingFactorProof(user, proof);
+ if (!outcome.ok) {
+ await recordReproofFailure(
+ user.id,
+ ip,
+ "reproof",
+ proof.method,
+ outcome.reason,
+ );
+ return apiError("Verification failed", 401, {
+ errorCode: REPROOF_FAILED_CODE,
+ });
+ }
+
+ await refundReproof(user.id);
+
+ const now = new Date();
+ await prisma.session.update({
+ where: { id: session.id },
+ data:
+ proof.method === "password"
+ ? { reproofAt: now }
+ : { reproofAt: now, mfaVerifiedAt: now },
+ });
+
+ await auditLog("auth.reproof.succeeded", {
+ userId: user.id,
+ ipAddress: ip,
+ details: { method: proof.method },
+ });
+ annotate({
+ action: { name: "auth.reproof.succeeded" },
+ meta: { method: proof.method },
+ });
+
+ return apiSuccess({ method: proof.method, verifiedAt: now.toISOString() });
+});
+
+export const GET = apiHandler(async () => {
+ const { user, session } = await requireCookieAuth();
+ await assertRecentCookieProof(user, session.id);
+ return apiSuccess({ recent: true });
+});
diff --git a/src/app/api/auth/step-up/route.ts b/src/app/api/auth/step-up/route.ts
index 2514d777a..42e564c41 100644
--- a/src/app/api/auth/step-up/route.ts
+++ b/src/app/api/auth/step-up/route.ts
@@ -40,16 +40,17 @@ import {
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
-import { prisma } from "@/lib/db";
import {
checkAuthSurfaceRateLimit,
checkRateLimit,
rateLimitHeaders,
} from "@/lib/rate-limit";
-import { verifyPassword } from "@/lib/auth/password";
-import { verifyAuthentication } from "@/lib/auth/passkey";
-import { verifyMfaAuthentication } from "@/lib/auth/mfa/webauthn";
-import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
+import {
+ REPROOF_LIMIT,
+ REPROOF_WINDOW_MS,
+ reproofBucket,
+ verifyExistingFactorProof,
+} from "@/lib/auth/existing-factor-proof";
import {
mintStepUpElevation,
isFreshFactorMethod,
@@ -65,8 +66,8 @@ export const dynamic = "force-dynamic";
* against a user-supplied string — the same 5-per-15-minutes the password-change
* route uses, for the same reason.
*/
-const MINT_LIMIT = 5;
-const MINT_WINDOW_MS = 15 * 60 * 1000;
+const MINT_LIMIT = REPROOF_LIMIT;
+const MINT_WINDOW_MS = REPROOF_WINDOW_MS;
export const POST = apiHandler(async (request: NextRequest) => {
const auth = await requireBearerAuth();
@@ -91,8 +92,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
+ // The account's shared re-proof bucket: a guess spent here is a guess the
+ // web enrollment surfaces cannot spend again, and the other way round.
const userRl = await checkRateLimit(
- `auth:step-up:${user.id}`,
+ reproofBucket(user.id),
MINT_LIMIT,
MINT_WINDOW_MS,
);
@@ -122,78 +125,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
const method: StepUpMethod = parsed.data.method;
- let proved = false;
+ // The shared verifier: the same challenge ownership and ceremony checks, and
+ // the same confirmed-secret rule for TOTP, as every other re-proof surface.
+ // The replay guard and the accepted-step burn behave exactly as they do at
+ // login and at MFA-disable, so a code spent here cannot be replayed there.
+ const outcome = await verifyExistingFactorProof(user, parsed.data);
+ const proved = outcome.ok;
// Audit-only detail. Never reaches the response — see the file header.
- let failure = "invalid";
-
- /**
- * Resolve a WebAuthn challenge and confirm it belongs to THIS account before
- * any verification runs. Both ceremony helpers resolve a challenge by id
- * alone, so without this a challenge minted for another account could be
- * carried into the ceremony.
- */
- const challengeBelongsToCaller = async (id: string): Promise => {
- const row = await prisma.authChallenge.findUnique({
- where: { id },
- select: { userId: true },
- });
- return Boolean(row && row.userId === user.id);
- };
-
- if (parsed.data.method === "password") {
- if (!user.passwordHash) {
- // An SSO-provisioned account has no password to re-prove. Same refusal as
- // a wrong one: the account's credential shape is not a token holder's to
- // enumerate. `/api/auth/step-up/options` is the honest discovery path —
- // it 409s when there is no credential of the requested kind either.
- failure = "no_password";
- } else {
- proved = await verifyPassword(user.passwordHash, parsed.data.password);
- if (!proved) failure = "bad_password";
- }
- } else if (parsed.data.method === "totp") {
- // The shared factor verifier, so the replay guard and the accepted-step
- // burn behave exactly as they do at login and at MFA-disable. A code spent
- // here cannot be replayed there.
- const result = await verifyMfaFactor(user, "totp", parsed.data.code);
- proved = result.ok;
- if (!proved) failure = result.replay ? "totp_replay" : "bad_totp";
- } else if (parsed.data.method === "webauthn") {
- if (!(await challengeBelongsToCaller(parsed.data.challengeId))) {
- failure = "foreign_challenge";
- } else {
- try {
- // Scoped to the caller's own second-factor credentials by the verifier.
- proved = await verifyMfaAuthentication(
- parsed.data.challengeId,
- user.id,
- parsed.data.credential,
- );
- if (!proved) failure = "bad_assertion";
- } catch {
- // A malformed / expired challenge is a failed attempt, not a 500.
- failure = "bad_assertion";
- }
- }
- } else {
- if (!(await challengeBelongsToCaller(parsed.data.challengeId))) {
- failure = "foreign_challenge";
- } else {
- try {
- const result = await verifyAuthentication(
- parsed.data.challengeId,
- parsed.data.credential,
- );
- // Both halves matter: a verified assertion against SOMEONE ELSE'S
- // passkey proves possession of a factor, just not of this account's.
- proved =
- result.verification.verified && result.passkey.userId === user.id;
- if (!proved) failure = "bad_assertion";
- } catch {
- failure = "bad_assertion";
- }
- }
- }
+ const failure = outcome.ok ? "" : outcome.reason;
if (!proved) {
await auditLog("auth.stepup.mint.failed", {
diff --git a/src/app/api/coach/reminder-suggestions/route.ts b/src/app/api/coach/reminder-suggestions/route.ts
index de7a7f1ed..eaafd4844 100644
--- a/src/app/api/coach/reminder-suggestions/route.ts
+++ b/src/app/api/coach/reminder-suggestions/route.ts
@@ -44,8 +44,7 @@ import {
type ReminderScheduleInput,
} from "@/lib/measurement-reminders/scheduling";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
-
-const DEFAULT_TIMEZONE = "Europe/Berlin";
+import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
async function postAction(request: NextRequest): Promise {
const { user } = await requireAuth();
diff --git a/src/app/api/coach/suggested-actions/route.ts b/src/app/api/coach/suggested-actions/route.ts
index 8302bc33c..aa8e73959 100644
--- a/src/app/api/coach/suggested-actions/route.ts
+++ b/src/app/api/coach/suggested-actions/route.ts
@@ -47,8 +47,8 @@ import {
type ReminderScheduleInput,
} from "@/lib/measurement-reminders/scheduling";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
+import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
-const DEFAULT_TIMEZONE = "Europe/Berlin";
const CHECKUP_NOTIFY_HOUR = 9;
async function postAction(request: NextRequest): Promise {
diff --git a/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts b/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts
index aaaa0b7ff..eb3e9cfa1 100644
--- a/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts
+++ b/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts
@@ -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 = {};
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 },
diff --git a/src/app/api/custom-metrics/[id]/entries/__tests__/note-encryption.test.ts b/src/app/api/custom-metrics/[id]/entries/__tests__/note-encryption.test.ts
new file mode 100644
index 000000000..89609be1b
--- /dev/null
+++ b/src/app/api/custom-metrics/[id]/entries/__tests__/note-encryption.test.ts
@@ -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()),
+ // 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 }) => ({
+ ...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;
+ 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 }) => ({
+ ...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;
+ 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 }) => ({
+ ...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;
+ 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;
+ expect(data).not.toHaveProperty("note");
+ expect(data).not.toHaveProperty("noteEncrypted");
+ });
+});
diff --git a/src/app/api/custom-metrics/[id]/entries/route.ts b/src/app/api/custom-metrics/[id]/entries/route.ts
index 166304e3f..bb4477ede 100644
--- a/src/app/api/custom-metrics/[id]/entries/route.ts
+++ b/src/app/api/custom-metrics/[id]/entries/route.ts
@@ -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),
},
});
diff --git a/src/app/api/cycle/calendar/route.ts b/src/app/api/cycle/calendar/route.ts
index 6e68e71f1..7d3b07dda 100644
--- a/src/app/api/cycle/calendar/route.ts
+++ b/src/app/api/cycle/calendar/route.ts
@@ -39,7 +39,8 @@ import {
import { resolveCycleVerdict } from "@/lib/cycle/verdict";
import { addDays, dayDiff } from "@/lib/cycle/day-math";
import { BBT_WINDOW } from "@/lib/cycle/types";
-import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
+import { moodDateKey } from "@/lib/mood/date-key";
+import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
diff --git a/src/app/api/cycle/day-logs/[id]/route.ts b/src/app/api/cycle/day-logs/[id]/route.ts
index 211e07eea..4c92fe9a2 100644
--- a/src/app/api/cycle/day-logs/[id]/route.ts
+++ b/src/app/api/cycle/day-logs/[id]/route.ts
@@ -28,7 +28,7 @@ import {
ensureCycleForBleedingDay,
removeCycleStartedOn,
} from "@/lib/cycle/cycle-boundaries";
-import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
+import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { encrypt, decrypt } from "@/lib/crypto";
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
import { replaceSymptomLinks } from "@/lib/cycle/day-log-write";
diff --git a/src/app/api/cycle/day-logs/bulk/route.ts b/src/app/api/cycle/day-logs/bulk/route.ts
index 93c56116b..66abea65a 100644
--- a/src/app/api/cycle/day-logs/bulk/route.ts
+++ b/src/app/api/cycle/day-logs/bulk/route.ts
@@ -40,7 +40,7 @@ import {
type UnstableExternalIdShape,
} from "@/lib/validations/external-id";
import { upsertCycleDayLog } from "@/lib/cycle/day-log-write";
-import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
+import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
const BATCH_RATE_LIMIT_MAX = 60;
const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000;
diff --git a/src/app/api/cycle/day-logs/route.ts b/src/app/api/cycle/day-logs/route.ts
index cacf927fd..158d85c84 100644
--- a/src/app/api/cycle/day-logs/route.ts
+++ b/src/app/api/cycle/day-logs/route.ts
@@ -40,7 +40,7 @@ import {
import { upsertCycleDayLog } from "@/lib/cycle/day-log-write";
import { findOwningCycleId } from "@/lib/cycle/cycle-attribution";
import { toCycleDayLogDTO, dayLogSymptomInclude } from "@/lib/cycle/dto";
-import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
+import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
export const POST = apiHandler(withIdempotency<[NextRequest]>(postDayLog));
diff --git a/src/app/api/cycle/insights/route.ts b/src/app/api/cycle/insights/route.ts
index 287406817..4fc89c5b3 100644
--- a/src/app/api/cycle/insights/route.ts
+++ b/src/app/api/cycle/insights/route.ts
@@ -43,7 +43,8 @@ import {
type SymptomDay,
} from "@/lib/cycle/symptom-phase";
import { addDays } from "@/lib/cycle/day-math";
-import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
+import { moodDateKey } from "@/lib/mood/date-key";
+import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
/** Trailing window the phase-contrast walks (days). */
const WINDOW_DAYS = 365;
@@ -228,6 +229,7 @@ export const GET = apiHandler(async () => {
phaseByDay,
measurements,
userPriorityJson,
+ timeZone: tz,
});
const headline = selectHeadlinePhaseRow(rows);
// The lagged rows are served with their narrated `interpretation` intact, so
@@ -239,6 +241,7 @@ export const GET = apiHandler(async () => {
phaseByDay,
measurements,
userPriorityJson,
+ timeZone: tz,
locale,
});
diff --git a/src/app/api/cycle/period/route.ts b/src/app/api/cycle/period/route.ts
index 4fc37c6a8..80b276b3f 100644
--- a/src/app/api/cycle/period/route.ts
+++ b/src/app/api/cycle/period/route.ts
@@ -37,7 +37,7 @@ import {
toMenstrualCycleDTO,
dayLogSymptomInclude,
} from "@/lib/cycle/dto";
-import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
+import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { openCycleAt, type MovedAnchors } from "@/lib/cycle/cycle-boundaries";
export const POST = apiHandler(withIdempotency<[NextRequest]>(postPeriod));
diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts
index 95d65fdbf..084b12087 100644
--- a/src/app/api/dashboard/summary/route.ts
+++ b/src/app/api/dashboard/summary/route.ts
@@ -67,6 +67,7 @@ import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { validTimezoneOr } from "@/lib/tz/format";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today";
+import { computeStreak } from "@/lib/dashboard/streak";
import {
readLatestEver,
readSparkBuckets,
@@ -256,53 +257,6 @@ function trendOf(values: number[]): MetricCard["trend"] {
return delta > 0 ? "up" : "down";
}
-interface StreakInfo {
- currentDays: number;
- longest: number;
-}
-
-/** Compute the current logging-day streak (days where any measurement or
- * intake event was recorded, in the user's display timezone) plus the
- * longest streak in the last `STREAK_WINDOW_DAYS` days.
- *
- * v1.4.25 W7b — `userTz` parameterises the "today" pivot so a Pacific/
- * Auckland user gets their Auckland-day streak rather than the Berlin
- * one. The activity-day Set entries are already produced in the same
- * zone by the caller (via `userDayKey`), so the cursor walk only needs
- * the same zone here to align. */
-function computeStreak(activityDays: Set, userTz: string): StreakInfo {
- if (activityDays.size === 0) return { currentDays: 0, longest: 0 };
-
- const sorted = [...activityDays].sort();
- let longest = 1;
- let run = 1;
- for (let i = 1; i < sorted.length; i++) {
- const prev = new Date(`${sorted[i - 1]}T00:00:00.000Z`).getTime();
- const cur = new Date(`${sorted[i]}T00:00:00.000Z`).getTime();
- if (cur - prev === 86_400_000) {
- run += 1;
- longest = Math.max(longest, run);
- } else {
- run = 1;
- }
- }
-
- // Current streak: walk back from today (user's tz).
- const todayKey = userDayKey(new Date(), userTz);
- let currentDays = 0;
- let cursor = new Date(`${todayKey}T00:00:00.000Z`);
- // Allow yesterday's last day to count if today not yet logged.
- if (!activityDays.has(userDayKey(cursor, userTz))) {
- cursor = new Date(cursor.getTime() - 86_400_000);
- }
- while (activityDays.has(userDayKey(cursor, userTz))) {
- currentDays += 1;
- cursor = new Date(cursor.getTime() - 86_400_000);
- }
-
- return { currentDays, longest };
-}
-
/** v1.4.38 W-F — distinct activity day-keys from the streak window.
* The route only needs YYYY-MM-DD strings, so the aggregate runs
* `date_trunc('day', m.measured_at AT TIME ZONE $userTz)` and returns
diff --git a/src/app/api/documents/inbound/__tests__/route.test.ts b/src/app/api/documents/inbound/__tests__/route.test.ts
index bcb479701..a83d87578 100644
--- a/src/app/api/documents/inbound/__tests__/route.test.ts
+++ b/src/app/api/documents/inbound/__tests__/route.test.ts
@@ -51,10 +51,10 @@ vi.mock("@/lib/db", () => {
findFirst: vi.fn(),
},
documentImportKey: {
- findUnique: vi.fn(),
+ findFirst: vi.fn(),
},
documentSourceAlias: {
- findUnique: vi.fn(),
+ findFirst: vi.fn(),
createMany: vi.fn(),
count: vi.fn(),
},
@@ -239,10 +239,10 @@ beforeEach(() => {
[] as never,
);
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue(null as never);
- vi.mocked(prisma.documentImportKey.findUnique).mockResolvedValue(
+ vi.mocked(prisma.documentImportKey.findFirst).mockResolvedValue(
null as never,
);
- vi.mocked(prisma.documentSourceAlias.findUnique).mockResolvedValue(
+ vi.mocked(prisma.documentSourceAlias.findFirst).mockResolvedValue(
null as never,
);
vi.mocked(prisma.documentSourceAlias.createMany).mockResolvedValue({
@@ -783,7 +783,7 @@ describe("POST /api/documents/inbound — document import", () => {
});
it("remembers a purged import through the ledger", async () => {
- vi.mocked(prisma.documentImportKey.findUnique).mockResolvedValue({
+ vi.mocked(prisma.documentImportKey.findFirst).mockResolvedValue({
id: "key-1",
} as never);
const res = await post(
@@ -909,6 +909,8 @@ describe("POST /api/documents/inbound — document import", () => {
userId: "user-1",
documentId: "doc-manual",
sourceSystem: "PAPRA",
+ // Sent without an instance: the alias matches any Papra.
+ sourceInstance: null,
sourceId: "doc_9",
},
],
@@ -936,7 +938,7 @@ describe("POST /api/documents/inbound — document import", () => {
});
it("resolves a key held as an alias of a deleted document to deleted", async () => {
- vi.mocked(prisma.documentSourceAlias.findUnique).mockResolvedValue({
+ vi.mocked(prisma.documentSourceAlias.findFirst).mockResolvedValue({
document: docRow({ id: "doc-gone", deletedAt: new Date() }),
} as never);
const res = await post(
diff --git a/src/app/api/documents/inbound/route.ts b/src/app/api/documents/inbound/route.ts
index 70097db7b..f2c6723d4 100644
--- a/src/app/api/documents/inbound/route.ts
+++ b/src/app/api/documents/inbound/route.ts
@@ -32,7 +32,6 @@
* instruction inside it. Storing it does nothing with its contents.
*/
import { Buffer } from "node:buffer";
-import { createHash } from "node:crypto";
import { NextResponse } from "next/server";
@@ -51,42 +50,38 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
-import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { hashQueryTokens } from "@/lib/documents/content-index";
+import {
+ ingestDocument,
+ isoDateToUtc,
+ personalUploadBucket,
+ UPLOAD_LIMIT_PER_HOUR,
+ UPLOAD_WINDOW_MS,
+} from "@/lib/documents/ingest";
import {
loadConditionLinks,
loadDocumentEncounterLinks,
- narrowOwnedEncounterIds,
- narrowOwnedEpisodeIds,
} from "@/lib/documents/links";
import {
- encryptDocumentContent,
serialiseDocument,
type SerialisableDocument,
} from "@/lib/documents/store";
-import { enqueueDocumentIndex } from "@/lib/jobs/document-index";
-import { enqueueDocumentSummary } from "@/lib/jobs/document-summary";
-import { enqueueDocumentThumbnail } from "@/lib/jobs/document-thumbnail";
import { DOCUMENTS_WRITE_SCOPE } from "@/lib/documents/scopes";
import {
checkSourceLookupRateLimit,
findSourceKey,
- rememberSourceAlias,
type SourceKeyMatch,
} from "@/lib/documents/source-key";
import {
acquireDocumentUploadSlot,
- detectDocumentType,
resolveDocumentLimits,
resolveDocumentUploadLimitPerHour,
} from "@/lib/documents/upload-policy";
import { withIdempotency } from "@/lib/idempotency";
import { BodyTooLargeError, readBoundedBody } from "@/lib/labs/ocr-upload";
-import { linkTargets } from "@/lib/links";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
-import { isP2002 } from "@/lib/prisma-errors";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import {
documentCreateSchema,
@@ -99,13 +94,6 @@ import type { Prisma } from "@/generated/prisma/client";
export const dynamic = "force-dynamic";
-/**
- * A store-only upload touches no provider — the only abuse vector is disk, so
- * a generous per-user ceiling is enough (extraction has its own tight gate).
- */
-const UPLOAD_LIMIT_PER_HOUR = 60;
-const UPLOAD_WINDOW_MS = 60 * 60 * 1000;
-
/**
* Multipart envelope allowance on top of the per-file cap: boundaries plus
* the small metadata fields (title / kind / documentDate / episodeIds). The
@@ -114,11 +102,6 @@ const UPLOAD_WINDOW_MS = 60 * 60 * 1000;
const MULTIPART_OVERHEAD_BYTES = 64 * 1024;
const UPLOAD_BODY_TIMEOUT_MS = 30_000;
-/** Parse a YYYY-MM-DD form field into a UTC midnight Date. */
-function isoDateToUtc(value: string): Date {
- return new Date(`${value}T00:00:00.000Z`);
-}
-
/** §3.2 — 413 fileTooLarge with the configured limit in `meta`. */
function fileTooLarge(maxFileBytes: number): NextResponse {
return apiError("File is too large.", 413, {
@@ -128,14 +111,6 @@ function fileTooLarge(maxFileBytes: number): NextResponse {
});
}
-/** Internal signal: the quota gate inside the insert transaction tripped. */
-class QuotaExceededError extends Error {
- constructor(public readonly usedBytes: number) {
- super("Document quota exceeded");
- this.name = "QuotaExceededError";
- }
-}
-
/** What a single upload asked for, carried into the annotations. */
interface UploadContext {
userId: string;
@@ -299,14 +274,17 @@ async function processUpload(
const url = new URL(request.url);
const querySystem = url.searchParams.get("sourceSystem");
const queryId = url.searchParams.get("sourceId");
+ const queryInstance = url.searchParams.get("sourceInstance");
let queryKey: {
sourceSystem: DocumentSourceSystemValue;
sourceId: string;
+ sourceInstance?: string;
} | null = null;
if (querySystem !== null || queryId !== null) {
const parsedKey = documentSourceKeySchema.safeParse({
sourceSystem: querySystem ?? undefined,
sourceId: queryId ?? undefined,
+ sourceInstance: queryInstance ?? undefined,
});
if (!parsedKey.success) {
return apiValidationError(
@@ -337,6 +315,7 @@ async function processUpload(
user.id,
queryKey.sourceSystem,
queryKey.sourceId,
+ queryKey.sourceInstance ?? null,
);
if (match) {
return answerSourceKey(
@@ -357,7 +336,7 @@ async function processUpload(
// draw on. The cookie / wildcard bucket is unchanged.
const bucketKey = scoped
? `documents-upload:token:${auth.session.id}`
- : `documents-upload:${user.id}`;
+ : personalUploadBucket(user.id);
const rl = await checkRateLimit(
bucketKey,
scoped ? resolveDocumentUploadLimitPerHour() : UPLOAD_LIMIT_PER_HOUR,
@@ -433,6 +412,7 @@ async function processUpload(
encounterIds: rawEncounterIds.length > 0 ? rawEncounterIds : undefined,
sourceSystem: formData.get("sourceSystem") ?? undefined,
sourceId: formData.get("sourceId") ?? undefined,
+ sourceInstance: formData.get("sourceInstance") ?? undefined,
aiRead: formData.get("aiRead") ?? undefined,
});
if (!parsed.success) {
@@ -450,10 +430,14 @@ async function processUpload(
// both they have to agree.
const formSystem = parsed.data.sourceSystem ?? null;
const formId = formSystem ? (parsed.data.sourceId ?? null) : null;
+ const formInstance = formId ? (parsed.data.sourceInstance ?? null) : null;
if (
queryKey &&
formSystem !== null &&
- (formSystem !== queryKey.sourceSystem || formId !== queryKey.sourceId)
+ (formSystem !== queryKey.sourceSystem ||
+ formId !== queryKey.sourceId ||
+ (formInstance !== null &&
+ formInstance !== (queryKey.sourceInstance ?? null)))
) {
return apiError(
"The source key in the address and in the form differ.",
@@ -463,6 +447,9 @@ async function processUpload(
}
const sourceSystem = queryKey?.sourceSystem ?? formSystem;
const sourceId = queryKey?.sourceId ?? formId;
+ const sourceInstance = sourceId
+ ? (queryKey?.sourceInstance ?? formInstance ?? null)
+ : null;
const ctx: UploadContext = {
userId: user.id,
scoped,
@@ -476,7 +463,12 @@ async function processUpload(
// count (the unique index has no `deleted_at` predicate), and past the purge
// the ledger remembers.
if (sourceSystem && sourceId && !queryKey) {
- const match = await findSourceKey(user.id, sourceSystem, sourceId);
+ const match = await findSourceKey(
+ user.id,
+ sourceSystem,
+ sourceId,
+ sourceInstance,
+ );
if (match) return answerSourceKey(ctx, match);
}
@@ -486,157 +478,59 @@ async function processUpload(
} catch {
return apiError("Failed to read uploaded file", 400);
}
- if (buffer.byteLength > limits.maxFileBytes) {
- return fileTooLarge(limits.maxFileBytes);
- }
- if (buffer.byteLength === 0) {
- return apiError("Uploaded file is empty", 422, {
- errorCode: "documents.inbound.invalidMetadata",
- });
- }
- // §3.1 — magic-byte classification; the wire Content-Type is never trusted.
- const detected = detectDocumentType(
- buffer,
- typeof file.name === "string" ? file.name : null,
- );
- if (!detected) {
- return apiError("This file type is not supported.", 415, {
- errorCode: "documents.inbound.fileType",
- reason: "unsupportedType",
- });
- }
-
- // Pre-linking: every episode id must be a LIVE episode of the caller.
- const episodeIds = await narrowOwnedEpisodeIds(
- user.id,
- parsed.data.episodeIds ?? [],
- );
- if (episodeIds === null) {
- return apiError("Episode not found", 404, {
- errorCode: "documents.inbound.episodeNotFound",
- });
- }
-
- // Same for the visit ids the review step offered. A refusal rather than a
- // silent drop: the person saw a visit named and would otherwise watch the
- // upload succeed without the link they asked for.
- const encounterIds = await narrowOwnedEncounterIds(
- user.id,
- parsed.data.encounterIds ?? [],
- );
- if (encounterIds === null) {
- return apiError("Visit not found", 404, {
- errorCode: "documents.inbound.encounterNotFound",
- });
- }
-
- // sha256 of the PLAINTEXT for same-user duplicate detection.
- const contentSha256 = createHash("sha256").update(buffer).digest("hex");
-
- // Fast-path dedupe check; the partial unique index closes the race below.
- const existing = await prisma.inboundDocument.findFirst({
- where: { userId: user.id, contentSha256, deletedAt: null },
- omit: { contentEncrypted: true },
+ // Everything from here to the stored row is the shared ingest path
+ // (`src/lib/documents/ingest.ts`), the one the document picker's import
+ // stores through as well.
+ const result = await ingestDocument({
+ userId: user.id,
+ scoped,
+ ipAddress: getClientIp(request),
+ bytes: buffer,
+ filename: typeof file.name === "string" ? file.name : null,
+ title: parsed.data.title ?? null,
+ kind: parsed.data.kind ?? null,
+ documentDate: parsed.data.documentDate ?? null,
+ episodeIds: parsed.data.episodeIds ?? [],
+ encounterIds: parsed.data.encounterIds ?? [],
+ sourceSystem,
+ sourceId,
+ sourceInstance,
+ aiDeferred: ctx.aiDeferred,
+ sourceKeyChecked: true,
+ limits,
+ // Only the AI work queued after a fresh insert follows the capability;
+ // the upload itself is data and always accepted.
+ documentAi: () => getAiCapability("documentAi"),
});
- if (existing) {
- // An import sending bytes that are already stored under another key (or
- // none) is answered with that document — and the key is remembered for
- // it, so once the person deletes the document this key stays deleted too.
- if (sourceSystem && sourceId) {
- const remembered = await rememberSourceAlias(
- user.id,
- existing.id,
- sourceSystem,
- sourceId,
- );
- if (remembered === "limit") return aliasLimitResponse();
- }
- return duplicateResponse(ctx, existing);
- }
- // Quota gate + insert + pre-links in ONE transaction. Usage counts every
- // non-purged row — tombstones still hold TOAST bytes, so "deleted" bytes
- // are never invisible weight (undo-delete never changes usage).
- let document: SerialisableDocument;
- try {
- document = await prisma.$transaction(async (tx) => {
- // Serialise the quota gate per user: without this, N concurrent
- // uploads all read the same SUM before any of them commits and the
- // quota can be overshot by up to N × cap in one burst. The advisory
- // lock is transaction-scoped (released on commit/rollback) and keyed
- // on the user id, so uploads by different users never queue on each
- // other.
- // (`pg_advisory_xact_lock` returns void, which the client cannot
- // deserialize as a column — selecting FROM it yields a plain int row.)
- await tx.$queryRaw`
- SELECT 1 AS locked
- FROM pg_advisory_xact_lock(hashtextextended('documents-quota:' || ${user.id}, 0))
- `;
- const rows = await tx.$queryRaw>`
- SELECT COALESCE(SUM(byte_size), 0)::bigint AS used
- FROM inbound_documents
- WHERE user_id = ${user.id}
- `;
- const usedBytes = Number(rows[0]?.used ?? 0);
- if (usedBytes + buffer.byteLength > limits.quotaBytes) {
- throw new QuotaExceededError(usedBytes);
- }
-
- const { content, codec } = encryptDocumentContent(buffer);
-
- // No mass assignment — every column is set field-by-field; `userId`
- // comes from the session, never the body. `documentDate` defaults to
- // the upload day so display == sort == filter (user-editable later).
- const created = await tx.inboundDocument.create({
- data: {
- userId: user.id,
- kind: parsed.data.kind ?? "OTHER",
- // Stored plaintext on purpose (mirrors `filename`) so the list can
- // ILIKE-search + ORDER BY it. It MAY hold PHI the user types; that
- // is the accepted tradeoff for server-side search/sort — the
- // document body stays encrypted.
- title: parsed.data.title ?? null,
- filename:
- typeof file.name === "string" ? file.name.slice(0, 255) : null,
- mimeType: detected.mimeType,
- byteSize: buffer.byteLength,
- contentEncrypted: content,
- contentCodec: codec,
- contentSha256,
- status: "STORED",
- documentDate: parsed.data.documentDate
- ? isoDateToUtc(parsed.data.documentDate)
- : new Date(),
- sourceSystem,
- sourceId,
- aiReadDeferred: ctx.aiDeferred,
- },
- omit: { contentEncrypted: true },
+ switch (result.kind) {
+ case "sourceKey":
+ return answerSourceKey(ctx, result.match);
+ case "tooLarge":
+ return fileTooLarge(result.maxFileBytes);
+ case "empty":
+ return apiError("Uploaded file is empty", 422, {
+ errorCode: "documents.inbound.invalidMetadata",
});
-
- if (episodeIds.length > 0) {
- await linkTargets(tx, {
- userId: user.id,
- sourceKind: "document",
- sourceId: created.id,
- targetKind: "conditionEpisode",
- targetIds: episodeIds,
- });
- }
- if (encounterIds.length > 0) {
- await linkTargets(tx, {
- userId: user.id,
- sourceKind: "document",
- sourceId: created.id,
- targetKind: "encounter",
- targetIds: encounterIds,
- });
- }
- return created;
- });
- } catch (err) {
- if (err instanceof QuotaExceededError) {
+ case "unsupportedType":
+ return apiError("This file type is not supported.", 415, {
+ errorCode: "documents.inbound.fileType",
+ reason: "unsupportedType",
+ });
+ case "episodeNotFound":
+ return apiError("Episode not found", 404, {
+ errorCode: "documents.inbound.episodeNotFound",
+ });
+ case "encounterNotFound":
+ return apiError("Visit not found", 404, {
+ errorCode: "documents.inbound.encounterNotFound",
+ });
+ case "aliasLimit":
+ return aliasLimitResponse();
+ case "duplicate":
+ return duplicateResponse(ctx, result.document);
+ case "quotaExceeded":
// The figures are the owner's; a write-only token learns only that the
// vault is full.
return apiError("Storage quota exceeded.", 413, {
@@ -644,107 +538,13 @@ async function processUpload(
reason: "quotaExceeded",
...(scoped
? {}
- : { quotaBytes: limits.quotaBytes, usedBytes: err.usedBytes }),
+ : { quotaBytes: result.quotaBytes, usedBytes: result.usedBytes }),
});
- }
- if (isP2002(err)) {
- // A racing upload won one of the two partial unique indexes — the same
- // source key, or the same bytes. Surface the winner exactly as the fast
- // paths above would have.
- if (sourceSystem && sourceId) {
- const match = await findSourceKey(user.id, sourceSystem, sourceId);
- if (match) return answerSourceKey(ctx, match);
- }
- const winner = await prisma.inboundDocument.findFirst({
- where: { userId: user.id, contentSha256, deletedAt: null },
- omit: { contentEncrypted: true },
- });
- if (winner) {
- if (sourceSystem && sourceId) {
- const remembered = await rememberSourceAlias(
- user.id,
- winner.id,
- sourceSystem,
- sourceId,
- );
- if (remembered === "limit") return aliasLimitResponse();
- }
- return duplicateResponse(ctx, winner);
- }
- }
- throw err;
- }
-
- await auditLog("documents.inbound.store", {
- userId: user.id,
- ipAddress: getClientIp(request),
- details: {
- documentId: document.id,
- mime: detected.mimeType,
- ...(scoped ? { scoped: true } : {}),
- ...(sourceSystem ? { sourceSystem } : {}),
- },
- });
-
- annotate({
- action: { name: "documents.vault.upload" },
- meta: {
- documentId: document.id,
- byteSize: document.byteSize,
- servingClass: detected.servingClass,
- linked: episodeIds.length,
- linkedVisits: encounterIds.length,
- scoped,
- sourceSystem,
- aiDeferred: ctx.aiDeferred,
- },
- });
-
- // Auto-index the freshly stored document for content search: enqueue a
- // fire-and-forget background job (provider-first, local text-layer fallback).
- // The upload response never blocks on or fails because of indexing — the
- // enqueue is not awaited and swallows its own errors (a missing boss or a
- // transient send failure is a silent no-op). Only fresh inserts enqueue — a
- // duplicate upload returns early above and never reaches here.
- //
- // `aiRead=defer` narrows it to the local text layer: an import of a whole
- // archive must not turn into one provider call per file. The person reads
- // them later, deliberately, from the document itself.
- if (ctx.aiDeferred) {
- void enqueueDocumentIndex(user.id, document.id, { localOnly: true });
- } else {
- void enqueueDocumentIndex(user.id, document.id);
- }
-
- // Render a preview thumbnail in the background too (pure local compute — no
- // egress). Same fire-and-forget contract: the upload never blocks on or fails
- // because of it, and a dropped enqueue is recoverable via the boot backfill.
- // Only fresh inserts reach here (a duplicate returns early above).
- void enqueueDocumentThumbnail(user.id, document.id);
-
- // Summarise the freshly stored document in the background — but ONLY when the
- // `documentsAutoAiRead` opt-in is ON (the job re-checks it and the egress
- // consent, and no-ops otherwise) and the `documentAi` capability is open for
- // this record. The upload itself never depends on AI: the document is stored
- // above whatever the capability says, and the index job still runs its
- // provider-free text-layer path. The persisted summary shows on the detail
- // view. Same fire-and-forget contract: the upload never blocks on or fails
- // because of it. Only fresh inserts reach here (a duplicate returns early).
- //
- // A deferred upload skips it outright and keeps `summaryState: NONE`, so
- // the detail sheet offers "Generate summary" rather than a pending state.
- const documentAi = ctx.aiDeferred
- ? { available: false as const, reason: "deferred" }
- : await getAiCapability("documentAi");
- if (documentAi.available) {
- void enqueueDocumentSummary(user.id, document.id);
- } else {
- annotate({
- action: { name: "documents.summary.enqueueSkipped" },
- meta: { documentId: document.id, reason: documentAi.reason },
- });
+ case "stored":
+ break;
}
+ const document = result.document;
if (scoped) return receiptResponse(document.id, 201, { duplicate: false });
const [links, visitLinks] = await Promise.all([
diff --git a/src/app/api/documents/inbound/source/route.ts b/src/app/api/documents/inbound/source/route.ts
index caada8b66..ec675a97c 100644
--- a/src/app/api/documents/inbound/source/route.ts
+++ b/src/app/api/documents/inbound/source/route.ts
@@ -57,6 +57,7 @@ export const GET = apiHandler(async (request: Request) => {
const parsed = documentSourceKeySchema.safeParse({
sourceSystem: url.searchParams.get("sourceSystem") ?? undefined,
sourceId: url.searchParams.get("sourceId") ?? undefined,
+ sourceInstance: url.searchParams.get("sourceInstance") ?? undefined,
});
if (!parsed.success) {
return apiValidationError(
@@ -71,6 +72,7 @@ export const GET = apiHandler(async (request: Request) => {
user.id,
parsed.data.sourceSystem,
parsed.data.sourceId,
+ parsed.data.sourceInstance ?? null,
);
annotate({
action: { name: "documents.vault.sourceLookup" },
diff --git a/src/app/api/documents/sources/[system]/import/route.ts b/src/app/api/documents/sources/[system]/import/route.ts
new file mode 100644
index 000000000..7da5933af
--- /dev/null
+++ b/src/app/api/documents/sources/[system]/import/route.ts
@@ -0,0 +1,385 @@
+/**
+ * Document picker (#1038): import one picked document from a connected
+ * archive into the vault.
+ *
+ * One document per request, so the sheet can show each one's progress and
+ * result, and no single request holds more than one file in memory. The path:
+ *
+ * 1. the link target, when the picker was opened from a record, must be one
+ * of the person's own live records (404 before anything leaves the
+ * server);
+ * 2. the source key answers first, without a download: a document already
+ * imported (or already holding this key as an alias) is a `duplicate`,
+ * and one the person deleted here is `deleted` — nothing is fetched and
+ * nothing is stored, exactly as the token upload answers the same key;
+ * 3. only then is the source asked (on the search allowance, like every
+ * request to it) and an upload slot taken. One import per key and person
+ * runs at a time under an advisory lock, so a double click downloads the
+ * document once. The details and the original file are fetched (bounded
+ * by the vault's per-file cap); only a file that arrived is charged to
+ * the vault's own upload allowance (60 an hour, shared with uploads from
+ * the web and the phone), and it goes through `ingestDocument`, the same
+ * function the upload route stores through: type check, content dedup,
+ * quota, provenance (`sourceSystem` / `sourceId` / `sourceInstance`), the
+ * background jobs;
+ * 4. the document is linked to the record through `src/lib/links/`.
+ *
+ * AI reading follows the person's own setting, unlike the token upload's
+ * default of holding it back: the person picked this document by hand, as
+ * deliberately as an upload. The hourly allowance and the daily AI budget
+ * bound it either way.
+ *
+ * Cookie-only and owner-only; see `admitDocumentSourceCaller`.
+ */
+import { Buffer } from "node:buffer";
+
+import { NextRequest, NextResponse } from "next/server";
+
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import {
+ apiError,
+ apiValidationError,
+ getClientIp,
+ safeJson,
+ sanitiseZodIssues,
+} from "@/lib/api-response";
+import { getAiCapability } from "@/lib/ai/capabilities/gate";
+import { prisma } from "@/lib/db";
+import {
+ ingestDocument,
+ personalUploadBucket,
+ UPLOAD_LIMIT_PER_HOUR,
+ UPLOAD_WINDOW_MS,
+} from "@/lib/documents/ingest";
+import {
+ checkSourceLookupRateLimit,
+ findSourceKey,
+ type SourceKeyMatch,
+} from "@/lib/documents/source-key";
+import type { SourceClient } from "@/lib/documents/sources/clients";
+import { clientFor, loadConnection } from "@/lib/documents/sources/connections";
+import {
+ admitDocumentSourceCaller,
+ checkSearchRateLimit,
+ responseForSourceFailure,
+ sourceErrorResponse,
+ systemParam,
+} from "@/lib/documents/sources/route-support";
+import {
+ isSourceDocumentId,
+ type DocumentImportOutcome,
+ type DocumentPickerLinkKind,
+ type DocumentPickerSystem,
+ type DocumentSourceImportDto,
+} from "@/lib/documents/sources/types";
+import {
+ acquireDocumentUploadSlot,
+ resolveDocumentLimits,
+} from "@/lib/documents/upload-policy";
+import { linkTargets } from "@/lib/links";
+import { annotate } from "@/lib/logging/context";
+import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
+import { documentSourceImportSchema } from "@/lib/validations/document-sources";
+import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
+
+export const dynamic = "force-dynamic";
+
+type RouteParams = { params: Promise<{ system: string }> };
+
+/** How long an import waits for a database connection to hold its lock on. */
+const IMPORT_LOCK_WAIT_MS = 10_000;
+/** Longest a single import may hold its key: the download plus the store. */
+const IMPORT_LOCK_HOLD_MS = 150_000;
+
+interface LinkTarget {
+ kind: DocumentPickerLinkKind;
+ id: string;
+}
+
+/** Is the record the picker was opened from one of the person's live ones? */
+async function ownsLinkTarget(
+ userId: string,
+ link: LinkTarget,
+): Promise {
+ const where = { id: link.id, userId, deletedAt: null };
+ const select = { id: true } as const;
+ switch (link.kind) {
+ case "conditionEpisode":
+ return (
+ (await prisma.illnessEpisode.findFirst({ where, select })) !== null
+ );
+ case "encounter":
+ return (await prisma.encounter.findFirst({ where, select })) !== null;
+ case "vaccination":
+ return (
+ (await prisma.vaccinationRecord.findFirst({ where, select })) !== null
+ );
+ }
+}
+
+/** File the document against the record, from the record's side. */
+async function linkDocument(
+ userId: string,
+ link: LinkTarget | undefined,
+ documentId: string,
+): Promise {
+ if (!link) return false;
+ const result = await prisma.$transaction((tx) =>
+ linkTargets(tx, {
+ userId,
+ sourceKind: link.kind,
+ sourceId: link.id,
+ targetKind: "document",
+ targetIds: [documentId],
+ }),
+ );
+ return !result.unknownSource && result.unknownTargetIds.length === 0;
+}
+
+function respond(
+ system: string,
+ outcome: DocumentImportOutcome,
+ documentId: string | null,
+ linked: boolean,
+): NextResponse {
+ annotate({
+ action: { name: "documents.sources.import" },
+ meta: { system, outcome, linked, documentId },
+ });
+ const data: DocumentSourceImportDto = { outcome, documentId, linked };
+ return NextResponse.json(
+ { data, error: null },
+ { status: outcome === "imported" ? 201 : 200 },
+ );
+}
+
+/** A key the vault already holds: link a live one, never revive a deleted one. */
+async function answerHeldKey(
+ system: string,
+ userId: string,
+ link: LinkTarget | undefined,
+ match: SourceKeyMatch,
+): Promise {
+ if (match.state === "deleted") {
+ return respond(system, "deleted", match.id, false);
+ }
+ const linked = await linkDocument(userId, link, match.document.id);
+ return respond(system, "duplicate", match.document.id, linked);
+}
+
+export const POST = apiHandler(
+ async (request: NextRequest, { params }: RouteParams) => {
+ const auth = await requireAuth();
+ const refused = await admitDocumentSourceCaller(auth);
+ if (refused) return refused;
+ const system = await systemParam(params);
+ if (system instanceof Response) return system;
+ const userId = auth.user.id;
+
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 4 * 1024,
+ });
+ if (jsonError) return jsonError;
+ const parsed = documentSourceImportSchema.safeParse(body);
+ if (!parsed.success) {
+ return apiValidationError(
+ "Invalid import request",
+ sanitiseZodIssues(parsed.error.issues),
+ 422,
+ );
+ }
+ const { sourceId, kind, link } = parsed.data;
+ // Only an id this system could have issued goes into a request path.
+ if (!isSourceDocumentId(system, sourceId)) {
+ return apiError("Not a document id of this archive.", 422);
+ }
+
+ const row = await loadConnection(userId, system);
+ if (!row) return sourceErrorResponse("notConnected");
+ let client;
+ try {
+ client = clientFor(row);
+ } catch (err) {
+ return responseForSourceFailure(err);
+ }
+ const instance = client.instance;
+
+ if (link && !(await ownsLinkTarget(userId, link))) {
+ return sourceErrorResponse("linkTargetNotFound");
+ }
+
+ // The source key answers before anything is downloaded or charged, on the
+ // same metered lookup allowance the upload's key check draws on.
+ const lookupRl = await checkSourceLookupRateLimit(
+ false,
+ auth.session.id,
+ userId,
+ );
+ if (!lookupRl.allowed) {
+ return sourceErrorResponse(
+ "rateLimited",
+ undefined,
+ rateLimitHeaders(lookupRl),
+ );
+ }
+ const held = await findSourceKey(userId, system, sourceId, instance);
+ if (held) return answerHeldKey(system, userId, link, held);
+
+ // A request to the source counts against the search allowance like any
+ // other; the upload allowance is charged only once a file arrived.
+ const fetchRl = await checkSearchRateLimit(userId);
+ if (!fetchRl.allowed) {
+ return sourceErrorResponse(
+ "rateLimited",
+ undefined,
+ rateLimitHeaders(fetchRl),
+ );
+ }
+
+ const release = acquireDocumentUploadSlot(userId);
+ if (!release) {
+ return apiError(
+ "Too many uploads are already in progress. Try again shortly.",
+ 429,
+ {
+ errorCode: "documents.inbound.uploadBusy",
+ reason: "uploadBusy",
+ headers: { "Retry-After": "1" },
+ },
+ );
+ }
+
+ try {
+ // One import of one key at a time per person: a second tab or a double
+ // click waits here, then finds the key the first one stored, so the
+ // document is downloaded once. The lock is transaction-scoped and the
+ // transaction holds nothing else; the store runs in its own.
+ return await prisma.$transaction(
+ async (tx) => {
+ await tx.$queryRaw`
+ SELECT 1 AS locked
+ FROM pg_advisory_xact_lock(hashtextextended(${`documents-import:${userId}:${system}:${instance}:${sourceId}`}, 0))
+ `;
+ const raced = await findSourceKey(userId, system, sourceId, instance);
+ if (raced) return answerHeldKey(system, userId, link, raced);
+ return importOne({
+ request,
+ client,
+ userId,
+ system,
+ instance,
+ sourceId,
+ kind: kind ?? null,
+ link,
+ });
+ },
+ { maxWait: IMPORT_LOCK_WAIT_MS, timeout: IMPORT_LOCK_HOLD_MS },
+ );
+ } finally {
+ release();
+ }
+ },
+);
+
+/** Fetch one document and store it; the caller holds the key's lock. */
+async function importOne(args: {
+ request: Request;
+ client: SourceClient;
+ userId: string;
+ system: DocumentPickerSystem;
+ instance: string;
+ sourceId: string;
+ kind: InboundDocumentKindValue | null;
+ link: LinkTarget | undefined;
+}): Promise {
+ const { request, client, userId, system, instance, sourceId, kind, link } =
+ args;
+ const limits = await resolveDocumentLimits(userId);
+ let meta;
+ let file;
+ try {
+ meta = await client.document(sourceId);
+ file = await client.download(sourceId, limits.maxFileBytes);
+ } catch (err) {
+ return responseForSourceFailure(err);
+ }
+
+ const rl = await checkRateLimit(
+ personalUploadBucket(userId),
+ UPLOAD_LIMIT_PER_HOUR,
+ UPLOAD_WINDOW_MS,
+ );
+ if (!rl.allowed) {
+ return apiError("Too many uploads. Try again later.", 429, {
+ errorCode: "documents.inbound.rateLimited",
+ headers: rateLimitHeaders(rl),
+ });
+ }
+
+ const result = await ingestDocument({
+ userId,
+ scoped: false,
+ ipAddress: getClientIp(request),
+ bytes: Buffer.from(file.bytes),
+ filename: meta.filename ?? file.filename,
+ title: meta.title,
+ kind,
+ documentDate: meta.date,
+ episodeIds: [],
+ encounterIds: [],
+ sourceSystem: system,
+ sourceId,
+ sourceInstance: instance,
+ aiDeferred: false,
+ sourceKeyChecked: true,
+ limits,
+ // The import is data; only the summary queued after a fresh insert
+ // follows the capability (and the person's auto-read setting).
+ documentAi: () => getAiCapability("documentAi"),
+ });
+
+ switch (result.kind) {
+ case "sourceKey":
+ return answerHeldKey(system, userId, link, result.match);
+ case "duplicate": {
+ // The same bytes were already in the vault (an upload, or another
+ // archive); the key is now remembered for that document.
+ const linked = await linkDocument(userId, link, result.document.id);
+ return respond(system, "duplicate", result.document.id, linked);
+ }
+ case "stored": {
+ const linked = await linkDocument(userId, link, result.document.id);
+ return respond(system, "imported", result.document.id, linked);
+ }
+ case "tooLarge":
+ return apiError("File is too large.", 413, {
+ errorCode: "documents.inbound.fileTooLarge",
+ reason: "fileTooLarge",
+ maxFileBytes: result.maxFileBytes,
+ });
+ case "empty":
+ case "unsupportedType":
+ return apiError("This file type is not supported.", 415, {
+ errorCode: "documents.inbound.fileType",
+ reason: "unsupportedType",
+ });
+ case "quotaExceeded":
+ return apiError("Storage quota exceeded.", 413, {
+ errorCode: "documents.inbound.quotaExceeded",
+ reason: "quotaExceeded",
+ quotaBytes: result.quotaBytes,
+ usedBytes: result.usedBytes,
+ });
+ case "aliasLimit":
+ return apiError(
+ "This file is already stored under too many source ids.",
+ 409,
+ { errorCode: "documents.inbound.sourceAliasLimit" },
+ );
+ case "episodeNotFound":
+ case "encounterNotFound":
+ // No pre-links are passed on this path; unreachable, but a result
+ // the switch does not name must not fall through silently.
+ return sourceErrorResponse("linkTargetNotFound");
+ }
+}
diff --git a/src/app/api/documents/sources/[system]/route.ts b/src/app/api/documents/sources/[system]/route.ts
new file mode 100644
index 000000000..91cfb0dba
--- /dev/null
+++ b/src/app/api/documents/sources/[system]/route.ts
@@ -0,0 +1,204 @@
+/**
+ * Document picker (#1038): save or remove the person's connection to one
+ * document archive (`paperless` or `papra`).
+ *
+ * PUT validates the base address against the operator's list
+ * (`DOCUMENT_SOURCE_ORIGINS`), tests the connection live before storing
+ * anything (a wrong token or an unreachable host is reported here, not on the
+ * first search), then stores the token encrypted. A PUT without a token keeps
+ * the stored one, so the base path or the Papra organization can change
+ * without typing the key again, but only on the same origin: a new origin, and
+ * the first save, must carry it (422 `documents.sources.tokenRequired`). The response is the
+ * connection without its token, like every read of it.
+ *
+ * DELETE removes the row, and with it the token.
+ *
+ * Cookie-only and owner-only; see `admitDocumentSourceCaller`.
+ */
+import { NextRequest } from "next/server";
+
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import {
+ apiError,
+ apiSuccess,
+ apiValidationError,
+ getClientIp,
+ safeJson,
+ sanitiseZodIssues,
+} from "@/lib/api-response";
+import { auditLog } from "@/lib/auth/audit";
+import { decrypt, encrypt } from "@/lib/crypto";
+import { prisma } from "@/lib/db";
+import { sourceClient } from "@/lib/documents/sources/clients";
+import {
+ loadConnection,
+ toConnectionDto,
+} from "@/lib/documents/sources/connections";
+import { DocumentSourceError } from "@/lib/documents/sources/http";
+import { evaluateSourceBaseUrl } from "@/lib/documents/sources/origins";
+import {
+ admitDocumentSourceCaller,
+ checkConnectRateLimit,
+ responseForSourceFailure,
+ sourceErrorResponse,
+ systemParam,
+} from "@/lib/documents/sources/route-support";
+import { annotate } from "@/lib/logging/context";
+import { rateLimitHeaders } from "@/lib/rate-limit";
+import { documentSourceSaveSchema } from "@/lib/validations/document-sources";
+
+export const dynamic = "force-dynamic";
+
+type RouteParams = { params: Promise<{ system: string }> };
+
+export const PUT = apiHandler(
+ async (request: NextRequest, { params }: RouteParams) => {
+ const auth = await requireAuth();
+ const refused = await admitDocumentSourceCaller(auth);
+ if (refused) return refused;
+ const system = await systemParam(params);
+ if (system instanceof Response) return system;
+ const userId = auth.user.id;
+ annotate({ action: { name: "documents.sources.save" }, meta: { system } });
+
+ const rl = await checkConnectRateLimit(userId);
+ if (!rl.allowed) {
+ return sourceErrorResponse(
+ "rateLimited",
+ undefined,
+ rateLimitHeaders(rl),
+ );
+ }
+
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 8 * 1024,
+ });
+ if (jsonError) return jsonError;
+ const parsed = documentSourceSaveSchema.safeParse(body);
+ if (!parsed.success) {
+ return apiValidationError(
+ "Invalid connection details",
+ sanitiseZodIssues(parsed.error.issues),
+ 422,
+ );
+ }
+
+ const verdict = evaluateSourceBaseUrl(parsed.data.baseUrl);
+ if (!verdict.ok) {
+ return verdict.reason === "notAllowed"
+ ? sourceErrorResponse("originNotAllowed")
+ : apiError("Enter the full address, starting with https://.", 422, {
+ errorCode: "documents.sources.invalidAddress",
+ });
+ }
+
+ const organizationId =
+ system === "PAPRA" ? (parsed.data.organizationId ?? null) : null;
+ if (system === "PAPRA" && !organizationId) {
+ return apiError("Papra needs the organization id.", 422, {
+ errorCode: "documents.sources.organizationRequired",
+ });
+ }
+
+ const existing = await loadConnection(userId, system);
+ // The stored token is only ever sent back to the origin it was saved
+ // for. A new address on another origin needs the token typed again, or
+ // saving an address would hand the old archive's token to whatever the
+ // new one is.
+ const reusable =
+ existing !== null &&
+ evaluateSourceBaseUrl(existing.baseUrl, new Set([verdict.origin])).ok;
+ const token =
+ parsed.data.token ??
+ (existing && reusable ? decrypt(existing.tokenEncrypted) : null);
+ if (!token) {
+ return apiError("Enter the API token.", 422, {
+ errorCode: "documents.sources.tokenRequired",
+ });
+ }
+
+ // Live check before anything is stored: the row only ever holds a
+ // connection that answered once.
+ try {
+ await sourceClient({
+ system,
+ origin: verdict.origin,
+ baseUrl: verdict.baseUrl,
+ organizationId,
+ token,
+ }).test();
+ } catch (err) {
+ if (err instanceof DocumentSourceError) {
+ annotate({ meta: { refused: err.code } });
+ }
+ return responseForSourceFailure(err);
+ }
+
+ const now = new Date();
+ const tokenEncrypted = encrypt(token);
+ // Field by field; `userId` from the session, never the body.
+ const saved = await prisma.documentSourceConnection.upsert({
+ where: { userId_system: { userId, system } },
+ create: {
+ userId,
+ system,
+ baseUrl: verdict.baseUrl,
+ organizationId,
+ tokenEncrypted,
+ lastVerifiedAt: now,
+ },
+ update: {
+ baseUrl: verdict.baseUrl,
+ organizationId,
+ tokenEncrypted,
+ lastVerifiedAt: now,
+ },
+ select: {
+ system: true,
+ baseUrl: true,
+ organizationId: true,
+ lastVerifiedAt: true,
+ },
+ });
+
+ await auditLog("documents.sources.connect", {
+ userId,
+ ipAddress: getClientIp(request),
+ details: { system, origin: verdict.origin, replaced: existing !== null },
+ });
+
+ return apiSuccess(toConnectionDto(saved));
+ },
+);
+
+export const DELETE = apiHandler(
+ async (request: NextRequest, { params }: RouteParams) => {
+ const auth = await requireAuth();
+ // Neither the list nor the module is needed to forget a connection: an
+ // operator who removed the origin, or a person who switched Documents
+ // off, must still be able to delete the stored token.
+ const refused = await admitDocumentSourceCaller(auth, {
+ needsList: false,
+ needsModule: false,
+ });
+ if (refused) return refused;
+ const system = await systemParam(params);
+ if (system instanceof Response) return system;
+ annotate({
+ action: { name: "documents.sources.disconnect" },
+ meta: { system },
+ });
+
+ const { count } = await prisma.documentSourceConnection.deleteMany({
+ where: { userId: auth.user.id, system },
+ });
+ if (count > 0) {
+ await auditLog("documents.sources.disconnect", {
+ userId: auth.user.id,
+ ipAddress: getClientIp(request),
+ details: { system },
+ });
+ }
+ return apiSuccess({ disconnected: count > 0 });
+ },
+);
diff --git a/src/app/api/documents/sources/[system]/search/route.ts b/src/app/api/documents/sources/[system]/search/route.ts
new file mode 100644
index 000000000..edaf7a9c3
--- /dev/null
+++ b/src/app/api/documents/sources/[system]/search/route.ts
@@ -0,0 +1,159 @@
+/**
+ * Document picker (#1038): search one connected document archive.
+ *
+ * `q` searches the document's name (the title in Paperless-ngx, the name in
+ * Papra), `tag` narrows to one of the source's tags by its id, `from` / `to`
+ * to a date range (inclusive), `page` pages through 25 at a time. The answer
+ * is a minimal row per document (source id, title, date, tag names, size when
+ * the source says it) plus what HealthLog already holds under that source key:
+ * `new`, `imported` (with the vault document's id) or `deleted` (the person
+ * deleted it here; importing it again is refused). Nothing is stored.
+ *
+ * When the source ignores a filter and the local re-check empties a page, the
+ * route reads on (up to five source pages) and answers with the last page it
+ * read, so the client's "load more" continues after it.
+ *
+ * Sixty searches a minute, shared with the tag read. Cookie-only and
+ * owner-only; see `admitDocumentSourceCaller`.
+ */
+import { NextRequest } from "next/server";
+
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import {
+ apiSuccess,
+ apiValidationError,
+ sanitiseZodIssues,
+} from "@/lib/api-response";
+import { findSourceKeyStates } from "@/lib/documents/source-key";
+import type { SourceListItem } from "@/lib/documents/sources/clients";
+import { openConnection } from "@/lib/documents/sources/connections";
+import {
+ admitDocumentSourceCaller,
+ checkSearchRateLimit,
+ responseForSourceFailure,
+ sourceErrorResponse,
+ systemParam,
+} from "@/lib/documents/sources/route-support";
+import {
+ DOCUMENT_PICKER_MAX_PAGE,
+ type DocumentSourceResultDto,
+ type DocumentSourceSearchDto,
+} from "@/lib/documents/sources/types";
+import { annotate } from "@/lib/logging/context";
+import { rateLimitHeaders } from "@/lib/rate-limit";
+import { documentSourceSearchSchema } from "@/lib/validations/document-sources";
+
+export const dynamic = "force-dynamic";
+
+/** Source pages one search request may read to find a non-empty one. */
+const SEARCH_PAGES_PER_REQUEST = 5;
+
+type RouteParams = { params: Promise<{ system: string }> };
+
+export const GET = apiHandler(
+ async (request: NextRequest, { params }: RouteParams) => {
+ const auth = await requireAuth();
+ const refused = await admitDocumentSourceCaller(auth);
+ if (refused) return refused;
+ const system = await systemParam(params);
+ if (system instanceof Response) return system;
+ const userId = auth.user.id;
+
+ const url = new URL(request.url);
+ const parsed = documentSourceSearchSchema.safeParse({
+ q: url.searchParams.get("q") ?? undefined,
+ tag: url.searchParams.get("tag") ?? undefined,
+ from: url.searchParams.get("from") ?? undefined,
+ to: url.searchParams.get("to") ?? undefined,
+ page: url.searchParams.get("page") ?? undefined,
+ });
+ if (!parsed.success) {
+ return apiValidationError(
+ "Invalid search",
+ sanitiseZodIssues(parsed.error.issues),
+ 422,
+ );
+ }
+ const { q, tag, from, to, page } = parsed.data;
+
+ const rl = await checkSearchRateLimit(userId);
+ if (!rl.allowed) {
+ return sourceErrorResponse(
+ "rateLimited",
+ undefined,
+ rateLimitHeaders(rl),
+ );
+ }
+
+ // A page the local tag/date re-check emptied is not an answer: the next
+ // source page may hold matches. Read on, a bounded number of pages, and
+ // report the last one read so "load more" continues after it.
+ let found: { items: SourceListItem[]; hasMore: boolean } = {
+ items: [],
+ hasMore: false,
+ };
+ let lastPage = page;
+ let instance: string;
+ try {
+ const client = await openConnection(userId, system);
+ instance = client.instance;
+ for (
+ let next = page;
+ next < page + SEARCH_PAGES_PER_REQUEST &&
+ next <= DOCUMENT_PICKER_MAX_PAGE;
+ next += 1
+ ) {
+ found = await client.search({
+ q,
+ tagId: tag ?? null,
+ from: from ?? null,
+ to: to ?? null,
+ page: next,
+ });
+ lastPage = next;
+ if (found.items.length > 0 || !found.hasMore) break;
+ }
+ } catch (err) {
+ return responseForSourceFailure(err);
+ }
+
+ const states = await findSourceKeyStates(
+ userId,
+ system,
+ found.items.map((item) => item.sourceId),
+ instance,
+ );
+ const results: DocumentSourceResultDto[] = found.items.map((item) => {
+ const held = states.get(item.sourceId);
+ return {
+ sourceId: item.sourceId,
+ title: item.title,
+ date: item.date,
+ tags: item.tags,
+ sizeBytes: item.sizeBytes,
+ state: held?.state ?? "new",
+ documentId: held?.state === "imported" ? held.documentId : null,
+ };
+ });
+
+ // Counts only: the query and the titles are the person's own words and
+ // stay out of the wide event.
+ annotate({
+ action: { name: "documents.sources.search" },
+ meta: {
+ system,
+ page,
+ pagesRead: lastPage - page + 1,
+ count: results.length,
+ filtered: Boolean(q || tag || from || to),
+ },
+ });
+
+ const body: DocumentSourceSearchDto = {
+ results,
+ page: lastPage,
+ hasMore: found.hasMore && lastPage < DOCUMENT_PICKER_MAX_PAGE,
+ };
+ return apiSuccess(body);
+ },
+);
diff --git a/src/app/api/documents/sources/[system]/tags/route.ts b/src/app/api/documents/sources/[system]/tags/route.ts
new file mode 100644
index 000000000..4b788608c
--- /dev/null
+++ b/src/app/api/documents/sources/[system]/tags/route.ts
@@ -0,0 +1,52 @@
+/**
+ * Document picker (#1038): the tags of one connected document archive, for the
+ * picker's tag filter. The first 250 by name, as `{ id, name }` in the
+ * source's own ids. Shares the search allowance (60 a minute).
+ *
+ * Cookie-only and owner-only; see `admitDocumentSourceCaller`.
+ */
+import { NextRequest } from "next/server";
+
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { apiSuccess } from "@/lib/api-response";
+import { openConnection } from "@/lib/documents/sources/connections";
+import {
+ admitDocumentSourceCaller,
+ checkSearchRateLimit,
+ responseForSourceFailure,
+ sourceErrorResponse,
+ systemParam,
+} from "@/lib/documents/sources/route-support";
+import { annotate } from "@/lib/logging/context";
+import { rateLimitHeaders } from "@/lib/rate-limit";
+
+export const dynamic = "force-dynamic";
+
+type RouteParams = { params: Promise<{ system: string }> };
+
+export const GET = apiHandler(
+ async (_request: NextRequest, { params }: RouteParams) => {
+ const auth = await requireAuth();
+ const refused = await admitDocumentSourceCaller(auth);
+ if (refused) return refused;
+ const system = await systemParam(params);
+ if (system instanceof Response) return system;
+ annotate({ action: { name: "documents.sources.tags" }, meta: { system } });
+
+ const rl = await checkSearchRateLimit(auth.user.id);
+ if (!rl.allowed) {
+ return sourceErrorResponse(
+ "rateLimited",
+ undefined,
+ rateLimitHeaders(rl),
+ );
+ }
+
+ try {
+ const client = await openConnection(auth.user.id, system);
+ return apiSuccess({ tags: await client.tags() });
+ } catch (err) {
+ return responseForSourceFailure(err);
+ }
+ },
+);
diff --git a/src/app/api/documents/sources/[system]/test/route.ts b/src/app/api/documents/sources/[system]/test/route.ts
new file mode 100644
index 000000000..536b0c7d1
--- /dev/null
+++ b/src/app/api/documents/sources/[system]/test/route.ts
@@ -0,0 +1,71 @@
+/**
+ * Document picker (#1038): test the saved connection to one document archive.
+ *
+ * One request to the source with the stored token, answered `{ ok, latencyMs }`
+ * or with the reason it failed (`meta.errorCode`, and `meta.upstreamStatus`
+ * when the source answered at all; its body is never passed on). A success
+ * stamps `lastVerifiedAt`. Shares the save's allowance of ten a minute.
+ *
+ * Cookie-only and owner-only; see `admitDocumentSourceCaller`.
+ */
+import { NextRequest } from "next/server";
+
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { apiSuccess } from "@/lib/api-response";
+import {
+ markVerified,
+ openConnection,
+} from "@/lib/documents/sources/connections";
+import { DocumentSourceError } from "@/lib/documents/sources/http";
+import {
+ admitDocumentSourceCaller,
+ checkConnectRateLimit,
+ responseForSourceFailure,
+ sourceErrorResponse,
+ systemParam,
+} from "@/lib/documents/sources/route-support";
+import { annotate } from "@/lib/logging/context";
+import { rateLimitHeaders } from "@/lib/rate-limit";
+
+export const dynamic = "force-dynamic";
+
+type RouteParams = { params: Promise<{ system: string }> };
+
+export const POST = apiHandler(
+ async (_request: NextRequest, { params }: RouteParams) => {
+ const auth = await requireAuth();
+ const refused = await admitDocumentSourceCaller(auth);
+ if (refused) return refused;
+ const system = await systemParam(params);
+ if (system instanceof Response) return system;
+ const userId = auth.user.id;
+ annotate({ action: { name: "documents.sources.test" }, meta: { system } });
+
+ const rl = await checkConnectRateLimit(userId);
+ if (!rl.allowed) {
+ return sourceErrorResponse(
+ "rateLimited",
+ undefined,
+ rateLimitHeaders(rl),
+ );
+ }
+
+ const start = performance.now();
+ try {
+ const client = await openConnection(userId, system);
+ await client.test();
+ } catch (err) {
+ if (err instanceof DocumentSourceError) {
+ annotate({ meta: { refused: err.code } });
+ }
+ return responseForSourceFailure(err);
+ }
+ const latencyMs = Math.round(performance.now() - start);
+ const verifiedAt = await markVerified(userId, system);
+ return apiSuccess({
+ ok: true,
+ latencyMs,
+ lastVerifiedAt: verifiedAt.toISOString(),
+ });
+ },
+);
diff --git a/src/app/api/documents/sources/route.ts b/src/app/api/documents/sources/route.ts
new file mode 100644
index 000000000..e79f4b22f
--- /dev/null
+++ b/src/app/api/documents/sources/route.ts
@@ -0,0 +1,61 @@
+/**
+ * Document picker (#1038): which document archives this person can pick from.
+ *
+ * `available` says whether the operator listed any origin in
+ * `DOCUMENT_SOURCE_ORIGINS`; `allowedOrigins` lists them so the settings card
+ * can say which addresses will be accepted; `connections` are the person's own
+ * saved connections, never with their token (`hasToken` is the literal true).
+ * No request leaves the server here.
+ *
+ * Cookie-only and owner-only like every route under this path
+ * (`admitDocumentSourceCaller`), but it needs neither the documents module nor
+ * the list: with the list unset it answers 200 `available: false` (plus any
+ * rows still stored, so they can be deleted) rather than 404, because this is
+ * the one read the client makes to decide what to show.
+ */
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { apiSuccess } from "@/lib/api-response";
+import {
+ listConnections,
+ toConnectionDto,
+} from "@/lib/documents/sources/connections";
+import {
+ documentSourcesAvailable,
+ listedDocumentSourceOrigins,
+} from "@/lib/documents/sources/origins";
+import type { DocumentSourcesStatusDto } from "@/lib/documents/sources/types";
+import { admitDocumentSourceCaller } from "@/lib/documents/sources/route-support";
+import { annotate } from "@/lib/logging/context";
+
+export const dynamic = "force-dynamic";
+
+export const GET = apiHandler(async () => {
+ const auth = await requireAuth();
+ const refused = await admitDocumentSourceCaller(auth, {
+ needsList: false,
+ needsModule: false,
+ });
+ if (refused) return refused;
+
+ annotate({ action: { name: "documents.sources.status" } });
+
+ // Rows are listed even with the picker off: a connection whose origin the
+ // operator removed (or whose list is gone) must stay visible so it can be
+ // deleted. `originAllowed` is false for each of them then.
+ const rows = await listConnections(auth.user.id);
+ if (!documentSourcesAvailable()) {
+ const off: DocumentSourcesStatusDto = {
+ available: false,
+ allowedOrigins: [],
+ connections: rows.map(toConnectionDto),
+ };
+ return apiSuccess(off);
+ }
+
+ const status: DocumentSourcesStatusDto = {
+ available: true,
+ allowedOrigins: listedDocumentSourceOrigins(),
+ connections: rows.map(toConnectionDto),
+ };
+ return apiSuccess(status);
+});
diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts
index 4f6ea8736..a6a01a3e6 100644
--- a/src/app/api/export/__tests__/per-type-routes.test.ts
+++ b/src/app/api/export/__tests__/per-type-routes.test.ts
@@ -12,6 +12,15 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
+ session: {
+ findUnique: vi.fn(async () => ({
+ createdAt: new Date(),
+ mfaVerifiedAt: null,
+ reproofAt: null,
+ })),
+ },
+ webauthnMfaCredential: { count: vi.fn(async () => 0) },
+ passkey: { count: vi.fn(async () => 0) },
measurement: { findMany: vi.fn() },
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
diff --git a/src/app/api/export/__tests__/soft-delete-filter.test.ts b/src/app/api/export/__tests__/soft-delete-filter.test.ts
index ff386b3ec..45cb98d38 100644
--- a/src/app/api/export/__tests__/soft-delete-filter.test.ts
+++ b/src/app/api/export/__tests__/soft-delete-filter.test.ts
@@ -31,9 +31,17 @@ vi.mock("@/lib/db", () => ({
consentReceipt: { findMany: vi.fn() },
nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) },
user: { findUnique: vi.fn() },
- passkey: { findMany: vi.fn() },
+ passkey: { findMany: vi.fn(), count: vi.fn(async () => 0) },
apiToken: { count: vi.fn() },
- session: { count: vi.fn() },
+ session: {
+ count: vi.fn(),
+ findUnique: vi.fn(async () => ({
+ createdAt: new Date(),
+ mfaVerifiedAt: null,
+ reproofAt: null,
+ })),
+ },
+ webauthnMfaCredential: { count: vi.fn(async () => 0) },
appSettings: { findUnique: vi.fn() },
userAchievement: {
findMany: vi.fn(),
@@ -310,3 +318,42 @@ describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
}
});
});
+
+describe("GET /api/export — the whole record asks for a fresh proof", () => {
+ const stale = {
+ createdAt: new Date(Date.now() - 60 * 60 * 1000),
+ mfaVerifiedAt: null,
+ reproofAt: null,
+ };
+
+ it("type=all on a session that signed in an hour ago is asked to confirm", async () => {
+ vi.mocked(prisma.session.findUnique).mockResolvedValueOnce(stale as never);
+ const { GET } = await import("../route");
+ const res = await GET(mkReq("http://localhost/api/export?type=all"));
+ expect(res.status).toBe(401);
+ expect((await res.json()).meta.errorCode).toBe("auth.reproof.required");
+ expect(prisma.measurement.findMany).not.toHaveBeenCalled();
+ });
+
+ it("no type means the whole record, and is gated the same way", async () => {
+ vi.mocked(prisma.session.findUnique).mockResolvedValueOnce(stale as never);
+ const { GET } = await import("../route");
+ const res = await GET(mkReq("http://localhost/api/export"));
+ expect(res.status).toBe(401);
+ });
+
+ it("type=all right after signing in goes through", async () => {
+ const { GET } = await import("../route");
+ const res = await GET(mkReq("http://localhost/api/export?type=all"));
+ expect(res.status).toBe(200);
+ });
+
+ it("a single-type export stays on the session alone", async () => {
+ vi.mocked(prisma.session.findUnique).mockResolvedValue(stale as never);
+ const { GET } = await import("../route");
+ const res = await GET(
+ mkReq("http://localhost/api/export?type=measurements"),
+ );
+ expect(res.status).toBe(200);
+ });
+});
diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts
index d98b3ebf5..0f716e5d0 100644
--- a/src/app/api/export/encrypted/__tests__/route.test.ts
+++ b/src/app/api/export/encrypted/__tests__/route.test.ts
@@ -75,6 +75,7 @@ vi.mock("@/lib/db", () => ({
inboundDocument: { findMany: vi.fn().mockResolvedValue([]) },
session: { findUnique: vi.fn() },
webauthnMfaCredential: { count: vi.fn() },
+ passkey: { count: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
@@ -114,6 +115,14 @@ beforeEach(() => {
// Default: no registered security key. Tests that exercise the
// WebAuthn-only cohort override this.
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
+ vi.mocked(prisma.passkey.count).mockResolvedValue(0 as never);
+ // Default: a session that signed in a moment ago. The cases about the
+ // recent-proof gate set their own stamps.
+ vi.mocked(prisma.session.findUnique).mockResolvedValue({
+ createdAt: new Date(),
+ mfaVerifiedAt: null,
+ reproofAt: null,
+ } as never);
});
describe("POST /api/export/encrypted", () => {
@@ -147,6 +156,38 @@ describe("POST /api/export/encrypted", () => {
});
});
+ it("refuses a single-factor account whose session is not recent (401 reproof)", async () => {
+ vi.mocked(getSession).mockResolvedValue({
+ user: { id: "user-1", role: "USER", totpConfirmedAt: null },
+ session: { id: "sess-1" },
+ } as never);
+ vi.mocked(prisma.session.findUnique).mockResolvedValue({
+ createdAt: new Date(Date.now() - 60 * 60 * 1000),
+ mfaVerifiedAt: null,
+ reproofAt: null,
+ } as never);
+
+ const res = await POST(mkReq({ passphrase: PASSPHRASE }));
+ expect(res.status).toBe(401);
+ const body = await res.json();
+ expect(body.meta.errorCode).toBe("auth.reproof.required");
+ });
+
+ it("allows a single-factor account after a password re-proof", async () => {
+ vi.mocked(getSession).mockResolvedValue({
+ user: { id: "user-1", role: "USER", totpConfirmedAt: null },
+ session: { id: "sess-1" },
+ } as never);
+ vi.mocked(prisma.session.findUnique).mockResolvedValue({
+ createdAt: new Date(Date.now() - 60 * 60 * 1000),
+ mfaVerifiedAt: null,
+ reproofAt: new Date(),
+ } as never);
+
+ const res = await POST(mkReq({ passphrase: PASSPHRASE }));
+ expect(res.status).toBe(200);
+ });
+
it("blocks an MFA account without a fresh second factor (401 step-up)", async () => {
vi.mocked(getSession).mockResolvedValue({
user: { id: "user-1", role: "USER", totpConfirmedAt: new Date() },
diff --git a/src/app/api/export/encrypted/route.ts b/src/app/api/export/encrypted/route.ts
index bd19ae009..70acef73a 100644
--- a/src/app/api/export/encrypted/route.ts
+++ b/src/app/api/export/encrypted/route.ts
@@ -9,10 +9,9 @@
* `application/octet-stream`.
*
* SECURITY:
- * - Step-up: when the account has any second factor enrolled (TOTP OR a
- * security key), this is gated by `requireFreshMfaIfEnrolled` — exporting the
- * whole record is a sensitive action. Single-factor accounts fall back to a
- * normal authenticated session.
+ * - Fresh proof: `requireRecentProof`, the gate every whole-record export
+ * shares. A second factor within five minutes on an account that has one;
+ * otherwise a recent sign-in or a password re-proof.
* - The passphrase NEVER hits a log or wide-event: it is read off the parsed
* body, passed straight into the KDF, and never `annotate()`d. The egress
* redaction denylist already scrubs `/passphrase/i` (key-name) and the
@@ -20,19 +19,16 @@
* - There is NO server-side recovery — the passphrase is not stored. A
* forgotten passphrase means the archive is unrecoverable; the UI says so.
*
- * Auth: cookie session OR Bearer for single-factor accounts; cookie + fresh
- * second factor for MFA accounts (Bearer can never satisfy step-up).
+ * Auth: cookie session with a recent proof; Bearer on the token alone for an
+ * account without a second factor, with a second-factor `X-Step-Up`
+ * elevation for one with.
* Rate-limit: shared `export:` bucket (10/h) — same bucket as the
* plaintext export so the encrypted variant cannot be used to bypass the cap.
* Audit: `user.export.encrypted` with the row counts (never the passphrase).
*/
import { prisma } from "@/lib/db";
import { z } from "zod/v4";
-import {
- apiHandler,
- requireFreshMfaIfEnrolled,
- MFA_STEP_UP_MAX_AGE_SECONDS,
-} from "@/lib/api-handler";
+import { apiHandler, requireRecentProof } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -92,13 +88,13 @@ const encryptedExportSchema = z
.strict();
export const POST = apiHandler(async (request: NextRequest) => {
- // Resolve the session (cookie or Bearer), then escalate to a fresh second
- // factor when the account has one enrolled. `requireFreshMfaIfEnrolled`
- // covers BOTH cohorts — a confirmed TOTP secret AND a registered WebAuthn
- // security key — so a security-key-only account is gated too. A single-factor
- // account cannot produce a fresh-MFA proof, so it passes straight through
- // rather than being locked out of its own encrypted export.
- const auth = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS);
+ // A fresh proof, like every whole-record export (`requireRecentProof`). On
+ // the cookie path: a second factor within five minutes on an account that
+ // has one, otherwise a recent sign-in or password re-proof. On Bearer the
+ // shipped app calls this route without an elevation, so an account without
+ // a second factor still passes on its token; one with a second factor
+ // presents a second-factor elevation (before, it could not export at all).
+ const auth = await requireRecentProof({ bearer: "elevation-if-enrolled" });
const user = auth.user;
annotate({ action: { name: "user.export.encrypted" } });
@@ -119,6 +115,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
return returnAllZodIssues(parsed.error);
}
const { passphrase } = parsed.data;
+ await auth.commitElevation();
// Sealed as it is produced and spooled to a temporary file, then sent: the
// format puts the tag in front of the ciphertext, so the archive cannot go
diff --git a/src/app/api/export/full-backup/route.ts b/src/app/api/export/full-backup/route.ts
index b36201c92..a0581850a 100644
--- a/src/app/api/export/full-backup/route.ts
+++ b/src/app/api/export/full-backup/route.ts
@@ -25,12 +25,16 @@
* included; the payload's `manifest` field discloses both, and the export UI
* states this too.
*
- * Auth: cookie session OR Bearer token (`requireAuth`).
+ * Auth: a fresh proof (`requireRecentProof`). The file is the whole record in
+ * plain text, so a live session alone is not enough: a cookie session needs a
+ * recent sign-in or re-proof (a second factor on an account that has one), a
+ * Bearer caller an `X-Step-Up` elevation. No shipped client calls this route
+ * on Bearer.
* Rate-limit: shared `export:` bucket (10/h).
* Audit: `user.export.full-backup` with the row counts.
*/
import { prisma } from "@/lib/db";
-import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { apiHandler, requireRecentProof } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { apiError, getClientIp } from "@/lib/api-response";
@@ -41,13 +45,15 @@ import { streamToResponseBody } from "@/lib/export/response-stream";
import { NextRequest, NextResponse } from "next/server";
export const GET = apiHandler(async (request: NextRequest) => {
- const { user } = await requireAuth();
+ const auth = await requireRecentProof({ bearer: "elevation" });
+ const { user } = auth;
annotate({ action: { name: "user.export.full-backup" } });
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
if (!rl.allowed) {
return apiError("Maximum 10 exports per hour", 429);
}
+ await auth.commitElevation();
// Written into the response as it is produced (`streamFullBackupJson`), not
// built and stringified first: for an account of 1.25 million measurements
diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts
index 9a761419c..664e6fc4f 100644
--- a/src/app/api/export/health-record/__tests__/route.test.ts
+++ b/src/app/api/export/health-record/__tests__/route.test.ts
@@ -32,6 +32,17 @@ vi.mock("@/lib/db", () => ({
userHealthProfile: { findUnique: vi.fn() },
healthProfileFactRevision: { findMany: vi.fn() },
user: { findUnique: vi.fn(), update: vi.fn() },
+ // The whole-record gate reads the session's own stamps: a sign-in a
+ // moment ago is the proof, on an account without a second factor.
+ session: {
+ findUnique: vi.fn(async () => ({
+ createdAt: new Date(),
+ mfaVerifiedAt: null,
+ reproofAt: null,
+ })),
+ },
+ webauthnMfaCredential: { count: vi.fn(async () => 0) },
+ passkey: { count: vi.fn(async () => 0) },
},
toJson: (value: unknown) => value,
}));
@@ -908,3 +919,18 @@ describe("POST /api/export/health-record — the insurance leaf", () => {
expect(text).toContain("A123456789");
});
});
+
+describe("POST /api/export/health-record — a fresh proof on the browser", () => {
+ it("a session that signed in an hour ago is asked to confirm first", async () => {
+ vi.mocked(prisma.session.findUnique).mockResolvedValueOnce({
+ createdAt: new Date(Date.now() - 60 * 60 * 1000),
+ mfaVerifiedAt: null,
+ reproofAt: null,
+ } as never);
+ const { POST } = await import("../route");
+ const res = await POST(mkReq({ format: "pdf", selection: allLeaves() }));
+ expect(res.status).toBe(401);
+ expect((await res.json()).meta.errorCode).toBe("auth.reproof.required");
+ expect(prisma.measurement.findMany).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/api/export/health-record/route.ts b/src/app/api/export/health-record/route.ts
index 9937fa4b8..282da0731 100644
--- a/src/app/api/export/health-record/route.ts
+++ b/src/app/api/export/health-record/route.ts
@@ -22,7 +22,11 @@
*/
import { NextRequest, NextResponse } from "next/server";
-import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
+import {
+ apiHandler,
+ assertRecentCookieProof,
+ requireRecordAuth,
+} from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -88,7 +92,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
// v1.37.0 — MANAGE: the doctor report is the one export an invited manager
// reaches, because it renders a prepared artefact from a declared selection
// rather than handing over the record whole.
- const { user, actor } = await requireRecordAuth("manage", "record");
+ const auth = await requireRecordAuth("manage", "record");
+ const { user, actor } = auth;
+ // A whole-record artefact: a browser session must have signed in or
+ // re-proved within five minutes, the gate every whole-record export takes.
+ // The proof is the person at the keyboard, so it is the actor's session
+ // that is asked, whether they export their own record or one they manage.
+ // A token is its own proof here, as before: the shipped app calls this
+ // route with its token and no elevation.
+ if (auth.authMethod === "cookie") {
+ await assertRecentCookieProof(actor, auth.session.id);
+ }
annotate({ action: { name: "export.health-record.build" } });
// The whole doctor-report surface is the `doctorReport` module. Refuse with
diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts
index 82a2c133e..0e26e7560 100644
--- a/src/app/api/export/route.ts
+++ b/src/app/api/export/route.ts
@@ -1,5 +1,5 @@
import { prisma } from "@/lib/db";
-import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { apiHandler, requireAuth, requireRecentProof } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { apiError, getClientIp } from "@/lib/api-response";
@@ -24,12 +24,23 @@ import { NextRequest, NextResponse } from "next/server";
* Export user data.
* Query params:
* format: "csv" | "json" (default: json)
- * type: "measurements" | "medications" | "intake" | "all" (default: all)
+ * type: "measurements" | "medications" | "intake" | "mood" | "all"
+ * (default: all)
*
- * Re-authentication is required via session (user must be logged in).
+ * `type=all` (the default) is the whole record in one file, so it takes the
+ * same fresh-proof gate as every other whole-record export
+ * (`requireRecentProof`): a browser session must have signed in or re-proved
+ * within five minutes, a token must present a step-up elevation. No shipped
+ * client calls this route with a token. The single-type exports stay on the
+ * session alone, like their dedicated routes under `/api/export/*`.
*/
export const GET = apiHandler(async (request: NextRequest) => {
- const { user } = await requireAuth();
+ const wholeRecord =
+ (new URL(request.url).searchParams.get("type") ?? "all") === "all";
+ const auth = wholeRecord
+ ? await requireRecentProof({ bearer: "elevation" })
+ : { ...(await requireAuth()), commitElevation: async () => {} };
+ const { user } = auth;
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
if (!rl.allowed) {
@@ -54,6 +65,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
);
}
+ // Spend a Bearer elevation only once the request is about to act.
+ await auth.commitElevation();
+
const userId = user.id;
await auditLog("export.download", {
userId,
diff --git a/src/app/api/fhir/Patient/$everything/route.ts b/src/app/api/fhir/Patient/$everything/route.ts
index df9318b9f..68faf6304 100644
--- a/src/app/api/fhir/Patient/$everything/route.ts
+++ b/src/app/api/fhir/Patient/$everything/route.ts
@@ -5,19 +5,25 @@
* the Composition, the authoring Device, Patient, Coverage, every Observation
* (vitals / glucose / labs / adherence / mood / wellness AND the cycle set),
* MedicationStatements, MedicationAdministrations, Conditions, Encounters,
- * AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport.
+ * AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport (present
+ * when the window holds a vital sign).
*
* The set is the document export's, flattened — same builder, same order, same
* `urn:uuid` entry identities, so a reference between two resources still
* resolves inside the response and the operation can never quietly carry less
* than the document does.
*
- * Read-only; `userId` narrowed from `requireAuth`. Offset paging applies across
+ * Read-only; `userId` narrowed from `requireAuth`. A browser session needs a
+ * recent sign-in or re-proof (`assertRecentCookieProof`). Offset paging applies across
* the flattened resource list via `_count` (clamped ≤200) / `_offset`.
*/
import { NextRequest } from "next/server";
-import { apiHandler, requireAuth } from "@/lib/api-handler";
+import {
+ apiHandler,
+ assertRecentCookieProof,
+ requireAuth,
+} from "@/lib/api-handler";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -31,7 +37,16 @@ import {
} from "@/lib/fhir/rest";
export const GET = apiHandler(async (request: NextRequest) => {
- const { user } = await requireAuth(FHIR_READ_SCOPE);
+ const auth = await requireAuth(FHIR_READ_SCOPE);
+ const { user } = auth;
+ // The whole record in one response: a browser session must have signed in
+ // or re-proved within five minutes, like every other whole-record export.
+ // A token is its own proof here, as before: the shipped app and the FHIR
+ // read tokens call this route without an elevation, and a refusal would
+ // read to the app as a dead session.
+ if (auth.authMethod === "cookie") {
+ await assertRecentCookieProof(user, auth.session.id);
+ }
annotate({ action: { name: "fhir.everything.read" } });
// v1.30 — the FHIR REST face serves the SAME whole-record aggregate as
diff --git a/src/app/api/fhir/__tests__/capability-and-everything.test.ts b/src/app/api/fhir/__tests__/capability-and-everything.test.ts
index 22f1f077f..c402cf539 100644
--- a/src/app/api/fhir/__tests__/capability-and-everything.test.ts
+++ b/src/app/api/fhir/__tests__/capability-and-everything.test.ts
@@ -15,6 +15,21 @@ import { existsSync } from "node:fs";
import path from "node:path";
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ // The whole-record gate reads the session's own stamps: a sign-in a
+ // moment ago is the proof, on an account without a second factor.
+ session: {
+ findUnique: vi.fn(async () => ({
+ createdAt: new Date(),
+ mfaVerifiedAt: null,
+ reproofAt: null,
+ })),
+ },
+ webauthnMfaCredential: { count: vi.fn(async () => 0) },
+ passkey: { count: vi.fn(async () => 0) },
+ },
+}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
@@ -39,6 +54,7 @@ vi.mock("next/headers", () => ({
import { GET as metadataGet } from "../metadata/route";
import { GET as everythingGet } from "../Patient/$everything/route";
+import { prisma } from "@/lib/db";
import { GET as observationRead } from "../Observation/[id]/route";
import { GET as patientRead } from "../Patient/[id]/route";
import { GET as coverageRead } from "../Coverage/[id]/route";
@@ -291,6 +307,17 @@ describe("GET /api/fhir/{type}/{id} — read", () => {
});
describe("GET /api/fhir/Patient/$everything", () => {
+ it("asks a browser session that signed in an hour ago to confirm first", async () => {
+ vi.mocked(prisma.session.findUnique).mockResolvedValueOnce({
+ createdAt: new Date(Date.now() - 60 * 60 * 1000),
+ mfaVerifiedAt: null,
+ reproofAt: null,
+ } as never);
+ const res = await everythingGet(req("/api/fhir/Patient/$everything"));
+ expect(res.status).toBe(401);
+ expect((await res.json()).meta.errorCode).toBe("auth.reproof.required");
+ });
+
it("returns every resource family the document bundle carries", async () => {
const res = await everythingGet(
req("/api/fhir/Patient/$everything?_count=200"),
diff --git a/src/app/api/fhir/__tests__/module-gate.test.ts b/src/app/api/fhir/__tests__/module-gate.test.ts
index 40d328758..eb97a9833 100644
--- a/src/app/api/fhir/__tests__/module-gate.test.ts
+++ b/src/app/api/fhir/__tests__/module-gate.test.ts
@@ -32,6 +32,17 @@ vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
cycleProfile: { findUnique: vi.fn() },
+ // The whole-record gate reads the session's own stamps: a sign-in a
+ // moment ago is the proof, on an account without a second factor.
+ session: {
+ findUnique: vi.fn(async () => ({
+ createdAt: new Date(),
+ mfaVerifiedAt: null,
+ reproofAt: null,
+ })),
+ },
+ webauthnMfaCredential: { count: vi.fn(async () => 0) },
+ passkey: { count: vi.fn(async () => 0) },
},
}));
vi.mock("@/lib/modules/operator-availability", async (importOriginal) => {
diff --git a/src/app/api/illness/episodes/[id]/correlation/route.ts b/src/app/api/illness/episodes/[id]/correlation/route.ts
index c0f647364..abc456568 100644
--- a/src/app/api/illness/episodes/[id]/correlation/route.ts
+++ b/src/app/api/illness/episodes/[id]/correlation/route.ts
@@ -22,7 +22,7 @@ import { apiSuccess, apiError } from "@/lib/api-response";
import { requireIllnessEnabled } from "@/lib/illness/gate";
import { computeEpisodeCorrelation } from "@/lib/illness/correlation-read";
import { notifyIllnessRedFlag } from "@/lib/illness/red-flag-notify";
-import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
+import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
type RouteParams = { params: Promise<{ id: string }> };
diff --git a/src/app/api/illness/episodes/[id]/day-logs/route.ts b/src/app/api/illness/episodes/[id]/day-logs/route.ts
index 2202f338f..56b18f31d 100644
--- a/src/app/api/illness/episodes/[id]/day-logs/route.ts
+++ b/src/app/api/illness/episodes/[id]/day-logs/route.ts
@@ -34,7 +34,7 @@ import {
} from "@/lib/validations/illness";
import { upsertIllnessDayLog } from "@/lib/illness/day-log-write";
import { toIllnessDayLogDTO, dayLogSymptomInclude } from "@/lib/illness/dto";
-import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
+import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
type RouteParams = { params: Promise<{ id: string }> };
diff --git a/src/app/api/import/apple-health-export/__tests__/route.test.ts b/src/app/api/import/apple-health-export/__tests__/route.test.ts
index 5a0aad7ef..8acfc3b04 100644
--- a/src/app/api/import/apple-health-export/__tests__/route.test.ts
+++ b/src/app/api/import/apple-health-export/__tests__/route.test.ts
@@ -27,6 +27,7 @@ vi.mock("next/headers", () => ({
}));
const mocks = vi.hoisted(() => ({
+ runningImportFindFirst: vi.fn(async () => null),
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
bossSend: vi.fn().mockResolvedValue("boss-job-1"),
getGlobalBoss: vi.fn(),
@@ -35,6 +36,7 @@ const mocks = vi.hoisted(() => ({
importJobUpdate: vi.fn(),
streamToDisk: vi.fn(),
unlink: vi.fn().mockResolvedValue(undefined),
+ rm: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
@@ -51,7 +53,7 @@ vi.mock("@/lib/multipart/stream-to-disk", () => ({
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal();
- return { ...actual, unlink: mocks.unlink };
+ return { ...actual, unlink: mocks.unlink, rm: mocks.rm };
});
vi.mock("@/lib/db", () => ({
@@ -61,6 +63,16 @@ vi.mock("@/lib/db", () => ({
findFirst: mocks.importJobFindFirst,
update: mocks.importJobUpdate,
},
+ // The one-running-import check and the new row, in one transaction.
+ // The check finds nothing running unless a test says otherwise.
+ $transaction: async (fn: (tx: unknown) => unknown) =>
+ fn({
+ $executeRaw: async () => 0,
+ importJob: {
+ findFirst: mocks.runningImportFindFirst,
+ create: mocks.importJobCreate,
+ },
+ }),
},
}));
@@ -90,6 +102,7 @@ beforeEach(() => {
textFields: {},
});
mocks.unlink.mockResolvedValue(undefined);
+ mocks.rm.mockResolvedValue(undefined);
});
function multipartReq(): NextRequest {
@@ -220,3 +233,51 @@ describe("POST /api/import/apple-health-export — dedup by content hash (issue
);
});
});
+
+// The staged upload is the person's export in plain text. Every exit that
+// leaves no worker to consume it removes it, and a second import for the same
+// account is refused while one is running.
+describe("POST /api/import/apple-health-export — the staged upload on every exit", () => {
+ const removed = () =>
+ mocks.rm.mock.calls.some(([path]) => path === STAGED_PATH);
+
+ it("refuses a second import while one is running, and removes the upload", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ mocks.runningImportFindFirst.mockResolvedValue({
+ id: "ij-running",
+ status: "parsing",
+ } as never);
+ const res = await POST(multipartReq());
+ expect(res.status).toBe(409);
+ const body = await res.json();
+ expect(body.meta).toMatchObject({
+ errorCode: "import.apple_health.busy",
+ jobId: "ij-running",
+ });
+ expect(mocks.importJobCreate).not.toHaveBeenCalled();
+ expect(mocks.bossSend).not.toHaveBeenCalled();
+ expect(removed()).toBe(true);
+ });
+
+ it("removes the upload when no worker is running", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ mocks.getGlobalBoss.mockReturnValue(null);
+ const res = await POST(multipartReq());
+ expect(res.status).toBe(503);
+ expect(removed()).toBe(true);
+ });
+
+ it("fails the row and removes the upload when the queue takes no job", async () => {
+ vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
+ mocks.bossSend.mockResolvedValue(null);
+ const res = await POST(multipartReq());
+ expect(res.status).toBe(503);
+ expect(mocks.importJobUpdate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { id: "ij-1" },
+ data: expect.objectContaining({ status: "failed" }),
+ }),
+ );
+ expect(removed()).toBe(true);
+ });
+});
diff --git a/src/app/api/import/apple-health-export/route.ts b/src/app/api/import/apple-health-export/route.ts
index 131fd2b68..6919703ff 100644
--- a/src/app/api/import/apple-health-export/route.ts
+++ b/src/app/api/import/apple-health-export/route.ts
@@ -35,6 +35,11 @@ import {
type AppleHealthImportPayload,
} from "@/lib/jobs/apple-health-import-worker";
import { streamMultipartToDisk } from "@/lib/multipart/stream-to-disk";
+import {
+ createImportJobUnlessBusy,
+ discardStagedUpload,
+ IMPORT_BUSY_CODE,
+} from "@/lib/import/apple-health-staging";
import { unlink } from "node:fs/promises";
export const dynamic = "force-dynamic";
@@ -147,18 +152,28 @@ export const POST = apiHandler(async (request: NextRequest) => {
ipAddress: getClientIp(request),
details: { reason: "worker_not_running" },
});
+ // Nothing will ever read the staged export; it must not stay in /tmp.
+ await discardStagedUpload(uploaded.filePath);
throw new HttpError(503, "Background worker is not running");
}
- const importJob = await prisma.importJob.create({
- data: {
- userId: user.id,
- status: "queued",
- uploadBytes: uploaded.bytes,
- uploadSha256: uploaded.sha256,
- parserRevision: APPLE_HEALTH_IMPORT_PARSER_REVISION,
- },
+ // One running import per account (see `apple-health-staging.ts`).
+ const staged = await createImportJobUnlessBusy(prisma, {
+ userId: user.id,
+ status: "queued",
+ uploadBytes: uploaded.bytes,
+ uploadSha256: uploaded.sha256,
+ parserRevision: APPLE_HEALTH_IMPORT_PARSER_REVISION,
});
+ if ("busy" in staged) {
+ await discardStagedUpload(uploaded.filePath);
+ return apiError(
+ "An Apple Health import is already running for this account. Wait for it to finish, then upload again.",
+ 409,
+ { errorCode: IMPORT_BUSY_CODE, jobId: staged.busy.id },
+ );
+ }
+ const importJob = staged.created;
const payload: AppleHealthImportPayload = {
userId: user.id,
@@ -174,6 +189,21 @@ export const POST = apiHandler(async (request: NextRequest) => {
payload,
APPLE_HEALTH_IMPORT_SEND_OPTIONS,
);
+ if (!bossJobId) {
+ // The queue did not take the job, so no worker will consume the upload
+ // or move the row on. Fail the row now and remove the export.
+ await prisma.importJob.update({
+ where: { id: importJob.id },
+ data: {
+ status: "failed",
+ failureReason:
+ "The import could not be queued. Upload the export again.",
+ completedAt: new Date(),
+ },
+ });
+ await discardStagedUpload(uploaded.filePath);
+ throw new HttpError(503, "The import could not be queued");
+ }
await prisma.importJob.update({
where: { id: importJob.id },
diff --git a/src/app/api/import/csv/__tests__/route.test.ts b/src/app/api/import/csv/__tests__/route.test.ts
index 2ae1143a7..3ca886bea 100644
--- a/src/app/api/import/csv/__tests__/route.test.ts
+++ b/src/app/api/import/csv/__tests__/route.test.ts
@@ -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[]),
),
},
};
@@ -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[]),
);
});
@@ -218,7 +227,11 @@ describe("POST /api/import/csv — batched write + per-row envelope", () => {
it("surfaces an externalId row as updated when it already existed (updateMany)", async () => {
mMeasurement().findMany.mockResolvedValue([
- { type: "WEIGHT", externalId: "ext-1" },
+ {
+ type: "WEIGHT",
+ externalId: "ext-1",
+ measuredAt: new Date("2026-05-01T08:00:00Z"),
+ },
]);
const res = await POST(
@@ -239,7 +252,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(
@@ -379,7 +404,11 @@ describe("POST /api/import/csv — contextless blood glucose", () => {
it("keeps a re-upload of the same external id idempotent", async () => {
// The key already exists under (userId, type, source=IMPORT, externalId).
mMeasurement().findMany.mockResolvedValue([
- { type: "BLOOD_GLUCOSE", externalId: "sensor-1" },
+ {
+ type: "BLOOD_GLUCOSE",
+ externalId: "sensor-1",
+ measuredAt: new Date("2024-04-03T02:15:00Z"),
+ },
]);
const res = await POST(
@@ -424,3 +453,146 @@ 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 = {}) {
+ 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("re-folds the day a moved reading left as well as the day it moved to", async () => {
+ mMeasurement().findMany.mockResolvedValue([stored("ext-1")]);
+ await POST(
+ csvRequest(
+ [HEADER, "WEIGHT,80.5,kg,2026-05-03T08:00:00Z,,after run,ext-1"].join(
+ "\n",
+ ),
+ ),
+ );
+ const days = vi
+ .mocked(recomputeBucketsForMeasurement)
+ .mock.calls.map(
+ ([, type, at]) => `${type}@${at.toISOString().slice(0, 10)}`,
+ )
+ .sort();
+ expect(days).toEqual(["WEIGHT@2026-05-01", "WEIGHT@2026-05-03"]);
+ });
+
+ it("re-folds what it touched when a write chunk fails part-way", async () => {
+ mMeasurement().findMany.mockResolvedValue([]);
+ mMeasurement().createManyAndReturn.mockRejectedValue(
+ new Error("connection reset"),
+ );
+ await expect(
+ POST(
+ csvRequest(
+ [
+ HEADER,
+ "WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,,",
+ "WEIGHT,80.7,kg,2026-05-02T08:00:00Z,,,",
+ ].join("\n"),
+ ),
+ ),
+ ).rejects.toThrow("connection reset");
+ const days = vi
+ .mocked(recomputeBucketsForMeasurement)
+ .mock.calls.map(([, , at]) => at.toISOString().slice(0, 10))
+ .sort();
+ expect(days).toEqual(["2026-05-01", "2026-05-02"]);
+ });
+
+ 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);
+ });
+});
diff --git a/src/app/api/import/csv/route.ts b/src/app/api/import/csv/route.ts
index bbd46810c..37aafbf5e 100644
--- a/src/app/api/import/csv/route.ts
+++ b/src/app/api/import/csv/route.ts
@@ -2,8 +2,13 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
-import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
-import { encryptNote } from "@/lib/crypto/note-cipher";
+import {
+ apiSuccess,
+ apiError,
+ getClientIp,
+ readBodyText,
+} from "@/lib/api-response";
+import { encryptNote, readNote } from "@/lib/crypto/note-cipher";
import { checkRateLimit } from "@/lib/rate-limit";
import { NextRequest } from "next/server";
@@ -11,10 +16,7 @@ import {
parseCsvMeasurements,
type CsvRowResult,
} from "@/lib/import/csv-measurements";
-import {
- collapseToTypeDayKeys,
- recomputeBucketsForMeasurement,
-} from "@/lib/rollups/measurement-rollups";
+import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
import type {
MeasurementType,
GlucoseContext,
@@ -24,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(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(
+ rows: ReadonlyArray<{ row: NormalisedMeasurementRow }>,
+ keyOf: (r: { row: NormalisedMeasurementRow }) => K,
+): Map {
+ const byType = new Map>();
+ for (const r of rows) {
+ const type = r.row.type as MeasurementType;
+ const key = keyOf(r);
+ const bucket = byType.get(type) ?? new Map();
+ 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).
*
@@ -68,15 +127,16 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (Number.isFinite(declaredLength) && declaredLength > MAX_CSV_BYTES) {
return apiError("CSV exceeds the 16 MB limit", 413);
}
+ // Counted while it is read, so a body without a Content-Length cannot run
+ // past the cap before the check.
let text: string;
try {
- text = await request.text();
+ const read = await readBodyText(request, MAX_CSV_BYTES);
+ if (read.tooLarge) return apiError("CSV exceeds the 16 MB limit", 413);
+ text = read.text;
} catch {
return apiError("Could not read the request body", 400);
}
- if (Buffer.byteLength(text, "utf8") > MAX_CSV_BYTES) {
- return apiError("CSV exceeds the 16 MB limit", 413);
- }
const parsed = parseCsvMeasurements(text);
if (parsed.fatal) {
@@ -138,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();
const extInserts = new Map();
const extUpdates = new Map();
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");
@@ -190,29 +284,42 @@ export const POST = apiHandler(async (request: NextRequest) => {
type: m.type as MeasurementType,
measuredAt: m.measuredAt,
});
+ // An update that moves the reading to another instant leaves the day
+ // it came from: that day is re-folded too, or the old day keeps a
+ // reading that no longer exists there.
+ if (stored && stored.measuredAt.getTime() !== m.measuredAt.getTime()) {
+ touchedMeasurements.push({
+ type: m.type as MeasurementType,
+ measuredAt: stored.measuredAt,
+ });
+ }
}
// --- 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();
+ 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();
const plainInserts: NormalisedMeasurementRow[] = [];
for (const result of plainRows) {
@@ -233,107 +340,110 @@ 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),
+ // The re-fold runs whatever happens to the writes. A chunk that fails
+ // part-way leaves the earlier chunks written, and a retry reads those
+ // rows as duplicates and touches nothing: without the re-fold here their
+ // days would keep the rollups from before the import for good. Re-folding
+ // a (type, day) nothing landed on is harmless.
+ try {
+ 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 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([...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,
- createdRows,
- "csv_import",
- ).catch(() => {});
- void maybeEnqueueMorningRefresh(
- userId,
- createdRows
- .filter((row) => row.type === "SLEEP_DURATION")
- .map((row) => row.measuredAt),
- ).catch(() => {});
+ void emitInsertedMeasurementArrivals(
+ userId,
+ createdRows,
+ "csv_import",
+ ).catch(() => {});
+ void maybeEnqueueMorningRefresh(
+ userId,
+ createdRows
+ .filter((row) => row.type === "SLEEP_DURATION")
+ .map((row) => row.measuredAt),
+ ).catch(() => {});
- // Reconcile `inserted` against what `createMany` actually wrote
- // (mirrors the batch route's raced-duplicate downgrade). Under
- // `skipDuplicates` a conflicting row is silently absorbed — the key is
- // present in the table either way, so we cannot identify the SPECIFIC
- // raced lines; we only need the counters and per-line statuses to sum
- // to the truth. Downgrade enough `inserted` lines to `duplicate` so
- // the envelope matches the DB write count.
- const racedDuplicates = toCreate.length - createdCount;
- if (racedDuplicates > 0) {
- let downgraded = 0;
- for (const [line, outcome] of writeOutcome) {
- if (downgraded >= racedDuplicates) break;
- if (outcome === "inserted") {
- writeOutcome.set(line, "duplicate");
- inserted--;
- skipped++;
- downgraded++;
+ // Reconcile `inserted` against what `createMany` actually wrote
+ // (mirrors the batch route's raced-duplicate downgrade). Under
+ // `skipDuplicates` a conflicting row is silently absorbed — the key is
+ // present in the table either way, so we cannot identify the SPECIFIC
+ // raced lines; we only need the counters and per-line statuses to sum
+ // to the truth. Downgrade enough `inserted` lines to `duplicate` so
+ // the envelope matches the DB write count.
+ const racedDuplicates = toCreate.length - createdCount;
+ if (racedDuplicates > 0) {
+ let downgraded = 0;
+ for (const [line, outcome] of writeOutcome) {
+ if (downgraded >= racedDuplicates) break;
+ if (outcome === "inserted") {
+ writeOutcome.set(line, "duplicate");
+ inserted--;
+ skipped++;
+ downgraded++;
+ }
}
}
- }
-
- // One bounded rollup re-fold per touched (type, day) — a 10 000-row CSV
- // pays at most ~N (type, day) recomputes, not 10 000 per-row hooks.
- // Best-effort: a populator hiccup never fails the importer.
- if (touchedMeasurements.length > 0) {
- try {
- const keys = collapseToTypeDayKeys(touchedMeasurements);
- for (const k of keys) {
- await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt);
- }
- } catch (err) {
- annotate({
- meta: {
- measurement_rollup_csv_import_failed: true,
- measurement_rollup_csv_import_error:
- err instanceof Error ? err.message : String(err),
- },
- });
- }
+ } finally {
+ // One bounded rollup re-fold per touched (type, day) — a 10 000-row
+ // CSV pays at most ~N (type, day) recomputes, not 10 000 per-row hooks
+ // — and the status re-warm for the touched types. Best-effort: a
+ // populator hiccup never fails the importer.
+ await afterMeasurementMutation(userId, touchedMeasurements, "import.csv");
}
await auditLog("import.csv.upload", {
diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts
index 02bad9389..e827cc674 100644
--- a/src/app/api/import/route.ts
+++ b/src/app/api/import/route.ts
@@ -32,15 +32,13 @@ import {
} from "@/lib/validations/external-id";
import { encryptNote } from "@/lib/crypto/note-cipher";
import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups";
-import {
- collapseToTypeDayKeys,
- recomputeBucketsForMeasurement,
-} from "@/lib/rollups/measurement-rollups";
+import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
import type { MeasurementType } from "@/generated/prisma/client";
import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit";
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
import { isP2002, isP2025 } from "@/lib/prisma-errors";
-import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
+import { moodDateKey } from "@/lib/mood/date-key";
+import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { deriveA1 } from "@/lib/mood/level-a";
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
@@ -481,27 +479,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
}
- // v1.4.39.1 — refresh the persistent measurement rollup table for
- // every distinct (type, day) the import touched. Collapsed so a
- // 10 000-row CSV restore pays at most ~N (type, day) recomputes
- // rather than 10 000 per-row hooks. Best-effort: a populator hiccup
- // never fails the importer.
- if (touchedMeasurements.length > 0) {
- try {
- const keys = collapseToTypeDayKeys(touchedMeasurements);
- for (const k of keys) {
- await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt);
- }
- } catch (err) {
- annotate({
- meta: {
- measurement_rollup_import_failed: true,
- measurement_rollup_import_error:
- err instanceof Error ? err.message : String(err),
- },
- });
- }
- }
+ // Refresh the persistent measurement rollup table for every distinct
+ // (type, day) the import touched (collapsed, so a 10 000-row restore pays
+ // at most ~N recomputes) and re-warm the touched types' status
+ // assessments. Best-effort: a populator hiccup never fails the importer.
+ await afterMeasurementMutation(userId, touchedMeasurements, "import");
annotate({
meta: {
diff --git a/src/app/api/insights/cards/route.ts b/src/app/api/insights/cards/route.ts
index b37ae9652..499171ebe 100644
--- a/src/app/api/insights/cards/route.ts
+++ b/src/app/api/insights/cards/route.ts
@@ -34,6 +34,7 @@ import {
} from "@/lib/analytics/compliance";
import type { MeasurementType } from "@/generated/prisma/client";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
+import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
type Severity = "alert" | "caution" | "info" | "good";
@@ -216,7 +217,7 @@ export const GET = apiHandler(async () => {
arr.push(e);
eventsByMed.set(e.medicationId, arr);
}
- const tz = user.timezone || "Europe/Berlin";
+ const tz = user.timezone || DEFAULT_TIMEZONE;
for (const med of activeMeds) {
const events = eventsByMed.get(med.id) ?? [];
const mapped = events.map((e) => ({
diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts
index 46d6b9be7..cc55cd882 100644
--- a/src/app/api/insights/comprehensive/route.ts
+++ b/src/app/api/insights/comprehensive/route.ts
@@ -41,7 +41,7 @@ import {
ensureUserMoodRollupsFresh,
readMoodDayRollups,
} from "@/lib/rollups/mood-rollups";
-import { userDayKey } from "@/lib/tz/format";
+import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
export const dynamic = "force-dynamic";
@@ -125,7 +125,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
// pass in this route (resting-pulse proxy, medication-continuity, BP
// pairing). Was previously re-derived ad hoc (or hardcoded UTC) at each
// call site.
- const userTz = user.timezone || "Europe/Berlin";
+ const userTz = user.timezone || DEFAULT_TIMEZONE;
// Fetch user profile (height + DOB drive BMI + BP targets).
const dbUser = await prisma.user.findUnique({
@@ -445,7 +445,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
const medicationContext = buildComplianceMedicationContext(
med,
lastNonSkippedTakenAt(mapped),
- user.timezone || "Europe/Berlin",
+ user.timezone || DEFAULT_TIMEZONE,
);
const c7 = calculateCompliance(mapped, med.schedules, 7, med.createdAt, {
medicationContext,
diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts
index eb4e4358e..f93c5262f 100644
--- a/src/app/api/insights/correlations/route.ts
+++ b/src/app/api/insights/correlations/route.ts
@@ -41,6 +41,7 @@ import {
} from "@/lib/insights/correlation-patterns";
import { assembleDiscoveryMatrix } from "@/lib/insights/discovery-matrix";
import { fetchLabDraws } from "@/lib/insights/correlation-channel-series";
+import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
export const dynamic = "force-dynamic";
@@ -115,7 +116,7 @@ async function buildCorrelationsResponse(
where: { id: userId },
select: { timezone: true },
});
- const tz = profile?.timezone ?? "Europe/Berlin";
+ const tz = profile?.timezone ?? DEFAULT_TIMEZONE;
const now = new Date();
const since = new Date(now.getTime() - WINDOW_DAYS * MS_PER_DAY);
diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts
index b5706dd8b..266d77959 100644
--- a/src/app/api/insights/generate/route.ts
+++ b/src/app/api/insights/generate/route.ts
@@ -692,6 +692,7 @@ export const POST = apiHandler((request: NextRequest) =>
userId,
dbUser?.displayName ?? null,
locale,
+ await resolveUserTimezone(userId),
);
// v1.12.7 (B5) — inject the curated SOURCES block for the metric sections
diff --git a/src/app/api/internal/deploy-webhook/route.ts b/src/app/api/internal/deploy-webhook/route.ts
index a7a191a86..3d1e59cf1 100644
--- a/src/app/api/internal/deploy-webhook/route.ts
+++ b/src/app/api/internal/deploy-webhook/route.ts
@@ -1,3 +1,4 @@
+import { envOr } from "@/lib/env";
import { timingSafeEqual } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
@@ -190,7 +191,7 @@ async function notifyAdminsOfFailure(event: NormalizedEvent): Promise {
// Optional: operators can point this at their deployment
// dashboard (e.g. the Coolify UI). Falls back to an empty
// string so the body template still composes cleanly.
- logsUrl: process.env.DEPLOY_LOGS_URL ?? "",
+ logsUrl: envOr("DEPLOY_LOGS_URL", ""),
},
metadata: {
source: "deploy-webhook",
diff --git a/src/app/api/labs/ocr/extract/__tests__/route.test.ts b/src/app/api/labs/ocr/extract/__tests__/route.test.ts
index 66b874e13..4dcd20385 100644
--- a/src/app/api/labs/ocr/extract/__tests__/route.test.ts
+++ b/src/app/api/labs/ocr/extract/__tests__/route.test.ts
@@ -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", () => {
diff --git a/src/app/api/labs/ocr/extract/route.ts b/src/app/api/labs/ocr/extract/route.ts
index c6b1db112..16ca28416 100644
--- a/src/app/api/labs/ocr/extract/route.ts
+++ b/src/app/api/labs/ocr/extract/route.ts
@@ -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 }
- : {}),
};
}
diff --git a/src/app/api/mcp/oauth/__tests__/authorize.test.ts b/src/app/api/mcp/oauth/__tests__/authorize.test.ts
index f302a20c3..164da251e 100644
--- a/src/app/api/mcp/oauth/__tests__/authorize.test.ts
+++ b/src/app/api/mcp/oauth/__tests__/authorize.test.ts
@@ -1,7 +1,7 @@
/**
* Authorize endpoint — consent gate + audience + PKCE + redirect-URI binding.
*/
-import { describe, it, expect, vi, beforeEach } from "vitest";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
process.env.API_TOKEN_HMAC_KEY = "x".repeat(48);
process.env.APP_URL = "https://health.example";
@@ -21,6 +21,22 @@ vi.mock("@/lib/rate-limit", () => ({
})),
rateLimitHeaders: vi.fn(() => ({})),
}));
+// The consent reads the session's own proof stamps. By default the session
+// signed in a moment ago, which is a recent proof on an account without a
+// second factor; the cases below age it.
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ session: {
+ findUnique: vi.fn(async () => ({
+ createdAt: new Date(),
+ mfaVerifiedAt: null,
+ reproofAt: null,
+ })),
+ },
+ webauthnMfaCredential: { count: vi.fn(async () => 0) },
+ passkey: { count: vi.fn(async () => 0) },
+ },
+}));
vi.mock("@/lib/app-settings", () => ({
isApiGloballyEnabled: vi.fn(async () => true),
}));
@@ -30,6 +46,7 @@ import { getSession } from "@/lib/auth/session";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { registerDcrClient } from "@/lib/mcp/oauth/clients";
import { s256Challenge } from "@/lib/mcp/oauth/pkce";
+import { prisma } from "@/lib/db";
const REDIRECT = "https://claude.ai/api/mcp/auth_callback";
const VERIFIER = "a".repeat(64);
@@ -106,6 +123,16 @@ describe("GET /authorize — consent gate", () => {
expect(body).not.toMatch(/hlac_/);
});
+ it("names what the connection can read, document text included", async () => {
+ signedIn();
+ const body = await (await GET(getReq(authorizeUrl()) as never)).text();
+ // v1.39.3: search and fetch reach visits, conditions and the indexed
+ // text of stored documents, so the consent says so before Allow.
+ expect(body).toMatch(/visits/);
+ expect(body).toMatch(/text of your stored documents/);
+ expect(body).toMatch(/held back from AI reading/);
+ });
+
it("prompts sign-in (with a return link) when there is no session", async () => {
const res = await GET(getReq(authorizeUrl()) as never);
expect(res.status).toBe(200);
@@ -317,6 +344,82 @@ describe("POST /authorize — decision", () => {
});
});
+describe("authorize — allowing a connection needs a recent proof", () => {
+ // A queued stale stamp a case did not read must not leak into the next one.
+ afterEach(() => {
+ vi.mocked(prisma.session.findUnique).mockReset();
+ });
+
+ function staleSession() {
+ vi.mocked(prisma.session.findUnique).mockResolvedValueOnce({
+ createdAt: new Date(Date.now() - 60 * 60 * 1000),
+ mfaVerifiedAt: null,
+ reproofAt: null,
+ } as never);
+ }
+ const allow = {
+ response_type: "code",
+ client_id: CLIENT.clientId,
+ redirect_uri: REDIRECT,
+ code_challenge: CHALLENGE,
+ code_challenge_method: "S256",
+ scope: "health:read",
+ state: "xyz",
+ resource: RESOURCE,
+ decision: "allow",
+ };
+
+ it("GET shows a confirm link instead of the form when the session is an hour old", async () => {
+ signedIn();
+ staleSession();
+ const res = await GET(getReq(authorizeUrl()) as never);
+ expect(res.status).toBe(200);
+ const body = await res.text();
+ expect(body).toMatch(/Confirm it is you/);
+ expect(body).not.toMatch(/name="decision"/);
+ const href = body.match(/href="([^"]+)"/)?.[1] ?? "";
+ const next =
+ new URL(href.replace(/&/g, "&"), "https://x").searchParams.get(
+ "next",
+ ) ?? "";
+ expect(href.startsWith("/confirm-access?next=")).toBe(true);
+ // Back to this exact consent request.
+ expect(next).toBe(
+ new URL(authorizeUrl()).pathname + new URL(authorizeUrl()).search,
+ );
+ });
+
+ it("POST allow on an hour-old session mints nothing", async () => {
+ signedIn();
+ staleSession();
+ const res = await POST(postReq(allow) as never);
+ expect(res.status).toBe(401);
+ expect(res.headers.get("location")).toBeNull();
+ expect(await res.text()).toMatch(/confirm-access\?next=/);
+ });
+
+ it("an account with a second factor needs the factor, not a young session", async () => {
+ vi.mocked(getSession).mockResolvedValue({
+ session: {
+ id: "s1",
+ expiresAt: new Date(Date.now() + 1e6),
+ actingAsUserId: null,
+ recordEpoch: 0,
+ },
+ user: { id: "user-1", totpConfirmedAt: new Date("2026-01-01") } as never,
+ });
+ const res = await POST(postReq(allow) as never);
+ expect(res.status).toBe(401);
+ });
+
+ it("deny needs no proof", async () => {
+ signedIn();
+ staleSession();
+ const res = await POST(postReq({ ...allow, decision: "deny" }) as never);
+ expect(res.status).toBe(303);
+ });
+});
+
describe("authorize CSP — the consent form may lead only to the validated redirect", () => {
function csp(res: Response): string {
return res.headers.get("content-security-policy") ?? "";
diff --git a/src/app/api/mcp/oauth/authorize/route.ts b/src/app/api/mcp/oauth/authorize/route.ts
index 3f83189b0..b0ad1ef5e 100644
--- a/src/app/api/mcp/oauth/authorize/route.ts
+++ b/src/app/api/mcp/oauth/authorize/route.ts
@@ -11,7 +11,9 @@
* a GET (confused-deputy mitigation: a third party cannot silently
* obtain a grant — an authenticated human must click "Allow").
* POST — the consent decision. Requires a session, re-validates every
- * parameter, and on "Allow" mints a single-use, short-lived,
+ * parameter, and on "Allow" asks for a recent sign-in or re-proof
+ * (the GET shows a confirm link instead of the form without one),
+ * then mints a single-use, short-lived,
* self-describing authorization code bound to this user + client +
* redirect URI + PKCE challenge + scope + audience, then 303s back.
*
@@ -30,6 +32,8 @@ import { annotate } from "@/lib/logging/context";
import { withBackgroundEvent } from "@/lib/logging/background";
import { auditLog } from "@/lib/auth/audit";
import { getSession } from "@/lib/auth/session";
+import { assertRecentCookieProof } from "@/lib/api-handler";
+import { StepUpRequiredError } from "@/lib/api-errors";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import {
@@ -88,6 +92,42 @@ function html(body: string, status = 200): Response {
);
}
+/**
+ * Has this session signed in or re-proved recently enough to grant access?
+ *
+ * Allowing a connection hands a standing credential to the record to a third
+ * party, one that outlives the session: the same class of act as minting a
+ * token or making a share link, and it takes the same gate
+ * (`assertRecentCookieProof`: a sign-in or re-proof within five minutes, with
+ * the second factor on an account that has one). Without it, whoever held a
+ * stolen session could connect their own assistant and keep reading the
+ * record after the owner signed everything out.
+ */
+async function hasRecentProof(
+ session: NonNullable>>,
+): Promise {
+ try {
+ await assertRecentCookieProof(session.user, session.session.id);
+ return true;
+ } catch (err) {
+ if (err instanceof StepUpRequiredError) return false;
+ throw err;
+ }
+}
+
+/**
+ * The page shown instead of the consent form while the session carries no
+ * recent proof. It links to the confirm page, which runs the same re-proof
+ * dialog as the rest of the app and then comes back to this exact request.
+ */
+function reproofPage(clientName: string, returnTo: string, status = 200) {
+ const confirmHref = `/confirm-access?next=${encodeURIComponent(returnTo)}`;
+ return html(
+ `Confirm it is you Before ${htmlEscape(clientName)} can connect to your HealthLog data, confirm it is you. This is asked when you have not signed in or confirmed in the last five minutes.
Confirm and continue
`,
+ status,
+ );
+}
+
/**
* The scopes this surface grants for a request. `health:read` is always
* granted (the floor). `health:write` is granted ONLY when explicitly
@@ -359,6 +399,11 @@ async function authorizeGet(request: NextRequest): Promise {
);
}
+ if (!(await hasRecentProof(session))) {
+ annotate({ action: { name: "mcp.oauth.authorize.reproof_required" } });
+ return reproofPage(v.clientName, url.pathname + url.search);
+ }
+
annotate({ action: { name: "mcp.oauth.authorize.consent_shown" } });
// Consent screen — a plain POST form (no inline script/style, CSP-safe).
const hidden = (name: string, value: string) =>
@@ -375,6 +420,10 @@ async function authorizeGet(request: NextRequest): Promise {
const accessSummary = writeGranted
? `This grants read AND write access: the application will be able to read your own health records and log new measurements and mood entries to your account on your behalf. It cannot delete or change existing entries, edit medications, or reach admin functions.
`
: `Scope: ${htmlEscape(v.scope)} — read-only access to your own health records.
`;
+ // What a read reaches, in plain words, before the person clicks Allow.
+ // Document text is named on its own because it is the widest thing a
+ // connection can read (v1.39.3: search and fetch reach it).
+ const readSummary = `What it can read: your measurements, medications, lab values, visits and procedures, conditions and vaccinations, and your stored documents, including the text of your stored documents that HealthLog has indexed. It never receives the files themselves. A document you held back from AI reading is shared by its title and details only, without its text. Anything in a module you have switched off is not shared.
`;
// The one response whose form may lead off-origin: allow exactly the
// redirect URI `validate()` just matched against the resolved client.
return withAuthorizeCsp(
@@ -385,6 +434,7 @@ async function authorizeGet(request: NextRequest): Promise {
${provenance}
Scope: ${htmlEscape(v.scope)}
${accessSummary}
+ ${readSummary}
)}
>
diff --git a/src/app/confirm-access/__tests__/return-to.test.ts b/src/app/confirm-access/__tests__/return-to.test.ts
new file mode 100644
index 000000000..8f7f4555e
--- /dev/null
+++ b/src/app/confirm-access/__tests__/return-to.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vitest";
+
+import { confirmReturnTo } from "../return-to";
+
+describe("confirmReturnTo", () => {
+ it("returns to the connection consent it came from", () => {
+ expect(
+ confirmReturnTo("/api/mcp/oauth/authorize?client_id=a&state=b"),
+ ).toBe("/api/mcp/oauth/authorize?client_id=a&state=b");
+ });
+
+ it.each([
+ null,
+ "",
+ "/",
+ "/settings",
+ "https://evil.example/api/mcp/oauth/authorize?x=1",
+ "//evil.example/api/mcp/oauth/authorize?x=1",
+ "/api/mcp/oauth/authorize",
+ "/api/mcp/oauth/authorize/../token?x=1",
+ ])("goes nowhere for %s", (next) => {
+ expect(confirmReturnTo(next)).toBeNull();
+ });
+});
diff --git a/src/app/confirm-access/confirm-access-client.tsx b/src/app/confirm-access/confirm-access-client.tsx
new file mode 100644
index 000000000..ee369b423
--- /dev/null
+++ b/src/app/confirm-access/confirm-access-client.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import { useState } from "react";
+import { Loader2, ShieldCheck } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import {
+ recentProofErrorMessage,
+ useRecentProof,
+} from "@/components/settings/security-section/use-recent-proof";
+import { apiGet } from "@/lib/api/api-fetch";
+import { useTranslations } from "@/lib/i18n/context";
+
+export function ConfirmAccessClient({ returnTo }: { returnTo: string | null }) {
+ const { t } = useTranslations();
+ const recentProof = useRecentProof();
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+
+ async function confirm() {
+ if (!returnTo) return;
+ setBusy(true);
+ setError(null);
+ try {
+ // Answers 200 when the session's proof is recent, and otherwise the
+ // refusal the hook turns into the re-proof dialog.
+ await recentProof.run(() => apiGet("/api/auth/reproof"));
+ // A full navigation: the consent is a server page, not an app route.
+ window.location.assign(returnTo);
+ } catch (err) {
+ setError(recentProofErrorMessage(err, t("auth.confirmAccess.failed")));
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
+
+ {t("auth.confirmAccess.title")}
+
+ {returnTo ? (
+ <>
+
{t("auth.confirmAccess.body")}
+
+ void confirm()}
+ disabled={busy}
+ className="min-h-11 sm:min-h-9"
+ >
+ {busy && (
+
+ )}
+ {t("auth.confirmAccess.action")}
+
+
+ >
+ ) : (
+
{t("auth.confirmAccess.nothingToConfirm")}
+ )}
+ {error && (
+
+ {error}
+
+ )}
+ {recentProof.dialog}
+
+ );
+}
diff --git a/src/app/confirm-access/page.tsx b/src/app/confirm-access/page.tsx
new file mode 100644
index 000000000..b9885f7e7
--- /dev/null
+++ b/src/app/confirm-access/page.tsx
@@ -0,0 +1,21 @@
+import { ConfirmAccessClient } from "./confirm-access-client";
+import { confirmReturnTo } from "./return-to";
+
+/**
+ * Confirm it is you before connecting an AI assistant.
+ *
+ * The connection consent (`/api/mcp/oauth/authorize`) is a plain server page
+ * with no script, so it cannot run the re-proof dialog itself. When the
+ * session has not signed in or re-proved within five minutes it links here;
+ * this page runs the same dialog as the rest of the app and then goes back to
+ * the consent request it came from.
+ */
+export default async function ConfirmAccessPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ next?: string | string[] }>;
+}) {
+ const { next } = await searchParams;
+ const returnTo = confirmReturnTo(typeof next === "string" ? next : null);
+ return ;
+}
diff --git a/src/app/confirm-access/return-to.ts b/src/app/confirm-access/return-to.ts
new file mode 100644
index 000000000..166e8a49a
--- /dev/null
+++ b/src/app/confirm-access/return-to.ts
@@ -0,0 +1,26 @@
+/**
+ * Where the confirm page may send the browser once the person has confirmed
+ * it is them.
+ *
+ * Only back to the connection consent it came from, never anywhere else: the
+ * page takes `next` from the address bar, so any path it accepted would be a
+ * same-origin redirect somebody else could point. The consent request carries
+ * its own parameters and re-validates all of them when it is loaded again.
+ */
+export const CONSENT_PATH = "/api/mcp/oauth/authorize";
+
+export function confirmReturnTo(
+ next: string | null | undefined,
+): string | null {
+ if (!next || !next.startsWith(`${CONSENT_PATH}?`)) return null;
+ try {
+ const base = "http://confirm.invalid";
+ const resolved = new URL(next, base);
+ if (resolved.origin !== base || resolved.pathname !== CONSENT_PATH) {
+ return null;
+ }
+ return `${resolved.pathname}${resolved.search}`;
+ } catch {
+ return null;
+ }
+}
diff --git a/src/app/error.tsx b/src/app/error.tsx
index 00463ac36..90c65e283 100644
--- a/src/app/error.tsx
+++ b/src/app/error.tsx
@@ -4,7 +4,7 @@ import { useEffect } from "react";
import { ErrorDetails } from "@/components/error-details";
const CHUNK_RELOAD_KEY = "healthlog:chunk-reload-attempted";
-const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? "";
+const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "";
function isChunkLoadError(err: Error & { digest?: string }): boolean {
if (err.name === "ChunkLoadError") return true;
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 0c43d51e0..39d19e2fd 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -127,7 +127,7 @@ export default async function RootLayout({
id="healthlog-i18n-boot"
strategy="beforeInteractive"
nonce={nonce}
- src={`/i18n/${initialLocale}.js?v=${process.env.NEXT_PUBLIC_APP_VERSION ?? "dev"}`}
+ src={`/i18n/${initialLocale}.js?v=${process.env.NEXT_PUBLIC_APP_VERSION || "dev"}`}
/>
diff --git a/src/app/medications/page-client.tsx b/src/app/medications/page-client.tsx
index 54e39bf6d..e37e45adf 100644
--- a/src/app/medications/page-client.tsx
+++ b/src/app/medications/page-client.tsx
@@ -48,6 +48,7 @@ import { apiGet } from "@/lib/api/api-fetch";
import { useMedicationComplianceSummaryAll } from "@/lib/queries/use-medication-compliance-summary";
import { useMedicationListLayout } from "@/lib/queries/use-medication-list-layout";
import { applyMedicationOrder } from "@/lib/medications/medication-order";
+import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
interface Schedule {
id: string;
@@ -397,7 +398,7 @@ export default function MedicationsPageClient() {
// medications past their band tail stay takeable from the batch. Page
// order is preserved so the confirm dialog lists medications as rendered.
const dueMeds = deriveDueMedications(activeMeds, {
- tz: user?.timezone || "Europe/Berlin",
+ tz: user?.timezone || DEFAULT_TIMEZONE,
thresholds: thresholds ?? undefined,
doseStatusById: new Map(
(complianceRows ?? []).map((row) => [
diff --git a/src/components/admin/__tests__/admin-card-boundary.test.tsx b/src/components/admin/__tests__/admin-card-boundary.test.tsx
new file mode 100644
index 000000000..275b0d52b
--- /dev/null
+++ b/src/components/admin/__tests__/admin-card-boundary.test.tsx
@@ -0,0 +1,88 @@
+/**
+ * One admin card that throws while rendering paints an error card in its own
+ * place, and the page around it keeps working.
+ *
+ * The component suite renders on the server, where React does not run error
+ * boundaries, so the boundary's three moves are exercised directly: a thrown
+ * render flips it to the fallback, the fallback is the translated error card
+ * with a retry, and the retry puts the card back under a new key so it
+ * remounts rather than re-rendering the tree that threw.
+ */
+import type { ReactElement, ReactNode } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it, vi } from "vitest";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import {
+ AdminCardBoundary,
+ AdminCardError,
+ Boundary,
+} from "../admin-card-boundary";
+
+function markup(node: ReactNode): string {
+ return renderToStaticMarkup(
+ {node} ,
+ );
+}
+
+function boundaryWith(fallback: (retry: () => void) => ReactNode) {
+ const boundary = new Boundary({
+ renderFallback: fallback,
+ children: card body
,
+ });
+ // Outside a mounted tree `setState` does nothing, so apply it in place.
+ boundary.setState = ((update: unknown) => {
+ const next =
+ typeof update === "function"
+ ? (update as (s: typeof boundary.state) => object)(boundary.state)
+ : (update as object);
+ boundary.state = { ...boundary.state, ...next };
+ }) as typeof boundary.setState;
+ return boundary;
+}
+
+describe("AdminCardBoundary", () => {
+ it("renders the card untouched while nothing has thrown", () => {
+ const html = markup(
+
+ card body
+ ,
+ );
+ expect(html).toContain('data-slot="healthy-card"');
+ expect(html).not.toContain('data-slot="query-error-card"');
+ });
+
+ it("turns a thrown render into the fallback, not a page failure", () => {
+ expect(Boundary.getDerivedStateFromError()).toEqual({ failed: true });
+
+ const fallback = vi.fn(() => fallback
);
+ const boundary = boundaryWith(fallback);
+ boundary.setState(Boundary.getDerivedStateFromError());
+
+ expect(markup(boundary.render())).toContain('data-slot="fallback"');
+ expect(fallback).toHaveBeenCalledWith(boundary.retry);
+ });
+
+ it("paints the translated error card with a keyboard-reachable retry", () => {
+ const html = markup( {}} />);
+
+ expect(html).toContain('data-slot="query-error-card"');
+ expect(html).toContain('role="alert"');
+ expect(html).toContain("This part of the page could not be shown");
+ expect(html).toContain("The rest of the page still works");
+ expect(html).toContain('data-slot="query-error-retry"');
+ });
+
+ it("retries by remounting the card under a new key", () => {
+ const boundary = boundaryWith(() => null);
+ const before = boundary.render() as ReactElement;
+ boundary.setState({ failed: true });
+
+ boundary.retry();
+
+ expect(boundary.state.failed).toBe(false);
+ const after = boundary.render() as ReactElement;
+ expect(after.key).not.toBe(before.key);
+ expect(markup(after)).toContain("card body");
+ });
+});
diff --git a/src/components/admin/__tests__/ai-server-key-legacy-notice.test.tsx b/src/components/admin/__tests__/ai-server-key-legacy-notice.test.tsx
new file mode 100644
index 000000000..5ba65a28d
--- /dev/null
+++ b/src/components/admin/__tests__/ai-server-key-legacy-notice.test.tsx
@@ -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(
+
+
+ ,
+ );
+}
+
+const base = {
+ hasKey: false,
+ keyPreview: null,
+ model: "gpt-4o",
+ baseUrl: "https://api.openai.com/v1",
+};
+
+describe(" 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");
+ });
+});
diff --git a/src/components/admin/__tests__/offhost-backups-section.test.tsx b/src/components/admin/__tests__/offhost-backups-section.test.tsx
index d3dd46985..1bc8a886d 100644
--- a/src/components/admin/__tests__/offhost-backups-section.test.tsx
+++ b/src/components/admin/__tests__/offhost-backups-section.test.tsx
@@ -23,6 +23,11 @@ vi.mock("@tanstack/react-query", () => ({
import { I18nProvider } from "@/lib/i18n/context";
import { OffhostBackupsSection } from "../offhost-backups-section";
+const QUIET = {
+ pendingDeletions: { count: 0, oldestRequestedAt: null, lastFailure: null },
+ lifecycle: { state: "configured" as const, expirationDays: 30 },
+};
+
function render(data: BackupsList["offhost"] | null): string {
queryResult.current = {
data:
@@ -46,7 +51,12 @@ function render(data: BackupsList["offhost"] | null): string {
describe("", () => {
it("says off-host backup is not configured rather than showing an empty list", () => {
- const html = render({ configured: false, periodHours: 24, rows: [] });
+ const html = render({
+ configured: false,
+ periodHours: 24,
+ rows: [],
+ ...QUIET,
+ });
expect(html).toContain("Off-host backup is not configured");
expect(html).not.toContain('data-slot="offhost-backup-rows"');
});
@@ -55,6 +65,7 @@ describe("", () => {
const html = render({
configured: true,
periodHours: 24,
+ ...QUIET,
rows: [
{
userId: "u1",
@@ -109,4 +120,33 @@ describe("", () => {
// Never the bucket's own coordinates.
expect(html).not.toMatch(/secret|access[- ]key/i);
});
+
+ it("says when the bucket keeps copies forever, and when deletions are stuck", () => {
+ const html = render({
+ configured: true,
+ periodHours: 24,
+ rows: [],
+ pendingDeletions: {
+ count: 2,
+ oldestRequestedAt: "2026-09-20T10:00:00.000Z",
+ lastFailure: "AccessDenied",
+ },
+ lifecycle: { state: "missing", expirationDays: null },
+ });
+ expect(html).toContain('data-lifecycle-state="missing"');
+ expect(html).toContain("no lifecycle rule");
+ expect(html).toContain('data-slot="offhost-pending-deletions"');
+ expect(html).toContain("AccessDenied");
+ });
+
+ it("states the expiry the bucket enforces", () => {
+ const html = render({
+ configured: true,
+ periodHours: 24,
+ rows: [],
+ ...QUIET,
+ });
+ expect(html).toContain("after 30 days");
+ expect(html).not.toContain('data-slot="offhost-pending-deletions"');
+ });
});
diff --git a/src/components/admin/admin-card-boundary.tsx b/src/components/admin/admin-card-boundary.tsx
new file mode 100644
index 000000000..6db7cf035
--- /dev/null
+++ b/src/components/admin/admin-card-boundary.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+import { Component, Fragment, type ReactNode } from "react";
+
+import { QueryErrorCard } from "@/components/ui/query-error-card";
+import { useTranslations } from "@/lib/i18n/context";
+
+/**
+ * One admin card's error boundary.
+ *
+ * An admin page stacks independent cards, each reading its own endpoint. A
+ * card that threw while rendering took the whole page with it: a field the
+ * off-host card read and a stale payload did not carry left the backups page
+ * without its snapshot table, the one card the operator had come for. Each
+ * card now fails alone, as a `QueryErrorCard` in its place, and the others
+ * keep working. "Try again" remounts the card, which refetches what it reads;
+ * the error still reaches the client error tracker.
+ */
+export function AdminCardBoundary({ children }: { children: ReactNode }) {
+ return (
+ }>
+ {children}
+
+ );
+}
+
+/** What a failed card paints in its place. */
+export function AdminCardError({ onRetry }: { onRetry: () => void }) {
+ const { t } = useTranslations();
+ return (
+
+ );
+}
+
+export interface BoundaryProps {
+ renderFallback: (retry: () => void) => ReactNode;
+ children: ReactNode;
+}
+
+interface BoundaryState {
+ failed: boolean;
+ attempt: number;
+}
+
+/** The boundary itself, exported for its unit test. */
+export class Boundary extends Component {
+ state: BoundaryState = { failed: false, attempt: 0 };
+
+ static getDerivedStateFromError(): Pick {
+ return { failed: true };
+ }
+
+ componentDidCatch(error: Error): void {
+ if (typeof window !== "undefined") {
+ const g = window as typeof window & {
+ __healthlog_onError?: (err: Error) => void;
+ };
+ g.__healthlog_onError?.(error);
+ }
+ }
+
+ retry = (): void => {
+ this.setState((s) => ({ failed: false, attempt: s.attempt + 1 }));
+ };
+
+ render(): ReactNode {
+ if (this.state.failed) return this.props.renderFallback(this.retry);
+ // A new key on retry remounts the card, so it starts from a clean state
+ // rather than re-rendering the tree that threw.
+ return {this.props.children} ;
+ }
+}
diff --git a/src/components/admin/ai-server-key-section.tsx b/src/components/admin/ai-server-key-section.tsx
index 71799e308..40a8d91e8 100644
--- a/src/components/admin/ai-server-key-section.tsx
+++ b/src/components/admin/ai-server-key-section.tsx
@@ -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() {
/>
+ {settings?.legacyPrivateHostGrant ? (
+
+ ) : null}
{t("admin.aiServerKey.byokHint")}
{t("admin.aiServerKey.consentHint")}
@@ -201,3 +212,43 @@ export function AiServerKeySection() {
);
}
+
+/**
+ * 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 (
+
+
+
+
+
+ {t("admin.aiServerKey.legacyGrantTitle")}
+
+
{t("admin.aiServerKey.legacyGrantBody")}
+ {originsInUse.length > 0 ? (
+ <>
+
{t("admin.aiServerKey.legacyGrantOrigins")}
+
+ {line}
+
+ >
+ ) : (
+
{t("admin.aiServerKey.legacyGrantNoOrigins")}
+ )}
+
+
+
+ );
+}
diff --git a/src/components/admin/backup-restore-status.tsx b/src/components/admin/backup-restore-status.tsx
index 7707f7aa4..3b9fed7c7 100644
--- a/src/components/admin/backup-restore-status.tsx
+++ b/src/components/admin/backup-restore-status.tsx
@@ -71,6 +71,7 @@ function restoredCount(job: BackupRestoreJobView): number {
function failureText(
code: BackupRestoreFailureCode,
t: ReturnType
["t"],
+ keyIds: readonly string[] = [],
): string {
switch (code) {
case "backup_not_found":
@@ -91,6 +92,12 @@ function failureText(
return t("admin.section.backups.restoreFailureSectionMissing");
case "document_ciphertext_missing":
return t("admin.section.backups.restoreFailureDocument");
+ case "backup.key.missing":
+ return t("admin.section.backups.restoreFailureKeyMissing", {
+ keys: keyIds.join(", "),
+ });
+ case "backup.foreign_reference":
+ return t("admin.section.backups.restoreFailureForeignReference");
case "time_budget":
return t("admin.section.backups.restoreFailureTimeBudget");
case "transaction_failed":
@@ -289,7 +296,9 @@ export function RestoreJobStatus({
{t("admin.section.backups.restoreJobFailedTitle", { username })}
- {failureText(code, t)}
+
+ {failureText(code, t, job.failure?.keyIds)}
+
{changedNothing ? (
{t("admin.section.backups.restoreJobFailedKept")}
diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx
index b3bde1269..53d708383 100644
--- a/src/components/admin/backups-section.tsx
+++ b/src/components/admin/backups-section.tsx
@@ -59,6 +59,11 @@ import type {
} from "@/lib/export/restore-skips";
import type { BackupRestoreJobView } from "@/lib/jobs/backup-restore";
import { getApiErrorMessage } from "./_shared";
+import {
+ ReproofCancelledError,
+ throwIfReproofRequired,
+ useRecentProof,
+} from "@/components/settings/security-section/use-recent-proof";
import {
isActiveRestore,
RestoreJobStatus,
@@ -233,11 +238,23 @@ function RestoreRowDialog({
{t("admin.section.backups.previewLoading")}
)}
- {preview.isError && (
-
- {t("admin.section.backups.previewUnavailable")}
-
- )}
+ {preview.isError &&
+ (preview.error instanceof ApiError &&
+ preview.error.meta?.errorCode === "backup.key.missing" ? (
+ // The one preview failure the restore would repeat: say which
+ // key, so the operator can put it back before trying.
+
+ {t("admin.section.backups.previewKeyMissing", {
+ keys: Array.isArray(preview.error.meta.keyIds)
+ ? preview.error.meta.keyIds.map(String).join(", ")
+ : "",
+ })}
+
+ ) : (
+
+ {t("admin.section.backups.previewUnavailable")}
+
+ ))}
{preview.isSuccess && (
{previewRows.map((entry) => (
@@ -702,6 +719,57 @@ function ScheduleHealthNotice({
);
}
+/**
+ * Send a backup file to the admin upload route. Sent as the raw body,
+ * compressed on the way unless it already is: the server reads it as a stream,
+ * and a large export (hundreds of MB of JSON) shrinks about tenfold. No request
+ * timeout, because a file of that size takes longer to check and store than
+ * the default allows. The body is built here, per call, so a retry after a
+ * re-proof sends the file again rather than a spent stream.
+ */
+async function uploadOnce(file: File) {
+ const body = await uploadBody(file);
+ return apiFetch<{
+ id: string;
+ valid: true;
+ summary: {
+ measurements: number;
+ medications: number;
+ intakeEvents: number;
+ moodEntries: number;
+ // Optional for the same reason as the keys below: a file written
+ // before side effects rode the wire carries no key at all.
+ medicationSideEffects?: number;
+ cycles?: number;
+ cycleDayLogs?: number;
+ // v1.28 backup-completeness — optional so an older-schema upload
+ // (pre-v1.28) still totals correctly without these keys.
+ labResults?: number;
+ biomarkers?: number;
+ illnessEpisodes?: number;
+ illnessDayLogs?: number;
+ allergies?: number;
+ familyHistory?: number;
+ workouts?: number;
+ documents?: number;
+ // Optional for the same reason: a file written before each of these
+ // rode the wire carries no key, and an absent key must total as
+ // nothing rather than break the count.
+ nutrientDays?: number;
+ healthProfile?: number;
+ healthProfileFactRevisions?: number;
+ customMetrics?: number;
+ customMetricEntries?: number;
+ intradayProfiles?: number;
+ };
+ }>("/api/admin/backups/upload", {
+ method: "POST",
+ body,
+ headers: { "Content-Type": "application/gzip" },
+ signal: null,
+ });
+}
+
export function BackupsSection() {
const { t } = useTranslations();
const fmt = useFormatters();
@@ -744,52 +812,14 @@ export function BackupsSection() {
// Upload state — single-file flow, drives the file input + button.
const fileInputRef = useRef(null);
+ // Download, upload and restore reach every account's data, so each asks for
+ // a fresh proof unless this session signed in or re-proved within minutes.
+ const recentProof = useRecentProof();
const upload = useMutation({
mutationFn: async (file: File) => {
- // Sent as the raw body, compressed on the way unless it already is: the
- // server reads it as a stream, and a large export (hundreds of MB of
- // JSON) shrinks about tenfold. No request timeout, because a file of
- // that size takes longer to check and store than the default allows.
- const body = await uploadBody(file);
- return apiFetch<{
- id: string;
- valid: true;
- summary: {
- measurements: number;
- medications: number;
- intakeEvents: number;
- moodEntries: number;
- // Optional for the same reason as the keys below: a file written
- // before side effects rode the wire carries no key at all.
- medicationSideEffects?: number;
- cycles?: number;
- cycleDayLogs?: number;
- // v1.28 backup-completeness — optional so an older-schema upload
- // (pre-v1.28) still totals correctly without these keys.
- labResults?: number;
- biomarkers?: number;
- illnessEpisodes?: number;
- illnessDayLogs?: number;
- allergies?: number;
- familyHistory?: number;
- workouts?: number;
- documents?: number;
- // Optional for the same reason: a file written before each of these
- // rode the wire carries no key, and an absent key must total as
- // nothing rather than break the count.
- nutrientDays?: number;
- healthProfile?: number;
- healthProfileFactRevisions?: number;
- customMetrics?: number;
- customMetricEntries?: number;
- intradayProfiles?: number;
- };
- }>("/api/admin/backups/upload", {
- method: "POST",
- body,
- headers: { "Content-Type": "application/gzip" },
- signal: null,
- });
+ // Ask first, so a large file is not sent once only to be refused.
+ await recentProof.run(() => apiGet("/api/auth/reproof"));
+ return uploadOnce(file);
},
onSuccess: (data) => {
const total =
@@ -824,6 +854,7 @@ export function BackupsSection() {
queryClient.invalidateQueries({ queryKey: queryKeys.adminBackups() });
},
onError: (err) => {
+ if (err instanceof ReproofCancelledError) return;
toast.error(
err instanceof Error && err.message
? err.message
@@ -938,10 +969,12 @@ export function BackupsSection() {
// restore. Include the row id so two different backups can both be
// restored independently in the same minute.
const idempotencyKey = `restore-${row.id}-${randomId()}`;
- return apiPost<{ jobId: string; status: "queued" }>(
- `/api/admin/backups/${row.id}/restore`,
- { confirm: "RESTORE", restoreInstanceSettings },
- { headers: { "Idempotency-Key": idempotencyKey } },
+ return recentProof.run(() =>
+ apiPost<{ jobId: string; status: "queued" }>(
+ `/api/admin/backups/${row.id}/restore`,
+ { confirm: "RESTORE", restoreInstanceSettings },
+ { headers: { "Idempotency-Key": idempotencyKey } },
+ ),
);
},
onSuccess: (data) => {
@@ -955,6 +988,7 @@ export function BackupsSection() {
});
},
onError: (err) => {
+ if (err instanceof ReproofCancelledError) return;
if (
err instanceof ApiError &&
err.meta?.errorCode === "backup.restore.active"
@@ -979,7 +1013,11 @@ export function BackupsSection() {
try {
// apiFetchRaw: the download needs the raw Response for the blob +
// the Content-Disposition filename header.
- const res = await apiFetchRaw(`/api/admin/backups/${row.id}/download`);
+ const res = await recentProof.run(async () =>
+ throwIfReproofRequired(
+ await apiFetchRaw(`/api/admin/backups/${row.id}/download`),
+ ),
+ );
if (!res.ok) {
throw new Error(await getApiErrorMessage(res));
}
@@ -1002,6 +1040,7 @@ export function BackupsSection() {
URL.revokeObjectURL(url);
toast.success(t("admin.section.backups.downloadStarted"));
} catch (err) {
+ if (err instanceof ReproofCancelledError) return;
toast.error(
err instanceof Error && err.message
? err.message
@@ -1014,6 +1053,7 @@ export function BackupsSection() {
return (
+ {recentProof.dialog}
{/* v1.18.1 E3 — the snapshot count leads (numbers first) and the
"Run now" button follows. */}
{
// Idempotency-Key prevents a double-submit from re-running the
@@ -52,14 +60,16 @@ export function DangerZoneSection() {
// used to return nine fixed names, which is how nobody noticed the
// wipe had stopped covering the schema: the result line read the same
// whether it had cleared nine tables or ninety.
- return apiDelete<{
- cleared: boolean;
- deletedRows: number;
- models: Record;
- }>(
- "/api/admin/data",
- { confirm: WIPE_CONFIRM_TOKEN },
- { headers: { "Idempotency-Key": idempotencyKey } },
+ return recentProof.run(() =>
+ apiDelete<{
+ cleared: boolean;
+ deletedRows: number;
+ models: Record;
+ }>(
+ "/api/admin/data",
+ { confirm: WIPE_CONFIRM_TOKEN },
+ { headers: { "Idempotency-Key": idempotencyKey } },
+ ),
);
},
onSuccess: (data) => {
@@ -72,7 +82,7 @@ export function DangerZoneSection() {
);
},
onError: (err: Error) => {
- setWipeMsg(err.message);
+ setWipeMsg(recentProofErrorMessage(err, err.message));
},
});
@@ -84,6 +94,7 @@ export function DangerZoneSection() {
// are alarm enough. The header keeps the `AlertTriangle` icon so the
// section still flags its intent.
+ {recentProof.dialog}
;
+ unrecorded: { copies: number; oldestAt: string | null };
+ offhost: Array<{
+ keyId: string;
+ firstWrittenAt: string;
+ lastWrittenAt: string;
+ neededUntil: string | null;
+ }>;
+ offhostExpirationDays: number | null;
+ retiredKeysStillNeeded: string[];
+ };
+ safeToDropRetiredKeys: boolean;
}
export function EncryptionSection() {
const { t } = useTranslations();
+ const fmt = useFormatters();
const queryClient = useQueryClient();
const statusQuery = useQuery({
@@ -166,10 +187,20 @@ export function EncryptionSection() {
value={s.staleRows.toLocaleString()}
/>
- {s.rotationComplete ? (
+ {s.safeToDropRetiredKeys ? (
{t("admin.section.encryption.safeToDropLegacy")}
+ ) : s.rotationComplete ? (
+ // Every row is on the active key, and that is not the whole
+ // answer: a backup keeps the key its content was written under.
+
+ {s.backups.retiredKeysStillNeeded.length > 0
+ ? t("admin.section.encryption.backupsStillNeed", {
+ keys: s.backups.retiredKeysStillNeeded.join(", "),
+ })
+ : t("admin.section.encryption.backupsUnrecordedBadge")}
+
) : (
{t("admin.section.encryption.rotationIncomplete", {
@@ -179,6 +210,56 @@ export function EncryptionSection() {
)}
+ {/* ── Keys the backups still need ───────────────────────────── */}
+
+
+ {t("admin.section.encryption.backupsDetail")}
+
+ {s.backups.stored.map((row) => (
+
+ {t("admin.section.encryption.backupsStoredLine", {
+ key: row.keyId,
+ count: row.copies,
+ when: fmt.dateTime(row.oldestAt),
+ })}
+
+ ))}
+ {s.backups.unrecorded.copies > 0 && s.backups.unrecorded.oldestAt ? (
+
+ {t("admin.section.encryption.backupsUnrecordedLine", {
+ count: s.backups.unrecorded.copies,
+ when: fmt.dateTime(s.backups.unrecorded.oldestAt),
+ })}
+
+ ) : null}
+ {s.backups.offhost.map((row) => (
+
+ {row.neededUntil
+ ? t("admin.section.encryption.backupsOffhostLine", {
+ key: row.keyId,
+ when: fmt.dateTime(row.lastWrittenAt),
+ until: fmt.dateTime(row.neededUntil),
+ })
+ : t("admin.section.encryption.backupsOffhostLineUnknown", {
+ key: row.keyId,
+ when: fmt.dateTime(row.lastWrittenAt),
+ })}
+
+ ))}
+ {s.backups.stored.length === 0 &&
+ s.backups.unrecorded.copies === 0 &&
+ s.backups.offhost.length === 0 ? (
+
+ {t("admin.section.encryption.backupsNone")}
+
+ ) : null}
+
+
+
{/* ── Rotation status + trigger ──────────────────────────────── */}
+
+ {offhost.lifecycle.state === "configured"
+ ? t("admin.section.backups.offhost.lifecycleConfigured", {
+ days: offhost.lifecycle.expirationDays ?? 0,
+ })
+ : offhost.lifecycle.state === "missing"
+ ? t("admin.section.backups.offhost.lifecycleMissing")
+ : t("admin.section.backups.offhost.lifecycleUnknown")}
+
+ {offhost.pendingDeletions.count > 0 ? (
+
+ {offhost.pendingDeletions.lastFailure
+ ? t("admin.section.backups.offhost.pendingDeletionsFailing", {
+ count: offhost.pendingDeletions.count,
+ error: offhost.pendingDeletions.lastFailure,
+ })
+ : t("admin.section.backups.offhost.pendingDeletions", {
+ count: offhost.pendingDeletions.count,
+ })}
+
+ ) : null}
) : null}
diff --git a/src/components/admin/user-management-section.tsx b/src/components/admin/user-management-section.tsx
index 38c816b1a..696231967 100644
--- a/src/components/admin/user-management-section.tsx
+++ b/src/components/admin/user-management-section.tsx
@@ -38,6 +38,10 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { type AdminUser, PasswordInput } from "./_shared";
import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
+import {
+ recentProofErrorMessage,
+ useRecentProof,
+} from "@/components/settings/security-section/use-recent-proof";
/**
* Filter values for the v1.5 users sub-route. The User model does NOT
@@ -103,16 +107,22 @@ export function UserManagementSection() {
},
});
+ // Setting another account's password asks for a fresh proof unless this
+ // session signed in or re-proved within five minutes.
+ const recentProof = useRecentProof();
+
const resetPw = useMutation({
mutationFn: async ({ id, password }: { id: string; password: string }) => {
- await apiPost(`/api/admin/users/${id}/reset-password`, { password });
+ await recentProof.run(() =>
+ apiPost(`/api/admin/users/${id}/reset-password`, { password }),
+ );
},
onSuccess: () => {
setResetMsg(t("admin.passwordReset"));
setResetPassword("");
},
onError: (err: Error) => {
- setResetMsg(err.message);
+ setResetMsg(recentProofErrorMessage(err, err.message));
},
});
@@ -265,6 +275,7 @@ export function UserManagementSection() {
return (
+ {recentProof.dialog}
{t("admin.overview.versionTileUpToDate")}
+ ) : checkOff ? (
+
+ {t("admin.overview.versionTileCheckOff")}
+
) : null
}
/>
diff --git a/src/components/charts/compliance-heatmap.tsx b/src/components/charts/compliance-heatmap.tsx
index 7eec7a959..cc4a0583b 100644
--- a/src/components/charts/compliance-heatmap.tsx
+++ b/src/components/charts/compliance-heatmap.tsx
@@ -1,7 +1,12 @@
"use client";
import { useState, useMemo, useEffect, useRef } from "react";
-import { useTranslations, useFormatters } from "@/lib/i18n/context";
+import {
+ useDisplayTimezone,
+ useFormatters,
+ useTranslations,
+} from "@/lib/i18n/context";
+import { heatmapDays } from "@/lib/charts/heatmap-days";
interface DailyData {
expected: number;
@@ -79,6 +84,7 @@ export function ComplianceHeatmap({
}: ComplianceHeatmapProps) {
const { t } = useTranslations();
const fmt = useFormatters();
+ const timeZone = useDisplayTimezone();
// Day keys are UTC-anchored "YYYY-MM-DD"; format at noon UTC so the
// locale-aware renderer never lands a day off in either direction.
const formatDay = (dateKey: string) =>
@@ -175,7 +181,6 @@ export function ComplianceHeatmap({
t("charts.months.nov"),
t("charts.months.dec"),
];
- const now = new Date();
const cellList: Array<{
dateKey: string;
col: number;
@@ -184,34 +189,18 @@ export function ComplianceHeatmap({
data: DailyData;
}> = [];
- // Build array of dates from oldest to newest
- const dates: Date[] = [];
- for (let d = days - 1; d >= 0; d--) {
- const date = new Date(now.getTime() - d * 24 * 60 * 60 * 1000);
- dates.push(date);
- }
-
- // Start from the first date, align to Monday.
- //
- // v1.4.27 B7 / BL-P4-2 — read every weekday + month boundary off
- // the UTC accessor pair (`getUTCDay`, `getUTCMonth`) so the
- // computation matches the dateKey, which is also UTC-anchored
- // (`toISOString().slice(0, 10)`). Reading in server-tz here would
- // shift the Monday-alignment + month-marker placement when the
- // SSR pass runs on a server in a non-Berlin timezone.
- const firstDate = dates[0];
- const firstDow = (firstDate.getUTCDay() + 6) % 7; // Monday = 0
+ // Oldest to newest, one entry per local day in the user's zone.
+ const dates = heatmapDays(new Date(), timeZone, days);
+ const firstDow = dates[0]?.dow ?? 0;
let col = 0;
const markers: Array<{ col: number; label: string }> = [];
let lastMonth = -1;
for (let i = 0; i < dates.length; i++) {
- const date = dates[i];
- const dow = (date.getUTCDay() + 6) % 7; // Monday = 0
+ const { dateKey, dow, month } = dates[i];
const currentCol = Math.floor((i + firstDow) / 7);
const row = dow;
- const dateKey = date.toISOString().slice(0, 10);
const data = dailyCompliance[dateKey] ?? {
expected: 0,
taken: 0,
@@ -219,7 +208,6 @@ export function ComplianceHeatmap({
};
// Track month boundaries
- const month = date.getUTCMonth();
if (month !== lastMonth) {
markers.push({ col: currentCol, label: MONTH_LABELS[month] });
lastMonth = month;
@@ -237,7 +225,7 @@ export function ComplianceHeatmap({
}
return { cells: cellList, weeks: col + 1, monthMarkers: markers };
- }, [dailyCompliance, days, t]);
+ }, [dailyCompliance, days, t, timeZone]);
const activeDays = cells.filter((cell) => cell.data.expected > 0).length;
const summaryLabel =
diff --git a/src/components/charts/medication-compliance-chart.tsx b/src/components/charts/medication-compliance-chart.tsx
index 866759999..fa5aefa0e 100644
--- a/src/components/charts/medication-compliance-chart.tsx
+++ b/src/components/charts/medication-compliance-chart.tsx
@@ -69,6 +69,7 @@ import { useViewportWidth } from "@/hooks/use-viewport-width";
import { chooseTickInterval } from "@/lib/charts/x-axis-density";
import { apiGet } from "@/lib/api/api-fetch";
import { shouldFireDataReady } from "@/lib/charts/data-ready-latch";
+import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
interface DailyCompliancePoint {
/** Berlin calendar day, "YYYY-MM-DD". */
@@ -208,7 +209,7 @@ interface MedicationComplianceChartProps {
export function MedicationComplianceChart({
title,
- userTimezone = "Europe/Berlin",
+ userTimezone = DEFAULT_TIMEZONE,
compareBaseline,
onDataReady,
}: MedicationComplianceChartProps) {
diff --git a/src/components/charts/mood-heatmap.tsx b/src/components/charts/mood-heatmap.tsx
index 178064908..959d25d7e 100644
--- a/src/components/charts/mood-heatmap.tsx
+++ b/src/components/charts/mood-heatmap.tsx
@@ -1,7 +1,12 @@
"use client";
import { useState, useMemo, useEffect, useRef } from "react";
-import { useTranslations, useFormatters } from "@/lib/i18n/context";
+import {
+ useDisplayTimezone,
+ useFormatters,
+ useTranslations,
+} from "@/lib/i18n/context";
+import { heatmapDays } from "@/lib/charts/heatmap-days";
import { moodLabelKeyForScore } from "@/lib/mood/labels";
/**
@@ -62,6 +67,7 @@ export function MoodHeatmap({
}: MoodHeatmapProps) {
const { t } = useTranslations();
const fmt = useFormatters();
+ const timeZone = useDisplayTimezone();
// Day keys are UTC-anchored "YYYY-MM-DD"; format at noon UTC so the
// locale-aware renderer never lands a day off in either direction.
const formatDay = (dateKey: string) =>
@@ -147,7 +153,6 @@ export function MoodHeatmap({
t("charts.months.nov"),
t("charts.months.dec"),
];
- const now = new Date();
const cellList: Array<{
dateKey: string;
col: number;
@@ -156,30 +161,19 @@ export function MoodHeatmap({
cell: MoodHeatmapCell | null;
}> = [];
- const dates: Date[] = [];
- for (let d = days - 1; d >= 0; d--) {
- const date = new Date(now.getTime() - d * 24 * 60 * 60 * 1000);
- dates.push(date);
- }
-
- // Monday-align + month markers read off the UTC accessors so the
- // grid matches the UTC-anchored dateKey (mirrors compliance-heatmap).
- const firstDate = dates[0];
- const firstDow = (firstDate.getUTCDay() + 6) % 7; // Monday = 0
+ const dates = heatmapDays(new Date(), timeZone, days);
+ const firstDow = dates[0]?.dow ?? 0;
let col = 0;
const markers: Array<{ col: number; label: string }> = [];
let lastMonth = -1;
for (let i = 0; i < dates.length; i++) {
- const date = dates[i];
- const dow = (date.getUTCDay() + 6) % 7; // Monday = 0
+ const { dateKey, dow, month } = dates[i];
const currentCol = Math.floor((i + firstDow) / 7);
const row = dow;
- const dateKey = date.toISOString().slice(0, 10);
const cell = cellData[dateKey] ?? null;
- const month = date.getUTCMonth();
if (month !== lastMonth) {
markers.push({ col: currentCol, label: MONTH_LABELS[month] });
lastMonth = month;
@@ -197,7 +191,7 @@ export function MoodHeatmap({
}
return { cells: cellList, weeks: col + 1, monthMarkers: markers };
- }, [cellData, days, t]);
+ }, [cellData, days, t, timeZone]);
const loggedDays = cells.filter((cell) => cell.cell !== null).length;
const summaryLabel =
diff --git a/src/components/clinician/report-sections.tsx b/src/components/clinician/report-sections.tsx
index 10efc951c..bf41f8535 100644
--- a/src/components/clinician/report-sections.tsx
+++ b/src/components/clinician/report-sections.tsx
@@ -309,14 +309,14 @@ export function MedicationsSection({
empty={medications.length === 0 && complianceEntries.length === 0}
>
{medications.map((med) => {
- const comp = report.compliance[med.name];
+ const comp = report.compliance[med.id];
const rate =
complianceOn && comp && comp.total > 0
? `${Math.round((comp.taken / comp.total) * 100)}%`
: null;
return (
!medications.some((m) => m.name === name))
- .map(([name, c]) => (
+ .filter(([id]) => !medications.some((m) => m.id === id))
+ .map(([id, c]) => (
uploadInputRef.current?.click()}
- >
-
- {t("documents.pageUpload")}
-
+
+ {/* Renders only with a connected Paperless-ngx or Papra. */}
+
+ uploadInputRef.current?.click()}
+ >
+
+ {t("documents.pageUpload")}
+
+
) : null
}
/>
diff --git a/src/components/documents/episode-documents-card.tsx b/src/components/documents/episode-documents-card.tsx
index fc42f410b..2c9bc580f 100644
--- a/src/components/documents/episode-documents-card.tsx
+++ b/src/components/documents/episode-documents-card.tsx
@@ -32,6 +32,7 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { DocumentLinkPicker } from "./document-link-picker";
+import { ImportFromSourceButton } from "./sources/import-from-source-button";
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
import { documentDateKey } from "./vault-utils";
@@ -167,6 +168,9 @@ export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) {
{t("documents.episodeCard.upload")}
+
>
) : null}
{hasMore ? (
diff --git a/src/components/documents/sources/__tests__/document-sources-ui.test.tsx b/src/components/documents/sources/__tests__/document-sources-ui.test.tsx
new file mode 100644
index 000000000..208163bcb
--- /dev/null
+++ b/src/components/documents/sources/__tests__/document-sources-ui.test.tsx
@@ -0,0 +1,275 @@
+/**
+ * The document picker's client gates (#1038), pinned via static renders:
+ *
+ * - the import button exists only for a person in their own record with a
+ * usable connection; a delegate, an unset operator list, no connection, or
+ * a connection whose origin was removed leave no button;
+ * - its label names the one connected system, or stays generic for two;
+ * - the settings card renders nothing unless the operator enabled the
+ * picker, lists the allowed origins, and never puts a token in the markup;
+ * - every error code the routes emit has its own sentence, and an unknown
+ * one falls back to the generic sentence rather than a raw key.
+ */
+import { describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+let modules: Record = { inboundDocuments: true };
+vi.mock("@/hooks/use-auth", () => ({
+ useAuth: () => ({
+ user: { id: "u1", modules },
+ isLoading: false,
+ isAuthenticated: true,
+ }),
+}));
+
+let shared = false;
+vi.mock("@/hooks/use-record-capabilities", () => ({
+ useRecordCapabilities: () => ({
+ inSharedRecord: shared,
+ canManageDomain: () => !shared,
+ }),
+}));
+
+vi.mock("@/lib/api/api-fetch", async () => {
+ const actual = await vi.importActual(
+ "@/lib/api/api-fetch",
+ );
+ return { ...actual, apiGet: () => new Promise(() => {}) };
+});
+
+import { DocumentSourcesCard } from "@/components/settings/integrations/document-sources-card";
+import en from "../../../../../messages/en.json";
+import { ApiError } from "@/lib/api/api-fetch";
+import {
+ DOCUMENT_SOURCE_ERROR_CODES,
+ type DocumentSourcesStatusDto,
+} from "@/lib/documents/sources/types";
+import { I18nProvider } from "@/lib/i18n/context";
+import { queryKeys } from "@/lib/query-keys";
+
+import { ImportFromSourceButton } from "../import-from-source-button";
+import { mergeIds } from "../merge-ids";
+import { nextChoiceIndex } from "../segmented-choice";
+import { errorCodeOf, sourceErrorMessage } from "../source-errors";
+
+const PAPERLESS = {
+ system: "PAPERLESS" as const,
+ baseUrl: "http://paperless.lan:8000",
+ organizationId: null,
+ hasToken: true as const,
+ lastVerifiedAt: "2026-09-20T10:00:00.000Z",
+ originAllowed: true,
+};
+const PAPRA = {
+ ...PAPERLESS,
+ system: "PAPRA" as const,
+ baseUrl: "https://papra.example.com",
+ organizationId: "org_1",
+};
+
+function render(node: React.ReactNode, status?: DocumentSourcesStatusDto) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ if (status)
+ queryClient.setQueryData(queryKeys.documentSourcesStatus(), status);
+ return renderToStaticMarkup(
+
+ {node}
+ ,
+ );
+}
+
+const on = (connections: DocumentSourcesStatusDto["connections"]) => ({
+ available: true,
+ allowedOrigins: ["http://paperless.lan:8000", "https://papra.example.com"],
+ connections,
+});
+
+describe("", () => {
+ it("names the one connected system", () => {
+ const html = render( , on([PAPERLESS]));
+ expect(html).toContain('data-slot="document-source-open"');
+ expect(html).toContain("Import from Paperless-ngx");
+ });
+
+ it("stays generic with two", () => {
+ const html = render( , on([PAPERLESS, PAPRA]));
+ expect(html).toContain("Import from archive");
+ });
+
+ it("renders nothing without a usable connection", () => {
+ expect(render( , on([]))).toBe("");
+ expect(
+ render(
+ ,
+ on([{ ...PAPERLESS, originAllowed: false }]),
+ ),
+ ).toBe("");
+ expect(
+ render( , {
+ available: false,
+ allowedOrigins: [],
+ connections: [],
+ }),
+ ).toBe("");
+ });
+
+ it("renders nothing for a delegate, even with the owner's data cached", () => {
+ shared = true;
+ try {
+ expect(render( , on([PAPERLESS]))).toBe("");
+ } finally {
+ shared = false;
+ }
+ });
+
+ it("renders nothing with the documents module off", () => {
+ modules = { inboundDocuments: false };
+ try {
+ expect(render( , on([PAPERLESS]))).toBe("");
+ } finally {
+ modules = { inboundDocuments: true };
+ }
+ });
+});
+
+describe("", () => {
+ it("renders nothing when the operator has not enabled the picker", () => {
+ expect(
+ render( , {
+ available: false,
+ allowedOrigins: [],
+ connections: [],
+ }),
+ ).toBe("");
+ expect(render( )).toBe("");
+ });
+
+ it("renders nothing for a delegate", () => {
+ shared = true;
+ try {
+ expect(render( , on([PAPERLESS]))).toBe("");
+ } finally {
+ shared = false;
+ }
+ });
+
+ it("shows the form, the allowed origins and a saved-token placeholder, never a token", () => {
+ const html = render( , on([PAPERLESS]));
+ expect(html).toContain('data-testid="document-sources-card"');
+ expect(html).toContain("Document archives");
+ expect(html).toContain(
+ "Allowed on this server: http://paperless.lan:8000, https://papra.example.com",
+ );
+ expect(html).toContain("Saved for this address. Leave empty to keep it.");
+ expect(html).toContain('value="http://paperless.lan:8000"');
+ // The password field is empty: the server never sends the token back.
+ expect(html).toMatch(/id="document-source-token-paperless"[^>]*value=""/);
+ expect(html).toContain("Disconnect");
+ });
+
+ it("keeps a stored connection visible and removable once the operator list is gone", () => {
+ const html = render( , {
+ available: false,
+ allowedOrigins: [],
+ connections: [{ ...PAPERLESS, originAllowed: false }],
+ });
+ expect(html).toContain('data-testid="document-sources-card"');
+ expect(html).toContain("no longer allows document archives");
+ expect(html).toContain("Disconnect");
+ expect(html).not.toContain("Save and test");
+ expect(html).not.toContain('id="document-source-token-paperless"');
+ });
+
+ it("keeps a stored connection removable with the documents module off", () => {
+ modules = { inboundDocuments: false };
+ try {
+ const html = render( , on([PAPERLESS]));
+ expect(html).toContain("Documents are switched off");
+ expect(html).toContain("Disconnect");
+ expect(html).not.toContain("Save and test");
+ } finally {
+ modules = { inboundDocuments: true };
+ }
+ });
+
+ it("uses one tab stop for the system switch", () => {
+ const html = render( , on([PAPERLESS]));
+ expect(html.match(/role="radio"[^>]*tabindex="0"/gi)?.length).toBe(1);
+ expect(html.match(/role="radio"[^>]*tabindex="-1"/gi)?.length).toBe(1);
+ });
+
+ it("offers no disconnect or test for a system that is not connected", () => {
+ const html = render( , on([]));
+ expect(html).toContain("Not connected");
+ expect(html).not.toContain("Disconnect");
+ expect(html).toContain("Save and test");
+ });
+});
+
+describe("source error sentences", () => {
+ const t = (key: string) => {
+ let node: unknown = en;
+ for (const part of key.split(".")) {
+ node = (node as Record | undefined)?.[part];
+ }
+ return typeof node === "string" ? node : key;
+ };
+ const generic = t("documents.sourcePicker.errors.generic");
+
+ it("has its own sentence for every code the routes emit", () => {
+ const codes = [
+ ...DOCUMENT_SOURCE_ERROR_CODES,
+ "documents.sources.browserOnly",
+ "documents.sources.invalidAddress",
+ "documents.sources.organizationRequired",
+ "documents.sources.tokenRequired",
+ "documents.inbound.fileTooLarge",
+ "documents.inbound.quotaExceeded",
+ "documents.inbound.fileType",
+ "documents.inbound.rateLimited",
+ "documents.inbound.uploadBusy",
+ "module.disabled",
+ ];
+ const sentences = new Set();
+ for (const code of codes) {
+ const sentence = sourceErrorMessage(t, code);
+ expect(sentence, code).not.toBe(generic);
+ expect(sentence, code).not.toContain("documents.sourcePicker");
+ sentences.add(sentence);
+ }
+ expect(sentences.size).toBe(codes.length);
+ expect(sourceErrorMessage(t, "something.else")).toBe(generic);
+ expect(sourceErrorMessage(t, undefined)).toBe(generic);
+ });
+
+ it("reads the code off a failed request", () => {
+ expect(
+ errorCodeOf(
+ new ApiError("x", 502, { errorCode: "documents.sources.authRefused" }),
+ ),
+ ).toBe("documents.sources.authRefused");
+ expect(errorCodeOf(new Error("x"))).toBeUndefined();
+ });
+});
+
+describe("nextChoiceIndex", () => {
+ it("moves with the arrows, wraps, and jumps with Home and End", () => {
+ expect(nextChoiceIndex("ArrowRight", 0, 2)).toBe(1);
+ expect(nextChoiceIndex("ArrowRight", 1, 2)).toBe(0);
+ expect(nextChoiceIndex("ArrowLeft", 0, 2)).toBe(1);
+ expect(nextChoiceIndex("ArrowDown", 0, 3)).toBe(1);
+ expect(nextChoiceIndex("ArrowUp", 0, 3)).toBe(2);
+ expect(nextChoiceIndex("Home", 2, 3)).toBe(0);
+ expect(nextChoiceIndex("End", 0, 3)).toBe(2);
+ expect(nextChoiceIndex("Enter", 0, 3)).toBeNull();
+ });
+});
+
+describe("mergeIds", () => {
+ it("adds imported ids to a form's selection once, in order", () => {
+ expect(mergeIds(["a", "b"], ["b", "c", "c"])).toEqual(["a", "b", "c"]);
+ });
+});
diff --git a/src/components/documents/sources/document-source-picker.tsx b/src/components/documents/sources/document-source-picker.tsx
new file mode 100644
index 000000000..09dc62404
--- /dev/null
+++ b/src/components/documents/sources/document-source-picker.tsx
@@ -0,0 +1,764 @@
+"use client";
+
+/**
+ * The document picker (#1038): search a connected Paperless-ngx or Papra by
+ * document name, narrow by tag and date, tick the documents to bring over,
+ * import them.
+ *
+ * Name first, because that is how the person looks for a letter: the search
+ * field opens focused and debounces into the source's own name search. Tag and
+ * date are filters beside it, not the entry point. Each result says what
+ * HealthLog already holds under that source key: already imported, deleted
+ * here (not offered again: a deletion was a decision), or new.
+ *
+ * The import runs one document per request, in order, so every row can show
+ * its own progress and result, and one failure does not hide the others. A
+ * refusal that will answer every following document the same way (the hourly
+ * upload allowance, a full vault, a refused token) stops the run and says so.
+ *
+ * Where it was opened from decides what an import does besides storing:
+ *
+ * - `link`: the record already exists (a condition's page), so the server
+ * files the document against it as part of the import;
+ * - `onImported`: a form that owns its selection (a visit, a vaccination)
+ * gets the new ids and adds them itself. Linking on the server there would
+ * be undone by the form's own save, which replaces its links.
+ *
+ * With either, a document already in HealthLog can be picked too: it is not
+ * stored twice, it is linked or handed to the form.
+ */
+import {
+ useInfiniteQuery,
+ useQuery,
+ useQueryClient,
+} from "@tanstack/react-query";
+import {
+ Check,
+ CircleAlert,
+ CircleCheck,
+ Loader2,
+ Search,
+ SearchX,
+ X,
+} from "lucide-react";
+import { useEffect, useMemo, useRef, useState } from "react";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { EmptyState } from "@/components/ui/empty-state";
+import { DateField } from "@/components/ui/date-field";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { NativeSelect } from "@/components/ui/native-select";
+import { QueryErrorRow } from "@/components/ui/query-error-row";
+import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
+import { Skeleton } from "@/components/ui/skeleton";
+import { TagChip } from "@/components/ui/tag-chip";
+import { apiGet, apiPost } from "@/lib/api/api-fetch";
+import {
+ DOCUMENT_PICKER_MAX_SELECTION,
+ slugForSystem,
+ type DocumentImportOutcome,
+ type DocumentPickerLinkKind,
+ type DocumentPickerSystem,
+ type DocumentSourceImportDto,
+ type DocumentSourceResultDto,
+ type DocumentSourceSearchDto,
+ type DocumentSourceTagDto,
+} from "@/lib/documents/sources/types";
+import { useFormatters, useTranslations } from "@/lib/i18n/context";
+import { invalidateKeys, queryKeys } from "@/lib/query-keys";
+import { cn } from "@/lib/utils";
+import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
+
+import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
+import type { WrittenOutcome } from "@/lib/outcome/written-outcome";
+
+import { formatBytes } from "../vault-utils";
+import {
+ errorCodeOf,
+ isStoppingError,
+ sourceErrorMessage,
+} from "./source-errors";
+import { SegmentedChoice } from "./segmented-choice";
+import { systemName } from "./use-document-sources";
+
+export interface DocumentPickerLink {
+ kind: DocumentPickerLinkKind;
+ id: string;
+}
+
+export interface DocumentSourcePickerProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ /** Connected systems, in the order the switch shows them. */
+ systems: DocumentPickerSystem[];
+ /** File the imported documents against this existing record. */
+ link?: DocumentPickerLink;
+ /** Document type for everything imported (default OTHER). */
+ kind?: InboundDocumentKindValue;
+ /** Hand the vault ids to a form that links them on its own save. */
+ onImported?: (documentIds: string[]) => void;
+}
+
+type RowRun =
+ | { state: "waiting" }
+ | { state: "working" }
+ | { state: DocumentImportOutcome; linked: boolean }
+ | { state: "failed"; message: string };
+
+const SEARCH_DEBOUNCE_MS = 300;
+const IMPORT_TIMEOUT_MS = 120_000;
+
+export function DocumentSourcePicker({
+ open,
+ onOpenChange,
+ systems,
+ link,
+ kind,
+ onImported,
+}: DocumentSourcePickerProps) {
+ const { t, locale } = useTranslations();
+ const format = useFormatters();
+ const queryClient = useQueryClient();
+
+ const [system, setSystem] = useState(null);
+ const active =
+ system && systems.includes(system) ? system : (systems[0] ?? null);
+ const slug = active ? slugForSystem(active) : null;
+
+ const [searchDraft, setSearchDraft] = useState("");
+ const [q, setQ] = useState("");
+ const [tag, setTag] = useState("");
+ const [from, setFrom] = useState("");
+ const [to, setTo] = useState("");
+ const [selected, setSelected] = useState>(new Map());
+ const [runs, setRuns] = useState>(new Map());
+ const [running, setRunning] = useState(false);
+ const [finished, setFinished] = useState(false);
+ const [stopMessage, setStopMessage] = useState(null);
+ const [atMax, setAtMax] = useState(false);
+ /** The latest per-row result, for screen readers (aria-live below). */
+ const [announcement, setAnnouncement] = useState("");
+
+ // A new search or filter starts over: a selection made in one result list
+ // must not ride along unseen into another, and a finished run's result
+ // lines belong to the list they were made in.
+ const startOver = () => {
+ setSelected(new Map());
+ setRuns(new Map());
+ setFinished(false);
+ setStopMessage(null);
+ setAtMax(false);
+ };
+
+ const debounceRef = useRef | null>(null);
+ useEffect(
+ () => () => {
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ },
+ [],
+ );
+ const onSearchChange = (value: string) => {
+ setSearchDraft(value);
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ debounceRef.current = setTimeout(() => {
+ const next = value.trim();
+ if (next !== q) {
+ startOver();
+ setQ(next);
+ }
+ }, SEARCH_DEBOUNCE_MS);
+ };
+ const filterSetter =
+ (set: (value: string) => void) =>
+ (value: string): void => {
+ startOver();
+ set(value);
+ };
+
+ const reset = () => {
+ setSearchDraft("");
+ setQ("");
+ setTag("");
+ setFrom("");
+ setTo("");
+ startOver();
+ };
+
+ const switchSystem = (next: DocumentPickerSystem) => {
+ if (next === active || running) return;
+ reset();
+ setSystem(next);
+ };
+
+ const tags = useQuery({
+ queryKey: queryKeys.documentSourceTags(slug ?? ""),
+ enabled: open && slug !== null,
+ queryFn: () =>
+ apiGet<{ tags: DocumentSourceTagDto[] }>(
+ `/api/documents/sources/${slug}/tags`,
+ ),
+ staleTime: 5 * 60_000,
+ });
+
+ const facets = {
+ q,
+ ...(tag ? { tag } : {}),
+ ...(from ? { from } : {}),
+ ...(to ? { to } : {}),
+ };
+ const rangeInvalid = Boolean(from && to && from > to);
+ const search = useInfiniteQuery({
+ queryKey: queryKeys.documentSourceSearch(slug ?? "", facets),
+ enabled: open && slug !== null && !rangeInvalid,
+ initialPageParam: 1,
+ queryFn: ({ pageParam }) => {
+ const sp = new URLSearchParams({ page: String(pageParam) });
+ if (q) sp.set("q", q);
+ if (tag) sp.set("tag", tag);
+ if (from) sp.set("from", from);
+ if (to) sp.set("to", to);
+ return apiGet(
+ `/api/documents/sources/${slug}/search?${sp.toString()}`,
+ );
+ },
+ getNextPageParam: (last) => (last.hasMore ? last.page + 1 : undefined),
+ staleTime: 30_000,
+ });
+
+ const results = useMemo(() => {
+ const seen = new Set();
+ const out: DocumentSourceResultDto[] = [];
+ for (const page of search.data?.pages ?? []) {
+ for (const row of page.results) {
+ if (seen.has(row.sourceId)) continue;
+ seen.add(row.sourceId);
+ out.push(row);
+ }
+ }
+ return out;
+ }, [search.data]);
+
+ // An already imported document is worth picking only when the pick does
+ // something with it: link it, or hand it to the form.
+ const canTakeImported = Boolean(link || onImported);
+ const selectable = (row: DocumentSourceResultDto) =>
+ row.state === "new" || (row.state === "imported" && canTakeImported);
+
+ const toggle = (row: DocumentSourceResultDto) => {
+ if (running || finished || !selectable(row)) return;
+ const next = new Map(selected);
+ if (next.has(row.sourceId)) {
+ next.delete(row.sourceId);
+ setAtMax(false);
+ } else if (next.size < DOCUMENT_PICKER_MAX_SELECTION) {
+ next.set(row.sourceId, row.title);
+ } else {
+ // Said rather than silently ignored: the tick that did not appear.
+ setAtMax(true);
+ return;
+ }
+ setSelected(next);
+ };
+
+ async function runImport() {
+ if (!slug || selected.size === 0) return;
+ setRunning(true);
+ setStopMessage(null);
+ const order = [...selected.keys()];
+ setRuns(new Map(order.map((id) => [id, { state: "waiting" }])));
+ const handed: string[] = [];
+ const mark = (id: string, run: RowRun) => {
+ setRuns((prev) => new Map(prev).set(id, run));
+ const title = selected.get(id) ?? id;
+ const said = runLabel(t, run);
+ if (said) setAnnouncement(`${title}: ${said}`);
+ };
+
+ for (const sourceId of order) {
+ mark(sourceId, { state: "working" });
+ try {
+ const result = await apiPost(
+ `/api/documents/sources/${slug}/import`,
+ {
+ sourceId,
+ ...(kind ? { kind } : {}),
+ ...(link ? { link } : {}),
+ },
+ // The server may take up to a minute to fetch a large file from
+ // the source; the default 15 s client timeout would abandon it.
+ { signal: AbortSignal.timeout(IMPORT_TIMEOUT_MS) },
+ );
+ mark(sourceId, { state: result.outcome, linked: result.linked });
+ if (result.outcome !== "deleted" && result.documentId) {
+ handed.push(result.documentId);
+ }
+ } catch (err) {
+ const code = errorCodeOf(err);
+ const message = sourceErrorMessage(t, code);
+ mark(sourceId, { state: "failed", message });
+ if (isStoppingError(code)) {
+ setStopMessage(message);
+ break;
+ }
+ }
+ }
+
+ setRunning(false);
+ setFinished(true);
+ setSelected(new Map());
+ // The vault, its pickers and the record's own lists read documents
+ // through the `["documents"]` prefix; the picker's states through its own.
+ void invalidateKeys(queryClient, [
+ queryKeys.documents(),
+ queryKeys.documentSources(),
+ ]);
+ if (handed.length > 0) onImported?.(handed);
+ }
+
+ const counts = useMemo(() => {
+ let imported = 0;
+ let present = 0;
+ let failed = 0;
+ for (const run of runs.values()) {
+ if (run.state === "imported") imported += 1;
+ else if (run.state === "duplicate" || run.state === "deleted")
+ present += 1;
+ else if (run.state === "failed") failed += 1;
+ }
+ return { imported, present, failed };
+ }, [runs]);
+
+ const name = active ? systemName(active) : "";
+ const hasFilters = Boolean(tag || from || to);
+
+ const footer = (
+
+
+ {finished
+ ? null
+ : running
+ ? t("documents.sourcePicker.importing")
+ : t("documents.sourcePicker.selectedCount", {
+ count: selected.size,
+ max: DOCUMENT_PICKER_MAX_SELECTION,
+ })}
+
+ {finished ? (
+
+
+ {t("documents.sourcePicker.pickMore")}
+
+ onOpenChange(false)}
+ >
+ {t("documents.sourcePicker.done")}
+
+
+ ) : (
+
void runImport()}
+ data-slot="document-source-import"
+ >
+ {running ? (
+
+ ) : null}
+ {t("documents.sourcePicker.importAction", { count: selected.size })}
+
+ )}
+
+ );
+
+ return (
+ {
+ // A run in progress finishes before the sheet can close; its rows
+ // are the only place the outcome is shown.
+ if (!next && running) return;
+ if (!next) reset();
+ onOpenChange(next);
+ }}
+ title={t("documents.sourcePicker.title", { name })}
+ description={t("documents.sourcePicker.description")}
+ contentWidth="lg"
+ footer={footer}
+ >
+
+ {systems.length > 1 && active ? (
+
+ ) : null}
+
+
+
+ {t("documents.sourcePicker.searchLabel")}
+
+
+
+ onSearchChange(e.target.value)}
+ placeholder={t("documents.sourcePicker.searchPlaceholder")}
+ className="pl-9"
+ maxLength={200}
+ autoComplete="off"
+ data-slot="document-source-search"
+ />
+
+
+
+
+
+
+ {t("documents.sourcePicker.tagLabel")}
+
+ filterSetter(setTag)(e.target.value)}
+ disabled={tags.isPending && !tags.isError}
+ >
+ {t("documents.sourcePicker.tagAny")}
+ {(tags.data?.tags ?? []).map((option) => (
+
+ {option.name}
+
+ ))}
+
+
+
+
+ {t("documents.sourcePicker.fromLabel")}
+
+
+
+
+
+ {t("documents.sourcePicker.toLabel")}
+
+
+
+
+ {hasFilters ? (
+
{
+ startOver();
+ setTag("");
+ setFrom("");
+ setTo("");
+ }}
+ className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-8 items-center gap-1 rounded-sm text-xs underline-offset-2 hover:underline focus-visible:ring-[3px] focus-visible:outline-none"
+ >
+
+ {t("documents.sourcePicker.clearFilters")}
+
+ ) : null}
+
+ {finished ? (
+
+
+
+ {t("documents.sourcePicker.finishedHint")}
+
+
+ ) : null}
+
+ {atMax ? (
+
+ {t("documents.sourcePicker.maxReached", {
+ max: DOCUMENT_PICKER_MAX_SELECTION,
+ })}
+
+ ) : null}
+
+ {/* Each row's result as it lands, for a screen reader. */}
+
+ {announcement}
+
+
+ {stopMessage ? (
+
+ {t("documents.sourcePicker.stopped", { reason: stopMessage })}
+
+ ) : null}
+
+ {rangeInvalid ? (
+
+ {t("documents.sourcePicker.rangeInvalid")}
+
+ ) : search.isPending ? (
+
+ {Array.from({ length: 4 }, (_, i) => (
+
+ ))}
+
+ ) : search.isError ? (
+
void search.refetch()}
+ slot="document-source-error"
+ />
+ ) : results.length === 0 ? (
+ }
+ title={
+ q || hasFilters
+ ? t("documents.sourcePicker.noMatch")
+ : t("documents.sourcePicker.empty", { name })
+ }
+ description={
+ q || hasFilters
+ ? t("documents.sourcePicker.noMatchHint")
+ : undefined
+ }
+ />
+ ) : (
+
+ {results.map((row) => {
+ const on = selected.has(row.sourceId);
+ const run = runs.get(row.sourceId);
+ const allowed = selectable(row);
+ const meta = [
+ row.date ? format.date(`${row.date}T12:00:00.000Z`) : null,
+ row.sizeBytes !== null
+ ? formatBytes(row.sizeBytes, locale)
+ : null,
+ ]
+ .filter(Boolean)
+ .join(" · ");
+ return (
+
+ toggle(row)}
+ data-slot="document-source-result"
+ data-state={row.state}
+ className={cn(
+ "border-border flex min-h-11 w-full items-start gap-3 rounded-lg border p-3 text-left",
+ "focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
+ allowed && !running && !finished && "hover:bg-muted/50",
+ on && "border-primary/40 bg-primary/5",
+ // A row that just ran keeps full contrast: its result is
+ // the thing to read.
+ // After a run, the rows it did not touch step back:
+ // the ones with a result are what to read now.
+ ((!allowed && !run) || (finished && !run)) &&
+ "opacity-50",
+ )}
+ >
+
+
+
+ {row.title}
+
+ {meta ? (
+
+ {meta}
+
+ ) : null}
+ {row.tags.length > 0 ? (
+
+ {row.tags.slice(0, 6).map((name) => (
+ {name}
+ ))}
+
+ ) : null}
+ {run ? : null}
+
+ {row.state !== "new" && !run ? (
+
+ {row.state === "imported"
+ ? t("documents.sourcePicker.stateImported")
+ : t("documents.sourcePicker.stateDeleted")}
+
+ ) : null}
+
+
+ );
+ })}
+
+ )}
+
+ {search.hasNextPage && !search.isError ? (
+ void search.fetchNextPage()}
+ >
+ {search.isFetchingNextPage ? (
+
+ ) : null}
+ {t("documents.sourcePicker.loadMore")}
+
+ ) : null}
+
+
+ );
+}
+
+/** How the run went, in the shared outcome vocabulary. */
+function runOutcome(counts: {
+ imported: number;
+ present: number;
+ failed: number;
+}): WrittenOutcome {
+ const held = counts.imported + counts.present;
+ if (counts.failed === 0) return held > 0 ? "success" : "empty";
+ return held > 0 ? "partial" : "failed";
+}
+
+/** A row's result in words, or null while it has none yet. */
+function runLabel(
+ t: (key: string, params?: Record) => string,
+ run: RowRun,
+): string | null {
+ switch (run.state) {
+ case "waiting":
+ case "working":
+ return null;
+ case "failed":
+ return run.message;
+ case "deleted":
+ return t("documents.sourcePicker.result.deleted");
+ case "imported":
+ return run.linked
+ ? t("documents.sourcePicker.result.importedLinked")
+ : t("documents.sourcePicker.result.imported");
+ case "duplicate":
+ return run.linked
+ ? t("documents.sourcePicker.result.duplicateLinked")
+ : t("documents.sourcePicker.result.duplicate");
+ }
+}
+
+/** One row's progress or result, under its title. */
+function RunLine({ run }: { run: RowRun }) {
+ const { t } = useTranslations();
+ switch (run.state) {
+ case "waiting":
+ return (
+
+ {t("documents.sourcePicker.result.waiting")}
+
+ );
+ case "working":
+ return (
+
+
+ {t("documents.sourcePicker.result.working")}
+
+ );
+ case "failed":
+ return (
+
+
+ {run.message}
+
+ );
+ case "deleted":
+ return (
+
+ {t("documents.sourcePicker.result.deleted")}
+
+ );
+ case "imported":
+ case "duplicate":
+ return (
+
+
+ {run.state === "imported"
+ ? run.linked
+ ? t("documents.sourcePicker.result.importedLinked")
+ : t("documents.sourcePicker.result.imported")
+ : run.linked
+ ? t("documents.sourcePicker.result.duplicateLinked")
+ : t("documents.sourcePicker.result.duplicate")}
+
+ );
+ }
+}
diff --git a/src/components/documents/sources/import-from-source-button.tsx b/src/components/documents/sources/import-from-source-button.tsx
new file mode 100644
index 000000000..09fd872df
--- /dev/null
+++ b/src/components/documents/sources/import-from-source-button.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+/**
+ * "Import from Paperless-ngx / Papra": the button that opens the document
+ * picker (#1038), for every place a document can be added or linked.
+ *
+ * Renders nothing unless the person, in their own record, has at least one
+ * usable connection: no connection, a delegate or guardian, the module off, or
+ * the operator's list unset all leave no trace, because a button that can only
+ * lead to a refusal advertises a feature that is not there. Connecting happens
+ * in Settings, not here.
+ */
+import { Import } from "lucide-react";
+import { useState } from "react";
+
+import { Button } from "@/components/ui/button";
+import { useTranslations } from "@/lib/i18n/context";
+import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
+
+import {
+ DocumentSourcePicker,
+ type DocumentPickerLink,
+} from "./document-source-picker";
+import { systemName, useDocumentSourcesStatus } from "./use-document-sources";
+
+export function ImportFromSourceButton({
+ link,
+ kind,
+ onImported,
+ className,
+}: {
+ link?: DocumentPickerLink;
+ kind?: InboundDocumentKindValue;
+ onImported?: (documentIds: string[]) => void;
+ className?: string;
+}) {
+ const { t } = useTranslations();
+ const { connected } = useDocumentSourcesStatus();
+ const [open, setOpen] = useState(false);
+
+ if (connected.length === 0) return null;
+
+ const label =
+ connected.length === 1
+ ? t("documents.sourcePicker.openNamed", {
+ name: systemName(connected[0]),
+ })
+ : t("documents.sourcePicker.open");
+
+ return (
+ <>
+ setOpen(true)}
+ data-slot="document-source-open"
+ >
+
+ {label}
+
+
+ >
+ );
+}
diff --git a/src/components/documents/sources/merge-ids.ts b/src/components/documents/sources/merge-ids.ts
new file mode 100644
index 000000000..ab8579349
--- /dev/null
+++ b/src/components/documents/sources/merge-ids.ts
@@ -0,0 +1,6 @@
+/** Add imported ids to a form's selection, in order, never twice. */
+export function mergeIds(selected: string[], added: string[]): string[] {
+ const merged = [...selected];
+ for (const id of added) if (!merged.includes(id)) merged.push(id);
+ return merged;
+}
diff --git a/src/components/documents/sources/segmented-choice.tsx b/src/components/documents/sources/segmented-choice.tsx
new file mode 100644
index 000000000..a140cfa5e
--- /dev/null
+++ b/src/components/documents/sources/segmented-choice.tsx
@@ -0,0 +1,110 @@
+"use client";
+
+/**
+ * A small segmented radio group (Paperless-ngx | Papra) with the keyboard
+ * contract of a radio group: one tab stop (the checked option), arrow keys
+ * move and select, Home and End jump to the ends. Shared by the document
+ * picker's source switch and the settings card's system switch.
+ */
+import { useRef, type KeyboardEvent, type ReactNode } from "react";
+
+import { cn } from "@/lib/utils";
+
+/**
+ * The option an arrow, Home or End key moves to from `index`, wrapping at the
+ * ends; null for any other key.
+ */
+export function nextChoiceIndex(
+ key: string,
+ index: number,
+ count: number,
+): number | null {
+ if (count <= 0) return null;
+ switch (key) {
+ case "ArrowRight":
+ case "ArrowDown":
+ return (index + 1) % count;
+ case "ArrowLeft":
+ case "ArrowUp":
+ return (index - 1 + count) % count;
+ case "Home":
+ return 0;
+ case "End":
+ return count - 1;
+ default:
+ return null;
+ }
+}
+
+export function SegmentedChoice({
+ options,
+ value,
+ onChange,
+ label,
+ disabled = false,
+ renderOption,
+ slot,
+ className,
+}: {
+ options: readonly T[];
+ value: T;
+ onChange: (value: T) => void;
+ /** Accessible name of the group. */
+ label: string;
+ disabled?: boolean;
+ renderOption: (option: T) => ReactNode;
+ slot?: string;
+ className?: string;
+}) {
+ const refs = useRef>([]);
+
+ const onKeyDown = (event: KeyboardEvent, i: number) => {
+ const next = nextChoiceIndex(event.key, i, options.length);
+ if (next === null) return;
+ event.preventDefault();
+ if (disabled) return;
+ onChange(options[next]);
+ refs.current[next]?.focus();
+ };
+
+ return (
+
+ {options.map((option, i) => {
+ const checked = option === value;
+ return (
+ {
+ refs.current[i] = el;
+ }}
+ type="button"
+ role="radio"
+ aria-checked={checked}
+ tabIndex={checked ? 0 : -1}
+ disabled={disabled}
+ onClick={() => onChange(option)}
+ onKeyDown={(event) => onKeyDown(event, i)}
+ data-slot={slot}
+ className={cn(
+ "flex min-h-9 items-center gap-1.5 rounded-sm px-3 text-sm font-medium",
+ "focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
+ checked
+ ? "bg-background text-foreground shadow-xs"
+ : "text-muted-foreground hover:text-foreground",
+ )}
+ >
+ {renderOption(option)}
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/documents/sources/source-errors.ts b/src/components/documents/sources/source-errors.ts
new file mode 100644
index 000000000..9ec63e293
--- /dev/null
+++ b/src/components/documents/sources/source-errors.ts
@@ -0,0 +1,84 @@
+/**
+ * The document picker's error codes (#1038), as sentences. One place for the
+ * sheet and the settings card, so the same refusal reads the same on both.
+ * Every branch names its key literally so the i18n coverage guard can see it.
+ */
+import { ApiError } from "@/lib/api/api-fetch";
+
+type T = (key: string, params?: Record) => string;
+
+/** The `meta.errorCode` a failed request carried, if any. */
+export function errorCodeOf(err: unknown): string | undefined {
+ if (err instanceof ApiError) {
+ const code = err.meta?.errorCode;
+ if (typeof code === "string") return code;
+ }
+ return undefined;
+}
+
+/** Is this a refusal that will not change on the next document? */
+export function isStoppingError(code: string | undefined): boolean {
+ return (
+ code === "documents.inbound.rateLimited" ||
+ code === "documents.inbound.quotaExceeded" ||
+ code === "documents.sources.authRefused" ||
+ code === "documents.sources.permissionMissing" ||
+ code === "documents.sources.unreachable" ||
+ code === "documents.sources.originNotAllowed" ||
+ code === "documents.sources.notConnected" ||
+ code === "documents.sources.unavailable" ||
+ code === "documents.sources.browserOnly" ||
+ code === "documents.sources.linkTargetNotFound"
+ );
+}
+
+export function sourceErrorMessage(t: T, code: string | undefined): string {
+ switch (code) {
+ case "documents.sources.unavailable":
+ return t("documents.sourcePicker.errors.unavailable");
+ case "documents.sources.notConnected":
+ return t("documents.sourcePicker.errors.notConnected");
+ case "documents.sources.originNotAllowed":
+ return t("documents.sourcePicker.errors.originNotAllowed");
+ case "documents.sources.unreachable":
+ return t("documents.sourcePicker.errors.unreachable");
+ case "documents.sources.redirected":
+ return t("documents.sourcePicker.errors.redirected");
+ case "documents.sources.authRefused":
+ return t("documents.sourcePicker.errors.authRefused");
+ case "documents.sources.permissionMissing":
+ return t("documents.sourcePicker.errors.permissionMissing");
+ case "documents.sources.versionTooOld":
+ return t("documents.sourcePicker.errors.versionTooOld");
+ case "documents.sources.badResponse":
+ return t("documents.sourcePicker.errors.badResponse");
+ case "documents.sources.notFound":
+ return t("documents.sourcePicker.errors.notFound");
+ case "documents.sources.linkTargetNotFound":
+ return t("documents.sourcePicker.errors.linkTargetNotFound");
+ case "documents.sources.rateLimited":
+ return t("documents.sourcePicker.errors.rateLimited");
+ case "documents.sources.browserOnly":
+ return t("documents.sourcePicker.errors.browserOnly");
+ case "documents.sources.invalidAddress":
+ return t("documents.sourcePicker.errors.invalidAddress");
+ case "documents.sources.organizationRequired":
+ return t("documents.sourcePicker.errors.organizationRequired");
+ case "documents.sources.tokenRequired":
+ return t("documents.sourcePicker.errors.tokenRequired");
+ case "documents.inbound.fileTooLarge":
+ return t("documents.sourcePicker.errors.fileTooLarge");
+ case "documents.inbound.quotaExceeded":
+ return t("documents.sourcePicker.errors.quotaExceeded");
+ case "documents.inbound.fileType":
+ return t("documents.sourcePicker.errors.fileType");
+ case "documents.inbound.rateLimited":
+ return t("documents.sourcePicker.errors.uploadLimit");
+ case "documents.inbound.uploadBusy":
+ return t("documents.sourcePicker.errors.uploadBusy");
+ case "module.disabled":
+ return t("documents.sourcePicker.errors.moduleDisabled");
+ default:
+ return t("documents.sourcePicker.errors.generic");
+ }
+}
diff --git a/src/components/documents/sources/use-document-sources.ts b/src/components/documents/sources/use-document-sources.ts
new file mode 100644
index 000000000..e19329ca2
--- /dev/null
+++ b/src/components/documents/sources/use-document-sources.ts
@@ -0,0 +1,58 @@
+"use client";
+
+/**
+ * The document picker's availability for the person on this screen (#1038).
+ *
+ * Reads `GET /api/documents/sources` only in the person's own record: a
+ * delegate or guardian never asks (the route would refuse them anyway), so
+ * the import buttons and the settings card render nothing for them.
+ * `connected` is the list of systems with a usable connection (the documents
+ * module on, the operator's list set, the origin still on it), in a stable
+ * order; the import buttons read only that.
+ */
+import { useQuery } from "@tanstack/react-query";
+
+import { useAuth } from "@/hooks/use-auth";
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
+import { apiGet } from "@/lib/api/api-fetch";
+import type {
+ DocumentPickerSystem,
+ DocumentSourcesStatusDto,
+} from "@/lib/documents/sources/types";
+import { queryKeys } from "@/lib/query-keys";
+
+export function useDocumentSourcesStatus() {
+ const { user } = useAuth();
+ const { canManageDomain, inSharedRecord } = useRecordCapabilities();
+ // The status read touches only the person's own rows and never the
+ // network, so it runs in one's own record whatever the module says: a
+ // connection stored before Documents was switched off, or before the
+ // operator removed the list, must stay visible so it can be deleted.
+ const ownRecord =
+ user !== null &&
+ user !== undefined &&
+ !inSharedRecord &&
+ canManageDomain("documents");
+ const eligible = ownRecord && user?.modules?.inboundDocuments === true;
+
+ const query = useQuery({
+ queryKey: queryKeys.documentSourcesStatus(),
+ enabled: ownRecord,
+ queryFn: () => apiGet("/api/documents/sources"),
+ staleTime: 60_000,
+ });
+
+ const connected: DocumentPickerSystem[] =
+ eligible && query.data?.available
+ ? query.data.connections
+ .filter((c) => c.originAllowed)
+ .map((c) => c.system)
+ : [];
+
+ return { ownRecord, eligible, query, connected };
+}
+
+/** Display name of a system; product names, not translated. */
+export function systemName(system: DocumentPickerSystem): string {
+ return system === "PAPERLESS" ? "Paperless-ngx" : "Papra";
+}
diff --git a/src/components/encounters/encounter-link-pickers.tsx b/src/components/encounters/encounter-link-pickers.tsx
index 1dcf1e536..1e0eba0fe 100644
--- a/src/components/encounters/encounter-link-pickers.tsx
+++ b/src/components/encounters/encounter-link-pickers.tsx
@@ -32,6 +32,8 @@ import {
type EntityLinkOption,
} from "@/components/links/entity-link-picker";
import { useVaultDocumentOptions } from "@/components/links/vault-document-options";
+import { ImportFromSourceButton } from "@/components/documents/sources/import-from-source-button";
+import { mergeIds } from "@/components/documents/sources/merge-ids";
/** Fetch cap for the picker — the grouped sheet needs enough rows to group. */
export const PICKER_FETCH_LIMIT = 200;
@@ -152,6 +154,17 @@ export function EncounterLinkPickers({
options={documents.options}
searchPlaceholder={t("links.picker.searchPlaceholder")}
emptyLabel={t("encounters.form.linkNothingToOffer")}
+ extraAction={
+ // The form owns its selection and replaces the visit's links on
+ // save, so imported documents join the selection rather than
+ // being linked on the server.
+
+ onChange({ documentIds: mergeIds(documentIds, ids) })
+ }
+ />
+ }
/>
) : null}
diff --git a/src/components/insights/intraday-pulse-chart.tsx b/src/components/insights/intraday-pulse-chart.tsx
index 965014e94..09e1ae79a 100644
--- a/src/components/insights/intraday-pulse-chart.tsx
+++ b/src/components/insights/intraday-pulse-chart.tsx
@@ -19,7 +19,7 @@ import { useAuth } from "@/hooks/use-auth";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
-import { userDayKey, shiftDateKey } from "@/lib/tz/format";
+import { userDayKey, shiftDateKey, DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { TileHeader } from "@/components/insights/tile-header";
import { Button } from "@/components/ui/button";
import { QueryErrorCard } from "@/components/ui/query-error-card";
@@ -94,7 +94,7 @@ export function IntradayPulseChart({
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const fmt = useFormatters();
- const tz = userTimezone ?? "Europe/Berlin";
+ const tz = userTimezone ?? DEFAULT_TIMEZONE;
const todayKey = userDayKey(new Date(), tz);
// `null` tracks "today" live (so a session left open across midnight keeps
// following the current day); a non-null value pins the viewed day once
diff --git a/src/components/links/entity-link-picker.tsx b/src/components/links/entity-link-picker.tsx
index 3b3f4a467..8d70d10b0 100644
--- a/src/components/links/entity-link-picker.tsx
+++ b/src/components/links/entity-link-picker.tsx
@@ -31,7 +31,7 @@
* carries the option's date, so two doses of one vaccine read as two
* different records.
*/
-import { useMemo, useState } from "react";
+import { useMemo, useState, type ReactNode } from "react";
import Link from "next/link";
import { Check, Plus, Search, X } from "lucide-react";
import type { LucideIcon } from "lucide-react";
@@ -164,6 +164,7 @@ export function EntityLinkPicker({
error = false,
errorLabel,
onRetry,
+ extraAction,
}: {
icon: LucideIcon;
title: string;
@@ -183,6 +184,13 @@ export function EntityLinkPicker({
error?: boolean;
errorLabel?: string;
onRetry?: () => void;
+ /**
+ * A second way to add, beside the add button: the documents pickers put
+ * "Import from Paperless-ngx / Papra" here. Shown even when there is
+ * nothing to link yet, because an empty vault is exactly when importing is
+ * the way forward. The element renders nothing when it has nothing to offer.
+ */
+ extraAction?: ReactNode;
}) {
const { t } = useTranslations();
const [open, setOpen] = useState(false);
@@ -238,7 +246,10 @@ export function EntityLinkPicker({
slot={`${slot}-error`}
/>
) : options.length === 0 ? (
- {emptyLabel}
+
+
{emptyLabel}
+ {extraAction}
+
) : (
{selectedChips.length > 0 ? (
@@ -312,17 +323,20 @@ export function EntityLinkPicker({
) : null}
-
setOpen(true)}
- >
-
- {t("links.picker.add")}
-
+
+
setOpen(true)}
+ >
+
+ {t("links.picker.add")}
+
+ {extraAction}
+
)}
diff --git a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx
index 7bba97edb..da56bb22f 100644
--- a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx
+++ b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx
@@ -34,7 +34,7 @@ vi.mock("@/hooks/use-measurement-reminders", () => ({
}));
import { VorsorgeSection, postponeOffsetTargets } from "../vorsorge-section";
-import { DISPLAY_TIMEZONE } from "@/lib/format-locale";
+import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
/**
* Noon on the day `offsetDays` from today, counted on the DISPLAY zone's
@@ -53,7 +53,7 @@ import { DISPLAY_TIMEZONE } from "@/lib/format-locale";
function noonOnDisplayDay(offsetDays: number): Date {
// `en-CA` formats as YYYY-MM-DD, which is the shape we want to add days to.
const today = new Intl.DateTimeFormat("en-CA", {
- timeZone: DISPLAY_TIMEZONE,
+ timeZone: DEFAULT_TIMEZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
@@ -219,7 +219,7 @@ describe(" loading + empty", () => {
// the wall-clock gap to now exceeds the 24h a rolling delta would key on.
// Anchor the due instant to noon on the DISPLAY zone's today, not the
// process clock's. `vitest.config.mts` pins the suite to TZ=UTC while the
- // component floors in the profile zone (`DISPLAY_TIMEZONE`, Europe/Berlin),
+ // component floors in the profile zone (`DEFAULT_TIMEZONE`, Europe/Berlin),
// and those two disagree about the date for the two hours before UTC
// midnight. Anchoring on `setHours` made this case fail every night in
// that window, which is the same confusion of clocks the assertion exists
diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx
index 1674b2457..f1085e2c5 100644
--- a/src/components/measurement-reminders/vorsorge-section.tsx
+++ b/src/components/measurement-reminders/vorsorge-section.tsx
@@ -181,11 +181,13 @@ function toDateInputValue(iso: string | null): string {
return new Date(d.getTime() - offset * 60 * 1000).toISOString().slice(0, 10);
}
-/** ` ` value → an ISO instant at local midnight, or null. */
+/**
+ * ` ` value → the calendar date itself, or null. The server
+ * reads a bare date as that day in the profile's zone, so a browser set to
+ * another zone cannot move the first-due day.
+ */
function fromDateInputValue(value: string): string | null {
- if (!value) return null;
- const d = new Date(`${value}T00:00:00`);
- return Number.isNaN(d.getTime()) ? null : d.toISOString();
+ return /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : null;
}
interface FormState {
diff --git a/src/components/medications/__tests__/supply-runway.test.ts b/src/components/medications/__tests__/supply-runway.test.ts
index dd16adf8a..25513acc4 100644
--- a/src/components/medications/__tests__/supply-runway.test.ts
+++ b/src/components/medications/__tests__/supply-runway.test.ts
@@ -371,3 +371,98 @@ describe("units runway with a mixed units-per-dose value (#1034)", () => {
expect(estimateUnitsRunwayDays(10, [morning], 1.2)).toBe(8);
});
});
+
+describe("estimateDailyDoseCount — cadence parts the rate used to drop", () => {
+ it("stretches a monthly RRULE by its INTERVAL (every third month)", () => {
+ const perDay = estimateDailyDoseCount([
+ schedule({ rrule: "FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=10" }),
+ ]);
+ expect(perDay).toBeCloseTo(1 / 90, 10);
+ // Two doses left last about half a year, not two months.
+ expect(
+ estimateRunwayDays(2, [
+ schedule({ rrule: "FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=10" }),
+ ]),
+ ).toBe(180);
+ });
+
+ it("counts each BYMONTHDAY of a monthly RRULE", () => {
+ expect(
+ estimateDailyDoseCount([
+ schedule({ rrule: "FREQ=MONTHLY;BYMONTHDAY=1,15" }),
+ ]),
+ ).toBeCloseTo(2 / 30, 10);
+ });
+
+ it.each([
+ // Every Monday of the month is a weekly dose, not one a month.
+ ["FREQ=MONTHLY;BYDAY=MO", 1 / 7],
+ ["FREQ=MONTHLY;BYDAY=MO,TH", 2 / 7],
+ // With an ordinal it is one day of the month.
+ ["FREQ=MONTHLY;BYDAY=1MO", 1 / 30],
+ ["FREQ=MONTHLY;BYDAY=1MO,-1FR", 2 / 30],
+ // The last weekday of the month: BYSETPOS picks one of the five.
+ ["FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1", 1 / 30],
+ ["FREQ=MONTHLY;INTERVAL=2;BYDAY=MO", 1 / 14],
+ // Every Monday in January, and the first Monday of March and September.
+ ["FREQ=YEARLY;BYMONTH=1;BYDAY=MO", 30 / 7 / 365],
+ ["FREQ=YEARLY;BYMONTH=3,9;BYDAY=1MO", 2 / 365],
+ ])("counts the occurrences of %s", (rrule, perDay) => {
+ expect(estimateDailyDoseCount([schedule({ rrule })])).toBeCloseTo(
+ perDay,
+ 10,
+ );
+ });
+
+ it("stretches a daily RRULE by its INTERVAL (every other day)", () => {
+ expect(
+ estimateDailyDoseCount([schedule({ rrule: "FREQ=DAILY;INTERVAL=2" })]),
+ ).toBeCloseTo(1 / 2, 10);
+ });
+
+ it("stretches a yearly RRULE by its INTERVAL and counts BYMONTH", () => {
+ expect(
+ estimateDailyDoseCount([
+ schedule({ rrule: "FREQ=YEARLY;INTERVAL=2;BYMONTH=1;BYMONTHDAY=1" }),
+ ]),
+ ).toBeCloseTo(1 / 730, 10);
+ expect(
+ estimateDailyDoseCount([
+ schedule({ rrule: "FREQ=YEARLY;BYMONTH=3,9;BYMONTHDAY=1" }),
+ ]),
+ ).toBeCloseTo(2 / 365, 10);
+ });
+
+ it("scales a CYCLIC schedule by its on share (3 weeks on, 1 off)", () => {
+ const cyclic = schedule({
+ rrule: "FREQ=DAILY",
+ scheduleType: "CYCLIC",
+ cyclicOnWeeks: 3,
+ cyclicOffWeeks: 1,
+ });
+ expect(estimateDailyDoseCount([cyclic])).toBeCloseTo(3 / 4, 10);
+ expect(estimateRunwayDays(21, [cyclic])).toBe(28);
+ });
+
+ it("counts nothing for an as-needed (PRN) schedule", () => {
+ expect(estimateDailyDoseCount([schedule({ scheduleType: "PRN" })])).toBe(0);
+ });
+});
+
+describe("whole counts survive binary floating point", () => {
+ it("2.4 units at 0.8 per dose is three doses", () => {
+ // 2.4 / 0.8 === 2.9999999999999996 in IEEE 754.
+ expect(effectiveUnitsPerDose([schedule({})], 0.8)).toBe(0.8);
+ expect(estimateUnitsRunwayDays(2.4, [schedule({})], 0.8)).toBe(3);
+ });
+
+ it("a dose-count runway that divides exactly is not a day short", () => {
+ // Three doses on Mon/Wed/Fri: 9/7 a day, and 9 / (9/7) is
+ // 6.999999999999999 in IEEE 754 — a week of supply, not six days.
+ const s = schedule({
+ daysOfWeek: "1,3,5",
+ timesOfDay: ["08:00", "12:00", "20:00"],
+ });
+ expect(estimateRunwayDays(9, [s])).toBe(7);
+ });
+});
diff --git a/src/components/medications/card-parts/displayed-slot-instant.ts b/src/components/medications/card-parts/displayed-slot-instant.ts
index 62eedae0d..a8d633239 100644
--- a/src/components/medications/card-parts/displayed-slot-instant.ts
+++ b/src/components/medications/card-parts/displayed-slot-instant.ts
@@ -1,3 +1,4 @@
+import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
/**
* v1.12.3 — resolve the canonical slot instant of the dose a medication
* card is currently surfacing, so the card's "Genommen" / "Skip" buttons
@@ -138,7 +139,7 @@ export function resolveDisplayedSlotInstant(
currentWindowStatus,
nextDueAt,
now,
- timeZone = "Europe/Berlin",
+ timeZone = DEFAULT_TIMEZONE,
} = input;
// The card surfaces the current/overdue dose band — record that band's
diff --git a/src/components/medications/detail/supply-runway.ts b/src/components/medications/detail/supply-runway.ts
index 77c58b4a5..c264ab807 100644
--- a/src/components/medications/detail/supply-runway.ts
+++ b/src/components/medications/detail/supply-runway.ts
@@ -10,6 +10,7 @@
*/
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
+import { floorWhole } from "@/lib/medications/units-per-dose";
/** The schedule fields the runway estimate reads. */
export interface RunwaySchedule {
@@ -24,6 +25,62 @@ export interface RunwaySchedule {
* `unitsPerDose`, exactly like the consumption path's resolver.
*/
unitsPerDose?: number | null;
+ /**
+ * Schedule type. PRN consumes nothing on a cadence; CYCLIC consumes only
+ * in its "on" weeks, so its rate is scaled by on / (on + off).
+ */
+ scheduleType?: "SCHEDULED" | "PRN" | "CYCLIC" | string | null;
+ cyclicOnWeeks?: number | null;
+ cyclicOffWeeks?: number | null;
+}
+
+/** A positive integer RRULE part (`INTERVAL=3`), or `fallback`. */
+function rrulePositiveInt(
+ rrule: string,
+ key: string,
+ fallback: number,
+): number {
+ const m = new RegExp(`(?:^|;)${key}=(\\d+)(?:;|$)`, "i").exec(rrule);
+ const n = m ? Number(m[1]) : NaN;
+ return Number.isInteger(n) && n >= 1 ? n : fallback;
+}
+
+/** How many comma-separated values an RRULE list part carries, or 0. */
+function rruleListCount(rrule: string, key: string): number {
+ const m = new RegExp(`(?:^|;)${key}=([^;]+)`, "i").exec(rrule);
+ return m ? m[1].split(",").filter((v) => v.trim() !== "").length : 0;
+}
+
+/** The raw values of an RRULE list part (`BYDAY=1MO,FR` → `1MO`, `FR`). */
+function rruleList(rrule: string, key: string): string[] {
+ const m = new RegExp(`(?:^|;)${key}=([^;]+)`, "i").exec(rrule);
+ return m ? m[1].split(",").filter((v) => v.trim() !== "") : [];
+}
+
+/**
+ * Occurrences one period of a monthly or yearly rule holds, with the period
+ * `periodDays` long. `BYSETPOS` picks that many of the candidates. A
+ * `BYDAY` entry with an ordinal (`1MO`, `-1FR`) is one day of the period;
+ * one without (`MO`) is every such weekday in it, about `periodDays / 7`.
+ * Counting `FREQ=MONTHLY;BYDAY=MO` as one dose a month made a Monday
+ * medication's supply read four times longer than it lasts.
+ */
+function occurrencesPerPeriod(
+ rrule: string,
+ periodDays: number,
+ fallback: number,
+): number {
+ const setPos = rruleListCount(rrule, "BYSETPOS");
+ if (setPos > 0) return setPos;
+ const monthDays = rruleListCount(rrule, "BYMONTHDAY");
+ if (monthDays > 0) return monthDays;
+ const byDay = rruleList(rrule, "BYDAY");
+ if (byDay.length === 0) return fallback;
+ let count = 0;
+ for (const entry of byDay) {
+ count += /^[+-]?\d/.test(entry.trim()) ? 1 : periodDays / 7;
+ }
+ return count;
}
/**
@@ -32,17 +89,43 @@ export interface RunwaySchedule {
* weeks, monthly/yearly RRULEs).
*/
function scheduleDailyDoseCount(s: RunwaySchedule): number {
+ if (s.scheduleType === "PRN") return 0;
+ const rate = cadenceDailyDoseCount(s);
+ if (s.scheduleType === "CYCLIC") {
+ // Doses fall only in the "on" weeks of the cycle.
+ const on = s.cyclicOnWeeks ?? 0;
+ const off = s.cyclicOffWeeks ?? 0;
+ if (on > 0 && off > 0) return (rate * on) / (on + off);
+ }
+ return rate;
+}
+
+function cadenceDailyDoseCount(s: RunwaySchedule): number {
const times =
s.timesOfDay && s.timesOfDay.length > 0 ? s.timesOfDay.length : 1;
if (typeof s.rollingIntervalDays === "number" && s.rollingIntervalDays >= 1) {
return times / s.rollingIntervalDays;
}
const rrule = s.rrule ?? "";
- if (/FREQ=MONTHLY/.test(rrule)) {
- return times / 30;
+ // INTERVAL stretches every calendar frequency: FREQ=MONTHLY;INTERVAL=3 is
+ // one dose a quarter, not one a month.
+ const interval = rrulePositiveInt(rrule, "INTERVAL", 1);
+ if (/FREQ=DAILY/i.test(rrule)) {
+ return times / interval;
}
- if (/FREQ=YEARLY/.test(rrule)) {
- return times / 365;
+ if (/FREQ=MONTHLY/i.test(rrule)) {
+ const perMonth = occurrencesPerPeriod(rrule, 30, 1);
+ return (times * perMonth) / (30 * interval);
+ }
+ if (/FREQ=YEARLY/i.test(rrule)) {
+ // Within each BYMONTH month (or the year, without one), the day parts
+ // count as they would for a monthly rule.
+ const months = rruleListCount(rrule, "BYMONTH");
+ const perYear =
+ months > 0
+ ? months * occurrencesPerPeriod(rrule, 30, 1)
+ : occurrencesPerPeriod(rrule, 365, 1);
+ return (times * perYear) / (365 * interval);
}
// FREQ=WEEKLY;BYDAY=…;INTERVAL=… is the modern weekly encoding (the
// create path stores the cadence on the rrule and leaves daysOfWeek
@@ -52,14 +135,7 @@ function scheduleDailyDoseCount(s: RunwaySchedule): number {
// daysOfWeek fallback below with daysPerWeek=7, over-estimating the
// rate ~7× (≈14× bi-weekly) and firing low-stock alerts far too early.
if (/FREQ=WEEKLY/.test(rrule)) {
- const byday = /BYDAY=([^;]+)/.exec(rrule);
- const bydayCount =
- byday && byday[1].length > 0 ? byday[1].split(",").length : 1;
- const intervalMatch = /INTERVAL=(\d+)/.exec(rrule);
- const interval =
- intervalMatch && Number(intervalMatch[1]) >= 1
- ? Number(intervalMatch[1])
- : 1;
+ const bydayCount = Math.max(1, rruleListCount(rrule, "BYDAY"));
return (times * bydayCount) / (7 * interval);
}
const { daysOfWeek, intervalWeeks } = parseScheduleRecurrence(s.daysOfWeek);
@@ -137,7 +213,7 @@ export function estimateUnitsRunwayDays(
medicationUnitsPerDose,
);
if (perDayUnits <= 0) return null;
- return Math.floor(Math.max(0, unitsRemaining) / perDayUnits);
+ return floorWhole(Math.max(0, unitsRemaining) / perDayUnits);
}
/**
@@ -151,7 +227,7 @@ export function estimateRunwayDays(
if (dosesRemaining <= 0) return null;
const perDay = estimateDailyDoseCount(schedules);
if (perDay <= 0) return null;
- return Math.floor(dosesRemaining / perDay);
+ return floorWhole(dosesRemaining / perDay);
}
/**
diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx
index e80592a7f..642e4bb7a 100644
--- a/src/components/medications/dose-history-ledger.tsx
+++ b/src/components/medications/dose-history-ledger.tsx
@@ -111,6 +111,7 @@ import {
type LedgerRow,
} from "@/components/medications/dose-history-ledger-compute";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
+import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -182,7 +183,7 @@ export function DoseHistoryLedger({
// marked — the most-encountered of the set.
const recordName = useActiveRecordName();
const queryClient = useQueryClient();
- const timeZone = user?.timezone || "Europe/Berlin";
+ const timeZone = user?.timezone || DEFAULT_TIMEZONE;
const [addOpen, setAddOpen] = useState(false);
const [editingEvent, setEditingEvent] = useState<{
diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx
index b0f22972e..1895daa0a 100644
--- a/src/components/medications/glp1-medication-card.tsx
+++ b/src/components/medications/glp1-medication-card.tsx
@@ -37,6 +37,7 @@ import {
} from "@/components/medications/detail/supply-runway";
import { useAuth } from "@/hooks/use-auth";
import { useMedicationComplianceSummary } from "@/lib/queries/use-medication-compliance-summary";
+import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
/**
* v1.4.25 W4d — GLP-1 medication card variant.
@@ -202,7 +203,7 @@ export function Glp1MedicationCard({
// v1.16.9 — the card reasons in the PROFILE timezone; Berlin stays the
// last-resort fallback for logged-out mounts and legacy fixtures.
const { user } = useAuth();
- const userTz = user?.timezone || "Europe/Berlin";
+ const userTz = user?.timezone || DEFAULT_TIMEZONE;
const fmt = useFormatters();
// Issue #490 — the zone `fmt` renders dates in (mirror → Berlin). The
// weekday name paired with `fmt.dateShort` must be derived in this SAME
diff --git a/src/components/medications/log-intake-dialog.tsx b/src/components/medications/log-intake-dialog.tsx
index 111282526..411ef022f 100644
--- a/src/components/medications/log-intake-dialog.tsx
+++ b/src/components/medications/log-intake-dialog.tsx
@@ -44,6 +44,7 @@ import {
runLogIntake,
runUndoIntake,
} from "@/components/medications/use-medication-intake";
+import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
interface LogIntakeSchedule extends ScheduleWindowInput {
label: string | null;
@@ -120,7 +121,7 @@ export function LogIntakeDialog({
const recordName = useActiveRecordName();
const queryClient = useQueryClient();
const { user } = useAuth();
- const userTz = user?.timezone || "Europe/Berlin";
+ const userTz = user?.timezone || DEFAULT_TIMEZONE;
const formId = useId();
const [medicationOverride, setMedicationOverride] = useState(
diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx
index b59b2ac0b..a4d1623c3 100644
--- a/src/components/medications/medication-card.tsx
+++ b/src/components/medications/medication-card.tsx
@@ -34,6 +34,7 @@ import { LogInjectionSiteDialog } from "@/components/medications/log-injection-s
import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs";
import { useAuth } from "@/hooks/use-auth";
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
+import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
interface Schedule {
id: string;
@@ -149,7 +150,7 @@ export function MedicationCard({
// session carries); Berlin stays the last-resort fallback so logged-out
// mounts and legacy fixtures behave unchanged.
const { user } = useAuth();
- const userTz = user?.timezone || "Europe/Berlin";
+ const userTz = user?.timezone || DEFAULT_TIMEZONE;
const fmt = useFormatters();
const weekdayLabel = useWeekdayLabel();
// v1.8.5 — post-dose injection-site prompt state. Holds the intake
diff --git a/src/components/medications/medication-table.tsx b/src/components/medications/medication-table.tsx
index d5089f611..e52c9c92a 100644
--- a/src/components/medications/medication-table.tsx
+++ b/src/components/medications/medication-table.tsx
@@ -43,6 +43,7 @@ import {
} from "@/lib/queries/use-medication-compliance-summary";
import { useAuth } from "@/hooks/use-auth";
import { cn } from "@/lib/utils";
+import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
/**
* v1.16.10 — the compact table view for /medications (issue #316 item 3).
@@ -194,7 +195,7 @@ export function MedicationTable({
}: MedicationTableProps) {
const { t, locale } = useTranslations();
const { user } = useAuth();
- const userTz = user?.timezone || "Europe/Berlin";
+ const userTz = user?.timezone || DEFAULT_TIMEZONE;
const [sort, setSort] = useState(initialSort);
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
diff --git a/src/components/medications/sections/inventory-dialogs.tsx b/src/components/medications/sections/inventory-dialogs.tsx
index c2f95cec8..73939f220 100644
--- a/src/components/medications/sections/inventory-dialogs.tsx
+++ b/src/components/medications/sections/inventory-dialogs.tsx
@@ -35,6 +35,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { apiPatch, apiPost, apiPut } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import {
+ floorWhole,
formatUnitsPerDose,
parseUnitsPerDoseInput,
} from "@/lib/medications/units-per-dose";
@@ -267,10 +268,10 @@ export function AddInventoryDialog({
? t("medications.detail.bestand.quantityInUnits", { units })
: // A unit count below one dose must not read "≈ 0 doses" —
// it is simply less than one dose.
- Math.floor(parsed / unitsPerDose) === 0
+ floorWhole(parsed / unitsPerDose) === 0
? t("medications.detail.bestand.quantityUnderOneDose")
: t("medications.detail.bestand.quantityInDoses", {
- doses: Math.floor(parsed / unitsPerDose),
+ doses: floorWhole(parsed / unitsPerDose),
})}
)}
diff --git a/src/components/medications/sections/inventory-section.tsx b/src/components/medications/sections/inventory-section.tsx
index e6128df32..44750aa0d 100644
--- a/src/components/medications/sections/inventory-section.tsx
+++ b/src/components/medications/sections/inventory-section.tsx
@@ -60,7 +60,10 @@ import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
import { formatDate } from "@/lib/date-format";
import { queryKeys } from "@/lib/query-keys";
import { formatUnitCount } from "@/components/medications/units-per-dose";
-import { formatUnitsPerDose } from "@/lib/medications/units-per-dose";
+import {
+ floorWhole,
+ formatUnitsPerDose,
+} from "@/lib/medications/units-per-dose";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
import type { SupplySummary } from "@/lib/medications/inventory/summary";
import {
@@ -382,8 +385,8 @@ export function InventorySection({
{item.unitsRemaining == null || item.unitsTotal == null
? t("medications.detail.bestand.unknown")
: t("medications.detail.bestand.doses", {
- remaining: Math.floor(item.unitsRemaining / perDose),
- total: Math.floor(item.unitsTotal / perDose),
+ remaining: floorWhole(item.unitsRemaining / perDose),
+ total: floorWhole(item.unitsTotal / perDose),
})}
{perDose !== 1 &&
item.unitsRemaining != null &&
diff --git a/src/components/medications/take-all-due.ts b/src/components/medications/take-all-due.ts
index aaab08810..d2ff9ac7a 100644
--- a/src/components/medications/take-all-due.ts
+++ b/src/components/medications/take-all-due.ts
@@ -57,6 +57,7 @@ import {
} from "@/lib/medications/window-status";
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
import type { DoseStatus } from "@/lib/analytics/compliance";
+import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
type Translator = (
key: string,
@@ -142,7 +143,7 @@ export function deriveDueMedications(
} = {},
): DueMedication[] {
const now = options.now ?? new Date();
- const tz = options.tz ?? "Europe/Berlin";
+ const tz = options.tz ?? DEFAULT_TIMEZONE;
const { lateMinutes, missedMinutes } =
options.thresholds ?? DEFAULT_THRESHOLDS;
diff --git a/src/components/onboarding/__tests__/done-screen.test.tsx b/src/components/onboarding/__tests__/done-screen.test.tsx
index 2985b6b12..9691b2d7e 100644
--- a/src/components/onboarding/__tests__/done-screen.test.tsx
+++ b/src/components/onboarding/__tests__/done-screen.test.tsx
@@ -134,7 +134,7 @@ describe(" AI panel", () => {
const html = render();
expect(html).toContain('data-slot="onboarding-ai-panel"');
// Local model is surfaced first (the calm private default).
- expect(html).toContain("nothing leaves your network");
+ expect(html).toContain("AI requests stay on your network");
// The honest release valve is present and prominent.
expect(html).toContain('data-slot="onboarding-ai-keyless"');
expect(html).toContain("fully useful without AI");
diff --git a/src/components/providers.tsx b/src/components/providers.tsx
index 48c445aee..2b626370c 100644
--- a/src/components/providers.tsx
+++ b/src/components/providers.tsx
@@ -48,7 +48,7 @@ import {
subscribeToMeaningfulVisibilityRefresh,
} from "@/lib/pwa/query-client-options";
-const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? "";
+const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "";
// ── Theme Context ────────────────────────────────────
diff --git a/src/components/settings/__tests__/password-card-placement.test.tsx b/src/components/settings/__tests__/password-card-placement.test.tsx
index de120af1e..e08ced3b9 100644
--- a/src/components/settings/__tests__/password-card-placement.test.tsx
+++ b/src/components/settings/__tests__/password-card-placement.test.tsx
@@ -116,7 +116,11 @@ describe("change-password card placement", () => {
"settings.passwordReset",
"settings.changePassword",
"/api/auth/password",
- "current-password",
+ // A change-password form is the pair current + new. The Account form
+ // asks for the CURRENT password alone, beside a new email address,
+ // which is a proof and not a password change; the new-password half is
+ // what would mean the old form came back.
+ "new-password",
]) {
expect(account, `Account still carries ${marker}`).not.toContain(marker);
}
diff --git a/src/components/settings/access/__tests__/grant-invite-expiry.test.tsx b/src/components/settings/access/__tests__/grant-invite-expiry.test.tsx
index 49bb21f75..faae601af 100644
--- a/src/components/settings/access/__tests__/grant-invite-expiry.test.tsx
+++ b/src/components/settings/access/__tests__/grant-invite-expiry.test.tsx
@@ -70,15 +70,13 @@ describe("grant invitation lapse date", () => {
),
"utf8",
);
- const onError = source.slice(
- source.indexOf("onError: (err) => setError"),
- source.indexOf(
- " );\n };",
- source.indexOf("onError: (err) => setError"),
- ),
- );
+ // The failure arm of the submit: everything from its `catch` to the end
+ // of the handler.
+ const start = source.indexOf("} catch (err) {");
+ expect(start).toBeGreaterThan(-1);
+ const onError = source.slice(start, source.indexOf("\n };", start));
- expect(onError).toContain("setError(t(inviteErrorKey(err)))");
+ expect(onError).toContain("t(inviteErrorKey(err))");
expect(onError).not.toContain("setIdentifier");
expect(onError).not.toContain("setExpiresOn");
expect(source).toContain('role="alert"');
diff --git a/src/components/settings/access/grant-invite-card.tsx b/src/components/settings/access/grant-invite-card.tsx
index 3b86108ea..d9202302c 100644
--- a/src/components/settings/access/grant-invite-card.tsx
+++ b/src/components/settings/access/grant-invite-card.tsx
@@ -18,6 +18,10 @@ import { useModuleEnabled } from "@/hooks/use-module-enabled";
import { ApiError } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { useInviteGrant } from "@/lib/queries/use-account-grants";
+import {
+ ReproofCancelledError,
+ useRecentProof,
+} from "@/components/settings/security-section/use-recent-proof";
import { SHARE_DOMAINS, type ShareDomain } from "@/lib/sharing/scope";
import { DEFAULT_TIMEZONE, isValidTimezone } from "@/lib/tz/format";
import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock";
@@ -79,6 +83,7 @@ import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock";
export function GrantInviteCard() {
const { t } = useTranslations();
const invite = useInviteGrant();
+ const recentProof = useRecentProof();
const ownerTimezone = useInvitationOwnerTimezone();
const [identifier, setIdentifier] = useState("");
const [expiresOn, setExpiresOn] = useState("");
@@ -118,31 +123,40 @@ export function GrantInviteCard() {
setError(null);
};
- const submit = (event: React.FormEvent) => {
+ const submit = async (event: React.FormEvent) => {
event.preventDefault();
const value = identifier.trim();
if (value.length === 0 || choice.blocked) return;
setError(null);
setInvited(null);
- invite.mutate(
- {
- identifier: value,
- access,
- scope: choice.scope,
- expiresAt: endOfDayIso(expiresOn, ownerTimezone),
- },
- {
- onSuccess: (grant) => {
- setIdentifier("");
- setExpiresOn("");
- setAccess("READ");
- setNarrowed(null);
- setSections([]);
- setInvited(grant.account.username);
- },
- onError: (err) => setError(t(inviteErrorKey(err))),
- },
- );
+ // Offering access hands somebody a standing way into the record, so the
+ // server asks for a fresh proof unless the session signed in or re-proved
+ // within five minutes; the dialog collects it and the invitation is sent
+ // again.
+ try {
+ const grant = await recentProof.run(() =>
+ invite.mutateAsync({
+ identifier: value,
+ access,
+ scope: choice.scope,
+ expiresAt: endOfDayIso(expiresOn, ownerTimezone),
+ }),
+ );
+ setIdentifier("");
+ setExpiresOn("");
+ setAccess("READ");
+ setNarrowed(null);
+ setSections([]);
+ setInvited(grant.account.username);
+ } catch (err) {
+ if (err instanceof ReproofCancelledError) return;
+ setError(
+ err instanceof ApiError &&
+ err.meta?.errorCode === "auth.reproof.sign_in_again"
+ ? err.message
+ : t(inviteErrorKey(err)),
+ );
+ }
};
return (
@@ -159,7 +173,7 @@ export function GrantInviteCard() {
"recordSharing.invite.needsAccount",
)}`}
/>
-