mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
677 files changed
+38767
-5679
No files matched your search
+44
-12
@@ -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)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
+60
-15
@@ -85,6 +85,20 @@ API_TOKEN_HMAC_KEY="<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=""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# WHOOP -- optional integration
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -267,10 +281,15 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# docs/ops/tls-cert-pin.md for extraction + the re-pin runbook.
|
||||
# TLS_LEAF_SPKI_PINS=""
|
||||
|
||||
# Local-AI SSRF opt-in (optional). The local AI provider rejects private/
|
||||
# internal hosts by default. Set to an exact host allowlist (comma-separated,
|
||||
# e.g. "ollama.lan,10.0.0.5") or "true" for any private host — intended for a
|
||||
# self-hosted Ollama / LM Studio endpoint. See docs/integrations/ai-providers.md.
|
||||
# AI endpoints on your own network (optional). An AI base URL on a private
|
||||
# address is refused unless its exact origin is listed here, comma-separated
|
||||
# (e.g. "http://ollama.lan:11434,http://10.0.0.5:4000") — for a self-hosted
|
||||
# Ollama, LM Studio or LiteLLM gateway. Link-local, metadata and unspecified
|
||||
# addresses stay refused even when listed. See docs/integrations/ai-providers.md.
|
||||
# AI_PRIVATE_ORIGINS=""
|
||||
# Older form, kept for existing installs: an exact host list still works
|
||||
# (e.g. "ollama.lan,10.0.0.5"). "true" is deprecated: it now covers only the
|
||||
# admin AI key and admin accounts' own AI settings, and will be removed.
|
||||
# ALLOW_LOCAL_AI_PRIVATE_HOSTS=""
|
||||
|
||||
# Daily-briefing output-token ceiling (optional). Default 2500, clamped to
|
||||
@@ -345,13 +364,17 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# 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/<ts>.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/<ts>.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="<openssl rand -hex 32>"
|
||||
# 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="<openssl rand -hex 32>"
|
||||
# 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"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
+347
@@ -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:<id>`, `condition:<id>`, `document:<id>` and
|
||||
`vaccination:<id>`; `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
|
||||
|
||||
+22
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
+27
-6
@@ -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
|
||||
|
||||
@@ -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:<id>`, `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:<id>`, `lab:LDL`, `visit:<id>`, `condition:<id>`, `document:<id>`, `vaccination:<id>`). 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:<id>`: visit kind (a procedure also answers to "surgery" and
|
||||
"operation"), reason, outcome, body site and side, practitioner name and
|
||||
specialty.
|
||||
- `condition:<id>`: label, type, body site and side.
|
||||
- `document:<id>`: 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:<id>`: 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
|
||||
`<<<USER_TEXT_START>>>` / `<<<USER_TEXT_END>>>` 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.
|
||||
|
||||
+1617
-505
File diff suppressed because it is too large.
Load diff
@@ -140,16 +140,35 @@ Base URL `http://<vllm-host>:8000/v1`, API key `your-shared-secret`,
|
||||
model `Qwen/Qwen2.5-14B-Instruct`. The local-client encrypts the
|
||||
API key at rest like every other provider credential.
|
||||
|
||||
**SSRF guard.** Local endpoints route through the same validation
|
||||
as every other outbound URL HealthLog hits. The guard rejects
|
||||
private-IP-range targets by default. `ALLOW_LOCAL_AI_PRIVATE_HOSTS`
|
||||
opens the escape hatch and accepts two forms: a comma-separated host
|
||||
allowlist (e.g. `ALLOW_LOCAL_AI_PRIVATE_HOSTS=ollama.lan,10.0.0.5`),
|
||||
which permits only those exact hostnames, or the legacy
|
||||
`ALLOW_LOCAL_AI_PRIVATE_HOSTS=true`, which permits any private host —
|
||||
including the cloud-metadata endpoint, so prefer the host list. Leave
|
||||
it unset on public-facing instances unless you specifically run an
|
||||
internal endpoint.
|
||||
**Private endpoints.** Any user of the instance can type a base URL,
|
||||
so HealthLog refuses one on a private address unless the operator has
|
||||
listed it. List the endpoint's exact origin in `AI_PRIVATE_ORIGINS`,
|
||||
comma-separated, the same way `NOTIFICATION_PRIVATE_ORIGINS` works for
|
||||
notification relays:
|
||||
|
||||
```bash
|
||||
AI_PRIVATE_ORIGINS="http://ollama.lan:11434,http://10.0.0.5:4000"
|
||||
```
|
||||
|
||||
An origin is `scheme://host[:port]` and nothing else: no path, no
|
||||
wildcard, no range. A listed endpoint is called through a pinned
|
||||
connection that refuses to follow redirects, and the cloud-metadata
|
||||
address, link-local addresses and the unspecified address stay
|
||||
unreachable even when listed or when a listed name resolves to one.
|
||||
|
||||
`ALLOW_LOCAL_AI_PRIVATE_HOSTS`, the older setting, still works as a
|
||||
host list (`ollama.lan,10.0.0.5`, any port), with the same pinning.
|
||||
|
||||
Its `true` form used to open every private host on the network to
|
||||
every user. It is deprecated since v1.39.3 and will be removed in a
|
||||
later release. Until then it still covers the operator's own AI
|
||||
configurations, the server key in the admin panel and AI settings
|
||||
saved on an admin account, over the same pinned connection, so the
|
||||
metadata address and link-local addresses stay unreachable. Any other
|
||||
account's base URL needs its origin in `AI_PRIVATE_ORIGINS`. The boot
|
||||
summary names the setting while it is set, and the admin AI settings
|
||||
page shows the exact `AI_PRIVATE_ORIGINS` line for the endpoints saved
|
||||
on the instance.
|
||||
|
||||
**Model sizing.** Roughly: 7-8B for 8 GB GPU / Apple Silicon base
|
||||
(briefings fine, deeper Coach reasoning gets terse), 14-24B for
|
||||
@@ -186,9 +205,9 @@ therefore never be sent to a host you typed into a different form,
|
||||
and that property is structural rather than a comment — the OPENAI
|
||||
arm has no code path that reads the gateway's base URL.
|
||||
|
||||
**SSRF guard.** The gateway's base URL passes the same floor as the
|
||||
Local provider's: a public host always, a private one only when the
|
||||
operator allowlisted it via `ALLOW_LOCAL_AI_PRIVATE_HOSTS` (documented
|
||||
**Private endpoints.** The gateway's base URL passes the same floor
|
||||
as the Local provider's: a public host always, a private one only when
|
||||
the operator listed its origin in `AI_PRIVATE_ORIGINS` (documented
|
||||
under Local endpoints above). LAN gateways are the normal case for LiteLLM and vLLM, so
|
||||
the escape hatch matters here as much as it does for Ollama.
|
||||
|
||||
@@ -439,12 +458,12 @@ upstream error.
|
||||
- **"No AI provider configured."** Either set up at least one BYOK
|
||||
provider in `/settings/ai`, or configure an admin-shared OpenAI
|
||||
key in the admin panel.
|
||||
- **Local endpoint rejected as "internal/private host".** Set
|
||||
`ALLOW_LOCAL_AI_PRIVATE_HOSTS` to the endpoint's exact hostname
|
||||
(e.g. `ollama.lan`; comma-separate several) in the container
|
||||
environment, or to `true` to allow any private host. Prefer the host
|
||||
list; leave it unset on internet-facing instances unless you
|
||||
specifically want to allow private-range targets.
|
||||
- **Local endpoint rejected as "internal/private host".** Add the
|
||||
endpoint's exact origin to `AI_PRIVATE_ORIGINS` in the container
|
||||
environment (e.g. `http://ollama.lan:11434`; comma-separate several)
|
||||
and restart. With `ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` an admin
|
||||
account's endpoint still works, but no other account's does; the
|
||||
admin AI settings page lists the origins to add.
|
||||
- **Codex disconnects after a few weeks of inactivity.** ChatGPT
|
||||
refresh tokens lapse after extended idle periods. Reconnect via
|
||||
the **Connect ChatGPT** button — HealthLog re-runs the device-code
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <newKeyId>`
|
||||
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)_
|
||||
|
||||
|
||||
+101
-26
@@ -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|<object key>")
|
||||
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-<id>.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://<account>.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://<account>.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-<id>.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/<ts>.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).
|
||||
|
||||
|
||||
@@ -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":"<old>","v3":"<new>"}'
|
||||
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":"<new>"}'
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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 <username-or-email>
|
||||
@@ -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 <username-or-email>
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+23
-14
@@ -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. |
|
||||
@@ -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 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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<void>) | 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;
|
||||
|
||||
@@ -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<void>) | 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 {
|
||||
|
||||
@@ -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<void>) | 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;
|
||||
};
|
||||
|
||||
@@ -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<void>) | 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");
|
||||
|
||||
+52
-12
@@ -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<SeedOutcome> {
|
||||
// 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<SeedOutcome> {
|
||||
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<GeneratedReport> {
|
||||
// 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<void>) | 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);
|
||||
});
|
||||
|
||||
@@ -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_/);
|
||||
|
||||
@@ -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<void>> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void>,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
@@ -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<CreatedShare> {
|
||||
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<void>) | 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(
|
||||
|
||||
@@ -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: '<input placeholder="Europe/Berlin" />',
|
||||
filename: "/repo/src/components/settings/timezone-picker.tsx",
|
||||
errors: [{ messageId: "zoneLiteral" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
+172
-21
@@ -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",
|
||||
|
||||
+172
-21
@@ -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",
|
||||
|
||||
+172
-21
@@ -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",
|
||||
|
||||
+172
-21
@@ -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",
|
||||
|
||||
+172
-21
@@ -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",
|
||||
|
||||
+171
-20
@@ -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": "사용자 지표",
|
||||
|
||||
+172
-21
@@ -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",
|
||||
|
||||
+11
-10
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+5
-3
@@ -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",
|
||||
|
||||
Generated
+16
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
@@ -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);
|
||||
+165
-26
@@ -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_<ms>
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
type String @default("WEEKLY_AUTO") // WEEKLY_AUTO, MANUAL_UPLOAD_<ms>
|
||||
/// 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")
|
||||
|
||||
+1
-1
@@ -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/*`
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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 <all-or-none> 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.",
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -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<string> {
|
||||
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],
|
||||
|
||||
@@ -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 <service> 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<string>();
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
"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<string, number> {
|
||||
const out = new Map<string, number>();
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* The native OIDC leg ends at `healthlog://oidc-callback?error=<reason>`, and
|
||||
* the app maps each reason to its own message. The only written contract for
|
||||
* that vocabulary is the login route's 302 description in the OpenAPI
|
||||
* registry (the callback itself is provider-driven and has no entry).
|
||||
*
|
||||
* An app build once matched against hyphenated shorthand from a coordination
|
||||
* note (`identity-conflict`) while the server sent `oidc_identity_conflict`,
|
||||
* so every reason fell through to the generic message. This binds the two
|
||||
* ends that can be read exactly: every reason either route can send on a
|
||||
* redirect a native client may receive is named in that description, and
|
||||
* nothing is named there that no route sends.
|
||||
*
|
||||
* Its limit: it reads string literals passed to the redirect helpers, so a
|
||||
* reason assembled at runtime would slip past it. None is today.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ROOT = join(__dirname, "..");
|
||||
const read = (rel: string) => readFileSync(join(ROOT, rel), "utf8");
|
||||
|
||||
/** Literal reasons passed to one of the named redirect helpers. */
|
||||
function reasons(source: string, helpers: string[]): Set<string> {
|
||||
const found = new Set<string>();
|
||||
const pattern = new RegExp(
|
||||
`\\b(?:${helpers.join("|")})\\(\\s*"(oidc_[a-z_]+)"\\s*\\)`,
|
||||
"g",
|
||||
);
|
||||
for (const m of source.matchAll(pattern)) found.add(m[1]);
|
||||
return found;
|
||||
}
|
||||
|
||||
describe("native OIDC error reasons are documented", () => {
|
||||
// The login route answers both legs through `loginError`; the callback's
|
||||
// native-aware helper is `failRedirect`. `errorRedirect` in the callback is
|
||||
// the browser-only branch taken before the state (and so the native flag)
|
||||
// could be read, and never reaches the app.
|
||||
const sent = new Set([
|
||||
...reasons(read("app/api/auth/oidc/login/route.ts"), ["loginError"]),
|
||||
...reasons(read("app/api/auth/oidc/callback/route.ts"), ["failRedirect"]),
|
||||
]);
|
||||
|
||||
// The login route's 302 description is one string literal on one line.
|
||||
const description =
|
||||
read("lib/openapi/routes/auth.ts")
|
||||
.split("\n")
|
||||
.find(
|
||||
(line) =>
|
||||
line.includes("healthlog://oidc-callback?error=<reason>") &&
|
||||
line.includes("oidc_invalid_request"),
|
||||
) ?? "";
|
||||
const documented = new Set(
|
||||
[...description.matchAll(/`(oidc_[a-z_]+)`/g)].map((m) => m[1]),
|
||||
);
|
||||
|
||||
it("finds the reasons on both ends", () => {
|
||||
expect(sent.size).toBeGreaterThanOrEqual(8);
|
||||
expect(documented.size).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
|
||||
it("documents every reason a route sends", () => {
|
||||
expect([...sent].filter((r) => !documented.has(r))).toEqual([]);
|
||||
});
|
||||
|
||||
it("documents no reason that no route sends", () => {
|
||||
expect([...documented].filter((r) => !sent.has(r))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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<RegExp> {
|
||||
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<string, unknown>,
|
||||
) => 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<string, number>();
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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'",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
// 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<string, string> = {};
|
||||
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*:/);
|
||||
});
|
||||
});
|
||||
@@ -225,7 +225,9 @@ const DISPOSITIONS: Record<StructuredLeafId, Disposition> = {
|
||||
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<StructuredLeafId, Disposition> = {
|
||||
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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 <p> 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,
|
||||
|
||||
@@ -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 <div className="space-y-6">{children}</div>;
|
||||
// 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 (
|
||||
<div className="space-y-6">
|
||||
{Children.map(children, (card) => (
|
||||
<AdminCardBoundary>{card}</AdminCardBoundary>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -67,6 +67,11 @@ export const AI_ROUTES: Readonly<Record<string, AiRouteEntry>> = {
|
||||
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"],
|
||||
|
||||
@@ -27,11 +27,19 @@ vi.mock("@/lib/api-handler", async () => {
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
const requireAuth = vi.fn();
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: never[]) => Promise<Response>>(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 () => {},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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<typeof import("@/lib/sharing/grants")>()),
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -31,11 +31,19 @@ vi.mock("@/lib/api-handler", async () => {
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
const requireAuth = vi.fn();
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: never[]) => Promise<Response>>(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(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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<Uint8Array>,
|
||||
limit: number,
|
||||
): AsyncGenerator<Uint8Array> {
|
||||
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<Uint8Array>;
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<RotationState> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<BackupKeyNeeds> {
|
||||
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<string>();
|
||||
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,
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Loaded 100 of 677 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user