mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
Merge the release into the deletion grace work
The translation files merge key by key (no key changed on both sides), the backups list carries both the database dump and the pre-wipe copy window, and the backup-restore doc keeps the shorter rewrite plus the dump in the bucket and a lifecycle rule that covers database/.
This commit is contained in:
commit
9db5e755e6
254 files changed
+16378
-3184
No files matched your search
@@ -277,6 +277,31 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
|
||||
# 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
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Database dumps (optional, on by default)
|
||||
# -----------------------------------------------------------------------------
|
||||
# The whole-database dump that rebuilds a lost server: nightly, and before
|
||||
# every update that brings migrations. Written to DB_BACKUP_DIR with a
|
||||
# manifest beside it (row counts, key fingerprints, versions); with the
|
||||
# off-host bucket above set, each nightly dump is also uploaded under
|
||||
# `database/`, sealed with BACKUP_ENCRYPTION_KEY. Local dumps are not
|
||||
# encrypted: they sit beside the database volume, in the same trust zone.
|
||||
# Restore: `docker compose run --rm db-restore latest`. See
|
||||
# docs/ops/disaster-recovery.md.
|
||||
#
|
||||
# Set "false" to stop the nightly dump.
|
||||
# DB_BACKUP_ENABLED="true"
|
||||
# Where dumps are written. The bundled compose file mounts the `backups`
|
||||
# volume here; change it only together with that mount.
|
||||
# DB_BACKUP_DIR="/var/lib/healthlog/backups"
|
||||
# Five-field cron, in Europe/Berlin time like every scheduled job.
|
||||
# DB_BACKUP_SCHEDULE="30 1 * * *"
|
||||
# Nightly dumps kept (at least 1). The last two pre-migration dumps are kept
|
||||
# besides.
|
||||
# DB_BACKUP_KEEP="7"
|
||||
# Set "false" to start an update without the dump before its migrations.
|
||||
# DB_BACKUP_BEFORE_MIGRATE="true"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stored backups in the database (optional)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
+33
-6
@@ -295,10 +295,10 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# Optional: deployment-dashboard URL (e.g. the Coolify UI) linked in
|
||||
# deploy-failure notifications. When unset the notification omits the link.
|
||||
# DEPLOY_LOGS_URL=""
|
||||
# Optional replay hardening: require a fresh X-Deploy-Webhook-Timestamp
|
||||
# header (unix seconds / ms or ISO-8601, +/- 5 minutes) on every webhook
|
||||
# call. Leave unset for Coolify's stock sender (it attaches no
|
||||
# timestamp); see docs/ops/deploy.md.
|
||||
# Optional replay protection: accept only signed webhook calls
|
||||
# (X-Deploy-Webhook-Signature, an HMAC of the timestamp and body under the
|
||||
# secret, with a timestamp within +/- 5 minutes). Leave unset for Coolify's
|
||||
# stock sender, which can only send the secret header; see docs/ops/deploy.md.
|
||||
# DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP="false"
|
||||
|
||||
|
||||
@@ -414,6 +414,31 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# key once the lifecycle rule has retired every object written under it.
|
||||
# BACKUP_ENCRYPTION_PREVIOUS_KEYS=""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Database dumps (optional, on by default)
|
||||
# -----------------------------------------------------------------------------
|
||||
# The whole-database dump that rebuilds a lost server: nightly, and before
|
||||
# every update that brings migrations. Written to DB_BACKUP_DIR with a
|
||||
# manifest beside it (row counts, key fingerprints, versions); with the
|
||||
# off-host bucket above set, each nightly dump is also uploaded under
|
||||
# `database/`, sealed with BACKUP_ENCRYPTION_KEY. Local dumps are not
|
||||
# encrypted: they sit beside the database volume, in the same trust zone.
|
||||
# Restore: `docker compose run --rm db-restore latest`. See
|
||||
# docs/ops/disaster-recovery.md.
|
||||
#
|
||||
# Set "false" to stop the nightly dump.
|
||||
# DB_BACKUP_ENABLED="true"
|
||||
# Where dumps are written. The bundled compose file mounts the `backups`
|
||||
# volume here; change it only together with that mount.
|
||||
# DB_BACKUP_DIR="/var/lib/healthlog/backups"
|
||||
# Five-field cron, in Europe/Berlin time like every scheduled job.
|
||||
# DB_BACKUP_SCHEDULE="30 1 * * *"
|
||||
# Nightly dumps kept (at least 1). The last two pre-migration dumps are kept
|
||||
# besides.
|
||||
# DB_BACKUP_KEEP="7"
|
||||
# Set "false" to start an update without the dump before its migrations.
|
||||
# DB_BACKUP_BEFORE_MIGRATE="true"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stored backups in the database (optional)
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -535,8 +560,10 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# 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. 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
|
||||
# directly-exposed host (the connection's own address is used), 1 (the
|
||||
# default when unset) behind a single reverse proxy; X-Real-IP is read only
|
||||
# at 1. Forwarding headers are never read from a connection that comes from a
|
||||
# public address: only a proxy on loopback or a private network is believed. 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"
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
name: Disaster recovery drill
|
||||
|
||||
# Rebuilds a lost server from the nightly database dump and the keys alone,
|
||||
# against real containers: the app image built from this checkout, the
|
||||
# bundled compose file with an S3-compatible bucket outside it, the demo seed
|
||||
# plus a Guardian with a managed profile, the dump taken and uploaded by the
|
||||
# scheduled job, every volume of the stack destroyed, a new stack, the
|
||||
# restore, and the app booted on the result (health, login, an encrypted
|
||||
# note, a document, the Guardian's profile, every table's row count against
|
||||
# the manifest). Two sources: from the bucket (`db-restore latest
|
||||
# --from-bucket`, the disaster path) and from dump files copied off the host
|
||||
# (`db-restore latest`).
|
||||
# `scripts/compose-dr-drill.sh` is the same drill, runnable locally; its
|
||||
# header lists what it checks and the mutations that must turn it red.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- docker-compose.yml
|
||||
- Dockerfile
|
||||
- docker-entrypoint.sh
|
||||
- scripts/compose-dr-drill.sh
|
||||
- scripts/dr-drill-seed.ts
|
||||
- scripts/db-dump.mjs
|
||||
- scripts/db-restore.mjs
|
||||
- scripts/pending-migrations.mjs
|
||||
- src/lib/database-backup/**
|
||||
- src/lib/jobs/database-backup-queue.ts
|
||||
- src/lib/jobs/reminder/backup-handlers.ts
|
||||
- .github/workflows/compose-dr-drill.yml
|
||||
schedule:
|
||||
# Nightly, 02:41 UTC: off the hour, after the evening merges.
|
||||
- cron: "41 2 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: compose-dr-drill-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
drill:
|
||||
name: Restore a lost server from its dump (${{ matrix.source }})
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
source: [bucket, local]
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
# The seed scripts run from the checkout against the drill's database.
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm db:generate
|
||||
|
||||
- name: Drill
|
||||
env:
|
||||
DRILL_SOURCE: ${{ matrix.source }}
|
||||
run: bash scripts/compose-dr-drill.sh
|
||||
+29
-3
@@ -77,7 +77,13 @@ FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb
|
||||
# security patches from the Alpine repo override it deliberately —
|
||||
# CVE-2026-14456 in openssl was the first instance the container scan
|
||||
# caught between base-image rebuilds).
|
||||
RUN apk add --no-cache tzdata && apk upgrade --no-cache
|
||||
#
|
||||
# `postgresql18-client` carries `pg_dump` and `pg_restore` for the nightly
|
||||
# database dump, the pre-migration dump in the entrypoint and the
|
||||
# `db-restore` service (docs/ops/disaster-recovery.md). Major 18 matches the
|
||||
# bundled database; a client dumps servers of its own major and older, so it
|
||||
# also serves an install still on its own Postgres 16.
|
||||
RUN apk add --no-cache tzdata postgresql18-client && apk upgrade --no-cache
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
@@ -111,6 +117,13 @@ ENV NEXT_PUBLIC_APP_BUILD_SHA=$NEXT_PUBLIC_APP_BUILD_SHA
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
# Where the database dumps go (`DB_BACKUP_DIR`). Owned by the app user, so a
|
||||
# fresh named volume mounted here starts with that owner: Docker fills an
|
||||
# empty named volume from the image directory it covers, ownership included.
|
||||
RUN mkdir -p /var/lib/healthlog/backups && \
|
||||
chown nextjs:nodejs /var/lib/healthlog/backups && \
|
||||
chmod 700 /var/lib/healthlog/backups
|
||||
|
||||
# Copy built assets
|
||||
COPY --from=builder /app/public ./public
|
||||
# The server reads its message catalogs from here on first use, one locale at
|
||||
@@ -233,11 +246,16 @@ COPY --from=builder /app/src/generated ./src/generated
|
||||
# the runbooks and the entrypoint against this list. `pending-migrations.mjs`
|
||||
# and `migrate-deploy.mjs` are the entrypoint's own: the first logs the
|
||||
# pending migration count, the second runs `migrate deploy` with its
|
||||
# lock-timeout retry. Root-owned on purpose: the app user can run them but not
|
||||
# rewrite them.
|
||||
# lock-timeout retry. `db-dump.mjs` takes the dump before migrating, and
|
||||
# `db-restore.mjs` is the `db-restore` service; both share
|
||||
# `src/lib/database-backup/dump-core.mjs` with the app. 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/scripts/pending-migrations.mjs ./scripts/pending-migrations.mjs
|
||||
COPY --from=builder /app/scripts/migrate-deploy.mjs ./scripts/migrate-deploy.mjs
|
||||
COPY --from=builder /app/scripts/db-dump.mjs ./scripts/db-dump.mjs
|
||||
COPY --from=builder /app/scripts/db-restore.mjs ./scripts/db-restore.mjs
|
||||
COPY --from=builder /app/src/lib/database-backup/dump-core.mjs ./src/lib/database-backup/dump-core.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
|
||||
@@ -247,6 +265,14 @@ RUN node scripts/reset-password.mjs 2>&1 | grep -q "reset-password: usage:"
|
||||
RUN env -u DATABASE_URL node scripts/pending-migrations.mjs && \
|
||||
node --input-type=module -e "await import('pg')" && \
|
||||
node --input-type=module -e "await import('./scripts/migrate-deploy.mjs')"
|
||||
# The dump and restore scripts load, the client tools they run are the
|
||||
# major the bundled database runs, and the S3 client `db-restore --from-bucket`
|
||||
# loads resolves from the shared module's place in the tree.
|
||||
RUN node scripts/db-dump.mjs 2>&1 | grep -q "db-dump: usage:" && \
|
||||
node --input-type=module -e "import { createRequire } from 'node:module'; createRequire('/app/src/lib/database-backup/dump-core.mjs').resolve('@aws-sdk/client-s3')" && \
|
||||
node scripts/db-restore.mjs 2>&1 | grep -q "db-restore: usage:" && \
|
||||
pg_dump --version | grep -q "(PostgreSQL) 18\." && \
|
||||
pg_restore --version | grep -q "(PostgreSQL) 18\."
|
||||
|
||||
# Install the migration CLI, its config dependency, and a pinned launcher for
|
||||
# the maintenance scripts already shipped in the standalone tree. The Prisma
|
||||
|
||||
@@ -50,7 +50,7 @@ Try the [live demo](https://demo.healthlog.dev) to see a working install — sig
|
||||
- **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 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.
|
||||
- **Context that explains the numbers.** An environmental module folds weather, daylight, temperature, air quality, and pollen 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. It is on by default, and nothing leaves the host until someone sets that location.
|
||||
- **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 AI requests stay on your network.
|
||||
@@ -85,7 +85,7 @@ Verify the running version:
|
||||
curl -s http://localhost:3000/api/version # returns version + buildSha + builtAt
|
||||
```
|
||||
|
||||
`SESSION_COOKIE_SECURE=false` is the one setting plain-HTTP self-hosts must set: without it the session cookie carries the `Secure` flag, the browser drops it over HTTP, and login silently fails. Leave it unset (or `true`) only when a TLS reverse proxy serves HTTPS — and then set `NEXT_PUBLIC_APP_URL` and `APP_URL` to the public URL first (see the [self-hosting guide](https://docs.healthlog.dev/self-hosting/)).
|
||||
`SESSION_COOKIE_SECURE=false` is the one setting plain-HTTP self-hosts must set: without it the session cookie carries the `Secure` flag, the browser drops it over HTTP, and login silently fails. Leave it unset (or `true`) only when a TLS reverse proxy serves HTTPS — and then set `NEXT_PUBLIC_APP_URL` and `APP_URL` to the public URL first (see the [self-hosting guide](https://docs.healthlog.dev/getting-started/self-host-in-10-minutes/)).
|
||||
|
||||
Persistent state lives in exactly one named volume (`postgres-data`, the Postgres data); the app container is stateless, so backing up the database backs up everything. An install from v1.45 or earlier moves its Postgres 16 database into it on the first start ([details](docs/ops/postgres-major-upgrade.md)).
|
||||
|
||||
|
||||
+65
-5
@@ -14,9 +14,11 @@
|
||||
# and login silently fails. This is the single most common self-host snag.
|
||||
# Leave it unset (or true) only when a TLS reverse proxy serves HTTPS.
|
||||
#
|
||||
# Persistent state lives in exactly one named volume (`postgres-data`,
|
||||
# Postgres' data). The app container is stateless, so a backup of the database
|
||||
# is a backup of everything. `pgdata` is where Postgres 16 kept the data up to
|
||||
# Persistent state lives in the named volume `postgres-data` (Postgres' data).
|
||||
# The app container is stateless, so a backup of the database is a backup of
|
||||
# everything. `backups` holds the database dumps the app takes every night and
|
||||
# before every migration (docs/ops/disaster-recovery.md); keep a copy of them
|
||||
# off this host, or set the off-host bucket below. `pgdata` is where Postgres 16 kept the data up to
|
||||
# v1.45; on an install that has it, `db-upgrade` moves it into Postgres 18 once
|
||||
# and then leaves it alone as the way back (docs/ops/postgres-major-upgrade.md). The optional GeoLite2 databases are either baked
|
||||
# into the image at build time or mounted read-only from the host and
|
||||
@@ -147,6 +149,20 @@ x-healthlog-env: &healthlog_env
|
||||
# serves anyway. A last resort for a refusal you are certain is false: it
|
||||
# disables a safety check. See docs/self-hosting/getting-started.md.
|
||||
ENCRYPTION_KEY_CHECK: "${ENCRYPTION_KEY_CHECK:-}"
|
||||
# The whole-database dump (docs/ops/disaster-recovery.md). Nightly at
|
||||
# DB_BACKUP_SCHEDULE (five-field cron in Europe/Berlin time, default
|
||||
# "30 1 * * *") into DB_BACKUP_DIR (default /var/lib/healthlog/backups, the
|
||||
# `backups` volume below), keeping DB_BACKUP_KEEP daily dumps (default 7)
|
||||
# and the last two taken before a migration. With the off-host bucket set,
|
||||
# each nightly dump is also uploaded under `database/`, sealed with
|
||||
# BACKUP_ENCRYPTION_KEY. DB_BACKUP_ENABLED=false stops the nightly dump;
|
||||
# DB_BACKUP_BEFORE_MIGRATE=false skips the dump before migrations. Change
|
||||
# DB_BACKUP_DIR only together with the volume mount.
|
||||
DB_BACKUP_ENABLED: "${DB_BACKUP_ENABLED:-}"
|
||||
DB_BACKUP_DIR: "${DB_BACKUP_DIR:-}"
|
||||
DB_BACKUP_SCHEDULE: "${DB_BACKUP_SCHEDULE:-}"
|
||||
DB_BACKUP_KEEP: "${DB_BACKUP_KEEP:-}"
|
||||
DB_BACKUP_BEFORE_MIGRATE: "${DB_BACKUP_BEFORE_MIGRATE:-}"
|
||||
BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}"
|
||||
BACKUP_ENCRYPTION_PREVIOUS_KEYS: "${BACKUP_ENCRYPTION_PREVIOUS_KEYS:-}"
|
||||
BACKUP_S3_ENDPOINT: "${BACKUP_S3_ENDPOINT:-}"
|
||||
@@ -476,6 +492,11 @@ services:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
<<: *healthlog_env
|
||||
# The database dumps (DB_BACKUP_DIR). Without this mount the app still
|
||||
# copies its nightly dump off-host when the bucket is set, but keeps none
|
||||
# here and takes none before a migration; the boot log says so.
|
||||
volumes:
|
||||
- backups:/var/lib/healthlog/backups
|
||||
# Bring your own GeoLite2 databases. The published image only carries
|
||||
# MMDBs if the build had a MaxMind licence key, so on `:latest` the
|
||||
# offline tier is usually absent and every login IP goes to the online
|
||||
@@ -485,9 +506,9 @@ services:
|
||||
# matches GEOLITE2_DIR's default (/opt/geolite2), so this works as-is
|
||||
# once uncommented. Mounting at a different container path also works,
|
||||
# just also set GEOLITE2_DIR in .env to match it.
|
||||
# Steps: docs/self-hosting/geolite2.md.
|
||||
# Steps: docs/self-hosting/geolite2.md. The line goes under `volumes:`
|
||||
# above, beside the backups line:
|
||||
#
|
||||
# volumes:
|
||||
# - ./geolite2:/opt/geolite2:ro
|
||||
depends_on:
|
||||
db:
|
||||
@@ -564,6 +585,9 @@ services:
|
||||
# environment:
|
||||
# <<: *healthlog_env
|
||||
# HEALTHLOG_PROCESS_TYPE: "worker"
|
||||
# # The worker takes the nightly database dump, so it needs the volume.
|
||||
# volumes:
|
||||
# - backups:/var/lib/healthlog/backups
|
||||
# depends_on:
|
||||
# db:
|
||||
# condition: service_healthy
|
||||
@@ -777,6 +801,42 @@ services:
|
||||
mv "$$MOVING" "$$MARK"
|
||||
log "done. The old volume stays as it was, as a way back; see docs/ops/postgres-major-upgrade.md"
|
||||
|
||||
# Restores a database dump into an EMPTY database (docs/ops/disaster-recovery.md).
|
||||
# Never started by `docker compose up`; run it by name:
|
||||
#
|
||||
# docker compose run --rm db-restore list
|
||||
# docker compose run --rm db-restore latest
|
||||
# docker compose run --rm db-restore healthlog-<time>-daily.dump
|
||||
# docker compose run --rm db-restore latest --from-bucket
|
||||
#
|
||||
# It checks the dump against its manifest and the keys in .env against the
|
||||
# keys the dump was written with, refuses a database that already holds
|
||||
# tables, restores in one transaction and compares every table's row count
|
||||
# with the manifest. Start the app only after it says "Restored".
|
||||
# `--from-bucket` downloads the dump and its manifest from the off-host bucket
|
||||
# (`database/`, the BACKUP_S3_* variables) and opens them with
|
||||
# BACKUP_ENCRYPTION_KEY: the restore after the host and its local dumps are
|
||||
# gone. Files you downloaded yourself restore from a directory you mount:
|
||||
# `docker compose run --rm -v ./restore:/restore:ro db-restore
|
||||
# /restore/healthlog-<time>-daily.dump.enc`.
|
||||
db-restore:
|
||||
image: ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest}
|
||||
profiles: ["restore"]
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
environment:
|
||||
<<: *healthlog_env
|
||||
volumes:
|
||||
- backups:/var/lib/healthlog/backups
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
entrypoint: ["node", "scripts/db-restore.mjs"]
|
||||
command: ["list"]
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
pgdata:
|
||||
backups:
|
||||
@@ -88,6 +88,15 @@ echo "HealthLog: Database is reachable."
|
||||
# Say how many migrations this image is about to apply, with a reminder to
|
||||
# back up first. Reporting only: it never stops the boot.
|
||||
node scripts/pending-migrations.mjs || true
|
||||
# When migrations are pending, dump the database into DB_BACKUP_DIR first, so
|
||||
# the state before them can be restored (docs/ops/disaster-recovery.md). No
|
||||
# writable directory: a warning, and the boot goes on. A writable directory
|
||||
# and a failed dump: the boot stops here, before anything is migrated.
|
||||
if ! node scripts/db-dump.mjs pre-migration; then
|
||||
echo "HealthLog: ERROR — not migrating without the dump. Free disk space or fix the error above and start again." >&2
|
||||
echo "HealthLog: to migrate without a dump this once, set DB_BACKUP_BEFORE_MIGRATE=false." >&2
|
||||
exit 1
|
||||
fi
|
||||
# `migrate-deploy.mjs` runs `prisma migrate deploy` and retries only a
|
||||
# migration that timed out on a lock and is marked safe to re-run; any other
|
||||
# failure stops the boot with Prisma's exit code (docs/ops/deploy.md).
|
||||
|
||||
+13
-7
@@ -17,9 +17,13 @@ notes, and machine-readable specs that ship alongside the source:
|
||||
encryption-key rotation, password reset and account recovery, env
|
||||
checks, TLS pinning, data repair. These are what an operator reaches
|
||||
for during an incident.
|
||||
- [`self-hosting/`](./self-hosting/) — horizontal-scaling notes
|
||||
(`HEALTHLOG_PROCESS_TYPE=web|worker|all`) and the deploy-pipeline
|
||||
recipe. Routine install steps stay on `docs.healthlog.dev`. Includes
|
||||
- [`self-hosting/`](./self-hosting/) — operator notes next to the code:
|
||||
[`getting-started.md`](./self-hosting/getting-started.md) (install and
|
||||
updates, the page the boot log points at), the TrueNAS, Unraid and
|
||||
Portainer installs with their catalog templates, Tailscale, the reverse
|
||||
proxy, horizontal scaling (`HEALTHLOG_PROCESS_TYPE=web|worker|all`),
|
||||
notification channels, the environment module, document sources and
|
||||
reproductive-health data. Includes
|
||||
[`self-hosting/mcp.md`](./self-hosting/mcp.md) — enabling the
|
||||
off-by-default MCP connector and pointing Claude.ai / ChatGPT /
|
||||
Claude Desktop at it, and [`self-hosting/sso.md`](./self-hosting/sso.md)
|
||||
@@ -44,9 +48,11 @@ notes, and machine-readable specs that ship alongside the source:
|
||||
- [`migration/`](./migration/) — migration notes. Currently only
|
||||
v1.3 to v1.4; later releases are covered by `CHANGELOG.md` and
|
||||
<https://docs.healthlog.dev>.
|
||||
- [`integrations/`](./integrations/) — per-integration reference
|
||||
(Withings, Fitbit, Google Health, WHOOP, Apple Health, AI providers,
|
||||
data import).
|
||||
- [`integrations/`](./integrations/) — per-integration reference:
|
||||
Withings, WHOOP, Fitbit, Google Health, Strava, Nightscout, Dawarich and
|
||||
the Garmin route; the Apple Health, Health Connect, document and data
|
||||
imports; AI providers; and Home Assistant with its blueprint. Oura and
|
||||
Polar are covered on the docs site only.
|
||||
- [`adr/`](./adr/) — architecture decision records.
|
||||
- [`security/`](./security/) — threat-model notes.
|
||||
- [`diagrams/`](./diagrams/) — architecture diagrams rendered through
|
||||
@@ -70,4 +76,4 @@ notes, and machine-readable specs that ship alongside the source:
|
||||
|
||||
If you're a self-hoster looking to spin up an instance, start at
|
||||
<https://docs.healthlog.dev> or jump to the [Quick Start in the project
|
||||
README](../README.md#quick-start).
|
||||
README](../README.md#quick-start-docker-compose).
|
||||
+59
-46
@@ -24899,9 +24899,9 @@ paths:
|
||||
**Auth is Bearer only.** A cookie session does not authenticate here: the route reads the `Authorization` header
|
||||
itself, hashes it and looks the token up, so a browser with a valid login and no token gets 401. The token must
|
||||
carry either the `*` wildcard or the `medication:ingest` scope — AND, once the named medication resolves, either
|
||||
`*` or that medication's own `medication:{id}:ingest` scope. The second check runs after the lookup, which is
|
||||
why a token scoped to one medication gets 404 for a name it may not touch only when the name does not exist, and
|
||||
403 when it does.
|
||||
`*` or that medication's own `medication:{id}:ingest` scope. The second check runs after the lookup. A token
|
||||
scoped to one medication gets the same 403 for a name that does not exist as for one outside its scope, so it
|
||||
cannot be used to learn which medications exist; only a `*` token gets 404 for an unknown name.
|
||||
|
||||
|
||||
**The operator can switch this off.** With the global API kill switch disabled every request is refused with 403
|
||||
@@ -24909,8 +24909,9 @@ paths:
|
||||
|
||||
|
||||
**Replay is by `idempotencyKey`, and the status says which happened.** A key already stored for this token's
|
||||
owner returns the existing row with **200**; a fresh dose returns **201**. Nothing else distinguishes them —
|
||||
same body shape, same fields — so branch on the status code.
|
||||
owner and the same medication returns the existing row with **200**; a fresh dose returns **201**; a key already
|
||||
used for another medication returns **409** without its row. Replay is answered only after both scope checks.
|
||||
Nothing else distinguishes them — same body shape, same fields — so branch on the status code.
|
||||
|
||||
|
||||
**A dose is attributed to its scheduled slot, not stamped ad-hoc.** When the take falls inside a schedule's
|
||||
@@ -24954,14 +24955,22 @@ paths:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"403":
|
||||
description: Refused. Either the operator has the global API switch off (the message says so), or the token lacks
|
||||
`medication:ingest` / `*`, or it lacks the resolved medication's own `medication:{id}:ingest` / `*` scope.
|
||||
`medication:ingest` / `*`, or it lacks the resolved medication's own `medication:{id}:ingest` / `*` scope. A
|
||||
token without `*` also gets this answer for a name that matches no active medication.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"404":
|
||||
description: No ACTIVE medication of the token owner's matches `medicationName`. An inactive medication of the right
|
||||
name is also a 404 — the name is only searched among active ones.
|
||||
description: "Only for a `*` token: no ACTIVE medication of the token owner's matches `medicationName`. An inactive
|
||||
medication of the right name is also a 404 — the name is only searched among active ones."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"409":
|
||||
description: The `idempotencyKey` is already stored for a dose of another medication. Nothing was written and that dose
|
||||
is not returned.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -37174,6 +37183,7 @@ components:
|
||||
- SECURITY_ALERT
|
||||
- DAILY_BRIEFING
|
||||
- SHARED_RECORD_INTAKE
|
||||
- BACKUP_HEALTH
|
||||
enabled:
|
||||
type: boolean
|
||||
required:
|
||||
@@ -38558,7 +38568,7 @@ components:
|
||||
documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable,
|
||||
documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid,
|
||||
encounter.practitioner-not-found, encounter.reminder-conflict, encounter.reminder-not-found; encryption
|
||||
— encryption.keyBackup.stale, encryption.key_mismatch; environment — environment.backfill_pending,
|
||||
— encryption.key_mismatch; environment — environment.backfill_pending,
|
||||
environment.backfill_rate_limited, environment.invalid, environment.no_home,
|
||||
environment.range_too_large, environment.travel.not_found, environment.travel_suggestion.not_found;
|
||||
export — export.selection.empty, export.selection.unknown_leaf; familyHistory — familyHistory.invalid;
|
||||
@@ -38619,8 +38629,9 @@ components:
|
||||
profile.update.emailInUse, profile.update.emailRateLimited, profile.update.emailSecondFactorRequired,
|
||||
profile.update.invalidBody, profile.update.nothingSaved; profile_claim — profile_claim.invalid,
|
||||
profile_claim.oidc_only_unsupported, profile_claim.sso_unavailable, profile_claim.taken; record_write —
|
||||
record_write.rate_limited; report-selection — report-selection.body.invalid_json,
|
||||
report-selection.body.invalid_shape, report-selection.leaves.unknown; route — route.retired; search —
|
||||
record_write.rate_limited; recovery — recovery.kit.stale; report-selection —
|
||||
report-selection.body.invalid_json, report-selection.body.invalid_shape,
|
||||
report-selection.leaves.unknown; request — request.cross_site; route — route.retired; search —
|
||||
search.invalid; security_activity — security_activity.invalid; share-link —
|
||||
share-link.document.decryptFailed, share-link.document.notFound, share-link.document.rateLimited,
|
||||
share-link.documents.invalid, share-link.report.notFound, share-link.report.rateLimited,
|
||||
@@ -82201,7 +82212,7 @@ components:
|
||||
- type: "null"
|
||||
syncHealth:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`. Present on the GET read; omitted from the PATCH echo."
|
||||
description: "Deprecated, still served: read `health`. Present on the GET read; omitted from the PATCH echo."
|
||||
$ref: "#/components/schemas/SyncHealth"
|
||||
metricFreshness:
|
||||
description: Per-metric-type freshness for the `APPLE_HEALTH` source, with the per-type arrival facts. Present on the
|
||||
@@ -82879,7 +82890,7 @@ components:
|
||||
type: boolean
|
||||
state:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`."
|
||||
description: "Deprecated, still served: read `health`."
|
||||
type: string
|
||||
enum:
|
||||
- connected
|
||||
@@ -82902,14 +82913,14 @@ components:
|
||||
- type: "null"
|
||||
lastError:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`. Last recorded failure message, decrypted from the ledger.
|
||||
Null when the last attempt succeeded or none has run."
|
||||
description: "Deprecated, still served: read `health`. Last recorded failure message, decrypted from the ledger. Null
|
||||
when the last attempt succeeded or none has run."
|
||||
type:
|
||||
- string
|
||||
- "null"
|
||||
syncHealth:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`."
|
||||
description: "Deprecated, still served: read `health`."
|
||||
$ref: "#/components/schemas/SyncHealth"
|
||||
health:
|
||||
description: The unified connection state. Present whenever the connection exists; null in the rare case it resolved to
|
||||
@@ -83098,9 +83109,9 @@ components:
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
tokenExpired:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`. The stored access token's expiry is in the past. Read
|
||||
straight off the row — unlike the Withings status read, this endpoint does not refresh; the sync path
|
||||
refreshes lazily instead."
|
||||
description: "Deprecated, still served: read `health`. The stored access token's expiry is in the past. Read straight
|
||||
off the row — unlike the Withings status read, this endpoint does not refresh; the sync path refreshes
|
||||
lazily instead."
|
||||
type: boolean
|
||||
tokenExpiresAt:
|
||||
type: string
|
||||
@@ -83189,8 +83200,7 @@ components:
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
tokenExpired:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`. True after a refresh attempt that did not produce a live
|
||||
token."
|
||||
description: "Deprecated, still served: read `health`. True after a refresh attempt that did not produce a live token."
|
||||
type: boolean
|
||||
tokenRefreshFailed:
|
||||
description: The read tried to refresh an expired token and could not — either the per-user credentials are gone or
|
||||
@@ -83210,8 +83220,8 @@ components:
|
||||
- "null"
|
||||
hasActivityScope:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`. Pre-computed convenience flag the reconnect banner reads. A
|
||||
null `scope` counts as missing activity."
|
||||
description: "Deprecated, still served: read `health`. Pre-computed convenience flag the reconnect banner reads. A null
|
||||
`scope` counts as missing activity."
|
||||
type: boolean
|
||||
health:
|
||||
description: The unified connection state. Present whenever the connection exists; null in the rare case it resolved to
|
||||
@@ -83520,8 +83530,8 @@ components:
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
tokenExpired:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`. Read straight off the row — this endpoint does not refresh;
|
||||
the sync path refreshes lazily."
|
||||
description: "Deprecated, still served: read `health`. Read straight off the row — this endpoint does not refresh; the
|
||||
sync path refreshes lazily."
|
||||
type: boolean
|
||||
tokenExpiresAt:
|
||||
type: string
|
||||
@@ -83691,8 +83701,8 @@ components:
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
tokenExpired:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`. Read straight off the row — this endpoint does not refresh;
|
||||
the sync path refreshes lazily."
|
||||
description: "Deprecated, still served: read `health`. Read straight off the row — this endpoint does not refresh; the
|
||||
sync path refreshes lazily."
|
||||
type: boolean
|
||||
tokenExpiresAt:
|
||||
type: string
|
||||
@@ -83742,7 +83752,7 @@ components:
|
||||
when every type was read to its start. Kept after the backfill completes; cleared on reconnect.
|
||||
needsReauth:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`. The stored refresh token was rejected with `invalid_grant` —
|
||||
description: "Deprecated, still served: read `health`. The stored refresh token was rejected with `invalid_grant` —
|
||||
Google expires it after 7 days while the OAuth client is in Testing publishing mode, and a user-revoked
|
||||
grant lands here too. Distinct from `parked` and from `disconnected`: the connection still exists, it just
|
||||
cannot mint a token until the user reconnects. Only Google Health has this field."
|
||||
@@ -83946,7 +83956,7 @@ components:
|
||||
description: The user has stored their own BYO client id and secret.
|
||||
state:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`."
|
||||
description: "Deprecated, still served: read `health`."
|
||||
type: string
|
||||
enum:
|
||||
- connected
|
||||
@@ -83969,13 +83979,13 @@ components:
|
||||
- type: "null"
|
||||
lastError:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`."
|
||||
description: "Deprecated, still served: read `health`."
|
||||
type:
|
||||
- string
|
||||
- "null"
|
||||
syncHealth:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`."
|
||||
description: "Deprecated, still served: read `health`."
|
||||
$ref: "#/components/schemas/SyncHealth"
|
||||
health:
|
||||
description: The unified connection state. Present whenever the connection exists; null in the rare case it resolved to
|
||||
@@ -84370,7 +84380,7 @@ components:
|
||||
- disconnected
|
||||
- parked
|
||||
- unknown
|
||||
description: "Deprecated: removed in v1.44, read `health`."
|
||||
description: "Deprecated, still served: read `health`."
|
||||
deprecated: true
|
||||
lastSuccessAt:
|
||||
anyOf:
|
||||
@@ -84386,7 +84396,7 @@ components:
|
||||
- type: "null"
|
||||
lastError:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`."
|
||||
description: "Deprecated, still served: read `health`."
|
||||
type:
|
||||
- string
|
||||
- "null"
|
||||
@@ -84413,11 +84423,11 @@ components:
|
||||
additionalProperties: false
|
||||
- type: "null"
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`. Per-kind consecutive-failure counters, or null when the row
|
||||
has never written a bucket payload. Compare against the top-level `threshold` to know how close the provider
|
||||
is to the operator alert."
|
||||
description: "Deprecated, still served: read `health`. Per-kind consecutive-failure counters, or null when the row has
|
||||
never written a bucket payload. Compare against the top-level `threshold` to know how close the provider is
|
||||
to the operator alert."
|
||||
syncHealth:
|
||||
description: "Deprecated: removed in v1.44, read `health`."
|
||||
description: "Deprecated, still served: read `health`."
|
||||
deprecated: true
|
||||
$ref: "#/components/schemas/SyncHealth"
|
||||
metricFreshness:
|
||||
@@ -84454,7 +84464,7 @@ components:
|
||||
- type: "null"
|
||||
tokenExpired:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`. Null — not false — when there is no connection to judge."
|
||||
description: "Deprecated, still served: read `health`. Null — not false — when there is no connection to judge."
|
||||
type:
|
||||
- boolean
|
||||
- "null"
|
||||
@@ -84497,14 +84507,14 @@ components:
|
||||
- "null"
|
||||
hasActivityScope:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`. Withings only."
|
||||
description: "Deprecated, still served: read `health`. Withings only."
|
||||
type: boolean
|
||||
planUnavailable:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`. Withings only. The readings this connection's Withings API
|
||||
plan withholds (status 277 since the 2026-10-12 plan split), deduplicated and in display order. Empty when
|
||||
the plan refuses nothing. The sync skips these and keeps importing everything else; the list refreshes when
|
||||
the weekly re-probe asks again."
|
||||
description: "Deprecated, still served: read `health`. Withings only. The readings this connection's Withings API plan
|
||||
withholds (status 277 since the 2026-10-12 plan split), deduplicated and in display order. Empty when the
|
||||
plan refuses nothing. The sync skips these and keeps importing everything else; the list refreshes when the
|
||||
weekly re-probe asks again."
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
@@ -84533,7 +84543,7 @@ components:
|
||||
- weight
|
||||
needsReauth:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`. Google Health only."
|
||||
description: "Deprecated, still served: read `health`. Google Health only."
|
||||
type: boolean
|
||||
available:
|
||||
description: Oura / Polar / Strava only.
|
||||
@@ -84543,7 +84553,7 @@ components:
|
||||
type: boolean
|
||||
scopeReconnect:
|
||||
deprecated: true
|
||||
description: "Deprecated: removed in v1.44, read `health`. Oura only. Collections the stored grant does not cover: Oura
|
||||
description: "Deprecated, still served: read `health`. Oura only. Collections the stored grant does not cover: Oura
|
||||
refused them for a missing scope (named in its 401, or inferred when sibling collections succeeded with the
|
||||
same token), or the scopes Oura reported at connect leave theirs out. Not a failure and not counted toward
|
||||
the reconnect threshold; one reconnect asks for the missing scopes. Empty when the grant covers everything
|
||||
@@ -84599,7 +84609,7 @@ components:
|
||||
description: The unified connection state, null when the provider is not connected (and for Fitbit once
|
||||
`retirement.ended`). It supersedes `state`, `lastError`, `consecutiveFailuresByKind`, `syncHealth`,
|
||||
`tokenExpired`, `needsReauth`, `hasActivityScope`, `planUnavailable` and `scopeReconnect` on this entry;
|
||||
those nine are deprecated and removed in v1.44.
|
||||
those nine are deprecated and still served for clients that predate `health`.
|
||||
required:
|
||||
- integration
|
||||
- state
|
||||
@@ -88024,6 +88034,7 @@ components:
|
||||
- SECURITY_ALERT
|
||||
- DAILY_BRIEFING
|
||||
- SHARED_RECORD_INTAKE
|
||||
- BACKUP_HEALTH
|
||||
description: Notification category a per-channel preference can toggle.
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
@@ -88045,6 +88056,7 @@ components:
|
||||
- SECURITY_ALERT
|
||||
- DAILY_BRIEFING
|
||||
- SHARED_RECORD_INTAKE
|
||||
- BACKUP_HEALTH
|
||||
description: What each event does on a channel with no stored row, resolved for this account by the function delivery
|
||||
uses. Most are constant per category; the mood reminder follows the account's mood-reminder setting
|
||||
(`moodReminderEnabled` on `/api/user/profile`). Render a switch without a row from this map rather than from
|
||||
@@ -88115,6 +88127,7 @@ components:
|
||||
- SECURITY_ALERT
|
||||
- DAILY_BRIEFING
|
||||
- SHARED_RECORD_INTAKE
|
||||
- BACKUP_HEALTH
|
||||
description: Notification category a per-channel preference can toggle.
|
||||
enabled:
|
||||
type: boolean
|
||||
|
||||
@@ -295,7 +295,7 @@ Concrete consequences:
|
||||
RMSSD HRV has its own ladder, **WHOOP ≻ OURA ≻ POLAR ≻ APPLE_HEALTH**,
|
||||
separate from the SDNN one.
|
||||
|
||||
Override per-user via Settings → Sources (`/settings/sources`).
|
||||
Override per-user via Settings → Source priority (`/settings/sources`).
|
||||
|
||||
## Failure modes
|
||||
|
||||
|
||||
@@ -1,82 +1,7 @@
|
||||
# Dawarich integration
|
||||
|
||||
HealthLog can read daily movement context from a [Dawarich](https://dawarich.app)
|
||||
instance: how far you moved, how much of it by car or by train, how many places
|
||||
you visited, how long you were away from home, and whether a day was spent in
|
||||
another country. It is part of the Environment module and runs only while that
|
||||
module is on.
|
||||
|
||||
HealthLog never stores a coordinate, a track, a place name or a visit. It reads
|
||||
the day summaries and keeps one row of numbers per day. The place list is read
|
||||
live when you pick your home and is not saved.
|
||||
|
||||
## What is read
|
||||
|
||||
Once a night (03:20 Europe/Berlin) HealthLog fetches the last seven days, ending
|
||||
yesterday. Connecting fetches the last 30 days. Each request carries your API
|
||||
key as `Authorization: Bearer <key>`.
|
||||
|
||||
| Dawarich endpoint | What HealthLog keeps |
|
||||
| ----------------------- | -------------------------------------------------------------------------------------- |
|
||||
| `GET /api/v1/timeline` | total distance, distance by car (`driving`), distance by train, places visited |
|
||||
| `GET /api/v1/visits` | minutes spent at your home place, to work out time away from home |
|
||||
| `GET /api/v1/residency` | the country of each day, sealed at rest, and whether it differs from your home country |
|
||||
| `GET /api/v1/places` | nothing; the list is shown once so you can pick your home |
|
||||
| `GET /api/v1/users/me` | nothing; used to check the key |
|
||||
|
||||
Time away from home is the day's length minus the time a visit at your home
|
||||
place covers. Without a home place, or on a day without a single visit, it is
|
||||
left empty rather than shown as zero. Dawarich leaves out a mode that moved
|
||||
less than half a kilometre; HealthLog then reads that mode as zero for the day.
|
||||
|
||||
Residency needs API access on a hosted Dawarich plan; a self-hosted instance
|
||||
has it. Without it there are no travel days, and everything else still works.
|
||||
Dawarich keys residency by UTC date, so on a day that crosses a border late in
|
||||
the evening the country can be the next morning's.
|
||||
|
||||
## Connect
|
||||
|
||||
1. In Dawarich, open your account settings and copy the API key.
|
||||
2. In HealthLog, turn on the Environment module, then open
|
||||
**Settings → Integrations → Dawarich**.
|
||||
3. Enter the address of the instance and the key, and connect.
|
||||
4. Pick your home place from the list, and set your home country if you like.
|
||||
Without one, the country you spent most days in over the last year counts.
|
||||
|
||||
Run Dawarich and HealthLog in the same time zone. Dawarich groups the
|
||||
timeline by its own zone, HealthLog by yours; if they differ, a day can come
|
||||
back without its distances.
|
||||
|
||||
## A Dawarich instance on your own network
|
||||
|
||||
A public instance works without any server setting. An instance on a private
|
||||
network (a LAN address, a `.lan` name, a container on the same host) is refused
|
||||
unless the operator lists its exact origin:
|
||||
|
||||
```env
|
||||
DAWARICH_PRIVATE_ORIGINS="http://dawarich.lan:3000"
|
||||
```
|
||||
|
||||
The grammar is the one `NIGHTSCOUT_PRIVATE_ORIGINS` uses
|
||||
([Nightscout](./nightscout.md)): one `scheme://host[:port]` per entry, comma
|
||||
separated, no path, no wildcard and no range. Listing a host does not open its
|
||||
subdomains or another port. The unspecified address, link-local and the
|
||||
cloud-metadata range can never be listed. Restart HealthLog after changing it.
|
||||
The variable is on the `environment:` whitelist in `docker-compose.yml`.
|
||||
|
||||
## Where the values show up
|
||||
|
||||
- The day view has an **On the move** section with the day's figures.
|
||||
- Time away from home and the distance travelled join the correlation scan as
|
||||
two more factors.
|
||||
- Runs of at least two travel days that no trip covers yet are offered as
|
||||
trips under **Settings → Environment → Location periods**. Using one fills
|
||||
in the dates; you pick the place.
|
||||
- The Coach and the MCP `get_day` tool see the figures and whether it was a
|
||||
travel day, never the country.
|
||||
|
||||
## Disconnect
|
||||
|
||||
Disconnecting stops the nightly fetch and keeps the stored days. **Disconnect
|
||||
and delete days** removes them as well. The days are part of the backup; the
|
||||
address and key are not, so a restored account connects again.
|
||||
The Dawarich guide lives on the docs site:
|
||||
<https://docs.healthlog.dev/guides/connect-dawarich/>. It covers what is read
|
||||
and what is never stored, connecting an instance, allowing one on your own
|
||||
network (`DAWARICH_PRIVATE_ORIGINS`), where the values show up, and
|
||||
disconnecting.
|
||||
@@ -103,7 +103,7 @@ The Fitbit client id/secret are per-user (Settings), not env vars.
|
||||
|
||||
If Fitbit and another source (Withings, WHOOP, Apple Health) both supply the same
|
||||
vital — resting heart rate, blood oxygen, body temperature or respiratory rate —
|
||||
the source priority ladder in **Settings → Sources** decides which one wins
|
||||
the source priority ladder in **Settings → Source priority** decides which one wins
|
||||
per metric. FITBIT is ranked in every default metric ladder; override
|
||||
per metric on that page.
|
||||
|
||||
|
||||
@@ -305,7 +305,7 @@ triggers the verification + CASA requirements above.)
|
||||
|
||||
If Google Health and another source (Withings, WHOOP, Apple Health) both supply
|
||||
the same vital — resting heart rate, blood oxygen, respiratory rate — the source
|
||||
priority ladder in **Settings → Sources** decides which one wins
|
||||
priority ladder in **Settings → Source priority** decides which one wins
|
||||
per metric.
|
||||
|
||||
## Disconnect
|
||||
|
||||
@@ -1,74 +1,6 @@
|
||||
# Strava integration
|
||||
|
||||
HealthLog reads your Strava activities and folds them into the same workout
|
||||
history as every other source. Strava is a **workout source only** — its API is
|
||||
activity-centric and exposes no sleep, recovery, body-composition or glucose
|
||||
data, so connecting it adds runs, rides, swims and the rest to your workout
|
||||
surface and nothing else.
|
||||
|
||||
The OAuth app is brought **per user**: each self-hoster registers their own
|
||||
Strava API application and pastes the client id/secret into Settings. Strava
|
||||
caps every newly-created app at "single-player mode" (an athlete capacity of 1)
|
||||
until you request a capacity increase — perfectly fine for a personal
|
||||
self-host, and the reason a single shared app cannot serve many operators.
|
||||
|
||||
## What you get — and what you don't
|
||||
|
||||
**Covered** (one `Workout` row per activity): sport type, start and end time,
|
||||
moving duration, distance, elevation gain, average and maximum heart rate (when
|
||||
the activity recorded it), and calories. Power, cadence, the "trainer"/"commute"
|
||||
flags and the activity name are kept on the row's metadata for provenance but
|
||||
are **not** surfaced as training analytics — HealthLog treats Strava as another
|
||||
workout source, not a training-load platform.
|
||||
|
||||
**Not covered**: Strava exposes no daily metrics (steps, sleep, resting heart
|
||||
rate, weight, …). Those come from your wearable's own integration or from Apple
|
||||
Health / Google Health Connect.
|
||||
|
||||
## Duplicate activities are collapsed automatically
|
||||
|
||||
If you record a run on an Apple Watch **and** it also lands in Strava, the same
|
||||
run arrives twice — once via Apple Health, once via Strava. HealthLog keeps one
|
||||
canonical row per logical workout using your **source-priority ladder**: by
|
||||
default the device-native capture (Apple Watch, WHOOP, Withings) wins over the
|
||||
Strava copy, because a Strava activity is often a re-upload with lower-fidelity
|
||||
or missing heart-rate data. A run you record **only** on Strava (phone GPS)
|
||||
survives untouched. You can promote Strava above the wearables in
|
||||
**Settings → Sources** if you prefer the Strava copy.
|
||||
|
||||
## 1. Register a Strava API application
|
||||
|
||||
1. Sign in at <https://www.strava.com/settings/api>.
|
||||
2. Create an application. For **Authorization Callback Domain** enter the host
|
||||
of your HealthLog instance (the domain only, no scheme or path).
|
||||
3. Copy the **Client ID** and **Client Secret**.
|
||||
|
||||
## 2. Paste the credentials into HealthLog
|
||||
|
||||
1. Open **Settings → Integrations** and find the **Strava** card.
|
||||
2. Paste the Client ID and Client Secret into the **API Credentials** fields and
|
||||
save. (An operator can instead configure a shared app via the
|
||||
`STRAVA_CLIENT_ID` / `STRAVA_CLIENT_SECRET` environment variables; per-user
|
||||
credentials always take precedence.)
|
||||
3. Click **Connect Strava** and approve the access request. HealthLog requests
|
||||
the `activity:read_all` scope so it can read every activity, including those
|
||||
you marked "only me".
|
||||
|
||||
## 3. First sync
|
||||
|
||||
The first history import runs in the background shortly after you connect;
|
||||
afterwards an hourly poll pulls new activities. Strava rotates its refresh token
|
||||
on every refresh, and HealthLog persists the rotated token each time, so the
|
||||
connection stays healthy without you re-authorising.
|
||||
|
||||
## Disconnecting
|
||||
|
||||
Disconnecting revokes the grant at Strava and clears the stored tokens.
|
||||
Activities already imported stay in your history; reconnecting resumes the sync.
|
||||
|
||||
## Related
|
||||
|
||||
- Wearable that syncs to Strava but not directly to HealthLog? See the device's
|
||||
own page, or ingest via [Apple Health](./apple-health.md) /
|
||||
[Google Health Connect](./google-health.md).
|
||||
- **Garmin owners:** Garmin has no direct connector — see [garmin.md](./garmin.md).
|
||||
The Strava guide lives on the docs site:
|
||||
<https://docs.healthlog.dev/guides/connect-strava/>. It covers what Strava
|
||||
adds (workouts only), how duplicate activities from a wearable are collapsed,
|
||||
registering the Strava API application, the first sync and disconnecting.
|
||||
@@ -88,7 +88,7 @@ or right away after reconnecting Withings once.
|
||||
needed — `user.metrics` covers weight / BP / heart-list / sleep /
|
||||
SpO₂ / body comp / temperature / VO₂ max; `user.activity` is the
|
||||
workout-equivalent stream for steps / energy / distance / floors.
|
||||
See `WITHINGS_OAUTH_SCOPE` in `src/lib/withings/client.ts`.
|
||||
See `WITHINGS_OAUTH_SCOPE` in `src/lib/withings/oauth.ts`.
|
||||
4. Save the application. Withings will issue a **Client ID** and a
|
||||
**Client Secret** — keep the secret out of any chat log; it grants
|
||||
read access to every linked account's full health history.
|
||||
@@ -145,7 +145,7 @@ callback. The portal only owns the OAuth callback URL.
|
||||
## 4. Link from the Settings page
|
||||
|
||||
With the credentials saved in Settings, open
|
||||
`/settings/integrations/withings` and click **Connect Withings**. The flow:
|
||||
`/settings/integrations#withings` and click **Connect Withings**. The flow:
|
||||
|
||||
1. The Connect button redirects to Withings' `oauth2/authorize`.
|
||||
2. The user signs in on Withings and grants the requested scopes.
|
||||
@@ -181,7 +181,7 @@ import:
|
||||
when Apple Health is also linked, so the Apple Health stream wins
|
||||
for the cumulative axis. The Withings rows stay in the DB.
|
||||
|
||||
Override per-user via Settings → Sources (`/settings/sources`).
|
||||
Override per-user via Settings → Source priority (`/settings/sources`).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -199,7 +199,7 @@ Override per-user via Settings → Sources (`/settings/sources`).
|
||||
### The webhook stops firing
|
||||
|
||||
Webhook subscriptions can lapse after a token revoke or a portal-
|
||||
side cleanup. Re-subscribe by opening `/settings/integrations/withings`
|
||||
side cleanup. Re-subscribe by opening `/settings/integrations#withings`
|
||||
and clicking **Disconnect** then **Connect Withings** again — the
|
||||
callback exchange re-runs `Notify.subscribe()` with a fresh callback
|
||||
URL.
|
||||
|
||||
@@ -112,6 +112,11 @@ Objects are named `YYYY-MM-DD/user-<id>.json.enc`: the record as JSON,
|
||||
gzipped and sealed with AES-256-GCM under `BACKUP_ENCRYPTION_KEY`, with the
|
||||
object's own name as associated data and the key's id in the header.
|
||||
|
||||
The nightly database dump goes into the same bucket, as
|
||||
`database/healthlog-<time>-daily.dump.enc` with its manifest beside it, sealed
|
||||
the same way. It is the copy that rebuilds a lost server
|
||||
([disaster-recovery.md](disaster-recovery.md)).
|
||||
|
||||
**Permissions.** The worker needs `PutObject`, `GetObject`, `ListBucket`,
|
||||
`DeleteObject` and `AbortMultipartUpload` (R2's Object Read & Write token covers
|
||||
them). It deletes only two things: the objects of an account that was erased or
|
||||
@@ -121,8 +126,8 @@ Backups → Off-host. The admin wipe of every account deletes nothing in the
|
||||
bucket.
|
||||
|
||||
**Retention.** The app never expires objects by age. Set a lifecycle rule on
|
||||
the bucket (for example, expire all objects after 30 days); Admin → Backups
|
||||
says when there is none.
|
||||
the bucket that covers both the account objects and `database/` (for example,
|
||||
expire all objects after 30 days); Admin → Backups says when there is none.
|
||||
|
||||
**Checking it works.** Admin → Backups → Off-host shows, per account, when the
|
||||
last object landed: fresh, due (one night missed), stale (two missed), never,
|
||||
|
||||
+59
-36
@@ -13,11 +13,13 @@ Migrations land **before** the new app container takes traffic. The
|
||||
canonical command sequence on a single-instance host:
|
||||
|
||||
```sh
|
||||
# 0. Dump the database first. A migration is not undone by going back to
|
||||
# the previous image; the dump is the way back. See "Updates" in
|
||||
# docs/self-hosting/getting-started.md.
|
||||
docker compose exec -T db pg_dump -U healthlog healthlog \
|
||||
| gzip > healthlog-before-update-$(date +%F).sql.gz
|
||||
# 0. Nothing to do by hand for the way back: when the new image brings
|
||||
# migrations, its entrypoint first writes
|
||||
# healthlog-<time>-pre-migration.dump into DB_BACKUP_DIR (the `backups`
|
||||
# volume) and keeps the last two. A migration is not undone by going back
|
||||
# to the previous image; that dump is the way back
|
||||
# (docs/ops/disaster-recovery.md, "Going back to before a migration").
|
||||
# Copy it off the host if the host itself is what you are worried about.
|
||||
|
||||
# 1. Pull the new image. If you pin a version with HEALTHLOG_IMAGE_REF,
|
||||
# set it to the new one first.
|
||||
@@ -33,6 +35,14 @@ docker compose run --rm app sh -c 'exit 0'
|
||||
docker compose up -d app
|
||||
```
|
||||
|
||||
Step 2 takes the pre-migration dump too, in the one-off container, before it
|
||||
migrates. When the backups directory is writable and that dump fails (a full
|
||||
disk, most often), the entrypoint stops before migrating and says so; free
|
||||
the space and run the step again, or set `DB_BACKUP_BEFORE_MIGRATE=false` for
|
||||
one start without the dump. Without a writable directory (an install that
|
||||
runs the image without the `backups` volume) the log carries a warning, no
|
||||
dump is taken, and the migrations run.
|
||||
|
||||
Use `docker compose run` rather than a plain `docker run` for step 2. A
|
||||
plain `docker run` starts outside the stack's network, where `db` does not
|
||||
resolve, and the entrypoint gives up after thirty seconds with "Database
|
||||
@@ -230,12 +240,15 @@ roll back:
|
||||
dev --create-only --name vX_Y_Z_rollback_<col>`), edit the SQL,
|
||||
commit, and re-deploy. Never `migrate resolve --rolled-back` against
|
||||
production unless you've taken a fresh `pg_dump` first.
|
||||
4. Rolling back from v1.40.2 or later to v1.40.1 or earlier: an account
|
||||
that saved the Coach lookback "Last year" reads its whole Coach
|
||||
preference blob as defaults on the older version, metric exclusions
|
||||
included, until the setting is saved again. Ask affected accounts to
|
||||
pick another lookback before the rollback, or re-save their exclusions
|
||||
after it.
|
||||
|
||||
When the data itself has to go back to before the update, restore the
|
||||
pre-migration dump with the previous image; the steps are in
|
||||
[`disaster-recovery.md`](disaster-recovery.md#going-back-to-before-a-migration). 4. Rolling back from v1.40.2 or later to v1.40.1 or earlier: an account
|
||||
that saved the Coach lookback "Last year" reads its whole Coach
|
||||
preference blob as defaults on the older version, metric exclusions
|
||||
included, until the setting is saved again. Ask affected accounts to
|
||||
pick another lookback before the rollback, or re-save their exclusions
|
||||
after it.
|
||||
|
||||
## Catalog templates
|
||||
|
||||
@@ -270,10 +283,13 @@ always confirm the served build after a deploy:
|
||||
```sh
|
||||
# Asserts prod + demo serve 1.9.0, prints each target's buildSha,
|
||||
# exits non-zero on a mismatch or timeout after a bounded retry loop.
|
||||
pnpm dlx tsx scripts/assert-deploy.ts 1.9.0
|
||||
HEALTHLOG_PROD_URL=https://healthlog.example.com \
|
||||
pnpm dlx tsx scripts/assert-deploy.ts 1.9.0
|
||||
```
|
||||
|
||||
Pass `--only=prod` / `--only=demo` to check a single target, or
|
||||
`HEALTHLOG_PROD_URL` is the base URL of your instance; it has no default and
|
||||
is required unless you pass `--only=demo`. Pass `--only=prod` /
|
||||
`--only=demo` to check a single target, or
|
||||
`--attempts=N --interval=ms` to widen the retry window while a slow
|
||||
pull settles.
|
||||
|
||||
@@ -506,29 +522,36 @@ low-friction default.
|
||||
|
||||
## Deploy-status webhook: replay protection
|
||||
|
||||
`POST /api/internal/deploy-webhook` authenticates via the static
|
||||
`X-Deploy-Webhook-Secret` header. Since v1.16.4 it additionally
|
||||
enforces a freshness window: a request carrying an
|
||||
`X-Deploy-Webhook-Timestamp` header (unix seconds, unix milliseconds,
|
||||
or ISO-8601) is rejected with `401 {"status":"stale"}` when the
|
||||
timestamp lies more than **5 minutes** from server time — so a captured
|
||||
request cannot be replayed later to re-page admins or forge deploy
|
||||
outcomes in the audit log.
|
||||
`POST /api/internal/deploy-webhook` accepts two kinds of request.
|
||||
|
||||
Two modes:
|
||||
- **Secret header.** `X-Deploy-Webhook-Secret` carries the shared secret.
|
||||
This is what Coolify's stock notification sender can do. It does not
|
||||
protect against a replay: anyone who captures one request holds the
|
||||
secret and can send any request they like. An `X-Deploy-Webhook-Timestamp`
|
||||
beside it is still checked (a stale or unparseable one is refused with
|
||||
`401 {"status":"stale"}`), but it is covered by nothing, so treat it as a
|
||||
staleness hint. Versions before 1.46 described it as replay protection.
|
||||
- **Signature.** `X-Deploy-Webhook-Signature: sha256=<hex>` is the
|
||||
HMAC-SHA256, under `DEPLOY_WEBHOOK_SECRET`, of the
|
||||
`X-Deploy-Webhook-Timestamp` value, a dot, and the exact request body. The
|
||||
timestamp (unix seconds, unix milliseconds or ISO-8601) must lie within
|
||||
**5 minutes** of server time. A signed request sends no secret, and a
|
||||
captured one is useless once the window has passed.
|
||||
|
||||
- **Default (tolerant).** Requests _without_ the header pass — Coolify's
|
||||
stock notification sender does not attach one. A header that is
|
||||
present but stale or unparseable is always rejected.
|
||||
- **Strict.** Set `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true` in the app
|
||||
environment to make the header mandatory, closing the replay window
|
||||
completely. Use this when the sender (or a reverse proxy in front of
|
||||
the app) can attach the timestamp, e.g.:
|
||||
Set `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true` to accept signed requests only.
|
||||
Use it when the sender, or a small relay in front of the app, can sign:
|
||||
|
||||
```sh
|
||||
curl -X POST https://<host>/api/internal/deploy-webhook \
|
||||
-H "X-Deploy-Webhook-Secret: $SECRET" \
|
||||
-H "X-Deploy-Webhook-Timestamp: $(date +%s)" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"status":"success"}'
|
||||
```
|
||||
```sh
|
||||
TS=$(date +%s)
|
||||
BODY='{"status":"success"}'
|
||||
SIG=$(printf '%s.%s' "$TS" "$BODY" \
|
||||
| openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.*= //')
|
||||
curl -X POST https://<host>/api/internal/deploy-webhook \
|
||||
-H "X-Deploy-Webhook-Timestamp: $TS" \
|
||||
-H "X-Deploy-Webhook-Signature: sha256=$SIG" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$BODY"
|
||||
```
|
||||
|
||||
Before 1.46 the strict setting accepted the secret header plus a fresh
|
||||
timestamp. Such a sender now gets 401 in strict mode and has to sign.
|
||||
@@ -0,0 +1,185 @@
|
||||
# Disaster recovery
|
||||
|
||||
The copy that rebuilds a lost server is the database dump. This page says
|
||||
what is dumped and where it goes, which keys a restore needs, how to restore
|
||||
onto a new server, and how the procedure is tested.
|
||||
|
||||
## What is backed up, and where
|
||||
|
||||
| Copy | When | Where | Kept |
|
||||
| ----------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------- | ------------------------------- |
|
||||
| Nightly dump | 01:30 Europe/Berlin, the zone every schedule runs in (`DB_BACKUP_SCHEDULE`) | `DB_BACKUP_DIR`, the `backups` volume of the bundled compose file | the newest 7 (`DB_BACKUP_KEEP`) |
|
||||
| Pre-migration dump | on start, when the new image brings migrations, before them | the same directory | the newest 2 |
|
||||
| Off-host copy of a dump | right after each nightly dump, if the bucket is configured | your bucket, under `database/`, sealed with `BACKUP_ENCRYPTION_KEY` | your bucket's lifecycle rule |
|
||||
|
||||
A dump is `pg_dump --format=custom` of the whole database: every account,
|
||||
sign-ins, passkeys, sharing grants and managed profiles, documents, the job
|
||||
queue and the instance settings. It is two files:
|
||||
|
||||
- `healthlog-<UTC time>-<daily|pre-migration>.dump`, the archive;
|
||||
- `healthlog-<UTC time>-<daily|pre-migration>.manifest.json` beside it: the
|
||||
app version and build, the newest applied migration, the Postgres version,
|
||||
the archive's size and SHA-256, the row count of every table, and the
|
||||
fingerprints (never the values) of the keys the database needs.
|
||||
|
||||
A dump counts as good only when both files exist under their final names. The
|
||||
archive is written as `.partial`, read back with `pg_restore --list`, and
|
||||
renamed after its manifest; a run that fails leaves nothing that looks good.
|
||||
The admin backups page shows the newest good dump, where it is, and the
|
||||
newest failed run. The start log says on every boot where dumps go, or that
|
||||
none can be kept.
|
||||
|
||||
Local dumps are not encrypted. They sit beside the database volume, in the
|
||||
same trust zone: whoever can read one volume can read the other. The columns
|
||||
HealthLog encrypts stay encrypted inside the dump, under `ENCRYPTION_KEYS`.
|
||||
Keep a copy of the dumps off the host; the off-host bucket does that for you,
|
||||
and it seals each copy with `BACKUP_ENCRYPTION_KEY` in the envelope the
|
||||
per-account copies use.
|
||||
|
||||
A dump holds every account that existed when it was taken, including one
|
||||
deleted since. Deleted accounts leave the dumps as they age out: after
|
||||
`DB_BACKUP_KEEP` nights on the server, and after your lifecycle rule in the
|
||||
bucket. Give that rule a prefix that covers `database/` too; the admin
|
||||
backups page reads the bucket's rules and says when none does. This is the
|
||||
retention period to state where you tell people that deleting their account
|
||||
deletes their data.
|
||||
|
||||
Without a writable `DB_BACKUP_DIR` (an install that runs the image without the
|
||||
`backups` volume), the nightly dump is uploaded off-host and nothing is kept
|
||||
on the server, and no dump is taken before migrations; with no bucket either,
|
||||
no dump is taken at all. The start log and the admin page say so.
|
||||
|
||||
The per-account copies (the weekly in-database copy, the nightly per-account
|
||||
objects) roll one account back on the same server. They cannot rebuild a
|
||||
server; see [`backup-restore.md`](backup-restore.md).
|
||||
|
||||
## The keys a restore needs
|
||||
|
||||
| Key | Without it |
|
||||
| ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ENCRYPTION_KEYS` + `ENCRYPTION_ACTIVE_KEY_ID`, or `ENCRYPTION_KEY` | Nothing encrypted can be read. The restore refuses when the active key is missing or different. Keep every retired id still in the map. |
|
||||
| `API_TOKEN_HMAC_KEY` | No data is lost, but every session, app token and share link stops working; people sign in again and re-create tokens. |
|
||||
| `BACKUP_ENCRYPTION_KEY` (and `BACKUP_ENCRYPTION_PREVIOUS_KEYS`) | Off-host copies do not open. |
|
||||
| `POSTGRES_PASSWORD` | Not needed: a new server can use a new one. |
|
||||
|
||||
The restore compares the keys in its environment with the fingerprints in the
|
||||
manifest before it writes anything, and names each key that is missing or
|
||||
different.
|
||||
|
||||
## Restore onto a new server
|
||||
|
||||
1. Install Docker and take the `docker-compose.yml` of the version that wrote
|
||||
the dump or a newer one. The manifest's `appVersion` names it; to run that
|
||||
exact version set `HEALTHLOG_IMAGE_REF=:v<version>` in `.env`.
|
||||
2. Write `.env` with the keys from your records (see above) and a new
|
||||
`POSTGRES_PASSWORD`.
|
||||
3. Do not run `docker compose up` yet. The app would create an empty schema,
|
||||
and the restore only writes into a database with no tables. Start only the
|
||||
database:
|
||||
|
||||
```bash
|
||||
docker compose up -d db
|
||||
```
|
||||
|
||||
4. Restore. When the host is lost, its local dumps went with it, and the
|
||||
copy is the one in your bucket: put the `BACKUP_S3_*` variables and
|
||||
`BACKUP_ENCRYPTION_KEY` (with any `BACKUP_ENCRYPTION_PREVIOUS_KEYS`) in
|
||||
`.env` as on the old server, then
|
||||
|
||||
```bash
|
||||
docker compose run --rm db-restore list --from-bucket
|
||||
docker compose run --rm db-restore latest --from-bucket
|
||||
```
|
||||
|
||||
`latest --from-bucket` downloads the newest `database/…dump.enc` whose
|
||||
manifest is in the bucket too, opens both with `BACKUP_ENCRYPTION_KEY`
|
||||
and restores. Name a dump instead of `latest` to take an older one.
|
||||
|
||||
With the files in hand instead (copied off the host, or downloaded from
|
||||
the bucket with your own tools: both `<name>.dump.enc` and
|
||||
`<name>.manifest.json.enc`, or a `.dump` with its `.manifest.json`), put
|
||||
them in a directory such as `./restore`, make them readable for the
|
||||
container (`chmod a+r restore/*`) and name the dump:
|
||||
|
||||
```bash
|
||||
docker compose run --rm -v "$PWD/restore:/restore:ro" db-restore list /restore
|
||||
docker compose run --rm -v "$PWD/restore:/restore:ro" db-restore \
|
||||
/restore/healthlog-20261024T233000Z-daily.dump
|
||||
```
|
||||
|
||||
Either way it checks the archive against the manifest's SHA-256, the keys
|
||||
against the manifest's fingerprints, and that the database is empty. Then
|
||||
it restores in one transaction, gathers planner statistics and counts
|
||||
every table against the manifest. A count that differs empties the
|
||||
database again and fails. Wait for the line that starts with `Restored`.
|
||||
|
||||
5. Start the stack and check it:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
curl -s http://localhost:3000/api/health
|
||||
```
|
||||
|
||||
Sign in. The app's own key check runs at start and refuses to serve on a
|
||||
key that does not open the data.
|
||||
|
||||
When the dumps are still in the server's `backups` volume (the database
|
||||
volume was lost, the backups volume was not), step 4 is
|
||||
`docker compose run --rm db-restore latest`.
|
||||
|
||||
`--ignore-key-mismatch` restores although the active encryption key differs.
|
||||
Use it only when that key is lost for good: everything encrypted stays
|
||||
unreadable.
|
||||
|
||||
## Going back to before a migration
|
||||
|
||||
The entrypoint dumps the database before it applies migrations. To return to
|
||||
that state (the previous image, the data as it was before the update):
|
||||
|
||||
```bash
|
||||
docker compose stop app
|
||||
# Keep the current state too, in case it is wanted later.
|
||||
docker compose exec -T db pg_dump -U healthlog -Fc healthlog > healthlog-current.dump
|
||||
docker compose exec db dropdb -U healthlog healthlog
|
||||
docker compose exec db createdb -U healthlog healthlog
|
||||
docker compose run --rm db-restore list
|
||||
docker compose run --rm db-restore healthlog-<time>-pre-migration.dump
|
||||
# Set HEALTHLOG_IMAGE_REF in .env to the version you ran before the update.
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The dump before a migration is taken while the new image starts. If it fails
|
||||
on a writable directory (a full disk, most often), the start stops before
|
||||
anything is migrated and the log names the switch: `DB_BACKUP_BEFORE_MIGRATE=false`
|
||||
starts without it, once.
|
||||
|
||||
## How this is tested
|
||||
|
||||
`scripts/compose-dr-drill.sh` plays this page against real containers: it
|
||||
builds the image, seeds several accounts (the demo account with documents
|
||||
and encrypted notes, a Guardian with a managed profile), lets the scheduled
|
||||
job take the dump and upload it to an S3-compatible bucket, destroys every
|
||||
volume of the stack (the local dumps included), and restores on a new stack
|
||||
given only the bucket and the keys
|
||||
(`docker compose run --rm db-restore latest --from-bucket`). A second run
|
||||
restores from the dump files instead (`DRILL_SOURCE=local`). Both then count
|
||||
every table against the manifest and boot the app: health, sign-in, an
|
||||
encrypted note read back, a document opened, the Guardian still seeing the
|
||||
profile. It runs in CI nightly and on every change to the
|
||||
compose file, the image or the dump and restore code
|
||||
(`.github/workflows/compose-dr-drill.yml`), and it is checked to fail on a
|
||||
wrong encryption key, a wrong off-host key, a dump that lost a table's rows
|
||||
and a target that is not empty.
|
||||
|
||||
Test your own copies too: once, and after any change to how the server is
|
||||
run, restore last night's dump on a spare machine with the steps above.
|
||||
|
||||
## Settings
|
||||
|
||||
| Variable | Default | What it does |
|
||||
| -------------------------- | ---------------------------- | -------------------------------------------------------------------------------- |
|
||||
| `DB_BACKUP_ENABLED` | on | `false` stops the nightly dump. |
|
||||
| `DB_BACKUP_DIR` | `/var/lib/healthlog/backups` | Where dumps are written and read. Change it only together with the volume mount. |
|
||||
| `DB_BACKUP_SCHEDULE` | `30 1 * * *` | Five-field cron, Europe/Berlin time like every HealthLog schedule. |
|
||||
| `DB_BACKUP_KEEP` | `7` | Nightly dumps kept (at least 1). Two pre-migration dumps are kept besides. |
|
||||
| `DB_BACKUP_BEFORE_MIGRATE` | on | `false` starts an update without the dump before its migrations. |
|
||||
@@ -96,8 +96,9 @@ maintenance refuses to start while one is.
|
||||
|
||||
Have at least twice the largest size free on the database volume.
|
||||
|
||||
- **Take a backup** (Settings, Data, Backups), as before any operation on the
|
||||
whole table.
|
||||
- **Take a backup**: a database dump, as in
|
||||
[Updates](../self-hosting/getting-started.md#updates), as before any
|
||||
operation on the whole table.
|
||||
|
||||
## Starting it
|
||||
|
||||
|
||||
@@ -58,9 +58,13 @@ Anything written after the move lives only in the Postgres 18 volume.
|
||||
|
||||
## Afterwards
|
||||
|
||||
Keep the old volume until the new database has run for a while. When you are
|
||||
sure, remove it (`docker volume ls` shows its full name, prefixed with the
|
||||
compose project):
|
||||
Keep the old volume until the new database has run for a while, then remove
|
||||
it. It is a complete copy of the database as it stood before the move, and
|
||||
nothing that happens afterwards reaches it: an account deleted, a document
|
||||
purged from the trash, or a key rotated after a leak is still there in the
|
||||
old volume, and so is everything else for anyone who can read the host's
|
||||
Docker volumes. Removing it is part of the move, not optional tidying.
|
||||
`docker volume ls` shows its full name, prefixed with the compose project:
|
||||
|
||||
```bash
|
||||
docker volume rm <project>_pgdata
|
||||
|
||||
@@ -214,11 +214,15 @@ as something to log in as.
|
||||
or delete the profile under a fresh second factor. Deleting the profile
|
||||
deletes everything recorded for it.
|
||||
- A guardian may keep that record's settings: modules, units, thresholds,
|
||||
its language and timezone, and its notification preferences. Connected
|
||||
services are visible as a status only. Connect, callback, sync,
|
||||
credential, disconnect, resume and test are not guardian surfaces in
|
||||
this release, and their deep links land on the shared-record unavailable
|
||||
page rather than on a raw refusal.
|
||||
its language and timezone, and its notification preferences. A guardian
|
||||
can also connect and disconnect the record's Withings, WHOOP, Oura, Polar,
|
||||
Strava and Google Health from its integrations page; the data lands in the
|
||||
profile's record, and a profile without its own provider app uses the
|
||||
guardian's. Fitbit (no new connections), Nightscout and Dawarich cannot be
|
||||
connected for a profile. Sync, the connection test and provider
|
||||
credentials are not guardian surfaces: once connected, the record syncs on
|
||||
its own schedule, and their deep links land on the shared-record
|
||||
unavailable page rather than on a raw refusal.
|
||||
- **Its reminders go to its guardians.** A record with no login has no
|
||||
channels of its own, so medication reminders, measurement reminders,
|
||||
safety-floor alerts and low-stock alerts fan out to every active
|
||||
|
||||
@@ -86,10 +86,11 @@ backfill button in settings fetches both for the range it is given.
|
||||
Since v1.42 the home, each dated location period and each stored day keep
|
||||
their coarse location sealed with the instance's encryption key (one value
|
||||
per row, `{ lat, lon, label }`, under the label
|
||||
`healthlog/environment-location/v1`). The boot-time encryption backfill seals
|
||||
rows written by older releases and clears their readable columns; the
|
||||
readable columns are dropped in a later release once the backfill reports
|
||||
zero rows everywhere. Key rotation (`scripts/rotate-encryption-key.ts`, or the
|
||||
`healthlog/environment-location/v1`). The boot-time encryption backfill sealed
|
||||
rows written by older releases and cleared their readable columns, and
|
||||
migration `0409` (v1.43) dropped those columns; it refuses to run while a row
|
||||
still holds a readable location
|
||||
([migration-0409-plaintext-location.md](../ops/migration-0409-plaintext-location.md)). Key rotation (`scripts/rotate-encryption-key.ts`, or the
|
||||
in-app rotation) covers all three.
|
||||
|
||||
What the person sees is unchanged: the settings page and
|
||||
|
||||
@@ -9,8 +9,7 @@ already installed, longer if a TLS-fronted public hostname is in scope
|
||||
|
||||
- **Docker 24+ with Compose v2.** The bundled `docker-compose.yml`
|
||||
pulls a multi-arch image from GitHub Container Registry (linux/amd64
|
||||
- linux/arm64), so a Raspberry Pi 5 or an x86 VPS both work
|
||||
unchanged.
|
||||
and linux/arm64), so a Raspberry Pi 5 or an x86 VPS both work unchanged.
|
||||
- **3 GB RAM (4 GB recommended), 10 GB free disk.** The Postgres data
|
||||
volume grows with measurement history; the Apple Health import worker
|
||||
can briefly hold a 1.5 GB upload in `/tmp` while it parses. The app
|
||||
@@ -23,11 +22,12 @@ already installed, longer if a TLS-fronted public hostname is in scope
|
||||
below the supported floor. On a busy instance, raise it.
|
||||
- **Outbound HTTPS** to `ghcr.io` (image pulls) plus whichever
|
||||
integration endpoints you plan to enable (Withings, OpenAI, etc.).
|
||||
The opt-in environmental-context module additionally reaches
|
||||
`archive-api.open-meteo.com` and `geocoding-api.open-meteo.com` for
|
||||
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.
|
||||
The environmental-context module additionally reaches
|
||||
`archive-api.open-meteo.com`, `air-quality-api.open-meteo.com` and
|
||||
`geocoding-api.open-meteo.com` for weather, air quality and the city
|
||||
search. It is on by default, but nothing leaves the host until someone
|
||||
sets 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
|
||||
@@ -270,14 +270,35 @@ ps` — the `app` healthcheck polls `/api/health` every 30 seconds
|
||||
`pull_policy: always` on the `app` service, and verify the running
|
||||
build with `GET /api/version` (it returns `version` + `buildSha`).
|
||||
|
||||
## Back up your encryption key
|
||||
## Save the recovery kit
|
||||
|
||||
`ENCRYPTION_KEY` (or the `ENCRYPTION_KEYS` map) is not stored in the database.
|
||||
Without it, the encrypted parts of the database and every in-database backup
|
||||
cannot be read. Keep a copy outside the server, then confirm it under Admin,
|
||||
Encryption: the step shows the key id and a fingerprint (the first 12 hex
|
||||
characters of SHA-256 over the key) and can check a pasted copy without
|
||||
storing it. Admins see a dashboard reminder until the active key is confirmed.
|
||||
No key HealthLog encrypts with is stored in the database. Without them the
|
||||
encrypted parts of the database, a database dump and every backup copy cannot
|
||||
be read. The recovery kit under Admin, Encryption lists every key a restore of
|
||||
this server needs:
|
||||
|
||||
- `ENCRYPTION_KEY` (or each id in the `ENCRYPTION_KEYS` map), including a
|
||||
retired id for as long as a stored or off-host backup copy still needs it;
|
||||
- `BACKUP_ENCRYPTION_KEY` and any `BACKUP_ENCRYPTION_PREVIOUS_KEYS`, when
|
||||
off-host copies are set up;
|
||||
- `API_TOKEN_HMAC_KEY`, without which every session, token and share link ends
|
||||
(no data is lost).
|
||||
|
||||
Each key is shown by id and a fingerprint (the first 12 hex characters of
|
||||
SHA-256 over the key), never the key itself, and **Check my copy** compares a
|
||||
pasted copy without storing it. Keep a copy of each outside the server, then
|
||||
confirm the kit. Admins see a dashboard reminder until it is confirmed, and the
|
||||
kit is due again whenever a key it needs changes: a rotation, a new backup
|
||||
key, a re-keyed install. The kit also links the
|
||||
[disaster recovery guide](../ops/disaster-recovery.md).
|
||||
|
||||
The **Recovery readiness** card on the admin overview and the Backups page
|
||||
judges every backup signal in one place: the database dump, the weekly account
|
||||
copies, the off-host copies, the last restore drill, whether off-host copies
|
||||
are set up, and the recovery kit. An hourly check sends each admin a
|
||||
**Backup health** notification when a signal turns to "needs attention" or "at
|
||||
risk", once a day while it stays at risk, and once when it recovers. Route or
|
||||
switch it off per channel under Notifications.
|
||||
|
||||
| Platform | Where the key is set |
|
||||
| --------- | -------------------------------------------------------------------------------------------------- |
|
||||
@@ -288,12 +309,13 @@ storing it. Admins see a dashboard reminder until the active key is confirmed.
|
||||
| Portainer | the stack's Environment variables |
|
||||
|
||||
Set `HEALTHLOG_PLATFORM` (`compose`, `truenas`, `coolify`, `unraid`,
|
||||
`portainer`) to open the step on your platform's instructions.
|
||||
`portainer`) to open the kit on your platform's instructions.
|
||||
|
||||
To compute the fingerprint of your copy:
|
||||
To compute the fingerprint of your copy of a hex key, and of the token key:
|
||||
|
||||
```bash
|
||||
echo -n "<your key>" | xxd -r -p | sha256sum | cut -c1-12
|
||||
printf %s "<API_TOKEN_HMAC_KEY>" | sha256sum | cut -c1-12
|
||||
```
|
||||
|
||||
**HealthLog refuses to serve: encryption key does not match this database.**
|
||||
@@ -346,6 +368,15 @@ written under that key, so remove the setting once the key is confirmed.
|
||||
Your database is the only stateful piece — back it up, and back up the
|
||||
key that decrypts it.
|
||||
|
||||
**Database dumps the app takes itself.** Every night, and before an update
|
||||
applies migrations, the app writes a `pg_dump` of the whole database with a
|
||||
manifest into the `backups` volume and keeps the newest few. With the
|
||||
off-host bucket configured, each nightly dump is also uploaded, encrypted.
|
||||
`docker compose run --rm db-restore latest` restores one into an empty
|
||||
database. What is kept where, the keys a restore needs, and restoring onto a
|
||||
new server: [`docs/ops/disaster-recovery.md`](../ops/disaster-recovery.md).
|
||||
Keep a copy of those dumps off this host.
|
||||
|
||||
**Local snapshot (the common single-box case).** Dump the `db` service
|
||||
with `pg_dump`:
|
||||
|
||||
@@ -379,8 +410,11 @@ encryption key safely (keep the old key until zero rows remain on it) see
|
||||
A new image can bring database migrations, and `docker-entrypoint.sh`
|
||||
applies them on the first start of that image. Most are additive, but some
|
||||
rewrite data, and going back to the previous image does not undo them. The
|
||||
only way back from a migration that went wrong is the dump you took before
|
||||
it. So take one before every pull:
|
||||
way back from a migration that went wrong is a dump taken before it. The
|
||||
entrypoint takes one itself (`healthlog-<time>-pre-migration.dump` in the
|
||||
`backups` volume, see
|
||||
[`docs/ops/disaster-recovery.md`](../ops/disaster-recovery.md)); a dump of
|
||||
your own before every pull, kept off the host, is the second copy:
|
||||
|
||||
```bash
|
||||
# 1. Dump the database (same command as under "Backup and restore").
|
||||
@@ -405,14 +439,18 @@ the `image:` line, a new major (16 to 18) is a dump and restore, described in
|
||||
The start log says what is about to happen before the migrations run:
|
||||
|
||||
```text
|
||||
HealthLog: 2 database migrations pending. Back up the database before updating (pg_dump, see the Updates section of docs/self-hosting/getting-started.md); a migration is not undone by going back to the previous image.
|
||||
HealthLog: 2 database migrations pending. Unless DB_BACKUP_BEFORE_MIGRATE=false, a pg_dump of the database is written to DB_BACKUP_DIR before they run (docs/ops/disaster-recovery.md); a migration is not undone by going back to the previous image.
|
||||
HealthLog: taking a database dump before 2 pending migration(s) into /var/lib/healthlog/backups...
|
||||
HealthLog: pre-migration dump written: healthlog-20261025T081502Z-pre-migration.dump (212.4 MB, 231 tables, 38 s).
|
||||
HealthLog: Running database migrations...
|
||||
```
|
||||
|
||||
or `HealthLog: Database schema is up to date.` when the image brings none.
|
||||
The line is a notice and does not pause the start: the migrations run right
|
||||
after it. So the dump belongs before step 2, not after you see the line; by
|
||||
then the database is already being migrated.
|
||||
The migrations run right after the dump. A dump that fails on a writable
|
||||
directory stops the start before anything is migrated, and the log names the
|
||||
switch that skips it once (`DB_BACKUP_BEFORE_MIGRATE=false`). Your own dump
|
||||
belongs before step 2, not after you see these lines; by then the database is
|
||||
already being migrated.
|
||||
|
||||
If an update to v1.43 stops at migration `0409_drop_plaintext_location`,
|
||||
nothing was changed; the recovery is in
|
||||
|
||||
@@ -19,7 +19,7 @@ on top of it, see [building skills](../api/mcp-skills.md).
|
||||
|
||||
> **Read this before enabling writes.** A remote, write-capable connector
|
||||
> reached over OAuth is a powerful combination. Read the
|
||||
> [trust model](#trust-model-read-this-before-enabling-writes) at the end
|
||||
> [trust model](#trust-model--read-this-before-enabling-writes) at the end
|
||||
> of this page first.
|
||||
|
||||
## Prerequisites
|
||||
@@ -43,7 +43,7 @@ LAN-only origin works for Claude Desktop over stdio).
|
||||
The switch lives with every other module: **Settings → Modules**, the
|
||||
**MCP** row. Turn it on and the `/mcp` endpoint goes live for your account.
|
||||
|
||||
**Settings → MCP** is where you use it. The page has three parts:
|
||||
**Settings → MCP connector** is where you use it. The page has three parts:
|
||||
|
||||
- **State** — whether the module is on, with a link to its switch in
|
||||
Modules, and your endpoint URL (`<origin>/mcp`). Turning the module off
|
||||
@@ -55,7 +55,7 @@ The switch lives with every other module: **Settings → Modules**, the
|
||||
|
||||
## 2. Mint a connector token
|
||||
|
||||
Still in **Settings → MCP → Tokens**:
|
||||
Still in **Settings → MCP connector → Tokens**:
|
||||
|
||||
1. Choose the scope with the **write** toggle:
|
||||
- **off** → a read-only token (`health:read`). This is the default and
|
||||
@@ -106,7 +106,7 @@ refused (`403`) as a DNS-rebinding defence.
|
||||
client-id-metadata-document clients with dynamic registration as a
|
||||
fallback.
|
||||
3. Approve the consent screen. The connection then appears under
|
||||
**Settings → MCP → Connections**, where you can revoke it.
|
||||
**Settings → MCP connector → Connections**, where you can revoke it.
|
||||
|
||||
No token to paste — OAuth mints and rotates a short-lived `health:read`
|
||||
access token (60 minutes, with a refresh token) behind the scenes.
|
||||
|
||||
@@ -95,7 +95,7 @@ different things depending on which button you press.
|
||||
two actions below, the row stays. A start that was folded into a later
|
||||
cycle stays for as long as that cycle does, because deleting the later
|
||||
cycle gives it back.
|
||||
- **Settings → Delete All Data** removes the rows outright, tombstones
|
||||
- **Settings → Advanced → Delete all data** removes the rows outright, tombstones
|
||||
included. Everything the account owns goes except what you sign in
|
||||
with; the confirmation dialog lists it.
|
||||
- **Deleting the account** removes the account row, and the database
|
||||
|
||||
@@ -40,22 +40,35 @@ 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` (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` |
|
||||
| Topology | `TRUST_PROXY_HOPS` |
|
||||
| --------------------------------------------------------- | ---------------------------------- |
|
||||
| App is internet-facing with no proxy | `0` (the connection's own address) |
|
||||
| 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.
|
||||
|
||||
Forwarding headers are believed only from a connection that could come
|
||||
from a proxy: loopback, a private or unique-local network, or CGNAT (a
|
||||
Tailscale address). A request that reaches HealthLog straight from a public
|
||||
address carries headers its sender wrote, so HealthLog ignores them and
|
||||
uses the connection's own address, and says so once in the log. A proxy on
|
||||
the same host or Docker network connects from a private address, so the
|
||||
usual setups above need nothing. If your proxy reaches HealthLog over a
|
||||
public address, put it on a private network with HealthLog instead (or, for
|
||||
Cloudflare straight to the origin, use `TRUST_CF_CONNECTING_IP=1`). The log
|
||||
line also shows up when the published port is open to the internet beside
|
||||
the proxy; bind it to the proxy's network or to `127.0.0.1` then.
|
||||
|
||||
`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
|
||||
in front both come from the caller; the address the connection came from
|
||||
is used instead. 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
|
||||
|
||||
@@ -20,7 +20,7 @@ database dataset and in one value you must keep yourself: the encryption key.
|
||||
```
|
||||
|
||||
Each run prints 64 hex characters. Put both in your password manager
|
||||
before you continue; see [Back up your encryption key](#back-up-your-encryption-key).
|
||||
before you continue; see [Save the recovery kit](#save-the-recovery-kit).
|
||||
|
||||
2. Open Apps, Discover Apps, search for **HealthLog** and choose Install.
|
||||
3. Fill in the form:
|
||||
@@ -49,9 +49,10 @@ HealthLog behind a reverse proxy with its own domain, change App URL to that
|
||||
|
||||
Settings the form does not show go under Additional Environment Variables, for
|
||||
example `UPDATE_CHECK_DISABLED=1`. The full list is in
|
||||
[environment.md](environment.md). To use your own GeoLite2 databases, add the
|
||||
directory under Additional Storage and point `GEOLITE2_DIR` at its mount path
|
||||
([geolite2.md](geolite2.md)).
|
||||
[`.env.production.example`](../../.env.production.example) and on the
|
||||
[environment variables reference](https://docs.healthlog.dev/reference/environment-variables/).
|
||||
To use your own GeoLite2 databases, add the directory under Additional Storage
|
||||
and point `GEOLITE2_DIR` at its mount path ([geolite2.md](geolite2.md)).
|
||||
|
||||
## Plain HTTP on the LAN
|
||||
|
||||
@@ -61,7 +62,7 @@ and the login page says so before you type a password: "Signing in cannot
|
||||
complete on this page". Turn it on only when HealthLog is served over
|
||||
`https://`, and set App URL to that `https://` address.
|
||||
|
||||
## Back up your encryption key
|
||||
## Save the recovery kit
|
||||
|
||||
HealthLog encrypts tokens, notes, documents and in-database backups with the
|
||||
Encryption Key. The key is not stored in the database. If the app is deleted
|
||||
@@ -75,9 +76,10 @@ password manager or another place outside the NAS.
|
||||
|
||||
1. Open Apps, select HealthLog and choose Edit. The key is the Encryption Key
|
||||
field under HealthLog Configuration.
|
||||
2. Sign in as the admin and open Admin, Encryption. The **Back up your
|
||||
encryption key** step shows the active key id and a fingerprint, the first
|
||||
12 hex characters of SHA-256 over the key. Compare it with your copy:
|
||||
2. Sign in as the admin and open Admin, Encryption. The **Recovery kit** lists
|
||||
every key a restore needs (the Encryption Key, the token key and, with
|
||||
off-host copies, the backup key), each with an id and a fingerprint, the
|
||||
first 12 hex characters of SHA-256 over the key. Compare it with your copy:
|
||||
|
||||
```bash
|
||||
echo -n "<your key>" | xxd -r -p | sha256sum | cut -c1-12
|
||||
@@ -86,10 +88,10 @@ password manager or another place outside the NAS.
|
||||
or paste the copy into **Check my copy**. It is compared and dropped, never
|
||||
stored or logged.
|
||||
|
||||
3. Press **I have backed up this key**. Until you do, admins see a reminder on
|
||||
the dashboard. After a key rotation the step comes back for the new key.
|
||||
3. Press **I have a copy of every key**. Until you do, admins see a reminder on
|
||||
the dashboard. After a key rotation or a new backup key the kit is due again.
|
||||
|
||||
The catalog app sets `HEALTHLOG_PLATFORM=truenas`, so the step opens on these
|
||||
The catalog app sets `HEALTHLOG_PLATFORM=truenas`, so the kit opens on these
|
||||
instructions.
|
||||
|
||||
## Backups and restore
|
||||
@@ -206,15 +208,15 @@ before you touch it.
|
||||
Until the catalog app is listed, install HealthLog with Apps, Discover Apps,
|
||||
the menu next to Custom App, Install via YAML, using these values:
|
||||
|
||||
| Setting | Value |
|
||||
| ------------ | ---------------------------------------------------------------------------------------- |
|
||||
| Image | `ghcr.io/mbombeck/healthlog`, tag `X.Y.Z` (amd64 and arm64) |
|
||||
| Port | `3000` (HTTP) |
|
||||
| User | runs as uid `1001` by default; no extra capabilities, `no-new-privileges` is fine |
|
||||
| Health check | `GET /api/health`, `200` when ready |
|
||||
| Start period | at least 60 s, with more retries than the image's 5 × 30 s: migrations run at every boot |
|
||||
| Database | PostgreSQL 18 (16 works too), no extensions, in its own container with its own dataset |
|
||||
| Volumes | none for the app; the database gets its own dataset |
|
||||
| Setting | Value |
|
||||
| ------------ | -------------------------------------------------------------------------------------- |
|
||||
| Image | `ghcr.io/mbombeck/healthlog`, tag `X.Y.Z` (amd64 and arm64) |
|
||||
| Port | `3000` (HTTP) |
|
||||
| User | runs as uid `1001` by default; no extra capabilities, `no-new-privileges` is fine |
|
||||
| Health check | `GET /api/health`, `200` when ready |
|
||||
| Start period | 10 min, like the image: migrations run at every boot and wait out a held lock |
|
||||
| Database | PostgreSQL 18 (16 works too), no extensions, in its own container with its own dataset |
|
||||
| Volumes | none for the app; the database gets its own dataset |
|
||||
|
||||
| Variable | Required | Notes |
|
||||
| ----------------------- | -------- | ---------------------------------------------------------------------------------------- |
|
||||
@@ -224,7 +226,7 @@ the menu next to Custom App, Install via YAML, using these values:
|
||||
| `NEXT_PUBLIC_APP_URL` | yes | the address people open, for example `http://truenas.local:30180` |
|
||||
| `APP_URL` | yes | the same address |
|
||||
| `SESSION_COOKIE_SECURE` | no | `false` for a LAN install served over plain `http://`; leave unset behind an HTTPS proxy |
|
||||
| `HEALTHLOG_PLATFORM` | no | `truenas`: the admin's key backup step opens on the TrueNAS instructions |
|
||||
| `HEALTHLOG_PLATFORM` | no | `truenas`: the admin's recovery kit opens on the TrueNAS instructions |
|
||||
|
||||
`HEALTHLOG_PROCESS_TYPE` stays unset (`all`): one container serves the web app
|
||||
and runs the background jobs. In a YAML install the key sits in the
|
||||
|
||||
@@ -116,20 +116,22 @@ PostgreSQL container you installed in Step 1. Back that one up.
|
||||
|
||||
`SESSION_COOKIE_SECURE=false` is required for plain-HTTP LAN installs
|
||||
(`http://tower:3000`). Left at its default, the session cookie carries the
|
||||
`Secure` flag, the browser refuses to send it over HTTP, and login appears to
|
||||
"do nothing" — the page reloads to the login screen with no error.
|
||||
`Secure` flag, which the browser would drop over HTTP. The login page says so
|
||||
before anything is sent: "Signing in cannot complete on this page".
|
||||
|
||||
Set `SESSION_COOKIE_SECURE=true` (or leave it unset) only when a TLS reverse
|
||||
proxy serves HealthLog over HTTPS. In that case also point `NEXT_PUBLIC_APP_URL`
|
||||
and `APP_URL` at the public `https://` URL.
|
||||
|
||||
## Back up your encryption key
|
||||
## Save the recovery kit
|
||||
|
||||
Copy `ENCRYPTION_KEY` from the template into your password manager, then
|
||||
confirm it under Admin, Encryption (set `HEALTHLOG_PLATFORM=unraid` so the step
|
||||
opens on the Unraid instructions). A reinstall with a new key over the old
|
||||
database makes the data unreadable; HealthLog then refuses to serve and says
|
||||
so. See [Getting started](getting-started.md#back-up-your-encryption-key).
|
||||
Copy `ENCRYPTION_KEY`, `API_TOKEN_HMAC_KEY` and, with off-host copies,
|
||||
`BACKUP_ENCRYPTION_KEY` from the template into your password manager, then
|
||||
confirm the recovery kit under Admin, Encryption (set
|
||||
`HEALTHLOG_PLATFORM=unraid` so the kit opens on the Unraid instructions). A
|
||||
reinstall with a new key over the old database makes the data unreadable;
|
||||
HealthLog then refuses to serve and says so. See
|
||||
[Getting started](getting-started.md#save-the-recovery-kit).
|
||||
|
||||
## Updates
|
||||
|
||||
|
||||
@@ -311,7 +311,7 @@ Tiles at the top of the dashboard live in a **single row**. Width caps
|
||||
at the chart's width directly below. **Never** wrap to a second row.
|
||||
|
||||
If the user enables more tiles than fit, the dashboard surfaces a banner
|
||||
inviting them to deactivate or reorder via Settings → Dashboard. The
|
||||
inviting them to deactivate or reorder via Settings → Arrangement. The
|
||||
layout editor in Settings shows a live preview of the constraint and
|
||||
flags overflow before save.
|
||||
|
||||
|
||||
@@ -255,7 +255,7 @@ test.describe("Today overview geometry", () => {
|
||||
const hero = await openDashboard(page, digest);
|
||||
const phone = viewport.width < 768;
|
||||
|
||||
// Late banners (the key-backup nudge) insert above the hero; let
|
||||
// Late banners (the recovery kit nudge) insert above the hero; let
|
||||
// the page settle so the capture shows one stable layout.
|
||||
await page.waitForLoadState("networkidle");
|
||||
mkdirSync(SHOTS, { recursive: true });
|
||||
|
||||
+195
-51
@@ -780,13 +780,13 @@
|
||||
"bmi": "kg/m²"
|
||||
}
|
||||
},
|
||||
"keyBackupBanner": {
|
||||
"title": "Sichere deinen Verschlüsselungsschlüssel",
|
||||
"body": "Ohne eine Kopie des Schlüssels lassen sich deine Daten nach einer Neuinstallation nicht wiederherstellen. Es dauert eine Minute.",
|
||||
"action": "Zum Schritt"
|
||||
},
|
||||
"quickAddSymptom": "Symptom erfassen",
|
||||
"todayCardVisible": "Obere Karte anzeigen"
|
||||
"todayCardVisible": "Obere Karte anzeigen",
|
||||
"recoveryKitBanner": {
|
||||
"title": "Wiederherstellungs-Kit sichern",
|
||||
"body": "Ohne Kopien der Schlüssel, die dieser Server nutzt, lassen sich seine Daten nach einer Neuinstallation nicht wiederherstellen. Das dauert nur ein paar Minuten.",
|
||||
"action": "Kit öffnen"
|
||||
}
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Messungen",
|
||||
@@ -8331,7 +8331,23 @@
|
||||
"restoreSkippedEnvironmentLocationCiphertext": "Ort eines Umweltzeitraums oder -tags mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (verschlüsselt behalten, diese Tage werden nicht neu abgerufen)",
|
||||
"typePreWipe": "Vor dem Löschen aller Daten",
|
||||
"retentionNote": "Die wöchentliche Kopie wird jeden Sonntag ersetzt. Eine Kopie, die vor „Alle Daten löschen“ angelegt wurde, wird nach {days} Tagen gelöscht. Hochgeladene Kopien bleiben, bis ihr Konto gelöscht wird.",
|
||||
"retentionNoteNoGrace": "Die wöchentliche Kopie wird jeden Sonntag ersetzt. Hochgeladene Kopien bleiben, bis ihr Konto gelöscht wird."
|
||||
"retentionNoteNoGrace": "Die wöchentliche Kopie wird jeden Sonntag ersetzt. Hochgeladene Kopien bleiben, bis ihr Konto gelöscht wird.",
|
||||
"database": {
|
||||
"title": "Datenbank-Dump",
|
||||
"lastGood": "Letzter gültiger Dump {when}, {size}, {where}.",
|
||||
"whereBoth": "auf diesem Server und extern gespeichert",
|
||||
"whereLocal": "auf diesem Server gespeichert",
|
||||
"whereOffhost": "nur extern gespeichert",
|
||||
"none": "Noch kein Datenbank-Dump. Der erste läuft heute Nacht.",
|
||||
"failedTitle": "Der letzte Datenbank-Dump ist fehlgeschlagen",
|
||||
"failedDescription": "{when}: {reason}",
|
||||
"disabled": "Datenbank-Dumps sind abgeschaltet (DB_BACKUP_ENABLED=false). Sichere die Datenbank auf anderem Weg.",
|
||||
"noDirectoryOffhost": "Dieser Server kann nicht nach {dir} schreiben. Dumps gehen deshalb nur extern, hier wird keiner aufbewahrt. Binde das Backup-Volume ein.",
|
||||
"noDirectory": "Dieser Server kann nicht nach {dir} schreiben und es ist kein externer Speicher eingerichtet. Deshalb entsteht kein Datenbank-Dump. Binde das Backup-Volume ein.",
|
||||
"retentionLocal": "Gelöschte Konten bleiben in den Dumps, bis diese aussortiert werden: Auf diesem Server bleiben die neuesten {nights}.",
|
||||
"retentionOffhost": "Externe Kopien entfernt die Lebenszyklus-Regel des Buckets nach {days} Tagen.",
|
||||
"retentionOffhostMissing": "Keine Lebenszyklus-Regel im Bucket gilt für database/. Dumps dort und die gelöschten Konten darin werden deshalb nie entfernt. Lege eine Regel für dieses Präfix an."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Gefahrenzone",
|
||||
@@ -8527,47 +8543,6 @@
|
||||
"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."
|
||||
},
|
||||
"keyBackup": {
|
||||
"title": "Sichere deinen Verschlüsselungsschlüssel",
|
||||
"description": "Ohne eine Kopie dieses Schlüssels lassen sich deine verschlüsselten Daten und Sicherungen nie wieder lesen.",
|
||||
"loading": "Status der Schlüsselsicherung wird geladen…",
|
||||
"loadError": "Der Status der Schlüsselsicherung konnte nicht geladen werden.",
|
||||
"badgeDue": "Offen",
|
||||
"badgeDone": "Gesichert",
|
||||
"keyId": "Aktive Schlüssel-ID",
|
||||
"fingerprint": "Fingerabdruck",
|
||||
"whatIsLost": "Bewahre eine Kopie des Schlüssels außerhalb dieses Servers auf, zum Beispiel in einem Passwortmanager. Wird der Server mit einem anderen Schlüssel neu installiert, nimmt HealthLog den Betrieb erst wieder auf, wenn der ursprüngliche Schlüssel zurück ist.",
|
||||
"fingerprintHint": "Der Fingerabdruck sind die ersten 12 Zeichen des SHA-256-Hashes des Schlüssels. Er erkennt den Schlüssel, ohne ihn preiszugeben, damit du ihn mit deiner Kopie vergleichen kannst.",
|
||||
"checkLabel": "Meine Kopie prüfen",
|
||||
"checkButton": "Prüfen",
|
||||
"checkNote": "Der eingefügte Schlüssel wird mit dem aktiven Schlüssel verglichen und danach verworfen. Er wird nie gespeichert oder protokolliert.",
|
||||
"checkInsecureNote": "Diese Seite läuft nicht über https://, also geht der eingefügte Schlüssel unverschlüsselt über das Netz, genau wie dein Passwort. Er wird verglichen und verworfen, nie gespeichert oder protokolliert.",
|
||||
"checkMatches": "Deine Kopie passt zum aktiven Schlüssel.",
|
||||
"checkNoMatch": "Deine Kopie passt nicht zum aktiven Schlüssel. Prüfe, ob du den ganzen Wert kopiert hast.",
|
||||
"checkFailed": "Die Prüfung ist fehlgeschlagen.",
|
||||
"confirmButton": "Ich habe diesen Schlüssel gesichert",
|
||||
"confirmed": "Schlüsselsicherung bestätigt.",
|
||||
"confirmFailed": "Die Bestätigung konnte nicht gespeichert werden.",
|
||||
"stale": "Der Schlüssel des Servers hat sich geändert, seit diese Seite geladen wurde. Prüfe die neue Schlüssel-ID und den Fingerabdruck und bestätige dann erneut.",
|
||||
"confirmedAt": "Bestätigt am {date}.",
|
||||
"confirmedAtBy": "Bestätigt am {date} von {who}.",
|
||||
"dueAgain": "Für Schlüssel {keyId} wurde eine Sicherung bestätigt, aber der Server nutzt jetzt einen anderen Schlüssel. Sichere den aktuellen.",
|
||||
"tileLabel": "Sicherung des Verschlüsselungsschlüssels",
|
||||
"tileDue": "Nicht bestätigt",
|
||||
"tileDone": "Bestätigt",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "Der Schlüssel ist ENCRYPTION_KEY (oder ENCRYPTION_KEYS) in der .env-Datei neben docker-compose.yml.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Öffne Apps, wähle HealthLog, dann Bearbeiten. Der Schlüssel steht im Feld Encryption Key unter HealthLog Configuration; bei einer per YAML installierten App ist es ENCRYPTION_KEY in den Umgebungsvariablen.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Öffne die Anwendung in Coolify und kopiere ENCRYPTION_KEY aus den Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "Bearbeite im Docker-Tab den HealthLog-Container und kopiere ENCRYPTION_KEY aus der Vorlage oder aus der .env deines Compose-Manager-Stacks.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Öffne den HealthLog-Stack in Portainer und kopiere ENCRYPTION_KEY aus seinen Umgebungsvariablen."
|
||||
}
|
||||
},
|
||||
"keyMismatch": {
|
||||
"title": "HealthLog kann seine Daten nicht öffnen",
|
||||
"body": "Der Verschlüsselungsschlüssel, mit dem dieser Server gestartet wurde, ist nicht der, mit dem seine Datenbank geschrieben wurde. Zum Schutz deiner Daten liefert HealthLog nichts aus, bis das behoben ist.",
|
||||
@@ -8598,6 +8573,166 @@
|
||||
"defaultAboveCeiling": "Die Standardstufe darf nicht über der höchsten Stufe liegen.",
|
||||
"gapLabel": "Stunden zwischen Karten-Erneuerungen nach einem Sync (Standard)",
|
||||
"gapHint": "Ganze Stunden von 1 bis 48. Leer lassen für AI_SYNC_REGEN_GAP_HOURS, sonst 6. Gerade wirksam: {hours} h."
|
||||
},
|
||||
"recoveryKit": {
|
||||
"title": "Wiederherstellungs-Kit",
|
||||
"description": "Jeder Schlüssel, den eine Wiederherstellung dieses Servers braucht, mit Kennung und Fingerabdruck.",
|
||||
"intro": "Bewahre von jedem Schlüssel unten eine Kopie außerhalb dieses Servers auf, zum Beispiel in einem Passwortmanager. Ohne sie lassen sich weder ein Datenbank-Dump noch eine Sicherungskopie wieder lesen, und HealthLog startet nicht, wenn ein benötigter Schlüssel fehlt.",
|
||||
"loading": "Wiederherstellungs-Kit wird geladen…",
|
||||
"loadError": "Das Wiederherstellungs-Kit konnte nicht geladen werden.",
|
||||
"badgeDue": "Offen",
|
||||
"badgeDone": "Gesichert",
|
||||
"keyId": "Kennung",
|
||||
"fingerprint": "Fingerabdruck",
|
||||
"noFingerprint": "Nicht auf diesem Server",
|
||||
"kind": {
|
||||
"application": "Anwendungsschlüssel {id}",
|
||||
"backup": "Schlüssel für externe Sicherungen",
|
||||
"backupPrevious": "Früherer Schlüssel für externe Sicherungen",
|
||||
"token": "Token-Schlüssel"
|
||||
},
|
||||
"need": {
|
||||
"active": "Verschlüsselt neue Daten.",
|
||||
"configured": "Liegt auf diesem Server.",
|
||||
"storedCopies": "Wird von {count} gespeicherten Kopien gebraucht.",
|
||||
"offhostUntil": "Wird von externen Kopien bis {date} gebraucht.",
|
||||
"offhostUnknown": "Wird von externen Kopien gebraucht, solange der Bucket sie aufbewahrt.",
|
||||
"backupActive": "Versiegelt jede neue externe Kopie.",
|
||||
"backupPrevious": "Öffnet externe Kopien, die vor dem letzten Schlüsselwechsel geschrieben wurden.",
|
||||
"token": "Signiert Sitzungen, Tokens und Freigabelinks. Ohne ihn werden alle abgemeldet, Daten gehen aber nicht verloren."
|
||||
},
|
||||
"source": "Gesetzt in {variable}.",
|
||||
"status": {
|
||||
"confirmed": "Gesichert",
|
||||
"new": "Noch nicht gesichert",
|
||||
"notConfigured": "Nicht auf diesem Server",
|
||||
"unreadable": "Kein gültiger Schlüssel"
|
||||
},
|
||||
"notConfiguredHint": "Eine Sicherung braucht diesen Schlüssel noch, aber dieser Server hat ihn nicht. Nur eine Kopie, die du anderswo aufbewahrst, kann diese Sicherungen lesen. Bestätige das Kit, sobald du diese Kopie hast.",
|
||||
"unreadableHint": "Die Variable ist gesetzt, enthält aber keinen gültigen Schlüssel. Behebe das, bevor du dich auf die externen Kopien verlässt.",
|
||||
"fingerprintHint": "Der Fingerabdruck besteht aus den ersten 12 Zeichen des SHA-256-Hashs eines Schlüssels. Er erkennt den Schlüssel, ohne ihn preiszugeben, sodass du ihn mit deiner Kopie vergleichen kannst.",
|
||||
"whereTitle": "Wo die Schlüssel gesetzt sind",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "Die Schlüssel stehen in der .env-Datei neben docker-compose.yml: ENCRYPTION_KEY oder ENCRYPTION_KEYS, API_TOKEN_HMAC_KEY und, mit externen Kopien, BACKUP_ENCRYPTION_KEY.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Öffne Apps, wähle HealthLog und dann Edit. Die Schlüssel stehen unter HealthLog Configuration; bei einer per YAML installierten App sind es ENCRYPTION_KEY, API_TOKEN_HMAC_KEY und BACKUP_ENCRYPTION_KEY in den Umgebungsvariablen.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Öffne die Anwendung in Coolify und kopiere ENCRYPTION_KEY, API_TOKEN_HMAC_KEY und BACKUP_ENCRYPTION_KEY aus den Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "Bearbeite auf dem Docker-Tab den HealthLog-Container und kopiere ENCRYPTION_KEY, API_TOKEN_HMAC_KEY und BACKUP_ENCRYPTION_KEY aus der Vorlage oder aus der .env deines Compose-Manager-Stacks.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Öffne den HealthLog-Stack in Portainer und kopiere ENCRYPTION_KEY, API_TOKEN_HMAC_KEY und BACKUP_ENCRYPTION_KEY aus den Umgebungsvariablen."
|
||||
},
|
||||
"restoreTitle": "Diesen Server wiederherstellen",
|
||||
"restoreBody": "Installiere HealthLog mit denselben Schlüsseln, spiele den neuesten Datenbank-Dump in eine leere Datenbank ein und starte dann die App. Die Anleitung führt durch jeden Schritt.",
|
||||
"restoreLink": "Anleitung zur Notfallwiederherstellung",
|
||||
"checkLabel": "Meine Kopie prüfen",
|
||||
"checkKeyLabel": "Zu prüfender Schlüssel",
|
||||
"checkValueLabel": "Deine Kopie des Schlüssels",
|
||||
"checkButton": "Prüfen",
|
||||
"checkNote": "Der eingefügte Schlüssel wird mit dem auf diesem Server verglichen und danach verworfen. Er wird nie gespeichert oder protokolliert.",
|
||||
"checkInsecureNote": "Diese Seite wird nicht über https:// ausgeliefert. Der eingefügte Schlüssel geht deshalb unverschlüsselt über das Netzwerk, genau wie dein Passwort. Er wird verglichen und verworfen, nie gespeichert oder protokolliert.",
|
||||
"checkMatches": "Deine Kopie passt zu diesem Schlüssel.",
|
||||
"checkNoMatch": "Deine Kopie passt nicht zu diesem Schlüssel. Prüfe, ob du den ganzen Wert kopiert hast.",
|
||||
"checkFailed": "Die Prüfung ist fehlgeschlagen.",
|
||||
"confirmButton": "Ich habe von jedem Schlüssel eine Kopie",
|
||||
"confirmed": "Wiederherstellungs-Kit bestätigt.",
|
||||
"confirmFailed": "Die Bestätigung konnte nicht gespeichert werden.",
|
||||
"stale": "Das Kit hat sich geändert, seit die Seite geladen wurde. Prüfe die Schlüssel, die es jetzt aufführt, und bestätige erneut.",
|
||||
"confirmedAt": "Bestätigt am {date}.",
|
||||
"confirmedAtBy": "Bestätigt am {date} von {who}.",
|
||||
"dueAgain": "Das Kit wurde am {date} bestätigt. Es führt jetzt auch {keys} auf, was diese Bestätigung nicht abgedeckt hat. Sichere von jedem eine Kopie und bestätige erneut."
|
||||
},
|
||||
"recoveryReadiness": {
|
||||
"title": "Wiederherstellbarkeit",
|
||||
"description": "Ob sich dieser Server nach einem Ausfall wiederherstellen ließe, beurteilt anhand jeder Sicherung, die er anlegt.",
|
||||
"loading": "Sicherungen werden geprüft…",
|
||||
"loadError": "Die Wiederherstellbarkeit konnte nicht geladen werden.",
|
||||
"state": {
|
||||
"ok": "Bereit",
|
||||
"attention": "Braucht Aufmerksamkeit",
|
||||
"failing": "Gefährdet"
|
||||
},
|
||||
"signal": {
|
||||
"database_dump": "Datenbank-Dump",
|
||||
"weekly_copy": "Wöchentliche Kontokopien",
|
||||
"offhost_copies": "Externe Kopien",
|
||||
"restore_drill": "Wiederherstellungsprobe",
|
||||
"offhost_setup": "Externe Sicherung",
|
||||
"recovery_kit": "Wiederherstellungs-Kit"
|
||||
},
|
||||
"at": {
|
||||
"database_dump": "Neuester Dump: {date}",
|
||||
"weekly_copy": "Älteste Kontokopie: {date}",
|
||||
"offhost_copies": "Älteste externe Kopie: {date}",
|
||||
"restore_drill": "Letzte Probe: {date}",
|
||||
"recovery_kit": "Bestätigt: {date}"
|
||||
},
|
||||
"dueBy": "Fällig bis {date}",
|
||||
"fix": {
|
||||
"database_dump": "Sicherungen öffnen",
|
||||
"weekly_copy": "Sicherungen öffnen",
|
||||
"offhost_copies": "Sicherungen öffnen",
|
||||
"restore_drill": "Sicherungen öffnen",
|
||||
"offhost_setup": "Sicherungen öffnen",
|
||||
"recovery_kit": "Kit öffnen"
|
||||
},
|
||||
"reason": {
|
||||
"database_dump": {
|
||||
"not_available": "Diese Version legt keinen geplanten Datenbank-Dump an.",
|
||||
"disabled": "Der geplante Datenbank-Dump ist ausgeschaltet, daher kann keine Kopie diesen Server wieder aufbauen. Zum Wiedereinschalten DB_BACKUP_ENABLED entfernen oder auf true setzen und HealthLog neu starten.",
|
||||
"unreadable": "Der Zustand des Datenbank-Dumps konnte nicht gelesen werden.",
|
||||
"never_succeeded": "Noch kein Datenbank-Dump ist gelungen, und der letzte Versuch ist fehlgeschlagen.",
|
||||
"never_ran": "In den {hours} Stunden, seit Dumps eingeschaltet sind, wurde kein Datenbank-Dump geschrieben.",
|
||||
"first_pending": "Der erste Datenbank-Dump ist noch nicht gelaufen.",
|
||||
"stale": "Der neueste Datenbank-Dump ist {hours} Stunden alt.",
|
||||
"last_failed": "Der letzte Datenbank-Dump ist fehlgeschlagen. Der davor ist noch aktuell.",
|
||||
"fresh": "Der neueste Datenbank-Dump ist aktuell.",
|
||||
"nowhere_to_write": "Das Dump-Verzeichnis {directory} ist nicht beschreibbar und es ist kein externer Bucket eingerichtet, daher kann kein Dump aufbewahrt werden."
|
||||
},
|
||||
"weekly_copy": {
|
||||
"no_accounts": "Es gibt noch keine Konten zum Kopieren.",
|
||||
"stale": "Die älteste wöchentliche Kopie ist {days} Tage alt. Konten im Rückstand: {accounts}.",
|
||||
"never": "Ein Konto hat seit {days} Tagen keine wöchentliche Kopie. Konten im Rückstand: {accounts}.",
|
||||
"first_pending": "Die erste wöchentliche Kopie entsteht am frühen Sonntagmorgen.",
|
||||
"fresh": "Jedes Konto hat eine Kopie aus der letzten Woche."
|
||||
},
|
||||
"offhost_copies": {
|
||||
"not_configured": "Es ist kein externer Bucket eingerichtet.",
|
||||
"stale": "Externe Kopien liegen mehr als zwei Nächte zurück. Konten im Rückstand: {accounts}.",
|
||||
"first_pending": "Der nächtliche Lauf für externe Kopien hat noch kein Konto erreicht.",
|
||||
"fresh": "Jedes Konto hat eine aktuelle Kopie im externen Bucket."
|
||||
},
|
||||
"restore_drill": {
|
||||
"not_configured": "Die Probe liest den externen Bucket, und der ist nicht eingerichtet.",
|
||||
"not_run_yet": "Die monatliche Wiederherstellungsprobe ist noch nicht gelaufen.",
|
||||
"failed": "Die letzte Wiederherstellungsprobe ist fehlgeschlagen: {failed} von {checked} Kopien ließen sich nicht zurücklesen.",
|
||||
"failed_run": "Die letzte Wiederherstellungsprobe konnte nicht zu Ende laufen.",
|
||||
"passed": "Die letzte Wiederherstellungsprobe hat {checked} Kopien zurückgelesen."
|
||||
},
|
||||
"offhost_setup": {
|
||||
"configured": "Kopien verlassen diesen Server jede Nacht.",
|
||||
"no_admin": "Es gibt noch kein Admin-Konto.",
|
||||
"not_configured": "Es ist kein externer Bucket eingerichtet, daher liegt jede Kopie nur auf diesem Server.",
|
||||
"not_configured_grace": "Es ist noch kein externer Bucket eingerichtet, daher liegt jede Kopie nur auf diesem Server."
|
||||
},
|
||||
"recovery_kit": {
|
||||
"confirmed": "Von jedem Schlüssel, den eine Wiederherstellung braucht, gibt es eine Kopie.",
|
||||
"out_of_date": "Das Wiederherstellungs-Kit führt jetzt auch {keys} auf, was die letzte Bestätigung nicht abgedeckt hat. Nichts ist kaputt: Sichere eine Kopie und bestätige das Kit erneut.",
|
||||
"no_admin": "Es gibt noch kein Admin-Konto.",
|
||||
"not_confirmed": "Niemand hat eine Kopie der Schlüssel bestätigt, die eine Wiederherstellung braucht: {keys}.",
|
||||
"not_confirmed_grace": "Sichere eine Kopie der Schlüssel, die eine Wiederherstellung braucht: {keys}."
|
||||
}
|
||||
},
|
||||
"recovered": {
|
||||
"database_dump": "Der Datenbank-Dump ist wieder aktuell.",
|
||||
"weekly_copy": "Jedes Konto hat wieder eine aktuelle wöchentliche Kopie.",
|
||||
"offhost_copies": "Jedes Konto hat wieder eine aktuelle externe Kopie.",
|
||||
"restore_drill": "Die Wiederherstellungsprobe ist wieder bestanden.",
|
||||
"offhost_setup": "Externe Kopien sind jetzt eingerichtet.",
|
||||
"recovery_kit": "Das Wiederherstellungs-Kit ist wieder bestätigt."
|
||||
}
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
@@ -9065,6 +9200,14 @@
|
||||
"checkupReminders": {
|
||||
"title": "Vorsorge-Erinnerungen",
|
||||
"description": "Schalte einzelne Vorsorge-Erinnerungen ein oder aus."
|
||||
},
|
||||
"eventBackupHealth": "Zustand der Sicherungen",
|
||||
"eventBackupHealthDesc": "Wenn eine Sicherung ausbleibt, zurückfällt oder wieder läuft. Nur für Admins.",
|
||||
"recoveryReadiness": {
|
||||
"titleAttention": "Sicherungen brauchen Aufmerksamkeit",
|
||||
"titleFailing": "Sicherungen sind gefährdet",
|
||||
"titleReminder": "Sicherungen sind weiterhin gefährdet",
|
||||
"titleRecovered": "Sicherungen laufen wieder normal"
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
@@ -11792,7 +11935,7 @@
|
||||
"dismiss": "Verwerfen",
|
||||
"empty": "Aus diesem Dokument konnte nichts gelesen werden.",
|
||||
"unavailableBody": "Richte einen KI-Anbieter ein, um Titel, Typ und Datum vorzuschlagen.",
|
||||
"unavailableLocalOcr": "Aktiviere lokale Texterkennung in den KI-Einstellungen, um Bilder auf diesem Gerät zu lesen.",
|
||||
"unavailableLocalOcr": "Schalte „Scans auf diesem Gerät lesen“ unter Einstellungen → Module → Labor ein, um Bilder auf diesem Gerät zu lesen.",
|
||||
"unavailableAction": "KI-Einstellungen öffnen",
|
||||
"errorGeneric": "Dokument konnte nicht gelesen werden. Bitte erneut versuchen.",
|
||||
"errorOcr": "Aus diesem Bild konnte kein Text gelesen werden.",
|
||||
@@ -11805,11 +11948,12 @@
|
||||
"errorFileType": "Dieser Dateityp kann nicht gelesen werden.",
|
||||
"errorNotIndexed": "Lies das Dokument zuerst aus, dann extrahiere.",
|
||||
"errorOutputLimit": "Der KI-Anbieter hat sein Ausgabelimit erreicht, bevor er einen einzigen Wert geliefert hat. Versuche ein Modell, das längere Antworten erlaubt.",
|
||||
"errorLocalOcrDisabled": "Aktiviere zuerst die lokale Texterkennung in den KI-Einstellungen.",
|
||||
"errorLocalOcrDisabled": "Schalte zuerst „Scans auf diesem Gerät lesen“ unter Einstellungen → Module → Labor ein.",
|
||||
"errorProvider": "Dafür ist kein KI-Anbieter eingerichtet.",
|
||||
"errorTextImageOnly": "Lokale Texterkennung liest nur Bilder, nicht diese Datei.",
|
||||
"errorConsent": "Für das Lesen von Dokumenten mit KI brauchst du zuerst deine Einwilligung.",
|
||||
"errorUnavailable": "Das Lesen von Dokumenten mit KI ist hier gerade nicht verfügbar."
|
||||
"errorUnavailable": "Das Lesen von Dokumenten mit KI ist hier gerade nicht verfügbar.",
|
||||
"unavailableLocalOcrAction": "Labor-Einstellungen öffnen"
|
||||
},
|
||||
"summary": {
|
||||
"summarise": "Zusammenfassen",
|
||||
|
||||
+195
-51
@@ -780,13 +780,13 @@
|
||||
"bmi": "kg/m²"
|
||||
}
|
||||
},
|
||||
"keyBackupBanner": {
|
||||
"title": "Back up your encryption key",
|
||||
"body": "Without a copy of the key, your data cannot be recovered after a reinstall. It takes a minute.",
|
||||
"action": "Open the step"
|
||||
},
|
||||
"quickAddSymptom": "Log symptom",
|
||||
"todayCardVisible": "Show the top card"
|
||||
"todayCardVisible": "Show the top card",
|
||||
"recoveryKitBanner": {
|
||||
"title": "Save your recovery kit",
|
||||
"body": "Without copies of the keys this server uses, its data cannot be recovered after a reinstall. It takes a few minutes.",
|
||||
"action": "Open the kit"
|
||||
}
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Measurements",
|
||||
@@ -8331,7 +8331,23 @@
|
||||
"restoreSkippedEnvironmentLocationCiphertext": "Location of an environment period or day sealed with a key this server does not hold (kept sealed, those days are not refreshed)",
|
||||
"typePreWipe": "Before all data was deleted",
|
||||
"retentionNote": "The weekly copy is replaced every Sunday. A copy taken before \"Delete all data\" is deleted after {days} days. Uploaded copies stay until their account is erased.",
|
||||
"retentionNoteNoGrace": "The weekly copy is replaced every Sunday. Uploaded copies stay until their account is erased."
|
||||
"retentionNoteNoGrace": "The weekly copy is replaced every Sunday. Uploaded copies stay until their account is erased.",
|
||||
"database": {
|
||||
"title": "Database dump",
|
||||
"lastGood": "Last good dump {when}, {size}, {where}.",
|
||||
"whereBoth": "kept on this server and off-host",
|
||||
"whereLocal": "kept on this server",
|
||||
"whereOffhost": "off-host only",
|
||||
"none": "No database dump yet. The first one runs tonight.",
|
||||
"failedTitle": "The last database dump failed",
|
||||
"failedDescription": "{when}: {reason}",
|
||||
"disabled": "Database dumps are switched off (DB_BACKUP_ENABLED=false). Back the database up another way.",
|
||||
"noDirectoryOffhost": "This server cannot write to {dir}, so dumps go off-host only and none is kept here. Mount the backups volume.",
|
||||
"noDirectory": "This server cannot write to {dir} and no off-host bucket is set, so no database dump can be taken. Mount the backups volume.",
|
||||
"retentionLocal": "Deleted accounts stay in the dumps until those age out: the newest {nights} are kept on this server.",
|
||||
"retentionOffhost": "Off-host copies are removed after {days} days by the bucket's lifecycle rule.",
|
||||
"retentionOffhostMissing": "No lifecycle rule in the bucket covers database/, so dumps there, and the deleted accounts inside them, are never removed. Add a rule for that prefix."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Danger zone",
|
||||
@@ -8527,47 +8543,6 @@
|
||||
"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."
|
||||
},
|
||||
"keyBackup": {
|
||||
"title": "Back up your encryption key",
|
||||
"description": "Without a copy of this key, your encrypted data and this server's backups cannot be read again.",
|
||||
"loading": "Loading the key backup status…",
|
||||
"loadError": "Could not load the key backup status.",
|
||||
"badgeDue": "To do",
|
||||
"badgeDone": "Backed up",
|
||||
"keyId": "Active key id",
|
||||
"fingerprint": "Fingerprint",
|
||||
"whatIsLost": "Keep a copy of the key somewhere outside this server, for example in a password manager. If the server is reinstalled with a different key, HealthLog refuses to start serving until the original key is back.",
|
||||
"fingerprintHint": "The fingerprint is the first 12 characters of the SHA-256 hash of the key. It identifies the key without revealing it, so you can compare it with your copy.",
|
||||
"checkLabel": "Check my copy",
|
||||
"checkButton": "Check",
|
||||
"checkNote": "The key you paste is compared with the active key and then discarded. It is never stored or logged.",
|
||||
"checkInsecureNote": "This page is not served over https://, so the key you paste crosses the network unencrypted, just like your password does. It is compared and discarded, never stored or logged.",
|
||||
"checkMatches": "Your copy matches the active key.",
|
||||
"checkNoMatch": "Your copy does not match the active key. Check that you copied the whole value.",
|
||||
"checkFailed": "The check failed.",
|
||||
"confirmButton": "I have backed up this key",
|
||||
"confirmed": "Key backup confirmed.",
|
||||
"confirmFailed": "Could not save the confirmation.",
|
||||
"stale": "The server's key changed since this page loaded. Check the new key id and fingerprint, then confirm again.",
|
||||
"confirmedAt": "Confirmed on {date}.",
|
||||
"confirmedAtBy": "Confirmed on {date} by {who}.",
|
||||
"dueAgain": "A backup was confirmed for key {keyId}, but the server now uses another key. Back up the current one.",
|
||||
"tileLabel": "Encryption key backup",
|
||||
"tileDue": "Not confirmed",
|
||||
"tileDone": "Confirmed",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "The key is ENCRYPTION_KEY (or ENCRYPTION_KEYS) in the .env file next to docker-compose.yml.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Open Apps, select HealthLog and choose Edit. The key is the Encryption Key field under HealthLog Configuration; in an app installed via YAML, it is ENCRYPTION_KEY in the environment settings.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Open the application in Coolify and copy ENCRYPTION_KEY from its Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "On the Docker tab, edit the HealthLog container and copy ENCRYPTION_KEY from the template, or from the .env of your Compose Manager stack.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Open the HealthLog stack in Portainer and copy ENCRYPTION_KEY from its environment variables."
|
||||
}
|
||||
},
|
||||
"keyMismatch": {
|
||||
"title": "HealthLog cannot open its data",
|
||||
"body": "The encryption key this server was started with is not the key its database was written with. To protect your data, HealthLog does not serve anything until this is fixed.",
|
||||
@@ -8598,6 +8573,166 @@
|
||||
"defaultAboveCeiling": "The default level cannot be above the highest level.",
|
||||
"gapLabel": "Hours between card refreshes after a sync (Standard)",
|
||||
"gapHint": "Whole hours from 1 to 48. Leave empty to use AI_SYNC_REGEN_GAP_HOURS, else 6. In effect now: {hours} h."
|
||||
},
|
||||
"recoveryKit": {
|
||||
"title": "Recovery kit",
|
||||
"description": "Every key a restore of this server needs, by id and fingerprint.",
|
||||
"intro": "Keep a copy of each key below outside this server, for example in a password manager. Without them, neither a database dump nor a backup copy can be read again, and HealthLog refuses to start if a key it needs is missing.",
|
||||
"loading": "Loading the recovery kit…",
|
||||
"loadError": "Could not load the recovery kit.",
|
||||
"badgeDue": "To do",
|
||||
"badgeDone": "Saved",
|
||||
"keyId": "Id",
|
||||
"fingerprint": "Fingerprint",
|
||||
"noFingerprint": "Not on this server",
|
||||
"kind": {
|
||||
"application": "Application key {id}",
|
||||
"backup": "Off-host backup key",
|
||||
"backupPrevious": "Previous off-host backup key",
|
||||
"token": "Token key"
|
||||
},
|
||||
"need": {
|
||||
"active": "Encrypts new data.",
|
||||
"configured": "Held by this server.",
|
||||
"storedCopies": "Needed by {count} stored copies.",
|
||||
"offhostUntil": "Needed by off-host copies until {date}.",
|
||||
"offhostUnknown": "Needed by off-host copies for as long as the bucket keeps them.",
|
||||
"backupActive": "Seals every new off-host copy.",
|
||||
"backupPrevious": "Opens off-host copies written before the last key change.",
|
||||
"token": "Signs sessions, tokens and share links. Without it everyone is signed out, but no data is lost."
|
||||
},
|
||||
"source": "Set in {variable}.",
|
||||
"status": {
|
||||
"confirmed": "Saved",
|
||||
"new": "Not saved yet",
|
||||
"notConfigured": "Not on this server",
|
||||
"unreadable": "Not a valid key"
|
||||
},
|
||||
"notConfiguredHint": "A backup still needs this key and this server does not hold it. Only a copy you keep elsewhere can read those backups. Confirm the kit once you have that copy.",
|
||||
"unreadableHint": "The variable is set but does not hold a valid key. Fix it before you rely on the off-host copies.",
|
||||
"fingerprintHint": "A fingerprint is the first 12 characters of the SHA-256 hash of a key. It identifies the key without revealing it, so you can compare it with your copy.",
|
||||
"whereTitle": "Where the keys are set",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "The keys are in the .env file next to docker-compose.yml: ENCRYPTION_KEY or ENCRYPTION_KEYS, API_TOKEN_HMAC_KEY and, with off-host copies, BACKUP_ENCRYPTION_KEY.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Open Apps, select HealthLog and choose Edit. The keys are under HealthLog Configuration; in an app installed via YAML, they are ENCRYPTION_KEY, API_TOKEN_HMAC_KEY and BACKUP_ENCRYPTION_KEY in the environment settings.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Open the application in Coolify and copy ENCRYPTION_KEY, API_TOKEN_HMAC_KEY and BACKUP_ENCRYPTION_KEY from its Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "On the Docker tab, edit the HealthLog container and copy ENCRYPTION_KEY, API_TOKEN_HMAC_KEY and BACKUP_ENCRYPTION_KEY from the template, or from the .env of your Compose Manager stack.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Open the HealthLog stack in Portainer and copy ENCRYPTION_KEY, API_TOKEN_HMAC_KEY and BACKUP_ENCRYPTION_KEY from its environment variables."
|
||||
},
|
||||
"restoreTitle": "Restoring this server",
|
||||
"restoreBody": "Install HealthLog with the same keys, restore the newest database dump into an empty database, then start the app. The guide walks through each step.",
|
||||
"restoreLink": "Disaster recovery guide",
|
||||
"checkLabel": "Check my copy",
|
||||
"checkKeyLabel": "Key to check",
|
||||
"checkValueLabel": "Your copy of the key",
|
||||
"checkButton": "Check",
|
||||
"checkNote": "The key you paste is compared with the one this server holds and then discarded. It is never stored or logged.",
|
||||
"checkInsecureNote": "This page is not served over https://, so the key you paste crosses the network unencrypted, just like your password does. It is compared and discarded, never stored or logged.",
|
||||
"checkMatches": "Your copy matches this key.",
|
||||
"checkNoMatch": "Your copy does not match this key. Check that you copied the whole value.",
|
||||
"checkFailed": "The check failed.",
|
||||
"confirmButton": "I have a copy of every key",
|
||||
"confirmed": "Recovery kit confirmed.",
|
||||
"confirmFailed": "Could not save the confirmation.",
|
||||
"stale": "The kit changed since this page loaded. Check the keys it lists now, then confirm again.",
|
||||
"confirmedAt": "Confirmed on {date}.",
|
||||
"confirmedAtBy": "Confirmed on {date} by {who}.",
|
||||
"dueAgain": "The kit was confirmed on {date}. It now also lists {keys}, which that confirmation did not cover. Save a copy of each and confirm again."
|
||||
},
|
||||
"recoveryReadiness": {
|
||||
"title": "Recovery readiness",
|
||||
"description": "Whether this server could be put back after a loss, judged from every backup it makes.",
|
||||
"loading": "Checking the backups…",
|
||||
"loadError": "Could not load the recovery readiness.",
|
||||
"state": {
|
||||
"ok": "Ready",
|
||||
"attention": "Needs attention",
|
||||
"failing": "At risk"
|
||||
},
|
||||
"signal": {
|
||||
"database_dump": "Database dump",
|
||||
"weekly_copy": "Weekly account copies",
|
||||
"offhost_copies": "Off-host copies",
|
||||
"restore_drill": "Restore drill",
|
||||
"offhost_setup": "Off-host backup",
|
||||
"recovery_kit": "Recovery kit"
|
||||
},
|
||||
"at": {
|
||||
"database_dump": "Newest dump: {date}",
|
||||
"weekly_copy": "Oldest account copy: {date}",
|
||||
"offhost_copies": "Least recent off-host copy: {date}",
|
||||
"restore_drill": "Last drill: {date}",
|
||||
"recovery_kit": "Confirmed: {date}"
|
||||
},
|
||||
"dueBy": "Due by {date}",
|
||||
"fix": {
|
||||
"database_dump": "Open backups",
|
||||
"weekly_copy": "Open backups",
|
||||
"offhost_copies": "Open backups",
|
||||
"restore_drill": "Open backups",
|
||||
"offhost_setup": "Open backups",
|
||||
"recovery_kit": "Open the kit"
|
||||
},
|
||||
"reason": {
|
||||
"database_dump": {
|
||||
"not_available": "This version does not schedule a database dump.",
|
||||
"disabled": "The scheduled database dump is switched off, so no copy can rebuild this server. To turn it back on, remove DB_BACKUP_ENABLED or set it to true, then restart HealthLog.",
|
||||
"unreadable": "The status of the database dump could not be read.",
|
||||
"never_succeeded": "No database dump has succeeded yet, and the last attempt failed.",
|
||||
"never_ran": "No database dump has been written in the {hours} hours since dumps were switched on.",
|
||||
"first_pending": "The first database dump has not run yet.",
|
||||
"stale": "The newest database dump is {hours} hours old.",
|
||||
"last_failed": "The last database dump failed. The one before it is still recent.",
|
||||
"fresh": "The newest database dump is recent.",
|
||||
"nowhere_to_write": "The dump directory {directory} cannot be written to and no off-host bucket is set up, so no dump can be kept."
|
||||
},
|
||||
"weekly_copy": {
|
||||
"no_accounts": "There are no accounts to copy yet.",
|
||||
"stale": "The oldest weekly copy is {days} days old. Accounts behind: {accounts}.",
|
||||
"never": "An account has had no weekly copy for {days} days. Accounts behind: {accounts}.",
|
||||
"first_pending": "The first weekly copy is made early on Sunday.",
|
||||
"fresh": "Every account has a copy from the last week."
|
||||
},
|
||||
"offhost_copies": {
|
||||
"not_configured": "No off-host bucket is set up.",
|
||||
"stale": "Off-host copies are more than two nights behind. Accounts behind: {accounts}.",
|
||||
"first_pending": "The nightly off-host run has not reached any account yet.",
|
||||
"fresh": "Every account has a recent copy in the off-host bucket."
|
||||
},
|
||||
"restore_drill": {
|
||||
"not_configured": "The drill reads the off-host bucket, which is not set up.",
|
||||
"not_run_yet": "The monthly restore drill has not run yet.",
|
||||
"failed": "The last restore drill failed: {failed} of {checked} copies could not be read back.",
|
||||
"failed_run": "The last restore drill could not finish.",
|
||||
"passed": "The last restore drill read back {checked} copies."
|
||||
},
|
||||
"offhost_setup": {
|
||||
"configured": "Copies leave this server every night.",
|
||||
"no_admin": "There is no admin account yet.",
|
||||
"not_configured": "No off-host bucket is set up, so every copy lives on this server only.",
|
||||
"not_configured_grace": "No off-host bucket is set up yet, so every copy lives on this server only."
|
||||
},
|
||||
"recovery_kit": {
|
||||
"confirmed": "A copy of every key a restore needs is kept.",
|
||||
"out_of_date": "The recovery kit now also lists {keys}, which the last confirmation did not cover. Nothing is broken: save a copy and confirm the kit again.",
|
||||
"no_admin": "There is no admin account yet.",
|
||||
"not_confirmed": "Nobody has confirmed a copy of the keys a restore needs: {keys}.",
|
||||
"not_confirmed_grace": "Save a copy of the keys a restore needs: {keys}."
|
||||
}
|
||||
},
|
||||
"recovered": {
|
||||
"database_dump": "The database dump is recent again.",
|
||||
"weekly_copy": "Every account has a recent weekly copy again.",
|
||||
"offhost_copies": "Every account has a recent off-host copy again.",
|
||||
"restore_drill": "The restore drill passed again.",
|
||||
"offhost_setup": "Off-host copies are set up now.",
|
||||
"recovery_kit": "The recovery kit is confirmed again."
|
||||
}
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
@@ -9065,6 +9200,14 @@
|
||||
"checkupReminders": {
|
||||
"title": "Checkup reminders",
|
||||
"description": "Switch individual checkup reminders on or off."
|
||||
},
|
||||
"eventBackupHealth": "Backup health",
|
||||
"eventBackupHealthDesc": "When a backup stops, falls behind or recovers. Sent to admins only.",
|
||||
"recoveryReadiness": {
|
||||
"titleAttention": "Backups need attention",
|
||||
"titleFailing": "Backups are at risk",
|
||||
"titleReminder": "Backups are still at risk",
|
||||
"titleRecovered": "Backups are back to normal"
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
@@ -11792,7 +11935,7 @@
|
||||
"dismiss": "Dismiss",
|
||||
"empty": "Nothing could be read from this document.",
|
||||
"unavailableBody": "Set up an AI provider to suggest a title, type, and date.",
|
||||
"unavailableLocalOcr": "Turn on local OCR in the AI settings to read images on this device.",
|
||||
"unavailableLocalOcr": "Turn on “Read scans on this device” under Settings → Modules → Labs to read images on this device.",
|
||||
"unavailableAction": "Open AI settings",
|
||||
"errorGeneric": "Couldn't read the document. Try again.",
|
||||
"errorOcr": "Couldn't read text from this image.",
|
||||
@@ -11805,11 +11948,12 @@
|
||||
"errorFileType": "This file type can't be read.",
|
||||
"errorNotIndexed": "Read the document first, then extract.",
|
||||
"errorOutputLimit": "The AI provider reached its output limit before it returned a single value. Try a model that allows longer answers.",
|
||||
"errorLocalOcrDisabled": "Turn on local OCR in the AI settings first.",
|
||||
"errorLocalOcrDisabled": "Turn on “Read scans on this device” under Settings → Modules → Labs first.",
|
||||
"errorProvider": "No AI provider is set up for this.",
|
||||
"errorTextImageOnly": "Local OCR reads images only, not this file.",
|
||||
"errorConsent": "Reading documents with AI needs your consent first.",
|
||||
"errorUnavailable": "Reading documents with AI is not available here right now."
|
||||
"errorUnavailable": "Reading documents with AI is not available here right now.",
|
||||
"unavailableLocalOcrAction": "Open lab settings"
|
||||
},
|
||||
"summary": {
|
||||
"summarise": "Summarise",
|
||||
|
||||
+195
-51
@@ -780,13 +780,13 @@
|
||||
"bmi": "kg/m²"
|
||||
}
|
||||
},
|
||||
"keyBackupBanner": {
|
||||
"title": "Haz una copia de tu clave de cifrado",
|
||||
"body": "Sin una copia de la clave, tus datos no se pueden recuperar tras una reinstalación. Es cuestión de un minuto.",
|
||||
"action": "Abrir el paso"
|
||||
},
|
||||
"quickAddSymptom": "Registrar síntoma",
|
||||
"todayCardVisible": "Mostrar la tarjeta superior"
|
||||
"todayCardVisible": "Mostrar la tarjeta superior",
|
||||
"recoveryKitBanner": {
|
||||
"title": "Guarda tu kit de recuperación",
|
||||
"body": "Sin copias de las claves que usa este servidor, sus datos no se pueden recuperar tras una reinstalación. Es cuestión de unos minutos.",
|
||||
"action": "Abrir el kit"
|
||||
}
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Mediciones",
|
||||
@@ -8331,7 +8331,23 @@
|
||||
"restoreSkippedEnvironmentLocationCiphertext": "Ubicación de un periodo o día ambiental cifrada con una clave que este servidor no tiene (se conserva cifrada, esos días no se actualizan)",
|
||||
"typePreWipe": "Antes de eliminar todos los datos",
|
||||
"retentionNote": "La copia semanal se reemplaza cada domingo. Una copia tomada antes de «Eliminar todos los datos» se elimina al cabo de {days} días. Las copias subidas se quedan hasta que se borra su cuenta.",
|
||||
"retentionNoteNoGrace": "La copia semanal se reemplaza cada domingo. Las copias subidas se quedan hasta que se borra su cuenta."
|
||||
"retentionNoteNoGrace": "La copia semanal se reemplaza cada domingo. Las copias subidas se quedan hasta que se borra su cuenta.",
|
||||
"database": {
|
||||
"title": "Volcado de la base de datos",
|
||||
"lastGood": "Último volcado correcto {when}, {size}, {where}.",
|
||||
"whereBoth": "guardado en este servidor y fuera de él",
|
||||
"whereLocal": "guardado en este servidor",
|
||||
"whereOffhost": "solo fuera del servidor",
|
||||
"none": "Aún no hay ningún volcado de la base de datos. El primero se hace esta noche.",
|
||||
"failedTitle": "El último volcado de la base de datos falló",
|
||||
"failedDescription": "{when}: {reason}",
|
||||
"disabled": "Los volcados de la base de datos están desactivados (DB_BACKUP_ENABLED=false). Haz la copia de la base de datos de otra forma.",
|
||||
"noDirectoryOffhost": "Este servidor no puede escribir en {dir}, así que los volcados solo se guardan fuera y aquí no queda ninguno. Monta el volumen de copias.",
|
||||
"noDirectory": "Este servidor no puede escribir en {dir} y no hay almacenamiento externo configurado, así que no se puede hacer ningún volcado. Monta el volumen de copias.",
|
||||
"retentionLocal": "Las cuentas eliminadas siguen en los volcados hasta que estos caducan: en este servidor se guardan los {nights} más recientes.",
|
||||
"retentionOffhost": "La regla de ciclo de vida del bucket elimina las copias externas tras {days} días.",
|
||||
"retentionOffhostMissing": "Ninguna regla de ciclo de vida del bucket cubre database/, así que los volcados de ahí, y las cuentas eliminadas que contienen, nunca se borran. Añade una regla para ese prefijo."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zona de peligro",
|
||||
@@ -8527,47 +8543,6 @@
|
||||
"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."
|
||||
},
|
||||
"keyBackup": {
|
||||
"title": "Haz una copia de tu clave de cifrado",
|
||||
"description": "Sin una copia de esta clave, tus datos cifrados y las copias de este servidor no se podrán volver a leer.",
|
||||
"loading": "Cargando el estado de la copia de la clave…",
|
||||
"loadError": "No se pudo cargar el estado de la copia de la clave.",
|
||||
"badgeDue": "Pendiente",
|
||||
"badgeDone": "Guardada",
|
||||
"keyId": "ID de la clave activa",
|
||||
"fingerprint": "Huella",
|
||||
"whatIsLost": "Guarda una copia de la clave fuera de este servidor, por ejemplo en un gestor de contraseñas. Si el servidor se reinstala con otra clave, HealthLog no vuelve a funcionar hasta que la clave original esté de vuelta.",
|
||||
"fingerprintHint": "La huella son los primeros 12 caracteres del hash SHA-256 de la clave. Identifica la clave sin revelarla, para que puedas compararla con tu copia.",
|
||||
"checkLabel": "Comprobar mi copia",
|
||||
"checkButton": "Comprobar",
|
||||
"checkNote": "La clave que pegues se compara con la clave activa y después se descarta. Nunca se guarda ni se registra.",
|
||||
"checkInsecureNote": "Esta página no se sirve por https://, así que la clave que pegues viaja por la red sin cifrar, igual que tu contraseña. Se compara y se descarta, nunca se guarda ni se registra.",
|
||||
"checkMatches": "Tu copia coincide con la clave activa.",
|
||||
"checkNoMatch": "Tu copia no coincide con la clave activa. Comprueba que copiaste el valor completo.",
|
||||
"checkFailed": "La comprobación ha fallado.",
|
||||
"confirmButton": "He guardado esta clave",
|
||||
"confirmed": "Copia de la clave confirmada.",
|
||||
"confirmFailed": "No se pudo guardar la confirmación.",
|
||||
"stale": "La clave del servidor cambió desde que se cargó esta página. Revisa el nuevo ID y la huella, y vuelve a confirmar.",
|
||||
"confirmedAt": "Confirmada el {date}.",
|
||||
"confirmedAtBy": "Confirmada el {date} por {who}.",
|
||||
"dueAgain": "Se confirmó una copia de la clave {keyId}, pero el servidor usa ahora otra clave. Guarda una copia de la actual.",
|
||||
"tileLabel": "Copia de la clave de cifrado",
|
||||
"tileDue": "Sin confirmar",
|
||||
"tileDone": "Confirmada",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "La clave es ENCRYPTION_KEY (o ENCRYPTION_KEYS) en el archivo .env junto a docker-compose.yml.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Abre Apps, selecciona HealthLog y elige Editar. La clave está en el campo Encryption Key de HealthLog Configuration; en una app instalada con YAML, es ENCRYPTION_KEY en las variables de entorno.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Abre la aplicación en Coolify y copia ENCRYPTION_KEY de sus Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "En la pestaña Docker, edita el contenedor de HealthLog y copia ENCRYPTION_KEY de la plantilla, o del .env de tu stack de Compose Manager.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Abre el stack de HealthLog en Portainer y copia ENCRYPTION_KEY de sus variables de entorno."
|
||||
}
|
||||
},
|
||||
"keyMismatch": {
|
||||
"title": "HealthLog no puede abrir sus datos",
|
||||
"body": "La clave de cifrado con la que se inició este servidor no es la clave con la que se escribió su base de datos. Para proteger tus datos, HealthLog no sirve nada hasta que se corrija.",
|
||||
@@ -8598,6 +8573,166 @@
|
||||
"defaultAboveCeiling": "El nivel predeterminado no puede estar por encima del nivel más alto.",
|
||||
"gapLabel": "Horas entre actualizaciones de tarjetas tras una sincronización (Estándar)",
|
||||
"gapHint": "Horas enteras de 1 a 48. Déjalo vacío para usar AI_SYNC_REGEN_GAP_HOURS, si no 6. Vigente ahora: {hours} h."
|
||||
},
|
||||
"recoveryKit": {
|
||||
"title": "Kit de recuperación",
|
||||
"description": "Cada clave que necesita una restauración de este servidor, con su ID y su huella.",
|
||||
"intro": "Guarda una copia de cada clave de abajo fuera de este servidor, por ejemplo en un gestor de contraseñas. Sin ellas no se puede volver a leer ni un volcado de la base de datos ni una copia de seguridad, y HealthLog se niega a arrancar si falta una clave que necesita.",
|
||||
"loading": "Cargando el kit de recuperación…",
|
||||
"loadError": "No se pudo cargar el kit de recuperación.",
|
||||
"badgeDue": "Pendiente",
|
||||
"badgeDone": "Guardado",
|
||||
"keyId": "ID",
|
||||
"fingerprint": "Huella",
|
||||
"noFingerprint": "No está en este servidor",
|
||||
"kind": {
|
||||
"application": "Clave de aplicación {id}",
|
||||
"backup": "Clave de las copias fuera del host",
|
||||
"backupPrevious": "Clave anterior de las copias fuera del host",
|
||||
"token": "Clave de tokens"
|
||||
},
|
||||
"need": {
|
||||
"active": "Cifra los datos nuevos.",
|
||||
"configured": "Está en este servidor.",
|
||||
"storedCopies": "La necesitan {count} copias guardadas.",
|
||||
"offhostUntil": "La necesitan las copias fuera del host hasta el {date}.",
|
||||
"offhostUnknown": "La necesitan las copias fuera del host mientras el bucket las conserve.",
|
||||
"backupActive": "Sella cada nueva copia fuera del host.",
|
||||
"backupPrevious": "Abre las copias fuera del host escritas antes del último cambio de clave.",
|
||||
"token": "Firma sesiones, tokens y enlaces para compartir. Sin ella se cierra la sesión de todo el mundo, pero no se pierden datos."
|
||||
},
|
||||
"source": "Definida en {variable}.",
|
||||
"status": {
|
||||
"confirmed": "Guardada",
|
||||
"new": "Aún sin guardar",
|
||||
"notConfigured": "No está en este servidor",
|
||||
"unreadable": "No es una clave válida"
|
||||
},
|
||||
"notConfiguredHint": "Una copia de seguridad aún necesita esta clave y este servidor no la tiene. Solo una copia que guardes en otro lugar puede leer esas copias de seguridad. Confirma el kit cuando tengas esa copia.",
|
||||
"unreadableHint": "La variable está definida pero no contiene una clave válida. Corrígela antes de confiar en las copias fuera del host.",
|
||||
"fingerprintHint": "La huella son los primeros 12 caracteres del hash SHA-256 de una clave. Identifica la clave sin revelarla, para que puedas compararla con tu copia.",
|
||||
"whereTitle": "Dónde se definen las claves",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "Las claves están en el archivo .env junto a docker-compose.yml: ENCRYPTION_KEY o ENCRYPTION_KEYS, API_TOKEN_HMAC_KEY y, con copias fuera del host, BACKUP_ENCRYPTION_KEY.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Abre Apps, selecciona HealthLog y elige Edit. Las claves están en HealthLog Configuration; en una app instalada con YAML, son ENCRYPTION_KEY, API_TOKEN_HMAC_KEY y BACKUP_ENCRYPTION_KEY en los ajustes de entorno.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Abre la aplicación en Coolify y copia ENCRYPTION_KEY, API_TOKEN_HMAC_KEY y BACKUP_ENCRYPTION_KEY de sus Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "En la pestaña Docker, edita el contenedor de HealthLog y copia ENCRYPTION_KEY, API_TOKEN_HMAC_KEY y BACKUP_ENCRYPTION_KEY de la plantilla, o del .env de tu stack de Compose Manager.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Abre el stack de HealthLog en Portainer y copia ENCRYPTION_KEY, API_TOKEN_HMAC_KEY y BACKUP_ENCRYPTION_KEY de sus variables de entorno."
|
||||
},
|
||||
"restoreTitle": "Restaurar este servidor",
|
||||
"restoreBody": "Instala HealthLog con las mismas claves, restaura el volcado de la base de datos más reciente en una base de datos vacía y luego arranca la app. La guía explica cada paso.",
|
||||
"restoreLink": "Guía de recuperación ante desastres",
|
||||
"checkLabel": "Comprobar mi copia",
|
||||
"checkKeyLabel": "Clave que comprobar",
|
||||
"checkValueLabel": "Tu copia de la clave",
|
||||
"checkButton": "Comprobar",
|
||||
"checkNote": "La clave que pegues se compara con la que tiene este servidor y después se descarta. Nunca se guarda ni se registra.",
|
||||
"checkInsecureNote": "Esta página no se sirve por https://, así que la clave que pegues viaja por la red sin cifrar, igual que tu contraseña. Se compara y se descarta, nunca se guarda ni se registra.",
|
||||
"checkMatches": "Tu copia coincide con esta clave.",
|
||||
"checkNoMatch": "Tu copia no coincide con esta clave. Comprueba que copiaste el valor completo.",
|
||||
"checkFailed": "La comprobación ha fallado.",
|
||||
"confirmButton": "Tengo una copia de cada clave",
|
||||
"confirmed": "Kit de recuperación confirmado.",
|
||||
"confirmFailed": "No se pudo guardar la confirmación.",
|
||||
"stale": "El kit cambió desde que se cargó esta página. Revisa las claves que muestra ahora y vuelve a confirmar.",
|
||||
"confirmedAt": "Confirmado el {date}.",
|
||||
"confirmedAtBy": "Confirmado el {date} por {who}.",
|
||||
"dueAgain": "El kit se confirmó el {date}. Ahora también incluye {keys}, que esa confirmación no cubría. Guarda una copia de cada una y vuelve a confirmar."
|
||||
},
|
||||
"recoveryReadiness": {
|
||||
"title": "Preparación para la recuperación",
|
||||
"description": "Si este servidor se podría recuperar tras una pérdida, a juzgar por cada copia de seguridad que hace.",
|
||||
"loading": "Comprobando las copias de seguridad…",
|
||||
"loadError": "No se pudo cargar la preparación para la recuperación.",
|
||||
"state": {
|
||||
"ok": "Listo",
|
||||
"attention": "Necesita atención",
|
||||
"failing": "En riesgo"
|
||||
},
|
||||
"signal": {
|
||||
"database_dump": "Volcado de la base de datos",
|
||||
"weekly_copy": "Copias semanales de las cuentas",
|
||||
"offhost_copies": "Copias fuera del host",
|
||||
"restore_drill": "Simulacro de restauración",
|
||||
"offhost_setup": "Copia fuera del host",
|
||||
"recovery_kit": "Kit de recuperación"
|
||||
},
|
||||
"at": {
|
||||
"database_dump": "Volcado más reciente: {date}",
|
||||
"weekly_copy": "Copia de cuenta más antigua: {date}",
|
||||
"offhost_copies": "Copia fuera del host más antigua: {date}",
|
||||
"restore_drill": "Último simulacro: {date}",
|
||||
"recovery_kit": "Confirmado: {date}"
|
||||
},
|
||||
"dueBy": "Vence el {date}",
|
||||
"fix": {
|
||||
"database_dump": "Abrir copias de seguridad",
|
||||
"weekly_copy": "Abrir copias de seguridad",
|
||||
"offhost_copies": "Abrir copias de seguridad",
|
||||
"restore_drill": "Abrir copias de seguridad",
|
||||
"offhost_setup": "Abrir copias de seguridad",
|
||||
"recovery_kit": "Abrir el kit"
|
||||
},
|
||||
"reason": {
|
||||
"database_dump": {
|
||||
"not_available": "Esta versión no programa un volcado de la base de datos.",
|
||||
"disabled": "El volcado programado de la base de datos está desactivado, así que ninguna copia puede reconstruir este servidor. Para volver a activarlo, quita DB_BACKUP_ENABLED o ponlo en true y reinicia HealthLog.",
|
||||
"unreadable": "No se pudo leer el estado del volcado de la base de datos.",
|
||||
"never_succeeded": "Ningún volcado de la base de datos ha salido bien todavía, y el último intento falló.",
|
||||
"never_ran": "No se ha escrito ningún volcado de la base de datos en las {hours} horas transcurridas desde que se activaron los volcados.",
|
||||
"first_pending": "El primer volcado de la base de datos aún no se ha ejecutado.",
|
||||
"stale": "El volcado de la base de datos más reciente tiene {hours} horas.",
|
||||
"last_failed": "El último volcado de la base de datos falló. El anterior sigue siendo reciente.",
|
||||
"fresh": "El último volcado de la base de datos es reciente.",
|
||||
"nowhere_to_write": "No se puede escribir en el directorio de volcados {directory} y no hay ningún bucket externo configurado, así que no se puede guardar ningún volcado."
|
||||
},
|
||||
"weekly_copy": {
|
||||
"no_accounts": "Todavía no hay cuentas que copiar.",
|
||||
"stale": "La copia semanal más antigua tiene {days} días. Cuentas con retraso: {accounts}.",
|
||||
"never": "Una cuenta lleva {days} días sin copia semanal. Cuentas con retraso: {accounts}.",
|
||||
"first_pending": "La primera copia semanal se hace el domingo de madrugada.",
|
||||
"fresh": "Cada cuenta tiene una copia de la última semana."
|
||||
},
|
||||
"offhost_copies": {
|
||||
"not_configured": "No hay ningún bucket fuera del host configurado.",
|
||||
"stale": "Las copias fuera del host llevan más de dos noches de retraso. Cuentas con retraso: {accounts}.",
|
||||
"first_pending": "La ejecución nocturna fuera del host aún no ha llegado a ninguna cuenta.",
|
||||
"fresh": "Cada cuenta tiene una copia reciente en el bucket fuera del host."
|
||||
},
|
||||
"restore_drill": {
|
||||
"not_configured": "El simulacro lee el bucket fuera del host, que no está configurado.",
|
||||
"not_run_yet": "El simulacro de restauración mensual aún no se ha ejecutado.",
|
||||
"failed": "El último simulacro de restauración falló: no se pudieron volver a leer {failed} de {checked} copias.",
|
||||
"failed_run": "El último simulacro de restauración no pudo terminar.",
|
||||
"passed": "El último simulacro de restauración volvió a leer {checked} copias."
|
||||
},
|
||||
"offhost_setup": {
|
||||
"configured": "Las copias salen de este servidor cada noche.",
|
||||
"no_admin": "Todavía no hay ninguna cuenta de administrador.",
|
||||
"not_configured": "No hay ningún bucket fuera del host configurado, así que cada copia está solo en este servidor.",
|
||||
"not_configured_grace": "Aún no hay ningún bucket fuera del host configurado, así que cada copia está solo en este servidor."
|
||||
},
|
||||
"recovery_kit": {
|
||||
"confirmed": "Se guarda una copia de cada clave que necesita una restauración.",
|
||||
"out_of_date": "El kit de recuperación ahora también incluye {keys}, que la última confirmación no cubría. No hay nada roto: guarda una copia y vuelve a confirmar el kit.",
|
||||
"no_admin": "Todavía no hay ninguna cuenta de administrador.",
|
||||
"not_confirmed": "Nadie ha confirmado una copia de las claves que necesita una restauración: {keys}.",
|
||||
"not_confirmed_grace": "Guarda una copia de las claves que necesita una restauración: {keys}."
|
||||
}
|
||||
},
|
||||
"recovered": {
|
||||
"database_dump": "El volcado de la base de datos vuelve a ser reciente.",
|
||||
"weekly_copy": "Cada cuenta vuelve a tener una copia semanal reciente.",
|
||||
"offhost_copies": "Cada cuenta vuelve a tener una copia fuera del host reciente.",
|
||||
"restore_drill": "El simulacro de restauración vuelve a superarse.",
|
||||
"offhost_setup": "Las copias fuera del host ya están configuradas.",
|
||||
"recovery_kit": "El kit de recuperación vuelve a estar confirmado."
|
||||
}
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
@@ -9065,6 +9200,14 @@
|
||||
"checkupReminders": {
|
||||
"title": "Recordatorios de chequeos",
|
||||
"description": "Activa o desactiva recordatorios de chequeos concretos."
|
||||
},
|
||||
"eventBackupHealth": "Estado de las copias de seguridad",
|
||||
"eventBackupHealthDesc": "Cuando una copia de seguridad se detiene, se retrasa o se recupera. Solo se envía a los administradores.",
|
||||
"recoveryReadiness": {
|
||||
"titleAttention": "Las copias de seguridad necesitan atención",
|
||||
"titleFailing": "Las copias de seguridad están en riesgo",
|
||||
"titleReminder": "Las copias de seguridad siguen en riesgo",
|
||||
"titleRecovered": "Las copias de seguridad vuelven a la normalidad"
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
@@ -11792,7 +11935,7 @@
|
||||
"dismiss": "Descartar",
|
||||
"empty": "No se pudo leer nada de este documento.",
|
||||
"unavailableBody": "Configura un proveedor de IA para sugerir título, tipo y fecha.",
|
||||
"unavailableLocalOcr": "Activa el OCR local en los ajustes de IA para leer imágenes en este dispositivo.",
|
||||
"unavailableLocalOcr": "Activa «Leer escaneos en este dispositivo» en Ajustes → Módulos → Laboratorio para leer imágenes en este dispositivo.",
|
||||
"unavailableAction": "Abrir ajustes de IA",
|
||||
"errorGeneric": "No se pudo leer el documento. Inténtalo de nuevo.",
|
||||
"errorOcr": "No se pudo leer texto de esta imagen.",
|
||||
@@ -11805,11 +11948,12 @@
|
||||
"errorFileType": "Este tipo de archivo no se puede leer.",
|
||||
"errorNotIndexed": "Primero lee el documento y luego extrae.",
|
||||
"errorOutputLimit": "El proveedor de IA alcanzó su límite de salida antes de devolver un solo valor. Prueba con un modelo que permita respuestas más largas.",
|
||||
"errorLocalOcrDisabled": "Activa primero el OCR local en los ajustes de IA.",
|
||||
"errorLocalOcrDisabled": "Activa primero «Leer escaneos en este dispositivo» en Ajustes → Módulos → Laboratorio.",
|
||||
"errorProvider": "No hay ningún proveedor de IA configurado para esto.",
|
||||
"errorTextImageOnly": "El OCR local solo lee imágenes, no este archivo.",
|
||||
"errorConsent": "Leer documentos con IA necesita primero tu consentimiento.",
|
||||
"errorUnavailable": "Leer documentos con IA no está disponible aquí ahora mismo."
|
||||
"errorUnavailable": "Leer documentos con IA no está disponible aquí ahora mismo.",
|
||||
"unavailableLocalOcrAction": "Abrir ajustes de laboratorio"
|
||||
},
|
||||
"summary": {
|
||||
"summarise": "Resumir",
|
||||
|
||||
+195
-51
@@ -780,13 +780,13 @@
|
||||
"bmi": "kg/m²"
|
||||
}
|
||||
},
|
||||
"keyBackupBanner": {
|
||||
"title": "Sauvegarde ta clé de chiffrement",
|
||||
"body": "Sans copie de la clé, tes données ne peuvent pas être récupérées après une réinstallation. Ça prend une minute.",
|
||||
"action": "Ouvrir l'étape"
|
||||
},
|
||||
"quickAddSymptom": "Noter un symptôme",
|
||||
"todayCardVisible": "Afficher la carte du haut"
|
||||
"todayCardVisible": "Afficher la carte du haut",
|
||||
"recoveryKitBanner": {
|
||||
"title": "Sauvegardez votre kit de récupération",
|
||||
"body": "Sans copies des clés utilisées par ce serveur, ses données ne peuvent pas être récupérées après une réinstallation. Ça prend quelques minutes.",
|
||||
"action": "Ouvrir le kit"
|
||||
}
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Mesures",
|
||||
@@ -8331,7 +8331,23 @@
|
||||
"restoreSkippedEnvironmentLocationCiphertext": "Lieu d'une période ou d'un jour environnemental chiffré avec une clé que ce serveur ne possède pas (conservé chiffré, ces jours ne sont pas actualisés)",
|
||||
"typePreWipe": "Avant la suppression de toutes les données",
|
||||
"retentionNote": "La copie hebdomadaire est remplacée chaque dimanche. Une copie prise avant « Supprimer toutes les données » est supprimée au bout de {days} jours. Les copies téléversées restent jusqu’à l’effacement de leur compte.",
|
||||
"retentionNoteNoGrace": "La copie hebdomadaire est remplacée chaque dimanche. Les copies téléversées restent jusqu’à l’effacement de leur compte."
|
||||
"retentionNoteNoGrace": "La copie hebdomadaire est remplacée chaque dimanche. Les copies téléversées restent jusqu’à l’effacement de leur compte.",
|
||||
"database": {
|
||||
"title": "Dump de la base de données",
|
||||
"lastGood": "Dernier dump valide {when}, {size}, {where}.",
|
||||
"whereBoth": "conservé sur ce serveur et hors site",
|
||||
"whereLocal": "conservé sur ce serveur",
|
||||
"whereOffhost": "hors site uniquement",
|
||||
"none": "Aucun dump de la base de données pour l'instant. Le premier a lieu cette nuit.",
|
||||
"failedTitle": "Le dernier dump de la base de données a échoué",
|
||||
"failedDescription": "{when} : {reason}",
|
||||
"disabled": "Les dumps de la base de données sont désactivés (DB_BACKUP_ENABLED=false). Sauvegardez la base de données autrement.",
|
||||
"noDirectoryOffhost": "Ce serveur ne peut pas écrire dans {dir} : les dumps partent uniquement hors site et aucun n'est conservé ici. Montez le volume de sauvegarde.",
|
||||
"noDirectory": "Ce serveur ne peut pas écrire dans {dir} et aucun stockage hors site n'est configuré : aucun dump ne peut être fait. Montez le volume de sauvegarde.",
|
||||
"retentionLocal": "Les comptes supprimés restent dans les dumps jusqu'à leur expiration : ce serveur conserve les {nights} plus récents.",
|
||||
"retentionOffhost": "La règle de cycle de vie du bucket supprime les copies hors site après {days} jours.",
|
||||
"retentionOffhostMissing": "Aucune règle de cycle de vie du bucket ne couvre database/ : les dumps qui s'y trouvent, et les comptes supprimés qu'ils contiennent, ne sont jamais effacés. Ajoutez une règle pour ce préfixe."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zone de danger",
|
||||
@@ -8527,47 +8543,6 @@
|
||||
"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."
|
||||
},
|
||||
"keyBackup": {
|
||||
"title": "Sauvegarde ta clé de chiffrement",
|
||||
"description": "Sans copie de cette clé, tes données chiffrées et les sauvegardes de ce serveur ne pourront plus être lues.",
|
||||
"loading": "Chargement de l'état de la sauvegarde de la clé…",
|
||||
"loadError": "Impossible de charger l'état de la sauvegarde de la clé.",
|
||||
"badgeDue": "À faire",
|
||||
"badgeDone": "Sauvegardée",
|
||||
"keyId": "ID de la clé active",
|
||||
"fingerprint": "Empreinte",
|
||||
"whatIsLost": "Garde une copie de la clé en dehors de ce serveur, par exemple dans un gestionnaire de mots de passe. Si le serveur est réinstallé avec une autre clé, HealthLog ne sert plus rien tant que la clé d'origine n'est pas revenue.",
|
||||
"fingerprintHint": "L'empreinte correspond aux 12 premiers caractères du hachage SHA-256 de la clé. Elle identifie la clé sans la révéler, pour que tu puisses la comparer à ta copie.",
|
||||
"checkLabel": "Vérifier ma copie",
|
||||
"checkButton": "Vérifier",
|
||||
"checkNote": "La clé collée est comparée à la clé active puis oubliée. Elle n'est jamais stockée ni journalisée.",
|
||||
"checkInsecureNote": "Cette page n'est pas servie en https://, donc la clé collée traverse le réseau en clair, comme ton mot de passe. Elle est comparée puis oubliée, jamais stockée ni journalisée.",
|
||||
"checkMatches": "Ta copie correspond à la clé active.",
|
||||
"checkNoMatch": "Ta copie ne correspond pas à la clé active. Vérifie que tu as copié toute la valeur.",
|
||||
"checkFailed": "La vérification a échoué.",
|
||||
"confirmButton": "J'ai sauvegardé cette clé",
|
||||
"confirmed": "Sauvegarde de la clé confirmée.",
|
||||
"confirmFailed": "Impossible d'enregistrer la confirmation.",
|
||||
"stale": "La clé du serveur a changé depuis le chargement de cette page. Vérifie le nouvel ID et l'empreinte, puis confirme à nouveau.",
|
||||
"confirmedAt": "Confirmée le {date}.",
|
||||
"confirmedAtBy": "Confirmée le {date} par {who}.",
|
||||
"dueAgain": "Une sauvegarde a été confirmée pour la clé {keyId}, mais le serveur utilise maintenant une autre clé. Sauvegarde la clé actuelle.",
|
||||
"tileLabel": "Sauvegarde de la clé de chiffrement",
|
||||
"tileDue": "Non confirmée",
|
||||
"tileDone": "Confirmée",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "La clé est ENCRYPTION_KEY (ou ENCRYPTION_KEYS) dans le fichier .env à côté de docker-compose.yml.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Ouvre Apps, sélectionne HealthLog et choisis Modifier. La clé se trouve dans le champ Encryption Key sous HealthLog Configuration ; pour une app installée via YAML, c'est ENCRYPTION_KEY dans les variables d'environnement.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Ouvre l'application dans Coolify et copie ENCRYPTION_KEY depuis ses Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "Dans l'onglet Docker, modifie le conteneur HealthLog et copie ENCRYPTION_KEY depuis le modèle, ou depuis le .env de ta pile Compose Manager.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Ouvre la pile HealthLog dans Portainer et copie ENCRYPTION_KEY depuis ses variables d'environnement."
|
||||
}
|
||||
},
|
||||
"keyMismatch": {
|
||||
"title": "HealthLog ne peut pas ouvrir ses données",
|
||||
"body": "La clé de chiffrement avec laquelle ce serveur a démarré n'est pas celle avec laquelle sa base de données a été écrite. Pour protéger tes données, HealthLog ne sert rien tant que ce n'est pas corrigé.",
|
||||
@@ -8598,6 +8573,166 @@
|
||||
"defaultAboveCeiling": "Le niveau par défaut ne peut pas dépasser le niveau le plus élevé.",
|
||||
"gapLabel": "Heures entre deux mises à jour des cartes après une synchronisation (Standard)",
|
||||
"gapHint": "Heures entières de 1 à 48. Laissez vide pour utiliser AI_SYNC_REGEN_GAP_HOURS, sinon 6. En vigueur : {hours} h."
|
||||
},
|
||||
"recoveryKit": {
|
||||
"title": "Kit de récupération",
|
||||
"description": "Chaque clé nécessaire à la restauration de ce serveur, avec son ID et son empreinte.",
|
||||
"intro": "Gardez une copie de chaque clé ci-dessous en dehors de ce serveur, par exemple dans un gestionnaire de mots de passe. Sans elles, ni un dump de la base de données ni une copie de sauvegarde ne peuvent être relus, et HealthLog refuse de démarrer s'il manque une clé dont il a besoin.",
|
||||
"loading": "Chargement du kit de récupération…",
|
||||
"loadError": "Impossible de charger le kit de récupération.",
|
||||
"badgeDue": "À faire",
|
||||
"badgeDone": "Sauvegardé",
|
||||
"keyId": "ID",
|
||||
"fingerprint": "Empreinte",
|
||||
"noFingerprint": "Absente de ce serveur",
|
||||
"kind": {
|
||||
"application": "Clé d'application {id}",
|
||||
"backup": "Clé des sauvegardes hors hôte",
|
||||
"backupPrevious": "Ancienne clé des sauvegardes hors hôte",
|
||||
"token": "Clé des jetons"
|
||||
},
|
||||
"need": {
|
||||
"active": "Chiffre les nouvelles données.",
|
||||
"configured": "Présente sur ce serveur.",
|
||||
"storedCopies": "Nécessaire à {count} copies stockées.",
|
||||
"offhostUntil": "Nécessaire aux copies hors hôte jusqu'au {date}.",
|
||||
"offhostUnknown": "Nécessaire aux copies hors hôte tant que le bucket les conserve.",
|
||||
"backupActive": "Scelle chaque nouvelle copie hors hôte.",
|
||||
"backupPrevious": "Ouvre les copies hors hôte écrites avant le dernier changement de clé.",
|
||||
"token": "Signe les sessions, les jetons et les liens de partage. Sans elle, tout le monde est déconnecté, mais aucune donnée n'est perdue."
|
||||
},
|
||||
"source": "Définie dans {variable}.",
|
||||
"status": {
|
||||
"confirmed": "Sauvegardée",
|
||||
"new": "Pas encore sauvegardée",
|
||||
"notConfigured": "Absente de ce serveur",
|
||||
"unreadable": "Clé non valide"
|
||||
},
|
||||
"notConfiguredHint": "Une sauvegarde a encore besoin de cette clé et ce serveur ne l'a pas. Seule une copie que vous conservez ailleurs peut lire ces sauvegardes. Confirmez le kit une fois que vous avez cette copie.",
|
||||
"unreadableHint": "La variable est définie mais ne contient pas de clé valide. Corrigez-la avant de vous fier aux copies hors hôte.",
|
||||
"fingerprintHint": "L'empreinte correspond aux 12 premiers caractères du hachage SHA-256 d'une clé. Elle identifie la clé sans la révéler, pour que vous puissiez la comparer à votre copie.",
|
||||
"whereTitle": "Où les clés sont définies",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "Les clés se trouvent dans le fichier .env à côté de docker-compose.yml : ENCRYPTION_KEY ou ENCRYPTION_KEYS, API_TOKEN_HMAC_KEY et, avec les copies hors hôte, BACKUP_ENCRYPTION_KEY.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Ouvrez Apps, sélectionnez HealthLog et choisissez Edit. Les clés se trouvent sous HealthLog Configuration ; pour une app installée via YAML, ce sont ENCRYPTION_KEY, API_TOKEN_HMAC_KEY et BACKUP_ENCRYPTION_KEY dans les paramètres d'environnement.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Ouvrez l'application dans Coolify et copiez ENCRYPTION_KEY, API_TOKEN_HMAC_KEY et BACKUP_ENCRYPTION_KEY depuis ses Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "Dans l'onglet Docker, modifiez le conteneur HealthLog et copiez ENCRYPTION_KEY, API_TOKEN_HMAC_KEY et BACKUP_ENCRYPTION_KEY depuis le modèle, ou depuis le .env de votre pile Compose Manager.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Ouvrez la pile HealthLog dans Portainer et copiez ENCRYPTION_KEY, API_TOKEN_HMAC_KEY et BACKUP_ENCRYPTION_KEY depuis ses variables d'environnement."
|
||||
},
|
||||
"restoreTitle": "Restaurer ce serveur",
|
||||
"restoreBody": "Installez HealthLog avec les mêmes clés, restaurez le dump de la base de données le plus récent dans une base vide, puis démarrez l'app. Le guide détaille chaque étape.",
|
||||
"restoreLink": "Guide de reprise après sinistre",
|
||||
"checkLabel": "Vérifier ma copie",
|
||||
"checkKeyLabel": "Clé à vérifier",
|
||||
"checkValueLabel": "Votre copie de la clé",
|
||||
"checkButton": "Vérifier",
|
||||
"checkNote": "La clé collée est comparée à celle que détient ce serveur puis oubliée. Elle n'est jamais stockée ni journalisée.",
|
||||
"checkInsecureNote": "Cette page n'est pas servie en https://, donc la clé collée traverse le réseau en clair, comme votre mot de passe. Elle est comparée puis oubliée, jamais stockée ni journalisée.",
|
||||
"checkMatches": "Votre copie correspond à cette clé.",
|
||||
"checkNoMatch": "Votre copie ne correspond pas à cette clé. Vérifiez que vous avez copié toute la valeur.",
|
||||
"checkFailed": "La vérification a échoué.",
|
||||
"confirmButton": "J'ai une copie de chaque clé",
|
||||
"confirmed": "Kit de récupération confirmé.",
|
||||
"confirmFailed": "Impossible d'enregistrer la confirmation.",
|
||||
"stale": "Le kit a changé depuis le chargement de cette page. Vérifiez les clés qu'il liste maintenant, puis confirmez à nouveau.",
|
||||
"confirmedAt": "Confirmé le {date}.",
|
||||
"confirmedAtBy": "Confirmé le {date} par {who}.",
|
||||
"dueAgain": "Le kit a été confirmé le {date}. Il liste désormais aussi {keys}, que cette confirmation ne couvrait pas. Sauvegardez une copie de chacune et confirmez à nouveau."
|
||||
},
|
||||
"recoveryReadiness": {
|
||||
"title": "Capacité de restauration",
|
||||
"description": "Indique si ce serveur pourrait être remis en route après une perte, d'après chaque sauvegarde qu'il réalise.",
|
||||
"loading": "Vérification des sauvegardes…",
|
||||
"loadError": "Impossible de charger la capacité de restauration.",
|
||||
"state": {
|
||||
"ok": "Prêt",
|
||||
"attention": "Demande de l'attention",
|
||||
"failing": "Menacé"
|
||||
},
|
||||
"signal": {
|
||||
"database_dump": "Dump de la base de données",
|
||||
"weekly_copy": "Copies hebdomadaires des comptes",
|
||||
"offhost_copies": "Copies hors hôte",
|
||||
"restore_drill": "Test de restauration",
|
||||
"offhost_setup": "Sauvegarde hors hôte",
|
||||
"recovery_kit": "Kit de récupération"
|
||||
},
|
||||
"at": {
|
||||
"database_dump": "Dump le plus récent : {date}",
|
||||
"weekly_copy": "Copie de compte la plus ancienne : {date}",
|
||||
"offhost_copies": "Copie hors hôte la plus ancienne : {date}",
|
||||
"restore_drill": "Dernier test : {date}",
|
||||
"recovery_kit": "Confirmé : {date}"
|
||||
},
|
||||
"dueBy": "À faire avant le {date}",
|
||||
"fix": {
|
||||
"database_dump": "Ouvrir les sauvegardes",
|
||||
"weekly_copy": "Ouvrir les sauvegardes",
|
||||
"offhost_copies": "Ouvrir les sauvegardes",
|
||||
"restore_drill": "Ouvrir les sauvegardes",
|
||||
"offhost_setup": "Ouvrir les sauvegardes",
|
||||
"recovery_kit": "Ouvrir le kit"
|
||||
},
|
||||
"reason": {
|
||||
"database_dump": {
|
||||
"not_available": "Cette version ne planifie pas de dump de la base de données.",
|
||||
"disabled": "Le dump planifié de la base de données est désactivé, donc aucune copie ne peut reconstruire ce serveur. Pour le réactiver, supprimez DB_BACKUP_ENABLED ou mettez-le à true, puis redémarrez HealthLog.",
|
||||
"unreadable": "L'état du dump de la base de données n'a pas pu être lu.",
|
||||
"never_succeeded": "Aucun dump de la base de données n'a encore réussi, et la dernière tentative a échoué.",
|
||||
"never_ran": "Aucun dump de la base de données n'a été écrit dans les {hours} heures écoulées depuis l'activation des dumps.",
|
||||
"first_pending": "Le premier dump de la base de données n'a pas encore été lancé.",
|
||||
"stale": "Le dump de la base de données le plus récent date de {hours} heures.",
|
||||
"last_failed": "Le dernier dump de la base de données a échoué. Le précédent est encore récent.",
|
||||
"fresh": "Le dernier dump de la base de données est récent.",
|
||||
"nowhere_to_write": "Le dossier des dumps {directory} n'est pas accessible en écriture et aucun bucket externe n'est configuré, donc aucun dump ne peut être conservé."
|
||||
},
|
||||
"weekly_copy": {
|
||||
"no_accounts": "Il n'y a pas encore de compte à copier.",
|
||||
"stale": "La copie hebdomadaire la plus ancienne date de {days} jours. Comptes en retard : {accounts}.",
|
||||
"never": "Un compte n'a pas eu de copie hebdomadaire depuis {days} jours. Comptes en retard : {accounts}.",
|
||||
"first_pending": "La première copie hebdomadaire est faite tôt le dimanche matin.",
|
||||
"fresh": "Chaque compte a une copie de la semaine dernière."
|
||||
},
|
||||
"offhost_copies": {
|
||||
"not_configured": "Aucun bucket hors hôte n'est configuré.",
|
||||
"stale": "Les copies hors hôte ont plus de deux nuits de retard. Comptes en retard : {accounts}.",
|
||||
"first_pending": "L'envoi nocturne hors hôte n'a encore atteint aucun compte.",
|
||||
"fresh": "Chaque compte a une copie récente dans le bucket hors hôte."
|
||||
},
|
||||
"restore_drill": {
|
||||
"not_configured": "Le test lit le bucket hors hôte, qui n'est pas configuré.",
|
||||
"not_run_yet": "Le test de restauration mensuel n'a pas encore été lancé.",
|
||||
"failed": "Le dernier test de restauration a échoué : {failed} copies sur {checked} n'ont pas pu être relues.",
|
||||
"failed_run": "Le dernier test de restauration n'a pas pu aller jusqu'au bout.",
|
||||
"passed": "Le dernier test de restauration a relu {checked} copies."
|
||||
},
|
||||
"offhost_setup": {
|
||||
"configured": "Les copies quittent ce serveur chaque nuit.",
|
||||
"no_admin": "Il n'y a pas encore de compte administrateur.",
|
||||
"not_configured": "Aucun bucket hors hôte n'est configuré, donc chaque copie se trouve uniquement sur ce serveur.",
|
||||
"not_configured_grace": "Aucun bucket hors hôte n'est encore configuré, donc chaque copie se trouve uniquement sur ce serveur."
|
||||
},
|
||||
"recovery_kit": {
|
||||
"confirmed": "Une copie de chaque clé nécessaire à une restauration est conservée.",
|
||||
"out_of_date": "Le kit de récupération liste désormais aussi {keys}, que la dernière confirmation ne couvrait pas. Rien n'est cassé : sauvegardez une copie et confirmez à nouveau le kit.",
|
||||
"no_admin": "Il n'y a pas encore de compte administrateur.",
|
||||
"not_confirmed": "Personne n'a confirmé de copie des clés nécessaires à une restauration : {keys}.",
|
||||
"not_confirmed_grace": "Sauvegardez une copie des clés nécessaires à une restauration : {keys}."
|
||||
}
|
||||
},
|
||||
"recovered": {
|
||||
"database_dump": "Le dump de la base de données est de nouveau récent.",
|
||||
"weekly_copy": "Chaque compte a de nouveau une copie hebdomadaire récente.",
|
||||
"offhost_copies": "Chaque compte a de nouveau une copie hors hôte récente.",
|
||||
"restore_drill": "Le test de restauration réussit de nouveau.",
|
||||
"offhost_setup": "Les copies hors hôte sont maintenant configurées.",
|
||||
"recovery_kit": "Le kit de récupération est de nouveau confirmé."
|
||||
}
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
@@ -9065,6 +9200,14 @@
|
||||
"checkupReminders": {
|
||||
"title": "Rappels de bilans",
|
||||
"description": "Activez ou désactivez chaque rappel de bilan."
|
||||
},
|
||||
"eventBackupHealth": "État des sauvegardes",
|
||||
"eventBackupHealthDesc": "Quand une sauvegarde s'arrête, prend du retard ou repart. Envoyé uniquement aux administrateurs.",
|
||||
"recoveryReadiness": {
|
||||
"titleAttention": "Les sauvegardes demandent de l'attention",
|
||||
"titleFailing": "Les sauvegardes sont menacées",
|
||||
"titleReminder": "Les sauvegardes sont toujours menacées",
|
||||
"titleRecovered": "Les sauvegardes sont revenues à la normale"
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
@@ -11792,7 +11935,7 @@
|
||||
"dismiss": "Ignorer",
|
||||
"empty": "Impossible de lire ce document.",
|
||||
"unavailableBody": "Configurez un fournisseur d'IA pour suggérer un titre, un type et une date.",
|
||||
"unavailableLocalOcr": "Activez l'OCR local dans les paramètres d'IA pour lire les images sur cet appareil.",
|
||||
"unavailableLocalOcr": "Activez « Lire les scans sur cet appareil » dans Paramètres → Modules → Analyses pour lire les images sur cet appareil.",
|
||||
"unavailableAction": "Ouvrir les paramètres d'IA",
|
||||
"errorGeneric": "Impossible de lire le document. Réessayez.",
|
||||
"errorOcr": "Impossible de lire le texte de cette image.",
|
||||
@@ -11805,11 +11948,12 @@
|
||||
"errorFileType": "Ce type de fichier ne peut pas être lu.",
|
||||
"errorNotIndexed": "Lisez d’abord le document, puis extrayez.",
|
||||
"errorOutputLimit": "Le fournisseur d’IA a atteint sa limite de sortie avant de renvoyer la moindre valeur. Essayez un modèle qui accepte des réponses plus longues.",
|
||||
"errorLocalOcrDisabled": "Activez d'abord l'OCR local dans les paramètres d'IA.",
|
||||
"errorLocalOcrDisabled": "Activez d'abord « Lire les scans sur cet appareil » dans Paramètres → Modules → Analyses.",
|
||||
"errorProvider": "Aucun fournisseur d'IA n'est configuré pour cela.",
|
||||
"errorTextImageOnly": "L'OCR local ne lit que les images, pas ce fichier.",
|
||||
"errorConsent": "La lecture de documents par IA nécessite d'abord votre consentement.",
|
||||
"errorUnavailable": "La lecture de documents par IA n'est pas disponible ici pour le moment."
|
||||
"errorUnavailable": "La lecture de documents par IA n'est pas disponible ici pour le moment.",
|
||||
"unavailableLocalOcrAction": "Ouvrir les paramètres des analyses"
|
||||
},
|
||||
"summary": {
|
||||
"summarise": "Résumer",
|
||||
|
||||
+195
-51
@@ -780,13 +780,13 @@
|
||||
"bmi": "kg/m²"
|
||||
}
|
||||
},
|
||||
"keyBackupBanner": {
|
||||
"title": "Fai un backup della tua chiave di cifratura",
|
||||
"body": "Senza una copia della chiave, i tuoi dati non si possono recuperare dopo una reinstallazione. Ci vuole un minuto.",
|
||||
"action": "Apri il passaggio"
|
||||
},
|
||||
"quickAddSymptom": "Registra sintomo",
|
||||
"todayCardVisible": "Mostra la scheda in alto"
|
||||
"todayCardVisible": "Mostra la scheda in alto",
|
||||
"recoveryKitBanner": {
|
||||
"title": "Salva il tuo kit di recupero",
|
||||
"body": "Senza copie delle chiavi usate da questo server, i suoi dati non si possono recuperare dopo una reinstallazione. Ci vogliono pochi minuti.",
|
||||
"action": "Apri il kit"
|
||||
}
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Misurazioni",
|
||||
@@ -8331,7 +8331,23 @@
|
||||
"restoreSkippedEnvironmentLocationCiphertext": "Luogo di un periodo o giorno ambientale cifrato con una chiave che questo server non possiede (mantenuto cifrato, quei giorni non vengono aggiornati)",
|
||||
"typePreWipe": "Prima dell’eliminazione di tutti i dati",
|
||||
"retentionNote": "La copia settimanale viene sostituita ogni domenica. Una copia presa prima di «Elimina tutti i dati» viene eliminata dopo {days} giorni. Le copie caricate restano finché il loro account non viene cancellato.",
|
||||
"retentionNoteNoGrace": "La copia settimanale viene sostituita ogni domenica. Le copie caricate restano finché il loro account non viene cancellato."
|
||||
"retentionNoteNoGrace": "La copia settimanale viene sostituita ogni domenica. Le copie caricate restano finché il loro account non viene cancellato.",
|
||||
"database": {
|
||||
"title": "Dump del database",
|
||||
"lastGood": "Ultimo dump valido {when}, {size}, {where}.",
|
||||
"whereBoth": "conservato su questo server e fuori sede",
|
||||
"whereLocal": "conservato su questo server",
|
||||
"whereOffhost": "solo fuori sede",
|
||||
"none": "Ancora nessun dump del database. Il primo viene eseguito stanotte.",
|
||||
"failedTitle": "L'ultimo dump del database non è riuscito",
|
||||
"failedDescription": "{when}: {reason}",
|
||||
"disabled": "I dump del database sono disattivati (DB_BACKUP_ENABLED=false). Esegui il backup del database in un altro modo.",
|
||||
"noDirectoryOffhost": "Questo server non può scrivere in {dir}, quindi i dump vanno solo fuori sede e qui non ne resta nessuno. Monta il volume dei backup.",
|
||||
"noDirectory": "Questo server non può scrivere in {dir} e non è configurato alcuno storage esterno, quindi non si può fare alcun dump. Monta il volume dei backup.",
|
||||
"retentionLocal": "Gli account eliminati restano nei dump finché questi non scadono: su questo server restano i {nights} più recenti.",
|
||||
"retentionOffhost": "La regola del ciclo di vita del bucket elimina le copie esterne dopo {days} giorni.",
|
||||
"retentionOffhostMissing": "Nessuna regola del ciclo di vita del bucket copre database/, quindi i dump lì, e gli account eliminati che contengono, non vengono mai rimossi. Aggiungi una regola per quel prefisso."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zona pericolosa",
|
||||
@@ -8527,47 +8543,6 @@
|
||||
"title": "Questa parte della pagina non è stata mostrata",
|
||||
"description": "Il resto della pagina funziona; riprova o ricarica se il problema continua."
|
||||
},
|
||||
"keyBackup": {
|
||||
"title": "Fai un backup della tua chiave di cifratura",
|
||||
"description": "Senza una copia di questa chiave, i tuoi dati cifrati e i backup di questo server non saranno più leggibili.",
|
||||
"loading": "Caricamento dello stato del backup della chiave…",
|
||||
"loadError": "Impossibile caricare lo stato del backup della chiave.",
|
||||
"badgeDue": "Da fare",
|
||||
"badgeDone": "Salvata",
|
||||
"keyId": "ID della chiave attiva",
|
||||
"fingerprint": "Impronta",
|
||||
"whatIsLost": "Conserva una copia della chiave fuori da questo server, per esempio in un gestore di password. Se il server viene reinstallato con un'altra chiave, HealthLog non torna in servizio finché la chiave originale non è di nuovo al suo posto.",
|
||||
"fingerprintHint": "L'impronta sono i primi 12 caratteri dell'hash SHA-256 della chiave. Identifica la chiave senza rivelarla, così puoi confrontarla con la tua copia.",
|
||||
"checkLabel": "Verifica la mia copia",
|
||||
"checkButton": "Verifica",
|
||||
"checkNote": "La chiave incollata viene confrontata con quella attiva e poi scartata. Non viene mai salvata né registrata.",
|
||||
"checkInsecureNote": "Questa pagina non è servita tramite https://, quindi la chiave incollata attraversa la rete in chiaro, come la tua password. Viene confrontata e scartata, mai salvata né registrata.",
|
||||
"checkMatches": "La tua copia corrisponde alla chiave attiva.",
|
||||
"checkNoMatch": "La tua copia non corrisponde alla chiave attiva. Controlla di aver copiato l'intero valore.",
|
||||
"checkFailed": "La verifica non è riuscita.",
|
||||
"confirmButton": "Ho salvato questa chiave",
|
||||
"confirmed": "Backup della chiave confermato.",
|
||||
"confirmFailed": "Impossibile salvare la conferma.",
|
||||
"stale": "La chiave del server è cambiata da quando questa pagina è stata caricata. Controlla il nuovo ID e l'impronta, poi conferma di nuovo.",
|
||||
"confirmedAt": "Confermato il {date}.",
|
||||
"confirmedAtBy": "Confermato il {date} da {who}.",
|
||||
"dueAgain": "È stato confermato un backup per la chiave {keyId}, ma ora il server usa un'altra chiave. Salva quella attuale.",
|
||||
"tileLabel": "Backup della chiave di cifratura",
|
||||
"tileDue": "Non confermato",
|
||||
"tileDone": "Confermato",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "La chiave è ENCRYPTION_KEY (o ENCRYPTION_KEYS) nel file .env accanto a docker-compose.yml.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Apri Apps, seleziona HealthLog e scegli Modifica. La chiave è nel campo Encryption Key sotto HealthLog Configuration; in un'app installata tramite YAML è ENCRYPTION_KEY nelle variabili d'ambiente.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Apri l'applicazione in Coolify e copia ENCRYPTION_KEY dalle sue Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "Nella scheda Docker, modifica il container di HealthLog e copia ENCRYPTION_KEY dal modello, oppure dal .env del tuo stack di Compose Manager.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Apri lo stack di HealthLog in Portainer e copia ENCRYPTION_KEY dalle sue variabili d'ambiente."
|
||||
}
|
||||
},
|
||||
"keyMismatch": {
|
||||
"title": "HealthLog non riesce ad aprire i suoi dati",
|
||||
"body": "La chiave di cifratura con cui è stato avviato questo server non è quella con cui è stato scritto il suo database. Per proteggere i tuoi dati, HealthLog non serve nulla finché il problema non è risolto.",
|
||||
@@ -8598,6 +8573,166 @@
|
||||
"defaultAboveCeiling": "Il livello predefinito non può superare il livello più alto.",
|
||||
"gapLabel": "Ore tra due aggiornamenti delle schede dopo una sincronizzazione (Standard)",
|
||||
"gapHint": "Ore intere da 1 a 48. Lascia vuoto per usare AI_SYNC_REGEN_GAP_HOURS, altrimenti 6. In vigore ora: {hours} h."
|
||||
},
|
||||
"recoveryKit": {
|
||||
"title": "Kit di recupero",
|
||||
"description": "Ogni chiave necessaria per ripristinare questo server, con ID e impronta.",
|
||||
"intro": "Conserva una copia di ciascuna chiave qui sotto fuori da questo server, per esempio in un gestore di password. Senza di esse non si possono più leggere né un dump del database né una copia di backup, e HealthLog si rifiuta di avviarsi se manca una chiave di cui ha bisogno.",
|
||||
"loading": "Caricamento del kit di recupero…",
|
||||
"loadError": "Impossibile caricare il kit di recupero.",
|
||||
"badgeDue": "Da fare",
|
||||
"badgeDone": "Salvato",
|
||||
"keyId": "ID",
|
||||
"fingerprint": "Impronta",
|
||||
"noFingerprint": "Non presente su questo server",
|
||||
"kind": {
|
||||
"application": "Chiave dell'applicazione {id}",
|
||||
"backup": "Chiave dei backup fuori host",
|
||||
"backupPrevious": "Chiave precedente dei backup fuori host",
|
||||
"token": "Chiave dei token"
|
||||
},
|
||||
"need": {
|
||||
"active": "Cifra i nuovi dati.",
|
||||
"configured": "Presente su questo server.",
|
||||
"storedCopies": "Serve a {count} copie archiviate.",
|
||||
"offhostUntil": "Serve alle copie fuori host fino al {date}.",
|
||||
"offhostUnknown": "Serve alle copie fuori host finché il bucket le conserva.",
|
||||
"backupActive": "Sigilla ogni nuova copia fuori host.",
|
||||
"backupPrevious": "Apre le copie fuori host scritte prima dell'ultimo cambio di chiave.",
|
||||
"token": "Firma sessioni, token e link di condivisione. Senza di essa tutti vengono disconnessi, ma nessun dato va perso."
|
||||
},
|
||||
"source": "Impostata in {variable}.",
|
||||
"status": {
|
||||
"confirmed": "Salvata",
|
||||
"new": "Non ancora salvata",
|
||||
"notConfigured": "Non presente su questo server",
|
||||
"unreadable": "Chiave non valida"
|
||||
},
|
||||
"notConfiguredHint": "Un backup ha ancora bisogno di questa chiave e questo server non ce l'ha. Solo una copia che conservi altrove può leggere quei backup. Conferma il kit quando hai quella copia.",
|
||||
"unreadableHint": "La variabile è impostata ma non contiene una chiave valida. Correggila prima di fare affidamento sulle copie fuori host.",
|
||||
"fingerprintHint": "L'impronta sono i primi 12 caratteri dell'hash SHA-256 di una chiave. Identifica la chiave senza rivelarla, così puoi confrontarla con la tua copia.",
|
||||
"whereTitle": "Dove sono impostate le chiavi",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "Le chiavi sono nel file .env accanto a docker-compose.yml: ENCRYPTION_KEY o ENCRYPTION_KEYS, API_TOKEN_HMAC_KEY e, con le copie fuori host, BACKUP_ENCRYPTION_KEY.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Apri Apps, seleziona HealthLog e scegli Edit. Le chiavi sono sotto HealthLog Configuration; in un'app installata tramite YAML sono ENCRYPTION_KEY, API_TOKEN_HMAC_KEY e BACKUP_ENCRYPTION_KEY nelle impostazioni dell'ambiente.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Apri l'applicazione in Coolify e copia ENCRYPTION_KEY, API_TOKEN_HMAC_KEY e BACKUP_ENCRYPTION_KEY dalle sue Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "Nella scheda Docker, modifica il container di HealthLog e copia ENCRYPTION_KEY, API_TOKEN_HMAC_KEY e BACKUP_ENCRYPTION_KEY dal modello, oppure dal .env del tuo stack di Compose Manager.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Apri lo stack di HealthLog in Portainer e copia ENCRYPTION_KEY, API_TOKEN_HMAC_KEY e BACKUP_ENCRYPTION_KEY dalle sue variabili d'ambiente."
|
||||
},
|
||||
"restoreTitle": "Ripristinare questo server",
|
||||
"restoreBody": "Installa HealthLog con le stesse chiavi, ripristina il dump del database più recente in un database vuoto, poi avvia l'app. La guida ti accompagna passo per passo.",
|
||||
"restoreLink": "Guida al ripristino dopo un disastro",
|
||||
"checkLabel": "Verifica la mia copia",
|
||||
"checkKeyLabel": "Chiave da verificare",
|
||||
"checkValueLabel": "La tua copia della chiave",
|
||||
"checkButton": "Verifica",
|
||||
"checkNote": "La chiave incollata viene confrontata con quella presente su questo server e poi scartata. Non viene mai salvata né registrata.",
|
||||
"checkInsecureNote": "Questa pagina non è servita tramite https://, quindi la chiave incollata attraversa la rete in chiaro, come la tua password. Viene confrontata e scartata, mai salvata né registrata.",
|
||||
"checkMatches": "La tua copia corrisponde a questa chiave.",
|
||||
"checkNoMatch": "La tua copia non corrisponde a questa chiave. Controlla di aver copiato l'intero valore.",
|
||||
"checkFailed": "La verifica non è riuscita.",
|
||||
"confirmButton": "Ho una copia di ogni chiave",
|
||||
"confirmed": "Kit di recupero confermato.",
|
||||
"confirmFailed": "Impossibile salvare la conferma.",
|
||||
"stale": "Il kit è cambiato da quando questa pagina è stata caricata. Controlla le chiavi che elenca ora, poi conferma di nuovo.",
|
||||
"confirmedAt": "Confermato il {date}.",
|
||||
"confirmedAtBy": "Confermato il {date} da {who}.",
|
||||
"dueAgain": "Il kit è stato confermato il {date}. Ora elenca anche {keys}, che quella conferma non copriva. Salva una copia di ciascuna e conferma di nuovo."
|
||||
},
|
||||
"recoveryReadiness": {
|
||||
"title": "Preparazione al ripristino",
|
||||
"description": "Se questo server potrebbe essere rimesso in piedi dopo una perdita, valutato in base a ogni backup che esegue.",
|
||||
"loading": "Controllo dei backup…",
|
||||
"loadError": "Impossibile caricare la preparazione al ripristino.",
|
||||
"state": {
|
||||
"ok": "Pronto",
|
||||
"attention": "Richiede attenzione",
|
||||
"failing": "A rischio"
|
||||
},
|
||||
"signal": {
|
||||
"database_dump": "Dump del database",
|
||||
"weekly_copy": "Copie settimanali degli account",
|
||||
"offhost_copies": "Copie fuori host",
|
||||
"restore_drill": "Prova di ripristino",
|
||||
"offhost_setup": "Backup fuori host",
|
||||
"recovery_kit": "Kit di recupero"
|
||||
},
|
||||
"at": {
|
||||
"database_dump": "Dump più recente: {date}",
|
||||
"weekly_copy": "Copia di account più vecchia: {date}",
|
||||
"offhost_copies": "Copia fuori host meno recente: {date}",
|
||||
"restore_drill": "Ultima prova: {date}",
|
||||
"recovery_kit": "Confermato: {date}"
|
||||
},
|
||||
"dueBy": "Scadenza: {date}",
|
||||
"fix": {
|
||||
"database_dump": "Apri i backup",
|
||||
"weekly_copy": "Apri i backup",
|
||||
"offhost_copies": "Apri i backup",
|
||||
"restore_drill": "Apri i backup",
|
||||
"offhost_setup": "Apri i backup",
|
||||
"recovery_kit": "Apri il kit"
|
||||
},
|
||||
"reason": {
|
||||
"database_dump": {
|
||||
"not_available": "Questa versione non pianifica un dump del database.",
|
||||
"disabled": "Il dump pianificato del database è disattivato, quindi nessuna copia può ricostruire questo server. Per riattivarlo, rimuovi DB_BACKUP_ENABLED o impostalo su true, poi riavvia HealthLog.",
|
||||
"unreadable": "Non è stato possibile leggere lo stato del dump del database.",
|
||||
"never_succeeded": "Nessun dump del database è ancora riuscito, e l'ultimo tentativo non è andato a buon fine.",
|
||||
"never_ran": "Nelle {hours} ore trascorse da quando i dump sono stati attivati non è stato scritto alcun dump del database.",
|
||||
"first_pending": "Il primo dump del database non è ancora stato eseguito.",
|
||||
"stale": "Il dump del database più recente ha {hours} ore.",
|
||||
"last_failed": "L'ultimo dump del database non è riuscito. Quello precedente è ancora recente.",
|
||||
"fresh": "L'ultimo dump del database è recente.",
|
||||
"nowhere_to_write": "La cartella dei dump {directory} non è scrivibile e non è configurato nessun bucket esterno, quindi non si può conservare alcun dump."
|
||||
},
|
||||
"weekly_copy": {
|
||||
"no_accounts": "Non ci sono ancora account da copiare.",
|
||||
"stale": "La copia settimanale più vecchia ha {days} giorni. Account in ritardo: {accounts}.",
|
||||
"never": "Un account non ha una copia settimanale da {days} giorni. Account in ritardo: {accounts}.",
|
||||
"first_pending": "La prima copia settimanale viene creata domenica mattina presto.",
|
||||
"fresh": "Ogni account ha una copia dell'ultima settimana."
|
||||
},
|
||||
"offhost_copies": {
|
||||
"not_configured": "Non è configurato alcun bucket fuori host.",
|
||||
"stale": "Le copie fuori host sono indietro di più di due notti. Account in ritardo: {accounts}.",
|
||||
"first_pending": "Il giro notturno fuori host non ha ancora raggiunto alcun account.",
|
||||
"fresh": "Ogni account ha una copia recente nel bucket fuori host."
|
||||
},
|
||||
"restore_drill": {
|
||||
"not_configured": "La prova legge il bucket fuori host, che non è configurato.",
|
||||
"not_run_yet": "La prova di ripristino mensile non è ancora stata eseguita.",
|
||||
"failed": "L'ultima prova di ripristino non è riuscita: {failed} copie su {checked} non si sono potute rileggere.",
|
||||
"failed_run": "L'ultima prova di ripristino non è riuscita ad arrivare in fondo.",
|
||||
"passed": "L'ultima prova di ripristino ha riletto {checked} copie."
|
||||
},
|
||||
"offhost_setup": {
|
||||
"configured": "Le copie lasciano questo server ogni notte.",
|
||||
"no_admin": "Non esiste ancora un account amministratore.",
|
||||
"not_configured": "Non è configurato alcun bucket fuori host, quindi ogni copia si trova solo su questo server.",
|
||||
"not_configured_grace": "Non è ancora configurato alcun bucket fuori host, quindi ogni copia si trova solo su questo server."
|
||||
},
|
||||
"recovery_kit": {
|
||||
"confirmed": "Esiste una copia di ogni chiave necessaria per un ripristino.",
|
||||
"out_of_date": "Il kit di ripristino ora elenca anche {keys}, che l'ultima conferma non copriva. Non c'è nulla di rotto: salva una copia e conferma di nuovo il kit.",
|
||||
"no_admin": "Non esiste ancora un account amministratore.",
|
||||
"not_confirmed": "Nessuno ha confermato una copia delle chiavi necessarie per un ripristino: {keys}.",
|
||||
"not_confirmed_grace": "Salva una copia delle chiavi necessarie per un ripristino: {keys}."
|
||||
}
|
||||
},
|
||||
"recovered": {
|
||||
"database_dump": "Il dump del database è di nuovo recente.",
|
||||
"weekly_copy": "Ogni account ha di nuovo una copia settimanale recente.",
|
||||
"offhost_copies": "Ogni account ha di nuovo una copia fuori host recente.",
|
||||
"restore_drill": "La prova di ripristino è di nuovo riuscita.",
|
||||
"offhost_setup": "Le copie fuori host ora sono configurate.",
|
||||
"recovery_kit": "Il kit di recupero è di nuovo confermato."
|
||||
}
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
@@ -9065,6 +9200,14 @@
|
||||
"checkupReminders": {
|
||||
"title": "Promemoria dei controlli",
|
||||
"description": "Attiva o disattiva i singoli promemoria dei controlli."
|
||||
},
|
||||
"eventBackupHealth": "Stato dei backup",
|
||||
"eventBackupHealthDesc": "Quando un backup si ferma, resta indietro o torna a funzionare. Inviato solo agli amministratori.",
|
||||
"recoveryReadiness": {
|
||||
"titleAttention": "I backup richiedono attenzione",
|
||||
"titleFailing": "I backup sono a rischio",
|
||||
"titleReminder": "I backup sono ancora a rischio",
|
||||
"titleRecovered": "I backup sono tornati alla normalità"
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
@@ -11792,7 +11935,7 @@
|
||||
"dismiss": "Ignora",
|
||||
"empty": "Impossibile leggere questo documento.",
|
||||
"unavailableBody": "Configura un provider IA per suggerire titolo, tipo e data.",
|
||||
"unavailableLocalOcr": "Attiva l'OCR locale nelle impostazioni IA per leggere le immagini su questo dispositivo.",
|
||||
"unavailableLocalOcr": "Attiva «Leggi le scansioni su questo dispositivo» in Impostazioni → Moduli → Esami per leggere le immagini su questo dispositivo.",
|
||||
"unavailableAction": "Apri impostazioni IA",
|
||||
"errorGeneric": "Impossibile leggere il documento. Riprova.",
|
||||
"errorOcr": "Impossibile leggere il testo da questa immagine.",
|
||||
@@ -11805,11 +11948,12 @@
|
||||
"errorFileType": "Questo tipo di file non può essere letto.",
|
||||
"errorNotIndexed": "Leggi prima il documento, poi estrai.",
|
||||
"errorOutputLimit": "Il fornitore di IA ha raggiunto il suo limite di output prima di restituire un solo valore. Prova con un modello che consente risposte più lunghe.",
|
||||
"errorLocalOcrDisabled": "Attiva prima l'OCR locale nelle impostazioni IA.",
|
||||
"errorLocalOcrDisabled": "Attiva prima «Leggi le scansioni su questo dispositivo» in Impostazioni → Moduli → Esami.",
|
||||
"errorProvider": "Nessun provider IA è configurato per questo.",
|
||||
"errorTextImageOnly": "L'OCR locale legge solo immagini, non questo file.",
|
||||
"errorConsent": "La lettura dei documenti con IA richiede prima il tuo consenso.",
|
||||
"errorUnavailable": "La lettura dei documenti con IA non è disponibile qui al momento."
|
||||
"errorUnavailable": "La lettura dei documenti con IA non è disponibile qui al momento.",
|
||||
"unavailableLocalOcrAction": "Apri le impostazioni degli esami"
|
||||
},
|
||||
"summary": {
|
||||
"summarise": "Riassumi",
|
||||
|
||||
+195
-51
@@ -780,13 +780,13 @@
|
||||
"bmi": "kg/m²"
|
||||
}
|
||||
},
|
||||
"keyBackupBanner": {
|
||||
"title": "암호화 키를 백업하세요",
|
||||
"body": "키 사본이 없으면 다시 설치한 뒤 데이터를 복구할 수 없습니다. 1분이면 됩니다.",
|
||||
"action": "단계 열기"
|
||||
},
|
||||
"quickAddSymptom": "증상 기록",
|
||||
"todayCardVisible": "상단 카드 표시"
|
||||
"todayCardVisible": "상단 카드 표시",
|
||||
"recoveryKitBanner": {
|
||||
"title": "복구 키트를 보관하세요",
|
||||
"body": "이 서버가 사용하는 키의 사본이 없으면 다시 설치한 뒤 데이터를 복구할 수 없습니다. 몇 분이면 됩니다.",
|
||||
"action": "키트 열기"
|
||||
}
|
||||
},
|
||||
"measurements": {
|
||||
"title": "측정",
|
||||
@@ -8306,7 +8306,23 @@
|
||||
"restoreSkippedEnvironmentLocationCiphertext": "이 서버에 없는 키로 암호화된 환경 기간 또는 날짜의 위치(암호화된 채 유지되며 해당 날짜는 새로 가져오지 않음)",
|
||||
"typePreWipe": "모든 데이터 삭제 전",
|
||||
"retentionNote": "주간 사본은 매주 일요일에 교체돼요. \"모든 데이터 삭제\" 전에 만든 사본은 {days}일 뒤에 삭제돼요. 올린 사본은 해당 계정이 지워질 때까지 남아요.",
|
||||
"retentionNoteNoGrace": "주간 사본은 매주 일요일에 교체돼요. 올린 사본은 해당 계정이 지워질 때까지 남아요."
|
||||
"retentionNoteNoGrace": "주간 사본은 매주 일요일에 교체돼요. 올린 사본은 해당 계정이 지워질 때까지 남아요.",
|
||||
"database": {
|
||||
"title": "데이터베이스 덤프",
|
||||
"lastGood": "마지막 정상 덤프 {when}, {size}, {where}.",
|
||||
"whereBoth": "이 서버와 외부 저장소에 보관됨",
|
||||
"whereLocal": "이 서버에 보관됨",
|
||||
"whereOffhost": "외부 저장소에만 있음",
|
||||
"none": "아직 데이터베이스 덤프가 없습니다. 첫 덤프는 오늘 밤에 실행됩니다.",
|
||||
"failedTitle": "마지막 데이터베이스 덤프가 실패했습니다",
|
||||
"failedDescription": "{when}: {reason}",
|
||||
"disabled": "데이터베이스 덤프가 꺼져 있습니다(DB_BACKUP_ENABLED=false). 다른 방법으로 데이터베이스를 백업하세요.",
|
||||
"noDirectoryOffhost": "이 서버는 {dir}에 쓸 수 없어 덤프가 외부 저장소로만 가고 여기에는 보관되지 않습니다. 백업 볼륨을 마운트하세요.",
|
||||
"noDirectory": "이 서버는 {dir}에 쓸 수 없고 외부 저장소도 설정되지 않아 데이터베이스 덤프를 만들 수 없습니다. 백업 볼륨을 마운트하세요.",
|
||||
"retentionLocal": "삭제된 계정은 덤프가 만료될 때까지 덤프에 남습니다. 이 서버에는 최신 덤프 {nights}개가 보관됩니다.",
|
||||
"retentionOffhost": "버킷의 수명 주기 규칙이 외부 사본을 {days}일 후에 삭제합니다.",
|
||||
"retentionOffhostMissing": "버킷의 어떤 수명 주기 규칙도 database/를 다루지 않아 그곳의 덤프와 그 안의 삭제된 계정이 절대 삭제되지 않습니다. 이 접두사에 대한 규칙을 추가하세요."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "위험 구역",
|
||||
@@ -8527,47 +8543,6 @@
|
||||
"title": "페이지의 이 부분을 표시할 수 없습니다",
|
||||
"description": "페이지의 나머지 부분은 계속 작동합니다. 다시 시도하거나 계속 실패하면 새로고침하세요."
|
||||
},
|
||||
"keyBackup": {
|
||||
"title": "암호화 키를 백업하세요",
|
||||
"description": "이 키의 사본이 없으면 암호화된 데이터와 이 서버의 백업을 다시는 읽을 수 없습니다.",
|
||||
"loading": "키 백업 상태를 불러오는 중…",
|
||||
"loadError": "키 백업 상태를 불러오지 못했습니다.",
|
||||
"badgeDue": "해야 함",
|
||||
"badgeDone": "백업됨",
|
||||
"keyId": "활성 키 ID",
|
||||
"fingerprint": "지문",
|
||||
"whatIsLost": "키의 사본을 이 서버 밖, 예를 들어 비밀번호 관리자에 보관하세요. 서버가 다른 키로 다시 설치되면 HealthLog는 원래 키가 돌아올 때까지 서비스를 시작하지 않습니다.",
|
||||
"fingerprintHint": "지문은 키의 SHA-256 해시 앞 12자입니다. 키를 드러내지 않고 식별하므로 사본과 비교할 수 있습니다.",
|
||||
"checkLabel": "내 사본 확인",
|
||||
"checkButton": "확인",
|
||||
"checkNote": "붙여넣은 키는 활성 키와 비교한 뒤 버립니다. 저장하거나 기록하지 않습니다.",
|
||||
"checkInsecureNote": "이 페이지는 https://로 제공되지 않으므로 붙여넣은 키가 비밀번호와 마찬가지로 암호화되지 않은 채 네트워크를 지나갑니다. 비교한 뒤 버리며 저장하거나 기록하지 않습니다.",
|
||||
"checkMatches": "사본이 활성 키와 일치합니다.",
|
||||
"checkNoMatch": "사본이 활성 키와 일치하지 않습니다. 전체 값을 복사했는지 확인하세요.",
|
||||
"checkFailed": "확인하지 못했습니다.",
|
||||
"confirmButton": "이 키를 백업했습니다",
|
||||
"confirmed": "키 백업이 확인되었습니다.",
|
||||
"confirmFailed": "확인을 저장하지 못했습니다.",
|
||||
"stale": "이 페이지를 연 뒤 서버의 키가 바뀌었습니다. 새 키 ID와 지문을 확인한 뒤 다시 확인하세요.",
|
||||
"confirmedAt": "{date}에 확인됨.",
|
||||
"confirmedAtBy": "{date}에 {who}님이 확인함.",
|
||||
"dueAgain": "키 {keyId}의 백업이 확인되었지만 서버는 이제 다른 키를 사용합니다. 현재 키를 백업하세요.",
|
||||
"tileLabel": "암호화 키 백업",
|
||||
"tileDue": "확인 안 됨",
|
||||
"tileDone": "확인됨",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "키는 docker-compose.yml 옆 .env 파일의 ENCRYPTION_KEY(또는 ENCRYPTION_KEYS)입니다.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Apps에서 HealthLog를 선택하고 Edit을 누르세요. 키는 HealthLog Configuration 아래의 Encryption Key 필드에 있습니다. YAML로 설치한 앱이라면 환경 설정의 ENCRYPTION_KEY입니다.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Coolify에서 애플리케이션을 열고 Environment Variables에서 ENCRYPTION_KEY를 복사하세요.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "Docker 탭에서 HealthLog 컨테이너를 편집하고 템플릿에서, 또는 Compose Manager 스택의 .env에서 ENCRYPTION_KEY를 복사하세요.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Portainer에서 HealthLog 스택을 열고 환경 변수에서 ENCRYPTION_KEY를 복사하세요."
|
||||
}
|
||||
},
|
||||
"keyMismatch": {
|
||||
"title": "HealthLog가 데이터를 열 수 없습니다",
|
||||
"body": "이 서버를 시작한 암호화 키가 데이터베이스를 기록할 때 쓴 키와 다릅니다. 데이터를 보호하기 위해 이 문제가 해결될 때까지 HealthLog는 아무것도 제공하지 않습니다.",
|
||||
@@ -8598,6 +8573,166 @@
|
||||
"defaultAboveCeiling": "기본 단계는 최고 단계보다 높을 수 없어요.",
|
||||
"gapLabel": "동기화 후 카드 새로 고침 간격(시간, 표준 단계)",
|
||||
"gapHint": "1에서 48 사이의 정수 시간이에요. 비워 두면 AI_SYNC_REGEN_GAP_HOURS를, 없으면 6을 사용해요. 현재 적용 값: {hours}시간."
|
||||
},
|
||||
"recoveryKit": {
|
||||
"title": "복구 키트",
|
||||
"description": "이 서버를 복원하는 데 필요한 모든 키를 ID와 지문으로 보여 줍니다.",
|
||||
"intro": "아래 각 키의 사본을 이 서버 밖, 예를 들어 비밀번호 관리자에 보관하세요. 키가 없으면 데이터베이스 덤프도 백업 사본도 다시 읽을 수 없으며, 필요한 키가 없으면 HealthLog는 시작하지 않습니다.",
|
||||
"loading": "복구 키트를 불러오는 중…",
|
||||
"loadError": "복구 키트를 불러오지 못했습니다.",
|
||||
"badgeDue": "해야 함",
|
||||
"badgeDone": "보관됨",
|
||||
"keyId": "ID",
|
||||
"fingerprint": "지문",
|
||||
"noFingerprint": "이 서버에 없음",
|
||||
"kind": {
|
||||
"application": "애플리케이션 키 {id}",
|
||||
"backup": "외부 보관 백업 키",
|
||||
"backupPrevious": "이전 외부 보관 백업 키",
|
||||
"token": "토큰 키"
|
||||
},
|
||||
"need": {
|
||||
"active": "새 데이터를 암호화합니다.",
|
||||
"configured": "이 서버에 있습니다.",
|
||||
"storedCopies": "저장된 사본 {count}개에 필요합니다.",
|
||||
"offhostUntil": "{date}까지 외부 보관 사본에 필요합니다.",
|
||||
"offhostUnknown": "버킷이 외부 보관 사본을 보관하는 동안 필요합니다.",
|
||||
"backupActive": "새 외부 보관 사본을 모두 봉인합니다.",
|
||||
"backupPrevious": "마지막 키 변경 전에 기록된 외부 보관 사본을 엽니다.",
|
||||
"token": "세션, 토큰, 공유 링크에 서명합니다. 이 키가 없으면 모두 로그아웃되지만 데이터는 잃지 않습니다."
|
||||
},
|
||||
"source": "{variable}에 설정됨.",
|
||||
"status": {
|
||||
"confirmed": "보관됨",
|
||||
"new": "아직 보관 안 됨",
|
||||
"notConfigured": "이 서버에 없음",
|
||||
"unreadable": "유효한 키가 아님"
|
||||
},
|
||||
"notConfiguredHint": "아직 이 키가 필요한 백업이 있지만 이 서버에는 키가 없습니다. 다른 곳에 보관한 사본만 그 백업을 읽을 수 있습니다. 그 사본을 확보한 뒤 키트를 확인하세요.",
|
||||
"unreadableHint": "변수는 설정되어 있지만 유효한 키가 들어 있지 않습니다. 외부 보관 사본에 의존하기 전에 고치세요.",
|
||||
"fingerprintHint": "지문은 키의 SHA-256 해시 앞 12자입니다. 키를 드러내지 않고 식별하므로 사본과 비교할 수 있습니다.",
|
||||
"whereTitle": "키가 설정된 위치",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "키는 docker-compose.yml 옆 .env 파일에 있습니다: ENCRYPTION_KEY 또는 ENCRYPTION_KEYS, API_TOKEN_HMAC_KEY, 그리고 외부 보관 사본을 쓰는 경우 BACKUP_ENCRYPTION_KEY.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Apps에서 HealthLog를 선택하고 Edit을 누르세요. 키는 HealthLog Configuration 아래에 있습니다. YAML로 설치한 앱이라면 환경 설정의 ENCRYPTION_KEY, API_TOKEN_HMAC_KEY, BACKUP_ENCRYPTION_KEY입니다.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Coolify에서 애플리케이션을 열고 Environment Variables에서 ENCRYPTION_KEY, API_TOKEN_HMAC_KEY, BACKUP_ENCRYPTION_KEY를 복사하세요.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "Docker 탭에서 HealthLog 컨테이너를 편집하고 템플릿에서, 또는 Compose Manager 스택의 .env에서 ENCRYPTION_KEY, API_TOKEN_HMAC_KEY, BACKUP_ENCRYPTION_KEY를 복사하세요.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Portainer에서 HealthLog 스택을 열고 환경 변수에서 ENCRYPTION_KEY, API_TOKEN_HMAC_KEY, BACKUP_ENCRYPTION_KEY를 복사하세요."
|
||||
},
|
||||
"restoreTitle": "이 서버 복원하기",
|
||||
"restoreBody": "같은 키로 HealthLog를 설치하고, 가장 최근의 데이터베이스 덤프를 빈 데이터베이스에 복원한 다음 앱을 시작하세요. 가이드에서 단계별로 안내합니다.",
|
||||
"restoreLink": "재해 복구 가이드",
|
||||
"checkLabel": "내 사본 확인",
|
||||
"checkKeyLabel": "확인할 키",
|
||||
"checkValueLabel": "내가 보관한 키 사본",
|
||||
"checkButton": "확인",
|
||||
"checkNote": "붙여넣은 키는 이 서버의 키와 비교한 뒤 버립니다. 저장하거나 기록하지 않습니다.",
|
||||
"checkInsecureNote": "이 페이지는 https://로 제공되지 않으므로 붙여넣은 키가 비밀번호와 마찬가지로 암호화되지 않은 채 네트워크를 지나갑니다. 비교한 뒤 버리며 저장하거나 기록하지 않습니다.",
|
||||
"checkMatches": "사본이 이 키와 일치합니다.",
|
||||
"checkNoMatch": "사본이 이 키와 일치하지 않습니다. 전체 값을 복사했는지 확인하세요.",
|
||||
"checkFailed": "확인하지 못했습니다.",
|
||||
"confirmButton": "모든 키의 사본을 보관했습니다",
|
||||
"confirmed": "복구 키트가 확인되었습니다.",
|
||||
"confirmFailed": "확인을 저장하지 못했습니다.",
|
||||
"stale": "이 페이지를 연 뒤 키트가 바뀌었습니다. 지금 표시된 키를 확인한 뒤 다시 확인하세요.",
|
||||
"confirmedAt": "{date}에 확인됨.",
|
||||
"confirmedAtBy": "{date}에 {who}님이 확인함.",
|
||||
"dueAgain": "키트는 {date}에 확인되었습니다. 이제 {keys}도 포함되며, 그 확인은 이를 다루지 않았습니다. 각각의 사본을 저장하고 다시 확인하십시오."
|
||||
},
|
||||
"recoveryReadiness": {
|
||||
"title": "복구 준비 상태",
|
||||
"description": "이 서버가 만드는 모든 백업을 기준으로, 서버를 잃었을 때 되살릴 수 있는지 판단합니다.",
|
||||
"loading": "백업을 확인하는 중…",
|
||||
"loadError": "복구 준비 상태를 불러오지 못했습니다.",
|
||||
"state": {
|
||||
"ok": "준비됨",
|
||||
"attention": "확인 필요",
|
||||
"failing": "위험"
|
||||
},
|
||||
"signal": {
|
||||
"database_dump": "데이터베이스 덤프",
|
||||
"weekly_copy": "주간 계정 사본",
|
||||
"offhost_copies": "외부 보관 사본",
|
||||
"restore_drill": "복원 훈련",
|
||||
"offhost_setup": "외부 보관 백업",
|
||||
"recovery_kit": "복구 키트"
|
||||
},
|
||||
"at": {
|
||||
"database_dump": "최신 덤프: {date}",
|
||||
"weekly_copy": "가장 오래된 계정 사본: {date}",
|
||||
"offhost_copies": "가장 오래된 외부 보관 사본: {date}",
|
||||
"restore_drill": "마지막 훈련: {date}",
|
||||
"recovery_kit": "확인: {date}"
|
||||
},
|
||||
"dueBy": "{date}까지",
|
||||
"fix": {
|
||||
"database_dump": "백업 열기",
|
||||
"weekly_copy": "백업 열기",
|
||||
"offhost_copies": "백업 열기",
|
||||
"restore_drill": "백업 열기",
|
||||
"offhost_setup": "백업 열기",
|
||||
"recovery_kit": "키트 열기"
|
||||
},
|
||||
"reason": {
|
||||
"database_dump": {
|
||||
"not_available": "이 버전은 데이터베이스 덤프를 예약하지 않습니다.",
|
||||
"disabled": "예약된 데이터베이스 덤프가 꺼져 있어 이 서버를 다시 구축할 수 있는 사본이 없습니다. 다시 켜려면 DB_BACKUP_ENABLED를 제거하거나 true로 설정한 뒤 HealthLog를 다시 시작하십시오.",
|
||||
"unreadable": "데이터베이스 덤프 상태를 읽지 못했습니다.",
|
||||
"never_succeeded": "아직 성공한 데이터베이스 덤프가 없으며 마지막 시도도 실패했습니다.",
|
||||
"never_ran": "덤프를 켠 뒤 {hours}시간 동안 데이터베이스 덤프가 기록되지 않았습니다.",
|
||||
"first_pending": "첫 데이터베이스 덤프가 아직 실행되지 않았습니다.",
|
||||
"stale": "최신 데이터베이스 덤프가 {hours}시간 전 것입니다.",
|
||||
"last_failed": "마지막 데이터베이스 덤프가 실패했습니다. 그 전 덤프는 아직 최근 것입니다.",
|
||||
"fresh": "최신 데이터베이스 덤프가 최근 것입니다.",
|
||||
"nowhere_to_write": "덤프 디렉터리 {directory}에 쓸 수 없고 외부 버킷도 설정되어 있지 않아 덤프를 보관할 수 없습니다."
|
||||
},
|
||||
"weekly_copy": {
|
||||
"no_accounts": "아직 복사할 계정이 없습니다.",
|
||||
"stale": "가장 오래된 주간 사본이 {days}일 전 것입니다. 뒤처진 계정: {accounts}.",
|
||||
"never": "{days}일 동안 주간 사본이 없는 계정이 있습니다. 뒤처진 계정: {accounts}.",
|
||||
"first_pending": "첫 주간 사본은 일요일 이른 시간에 만들어집니다.",
|
||||
"fresh": "모든 계정에 지난주 사본이 있습니다."
|
||||
},
|
||||
"offhost_copies": {
|
||||
"not_configured": "외부 보관 버킷이 설정되지 않았습니다.",
|
||||
"stale": "외부 보관 사본이 이틀 밤 넘게 뒤처져 있습니다. 뒤처진 계정: {accounts}.",
|
||||
"first_pending": "야간 외부 보관 실행이 아직 어떤 계정에도 도달하지 않았습니다.",
|
||||
"fresh": "모든 계정의 최근 사본이 외부 보관 버킷에 있습니다."
|
||||
},
|
||||
"restore_drill": {
|
||||
"not_configured": "훈련은 외부 보관 버킷을 읽는데, 버킷이 설정되지 않았습니다.",
|
||||
"not_run_yet": "월간 복원 훈련이 아직 실행되지 않았습니다.",
|
||||
"failed": "마지막 복원 훈련이 실패했습니다: 사본 {checked}개 중 {failed}개를 다시 읽지 못했습니다.",
|
||||
"failed_run": "마지막 복원 훈련을 끝까지 마치지 못했습니다.",
|
||||
"passed": "마지막 복원 훈련에서 사본 {checked}개를 다시 읽었습니다."
|
||||
},
|
||||
"offhost_setup": {
|
||||
"configured": "사본이 매일 밤 이 서버 밖으로 나갑니다.",
|
||||
"no_admin": "아직 관리자 계정이 없습니다.",
|
||||
"not_configured": "외부 보관 버킷이 설정되지 않아 모든 사본이 이 서버에만 있습니다.",
|
||||
"not_configured_grace": "외부 보관 버킷이 아직 설정되지 않아 모든 사본이 이 서버에만 있습니다."
|
||||
},
|
||||
"recovery_kit": {
|
||||
"confirmed": "복원에 필요한 모든 키의 사본이 보관되어 있습니다.",
|
||||
"out_of_date": "복구 키트에 이제 {keys}도 포함되며, 마지막 확인은 이를 다루지 않았습니다. 고장 난 것은 없습니다. 사본을 저장하고 키트를 다시 확인하십시오.",
|
||||
"no_admin": "아직 관리자 계정이 없습니다.",
|
||||
"not_confirmed": "복원에 필요한 키의 사본을 아무도 확인하지 않았습니다: {keys}.",
|
||||
"not_confirmed_grace": "복원에 필요한 키의 사본을 보관하세요: {keys}."
|
||||
}
|
||||
},
|
||||
"recovered": {
|
||||
"database_dump": "데이터베이스 덤프가 다시 최신 상태입니다.",
|
||||
"weekly_copy": "모든 계정에 다시 최근 주간 사본이 있습니다.",
|
||||
"offhost_copies": "모든 계정에 다시 최근 외부 보관 사본이 있습니다.",
|
||||
"restore_drill": "복원 훈련을 다시 통과했습니다.",
|
||||
"offhost_setup": "이제 외부 보관 사본이 설정되었습니다.",
|
||||
"recovery_kit": "복구 키트가 다시 확인되었습니다."
|
||||
}
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
@@ -9065,6 +9200,14 @@
|
||||
"checkupReminders": {
|
||||
"title": "검진 리마인더",
|
||||
"description": "검진 리마인더를 개별로 켜거나 끕니다."
|
||||
},
|
||||
"eventBackupHealth": "백업 상태",
|
||||
"eventBackupHealthDesc": "백업이 멈추거나 뒤처지거나 다시 정상으로 돌아오면 보내요. 관리자에게만 보내요.",
|
||||
"recoveryReadiness": {
|
||||
"titleAttention": "백업에 확인이 필요해요",
|
||||
"titleFailing": "백업이 위험해요",
|
||||
"titleReminder": "백업이 여전히 위험해요",
|
||||
"titleRecovered": "백업이 다시 정상이에요"
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
@@ -11792,7 +11935,7 @@
|
||||
"dismiss": "닫기",
|
||||
"empty": "이 문서에서 읽어낼 내용이 없었어요.",
|
||||
"unavailableBody": "AI 제공자를 설정하면 제목·종류·날짜를 추천해요.",
|
||||
"unavailableLocalOcr": "AI 설정에서 로컬 OCR을 켜면 이 기기에서 이미지를 읽어요.",
|
||||
"unavailableLocalOcr": "설정 → 모듈 → 검사에서 “이 기기에서 스캔 읽기”를 켜면 이 기기에서 이미지를 읽어요.",
|
||||
"unavailableAction": "AI 설정 열기",
|
||||
"errorGeneric": "문서를 읽지 못했어요. 다시 시도해 주세요.",
|
||||
"errorOcr": "이 이미지에서 텍스트를 읽지 못했어요.",
|
||||
@@ -11805,11 +11948,12 @@
|
||||
"errorFileType": "이 파일 형식은 읽을 수 없어요.",
|
||||
"errorNotIndexed": "문서를 먼저 읽은 뒤에 추출해 주세요.",
|
||||
"errorOutputLimit": "AI 제공자가 값을 하나도 돌려주기 전에 출력 한도에 도달했어요. 더 긴 답변을 허용하는 모델로 다시 시도해 주세요.",
|
||||
"errorLocalOcrDisabled": "AI 설정에서 로컬 OCR을 먼저 켜 주세요.",
|
||||
"errorLocalOcrDisabled": "설정 → 모듈 → 검사에서 “이 기기에서 스캔 읽기”를 먼저 켜 주세요.",
|
||||
"errorProvider": "이 작업에 쓸 AI 제공자가 설정돼 있지 않아요.",
|
||||
"errorTextImageOnly": "로컬 OCR은 이미지만 읽어요. 이 파일은 읽을 수 없어요.",
|
||||
"errorConsent": "AI로 문서를 읽으려면 먼저 동의가 필요합니다.",
|
||||
"errorUnavailable": "지금 여기에서는 AI로 문서를 읽을 수 없습니다."
|
||||
"errorUnavailable": "지금 여기에서는 AI로 문서를 읽을 수 없습니다.",
|
||||
"unavailableLocalOcrAction": "검사 설정 열기"
|
||||
},
|
||||
"summary": {
|
||||
"summarise": "요약하기",
|
||||
|
||||
+195
-51
@@ -780,13 +780,13 @@
|
||||
"bmi": "kg/m²"
|
||||
}
|
||||
},
|
||||
"keyBackupBanner": {
|
||||
"title": "Maak een back-up van je versleutelingssleutel",
|
||||
"body": "Zonder een kopie van de sleutel kunnen je gegevens na een herinstallatie niet worden hersteld. Het kost een minuutje.",
|
||||
"action": "Stap openen"
|
||||
},
|
||||
"quickAddSymptom": "Symptoom vastleggen",
|
||||
"todayCardVisible": "Bovenste kaart tonen"
|
||||
"todayCardVisible": "Bovenste kaart tonen",
|
||||
"recoveryKitBanner": {
|
||||
"title": "Bewaar je herstelkit",
|
||||
"body": "Zonder kopieën van de sleutels die deze server gebruikt, kunnen de gegevens na een herinstallatie niet worden hersteld. Het kost een paar minuten.",
|
||||
"action": "Kit openen"
|
||||
}
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Metingen",
|
||||
@@ -8331,7 +8331,23 @@
|
||||
"restoreSkippedEnvironmentLocationCiphertext": "Locatie van een omgevingsperiode of -dag versleuteld met een sleutel die deze server niet heeft (versleuteld behouden, die dagen worden niet bijgewerkt)",
|
||||
"typePreWipe": "Voor het verwijderen van alle gegevens",
|
||||
"retentionNote": "De wekelijkse kopie wordt elke zondag vervangen. Een kopie die vóór 'Alle gegevens verwijderen' is gemaakt, wordt na {days} dagen verwijderd. Geüploade kopieën blijven tot hun account gewist wordt.",
|
||||
"retentionNoteNoGrace": "De wekelijkse kopie wordt elke zondag vervangen. Geüploade kopieën blijven tot hun account gewist wordt."
|
||||
"retentionNoteNoGrace": "De wekelijkse kopie wordt elke zondag vervangen. Geüploade kopieën blijven tot hun account gewist wordt.",
|
||||
"database": {
|
||||
"title": "Databasedump",
|
||||
"lastGood": "Laatste geldige dump {when}, {size}, {where}.",
|
||||
"whereBoth": "bewaard op deze server en extern",
|
||||
"whereLocal": "bewaard op deze server",
|
||||
"whereOffhost": "alleen extern",
|
||||
"none": "Nog geen databasedump. De eerste wordt vannacht gemaakt.",
|
||||
"failedTitle": "De laatste databasedump is mislukt",
|
||||
"failedDescription": "{when}: {reason}",
|
||||
"disabled": "Databasedumps staan uit (DB_BACKUP_ENABLED=false). Maak op een andere manier een back-up van de database.",
|
||||
"noDirectoryOffhost": "Deze server kan niet schrijven naar {dir}, dus dumps gaan alleen extern en hier wordt er geen bewaard. Koppel het back-upvolume.",
|
||||
"noDirectory": "Deze server kan niet schrijven naar {dir} en er is geen externe opslag ingesteld, dus er kan geen databasedump worden gemaakt. Koppel het back-upvolume.",
|
||||
"retentionLocal": "Verwijderde accounts blijven in de dumps tot die verlopen: op deze server blijven de nieuwste {nights} bewaard.",
|
||||
"retentionOffhost": "De levenscyclusregel van de bucket verwijdert externe kopieën na {days} dagen.",
|
||||
"retentionOffhostMissing": "Geen levenscyclusregel in de bucket geldt voor database/, dus dumps daar, en de verwijderde accounts erin, worden nooit verwijderd. Voeg een regel voor dat voorvoegsel toe."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Gevarenzone",
|
||||
@@ -8527,47 +8543,6 @@
|
||||
"title": "Dit deel van de pagina kon niet worden weergegeven",
|
||||
"description": "De rest van de pagina werkt nog; probeer het opnieuw of herlaad de pagina als het blijft mislukken."
|
||||
},
|
||||
"keyBackup": {
|
||||
"title": "Maak een back-up van je versleutelingssleutel",
|
||||
"description": "Zonder een kopie van deze sleutel kunnen je versleutelde gegevens en de back-ups van deze server niet meer worden gelezen.",
|
||||
"loading": "Status van de sleutelback-up laden…",
|
||||
"loadError": "De status van de sleutelback-up kon niet worden geladen.",
|
||||
"badgeDue": "Te doen",
|
||||
"badgeDone": "Back-up gemaakt",
|
||||
"keyId": "Actieve sleutel-ID",
|
||||
"fingerprint": "Vingerafdruk",
|
||||
"whatIsLost": "Bewaar een kopie van de sleutel ergens buiten deze server, bijvoorbeeld in een wachtwoordmanager. Als de server opnieuw wordt geïnstalleerd met een andere sleutel, weigert HealthLog te starten tot de oorspronkelijke sleutel terug is.",
|
||||
"fingerprintHint": "De vingerafdruk bestaat uit de eerste 12 tekens van de SHA-256-hash van de sleutel. Hij identificeert de sleutel zonder deze prijs te geven, zodat je hem met je kopie kunt vergelijken.",
|
||||
"checkLabel": "Mijn kopie controleren",
|
||||
"checkButton": "Controleren",
|
||||
"checkNote": "De sleutel die je plakt, wordt vergeleken met de actieve sleutel en daarna weggegooid. Hij wordt nooit opgeslagen of gelogd.",
|
||||
"checkInsecureNote": "Deze pagina wordt niet via https:// geserveerd, dus de sleutel die je plakt gaat onversleuteld over het netwerk, net als je wachtwoord. Hij wordt vergeleken en weggegooid, nooit opgeslagen of gelogd.",
|
||||
"checkMatches": "Je kopie komt overeen met de actieve sleutel.",
|
||||
"checkNoMatch": "Je kopie komt niet overeen met de actieve sleutel. Controleer of je de hele waarde hebt gekopieerd.",
|
||||
"checkFailed": "De controle is mislukt.",
|
||||
"confirmButton": "Ik heb een back-up van deze sleutel gemaakt",
|
||||
"confirmed": "Sleutelback-up bevestigd.",
|
||||
"confirmFailed": "De bevestiging kon niet worden opgeslagen.",
|
||||
"stale": "De sleutel van de server is gewijzigd sinds deze pagina is geladen. Controleer de nieuwe sleutel-ID en vingerafdruk en bevestig daarna opnieuw.",
|
||||
"confirmedAt": "Bevestigd op {date}.",
|
||||
"confirmedAtBy": "Bevestigd op {date} door {who}.",
|
||||
"dueAgain": "Er is een back-up bevestigd voor sleutel {keyId}, maar de server gebruikt nu een andere sleutel. Maak een back-up van de huidige sleutel.",
|
||||
"tileLabel": "Back-up van versleutelingssleutel",
|
||||
"tileDue": "Niet bevestigd",
|
||||
"tileDone": "Bevestigd",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "De sleutel is ENCRYPTION_KEY (of ENCRYPTION_KEYS) in het .env-bestand naast docker-compose.yml.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Open Apps, selecteer HealthLog en kies Edit. De sleutel staat in het veld Encryption Key onder HealthLog Configuration; bij een app die via YAML is geïnstalleerd, is het ENCRYPTION_KEY in de omgevingsinstellingen.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Open de applicatie in Coolify en kopieer ENCRYPTION_KEY uit de Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "Bewerk op het tabblad Docker de HealthLog-container en kopieer ENCRYPTION_KEY uit de template, of uit het .env-bestand van je Compose Manager-stack.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Open de HealthLog-stack in Portainer en kopieer ENCRYPTION_KEY uit de omgevingsvariabelen."
|
||||
}
|
||||
},
|
||||
"keyMismatch": {
|
||||
"title": "HealthLog kan zijn gegevens niet openen",
|
||||
"body": "De versleutelingssleutel waarmee deze server is gestart, is niet de sleutel waarmee de database is geschreven. Om je gegevens te beschermen, levert HealthLog niets uit totdat dit is opgelost.",
|
||||
@@ -8598,6 +8573,166 @@
|
||||
"defaultAboveCeiling": "Het standaardniveau kan niet hoger zijn dan het hoogste niveau.",
|
||||
"gapLabel": "Uren tussen het vernieuwen van kaarten na een synchronisatie (Standaard)",
|
||||
"gapHint": "Hele uren van 1 tot 48. Laat leeg om AI_SYNC_REGEN_GAP_HOURS te gebruiken, anders 6. Nu van kracht: {hours} u."
|
||||
},
|
||||
"recoveryKit": {
|
||||
"title": "Herstelkit",
|
||||
"description": "Elke sleutel die nodig is om deze server terug te zetten, met ID en vingerafdruk.",
|
||||
"intro": "Bewaar van elke sleutel hieronder een kopie buiten deze server, bijvoorbeeld in een wachtwoordmanager. Zonder die kopieën kunnen een databasedump en een back-upkopie niet meer worden gelezen, en HealthLog weigert te starten als een benodigde sleutel ontbreekt.",
|
||||
"loading": "Herstelkit laden…",
|
||||
"loadError": "De herstelkit kon niet worden geladen.",
|
||||
"badgeDue": "Te doen",
|
||||
"badgeDone": "Bewaard",
|
||||
"keyId": "ID",
|
||||
"fingerprint": "Vingerafdruk",
|
||||
"noFingerprint": "Niet op deze server",
|
||||
"kind": {
|
||||
"application": "Applicatiesleutel {id}",
|
||||
"backup": "Sleutel voor externe back-ups",
|
||||
"backupPrevious": "Vorige sleutel voor externe back-ups",
|
||||
"token": "Tokensleutel"
|
||||
},
|
||||
"need": {
|
||||
"active": "Versleutelt nieuwe gegevens.",
|
||||
"configured": "Staat op deze server.",
|
||||
"storedCopies": "Nodig voor {count} opgeslagen kopieën.",
|
||||
"offhostUntil": "Nodig voor externe kopieën tot {date}.",
|
||||
"offhostUnknown": "Nodig voor externe kopieën zolang de bucket ze bewaart.",
|
||||
"backupActive": "Verzegelt elke nieuwe externe kopie.",
|
||||
"backupPrevious": "Opent externe kopieën die vóór de laatste sleutelwissel zijn geschreven.",
|
||||
"token": "Ondertekent sessies, tokens en deellinks. Zonder deze sleutel wordt iedereen afgemeld, maar gaan er geen gegevens verloren."
|
||||
},
|
||||
"source": "Ingesteld in {variable}.",
|
||||
"status": {
|
||||
"confirmed": "Bewaard",
|
||||
"new": "Nog niet bewaard",
|
||||
"notConfigured": "Niet op deze server",
|
||||
"unreadable": "Geen geldige sleutel"
|
||||
},
|
||||
"notConfiguredHint": "Een back-up heeft deze sleutel nog nodig, maar deze server heeft hem niet. Alleen een kopie die je elders bewaart, kan die back-ups lezen. Bevestig de kit zodra je die kopie hebt.",
|
||||
"unreadableHint": "De variabele is ingesteld, maar bevat geen geldige sleutel. Los dat op voordat je op de externe kopieën vertrouwt.",
|
||||
"fingerprintHint": "De vingerafdruk bestaat uit de eerste 12 tekens van de SHA-256-hash van een sleutel. Hij identificeert de sleutel zonder deze prijs te geven, zodat je hem met je kopie kunt vergelijken.",
|
||||
"whereTitle": "Waar de sleutels zijn ingesteld",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "De sleutels staan in het .env-bestand naast docker-compose.yml: ENCRYPTION_KEY of ENCRYPTION_KEYS, API_TOKEN_HMAC_KEY en, bij externe kopieën, BACKUP_ENCRYPTION_KEY.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Open Apps, selecteer HealthLog en kies Edit. De sleutels staan onder HealthLog Configuration; bij een app die via YAML is geïnstalleerd, zijn het ENCRYPTION_KEY, API_TOKEN_HMAC_KEY en BACKUP_ENCRYPTION_KEY in de omgevingsinstellingen.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Open de applicatie in Coolify en kopieer ENCRYPTION_KEY, API_TOKEN_HMAC_KEY en BACKUP_ENCRYPTION_KEY uit de Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "Bewerk op het tabblad Docker de HealthLog-container en kopieer ENCRYPTION_KEY, API_TOKEN_HMAC_KEY en BACKUP_ENCRYPTION_KEY uit de template, of uit het .env-bestand van je Compose Manager-stack.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Open de HealthLog-stack in Portainer en kopieer ENCRYPTION_KEY, API_TOKEN_HMAC_KEY en BACKUP_ENCRYPTION_KEY uit de omgevingsvariabelen."
|
||||
},
|
||||
"restoreTitle": "Deze server terugzetten",
|
||||
"restoreBody": "Installeer HealthLog met dezelfde sleutels, zet de nieuwste databasedump terug in een lege database en start dan de app. De handleiding loopt elke stap door.",
|
||||
"restoreLink": "Handleiding voor noodherstel",
|
||||
"checkLabel": "Mijn kopie controleren",
|
||||
"checkKeyLabel": "Te controleren sleutel",
|
||||
"checkValueLabel": "Jouw kopie van de sleutel",
|
||||
"checkButton": "Controleren",
|
||||
"checkNote": "De sleutel die je plakt, wordt vergeleken met die op deze server en daarna weggegooid. Hij wordt nooit opgeslagen of gelogd.",
|
||||
"checkInsecureNote": "Deze pagina wordt niet via https:// geserveerd, dus de sleutel die je plakt gaat onversleuteld over het netwerk, net als je wachtwoord. Hij wordt vergeleken en weggegooid, nooit opgeslagen of gelogd.",
|
||||
"checkMatches": "Je kopie komt overeen met deze sleutel.",
|
||||
"checkNoMatch": "Je kopie komt niet overeen met deze sleutel. Controleer of je de hele waarde hebt gekopieerd.",
|
||||
"checkFailed": "De controle is mislukt.",
|
||||
"confirmButton": "Ik heb een kopie van elke sleutel",
|
||||
"confirmed": "Herstelkit bevestigd.",
|
||||
"confirmFailed": "De bevestiging kon niet worden opgeslagen.",
|
||||
"stale": "De kit is gewijzigd sinds deze pagina is geladen. Controleer de sleutels die er nu in staan en bevestig daarna opnieuw.",
|
||||
"confirmedAt": "Bevestigd op {date}.",
|
||||
"confirmedAtBy": "Bevestigd op {date} door {who}.",
|
||||
"dueAgain": "De kit is bevestigd op {date}. Hij vermeldt nu ook {keys}, die die bevestiging niet dekte. Bewaar van elk een kopie en bevestig opnieuw."
|
||||
},
|
||||
"recoveryReadiness": {
|
||||
"title": "Herstelbaarheid",
|
||||
"description": "Of deze server na verlies weer op te bouwen is, beoordeeld op basis van elke back-up die hij maakt.",
|
||||
"loading": "Back-ups controleren…",
|
||||
"loadError": "De herstelbaarheid kon niet worden geladen.",
|
||||
"state": {
|
||||
"ok": "Gereed",
|
||||
"attention": "Aandacht nodig",
|
||||
"failing": "In gevaar"
|
||||
},
|
||||
"signal": {
|
||||
"database_dump": "Databasedump",
|
||||
"weekly_copy": "Wekelijkse accountkopieën",
|
||||
"offhost_copies": "Externe kopieën",
|
||||
"restore_drill": "Hersteltest",
|
||||
"offhost_setup": "Externe back-up",
|
||||
"recovery_kit": "Herstelkit"
|
||||
},
|
||||
"at": {
|
||||
"database_dump": "Nieuwste dump: {date}",
|
||||
"weekly_copy": "Oudste accountkopie: {date}",
|
||||
"offhost_copies": "Oudste externe kopie: {date}",
|
||||
"restore_drill": "Laatste test: {date}",
|
||||
"recovery_kit": "Bevestigd: {date}"
|
||||
},
|
||||
"dueBy": "Uiterlijk {date}",
|
||||
"fix": {
|
||||
"database_dump": "Back-ups openen",
|
||||
"weekly_copy": "Back-ups openen",
|
||||
"offhost_copies": "Back-ups openen",
|
||||
"restore_drill": "Back-ups openen",
|
||||
"offhost_setup": "Back-ups openen",
|
||||
"recovery_kit": "Kit openen"
|
||||
},
|
||||
"reason": {
|
||||
"database_dump": {
|
||||
"not_available": "Deze versie plant geen databasedump in.",
|
||||
"disabled": "De geplande databasedump staat uit, dus geen enkele kopie kan deze server opnieuw opbouwen. Verwijder DB_BACKUP_ENABLED of zet het op true en herstart HealthLog om hem weer aan te zetten.",
|
||||
"unreadable": "De status van de databasedump kon niet worden gelezen.",
|
||||
"never_succeeded": "Er is nog geen databasedump gelukt, en de laatste poging is mislukt.",
|
||||
"never_ran": "In de {hours} uur sinds dumps zijn ingeschakeld, is er geen databasedump geschreven.",
|
||||
"first_pending": "De eerste databasedump is nog niet uitgevoerd.",
|
||||
"stale": "De nieuwste databasedump is {hours} uur oud.",
|
||||
"last_failed": "De laatste databasedump is mislukt. De dump daarvoor is nog recent.",
|
||||
"fresh": "De nieuwste databasedump is recent.",
|
||||
"nowhere_to_write": "De dumpmap {directory} is niet beschrijfbaar en er is geen externe bucket ingesteld, dus er kan geen dump worden bewaard."
|
||||
},
|
||||
"weekly_copy": {
|
||||
"no_accounts": "Er zijn nog geen accounts om te kopiëren.",
|
||||
"stale": "De oudste wekelijkse kopie is {days} dagen oud. Achterlopende accounts: {accounts}.",
|
||||
"never": "Een account heeft al {days} dagen geen wekelijkse kopie. Achterlopende accounts: {accounts}.",
|
||||
"first_pending": "De eerste wekelijkse kopie wordt vroeg op zondag gemaakt.",
|
||||
"fresh": "Elk account heeft een kopie van de afgelopen week."
|
||||
},
|
||||
"offhost_copies": {
|
||||
"not_configured": "Er is geen externe bucket ingesteld.",
|
||||
"stale": "Externe kopieën lopen meer dan twee nachten achter. Achterlopende accounts: {accounts}.",
|
||||
"first_pending": "De nachtelijke run voor externe kopieën heeft nog geen enkel account bereikt.",
|
||||
"fresh": "Elk account heeft een recente kopie in de externe bucket."
|
||||
},
|
||||
"restore_drill": {
|
||||
"not_configured": "De test leest de externe bucket, en die is niet ingesteld.",
|
||||
"not_run_yet": "De maandelijkse hersteltest is nog niet uitgevoerd.",
|
||||
"failed": "De laatste hersteltest is mislukt: {failed} van {checked} kopieën konden niet worden teruggelezen.",
|
||||
"failed_run": "De laatste hersteltest kon niet worden afgerond.",
|
||||
"passed": "De laatste hersteltest heeft {checked} kopieën teruggelezen."
|
||||
},
|
||||
"offhost_setup": {
|
||||
"configured": "Kopieën verlaten deze server elke nacht.",
|
||||
"no_admin": "Er is nog geen beheerdersaccount.",
|
||||
"not_configured": "Er is geen externe bucket ingesteld, dus elke kopie staat alleen op deze server.",
|
||||
"not_configured_grace": "Er is nog geen externe bucket ingesteld, dus elke kopie staat alleen op deze server."
|
||||
},
|
||||
"recovery_kit": {
|
||||
"confirmed": "Van elke sleutel die een herstel nodig heeft, wordt een kopie bewaard.",
|
||||
"out_of_date": "De herstelkit vermeldt nu ook {keys}, die de laatste bevestiging niet dekte. Er is niets kapot: bewaar een kopie en bevestig de kit opnieuw.",
|
||||
"no_admin": "Er is nog geen beheerdersaccount.",
|
||||
"not_confirmed": "Niemand heeft een kopie bevestigd van de sleutels die een herstel nodig heeft: {keys}.",
|
||||
"not_confirmed_grace": "Bewaar een kopie van de sleutels die een herstel nodig heeft: {keys}."
|
||||
}
|
||||
},
|
||||
"recovered": {
|
||||
"database_dump": "De databasedump is weer recent.",
|
||||
"weekly_copy": "Elk account heeft weer een recente wekelijkse kopie.",
|
||||
"offhost_copies": "Elk account heeft weer een recente externe kopie.",
|
||||
"restore_drill": "De hersteltest is weer geslaagd.",
|
||||
"offhost_setup": "Externe kopieën zijn nu ingesteld.",
|
||||
"recovery_kit": "De herstelkit is weer bevestigd."
|
||||
}
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
@@ -9065,6 +9200,14 @@
|
||||
"checkupReminders": {
|
||||
"title": "Herinneringen voor controles",
|
||||
"description": "Zet afzonderlijke herinneringen voor controles aan of uit."
|
||||
},
|
||||
"eventBackupHealth": "Status van back-ups",
|
||||
"eventBackupHealthDesc": "Wordt verstuurd als een back-up stopt, achterloopt of weer bijgewerkt is. Alleen voor beheerders.",
|
||||
"recoveryReadiness": {
|
||||
"titleAttention": "Back-ups hebben aandacht nodig",
|
||||
"titleFailing": "Back-ups lopen gevaar",
|
||||
"titleReminder": "Back-ups lopen nog steeds gevaar",
|
||||
"titleRecovered": "Back-ups zijn weer in orde"
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
@@ -11792,7 +11935,7 @@
|
||||
"dismiss": "Negeren",
|
||||
"empty": "Er kon niets uit dit document worden gelezen.",
|
||||
"unavailableBody": "Stel een AI-provider in om een titel, soort en datum voor te stellen.",
|
||||
"unavailableLocalOcr": "Zet lokale OCR aan in de AI-instellingen om afbeeldingen op dit apparaat te lezen.",
|
||||
"unavailableLocalOcr": "Zet “Scans op dit apparaat lezen” aan onder Instellingen → Modules → Labwaarden om afbeeldingen op dit apparaat te lezen.",
|
||||
"unavailableAction": "AI-instellingen openen",
|
||||
"errorGeneric": "Het document kon niet worden gelezen. Probeer het opnieuw.",
|
||||
"errorOcr": "Er kon geen tekst uit deze afbeelding worden gelezen.",
|
||||
@@ -11805,11 +11948,12 @@
|
||||
"errorFileType": "Dit bestandstype kan niet worden gelezen.",
|
||||
"errorNotIndexed": "Laat het document eerst lezen en extraheer daarna.",
|
||||
"errorOutputLimit": "De AI-provider bereikte zijn uitvoerlimiet voordat hij één waarde teruggaf. Probeer een model dat langere antwoorden toestaat.",
|
||||
"errorLocalOcrDisabled": "Zet eerst lokale OCR aan in de AI-instellingen.",
|
||||
"errorLocalOcrDisabled": "Zet eerst “Scans op dit apparaat lezen” aan onder Instellingen → Modules → Labwaarden.",
|
||||
"errorProvider": "Hiervoor is geen AI-provider ingesteld.",
|
||||
"errorTextImageOnly": "Lokale OCR leest alleen afbeeldingen, niet dit bestand.",
|
||||
"errorConsent": "Voor het lezen van documenten met AI is eerst je toestemming nodig.",
|
||||
"errorUnavailable": "Documenten lezen met AI is hier op dit moment niet beschikbaar."
|
||||
"errorUnavailable": "Documenten lezen met AI is hier op dit moment niet beschikbaar.",
|
||||
"unavailableLocalOcrAction": "Instellingen voor labwaarden openen"
|
||||
},
|
||||
"summary": {
|
||||
"summarise": "Samenvatten",
|
||||
|
||||
+195
-51
@@ -780,13 +780,13 @@
|
||||
"bmi": "kg/m²"
|
||||
}
|
||||
},
|
||||
"keyBackupBanner": {
|
||||
"title": "Zrób kopię klucza szyfrowania",
|
||||
"body": "Bez kopii klucza Twoich danych nie da się odzyskać po ponownej instalacji. To zajmie minutę.",
|
||||
"action": "Otwórz krok"
|
||||
},
|
||||
"quickAddSymptom": "Zapisz objaw",
|
||||
"todayCardVisible": "Pokaż górną kartę"
|
||||
"todayCardVisible": "Pokaż górną kartę",
|
||||
"recoveryKitBanner": {
|
||||
"title": "Zapisz zestaw odzyskiwania",
|
||||
"body": "Bez kopii kluczy, których używa ten serwer, jego danych nie da się odzyskać po ponownej instalacji. To zajmie kilka minut.",
|
||||
"action": "Otwórz zestaw"
|
||||
}
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Pomiary",
|
||||
@@ -8331,7 +8331,23 @@
|
||||
"restoreSkippedEnvironmentLocationCiphertext": "Lokalizacja okresu lub dnia środowiskowego zaszyfrowana kluczem, którego ten serwer nie ma (zachowana w postaci zaszyfrowanej, te dni nie są odświeżane)",
|
||||
"typePreWipe": "Przed usunięciem wszystkich danych",
|
||||
"retentionNote": "Cotygodniowa kopia jest zastępowana w każdą niedzielę. Kopia wykonana przed „Usuń wszystkie dane” jest usuwana po {days} dniach. Przesłane kopie zostają, dopóki ich konto nie zostanie usunięte.",
|
||||
"retentionNoteNoGrace": "Cotygodniowa kopia jest zastępowana w każdą niedzielę. Przesłane kopie zostają, dopóki ich konto nie zostanie usunięte."
|
||||
"retentionNoteNoGrace": "Cotygodniowa kopia jest zastępowana w każdą niedzielę. Przesłane kopie zostają, dopóki ich konto nie zostanie usunięte.",
|
||||
"database": {
|
||||
"title": "Zrzut bazy danych",
|
||||
"lastGood": "Ostatni poprawny zrzut {when}, {size}, {where}.",
|
||||
"whereBoth": "przechowywany na tym serwerze i poza nim",
|
||||
"whereLocal": "przechowywany na tym serwerze",
|
||||
"whereOffhost": "tylko poza serwerem",
|
||||
"none": "Nie ma jeszcze zrzutu bazy danych. Pierwszy powstanie tej nocy.",
|
||||
"failedTitle": "Ostatni zrzut bazy danych się nie powiódł",
|
||||
"failedDescription": "{when}: {reason}",
|
||||
"disabled": "Zrzuty bazy danych są wyłączone (DB_BACKUP_ENABLED=false). Zrób kopię bazy danych w inny sposób.",
|
||||
"noDirectoryOffhost": "Ten serwer nie może zapisywać w {dir}, więc zrzuty trafiają tylko poza serwer i żaden nie zostaje tutaj. Podłącz wolumin kopii.",
|
||||
"noDirectory": "Ten serwer nie może zapisywać w {dir} i nie ustawiono magazynu zewnętrznego, więc nie da się wykonać zrzutu bazy. Podłącz wolumin kopii.",
|
||||
"retentionLocal": "Usunięte konta pozostają w zrzutach, dopóki te nie wygasną: na tym serwerze zostaje {nights} najnowszych.",
|
||||
"retentionOffhost": "Reguła cyklu życia bucketu usuwa kopie zewnętrzne po {days} dniach.",
|
||||
"retentionOffhostMissing": "Żadna reguła cyklu życia bucketu nie obejmuje database/, więc zrzuty tam, wraz z usuniętymi kontami w nich, nigdy nie są usuwane. Dodaj regułę dla tego prefiksu."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Strefa zagrożenia",
|
||||
@@ -8527,47 +8543,6 @@
|
||||
"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."
|
||||
},
|
||||
"keyBackup": {
|
||||
"title": "Zrób kopię klucza szyfrowania",
|
||||
"description": "Bez kopii tego klucza Twoich zaszyfrowanych danych i kopii zapasowych z tego serwera nie da się już odczytać.",
|
||||
"loading": "Wczytywanie stanu kopii klucza…",
|
||||
"loadError": "Nie udało się wczytać stanu kopii klucza.",
|
||||
"badgeDue": "Do zrobienia",
|
||||
"badgeDone": "Zapisany",
|
||||
"keyId": "ID aktywnego klucza",
|
||||
"fingerprint": "Odcisk",
|
||||
"whatIsLost": "Przechowuj kopię klucza poza tym serwerem, na przykład w menedżerze haseł. Jeśli serwer zostanie przeinstalowany z innym kluczem, HealthLog nie wznowi działania, dopóki oryginalny klucz nie wróci.",
|
||||
"fingerprintHint": "Odcisk to pierwsze 12 znaków skrótu SHA-256 klucza. Rozpoznaje klucz, nie ujawniając go, więc możesz porównać go ze swoją kopią.",
|
||||
"checkLabel": "Sprawdź moją kopię",
|
||||
"checkButton": "Sprawdź",
|
||||
"checkNote": "Wklejony klucz jest porównywany z aktywnym kluczem, a potem odrzucany. Nigdy nie jest zapisywany ani logowany.",
|
||||
"checkInsecureNote": "Ta strona nie jest serwowana przez https://, więc wklejony klucz przechodzi przez sieć bez szyfrowania, tak jak Twoje hasło. Jest porównywany i odrzucany, nigdy nie jest zapisywany ani logowany.",
|
||||
"checkMatches": "Twoja kopia pasuje do aktywnego klucza.",
|
||||
"checkNoMatch": "Twoja kopia nie pasuje do aktywnego klucza. Sprawdź, czy skopiowano całą wartość.",
|
||||
"checkFailed": "Sprawdzenie nie powiodło się.",
|
||||
"confirmButton": "Mam kopię tego klucza",
|
||||
"confirmed": "Kopia klucza potwierdzona.",
|
||||
"confirmFailed": "Nie udało się zapisać potwierdzenia.",
|
||||
"stale": "Klucz serwera zmienił się od wczytania tej strony. Sprawdź nowe ID i odcisk, a potem potwierdź ponownie.",
|
||||
"confirmedAt": "Potwierdzono {date}.",
|
||||
"confirmedAtBy": "Potwierdzono {date}, potwierdził(a): {who}.",
|
||||
"dueAgain": "Potwierdzono kopię klucza {keyId}, ale serwer używa teraz innego klucza. Zrób kopię obecnego.",
|
||||
"tileLabel": "Kopia klucza szyfrowania",
|
||||
"tileDue": "Niepotwierdzona",
|
||||
"tileDone": "Potwierdzona",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "Klucz to ENCRYPTION_KEY (lub ENCRYPTION_KEYS) w pliku .env obok docker-compose.yml.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Otwórz Apps, wybierz HealthLog i kliknij Edytuj. Klucz znajduje się w polu Encryption Key w sekcji HealthLog Configuration; w aplikacji zainstalowanej przez YAML to ENCRYPTION_KEY w zmiennych środowiskowych.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Otwórz aplikację w Coolify i skopiuj ENCRYPTION_KEY z jej Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "Na karcie Docker edytuj kontener HealthLog i skopiuj ENCRYPTION_KEY z szablonu albo z pliku .env stosu Compose Manager.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Otwórz stos HealthLog w Portainerze i skopiuj ENCRYPTION_KEY z jego zmiennych środowiskowych."
|
||||
}
|
||||
},
|
||||
"keyMismatch": {
|
||||
"title": "HealthLog nie może otworzyć swoich danych",
|
||||
"body": "Klucz szyfrowania, z którym uruchomiono ten serwer, nie jest kluczem, którym zapisano jego bazę danych. Aby chronić Twoje dane, HealthLog niczego nie udostępnia, dopóki to nie zostanie naprawione.",
|
||||
@@ -8598,6 +8573,166 @@
|
||||
"defaultAboveCeiling": "Poziom domyślny nie może być wyższy niż najwyższy poziom.",
|
||||
"gapLabel": "Godziny między odświeżeniami kart po synchronizacji (Standardowy)",
|
||||
"gapHint": "Pełne godziny od 1 do 48. Zostaw puste, aby użyć AI_SYNC_REGEN_GAP_HOURS, w przeciwnym razie 6. Obecnie obowiązuje: {hours} h."
|
||||
},
|
||||
"recoveryKit": {
|
||||
"title": "Zestaw odzyskiwania",
|
||||
"description": "Każdy klucz potrzebny do przywrócenia tego serwera, z ID i odciskiem.",
|
||||
"intro": "Przechowuj kopię każdego z poniższych kluczy poza tym serwerem, na przykład w menedżerze haseł. Bez nich nie da się ponownie odczytać ani zrzutu bazy danych, ani kopii zapasowej, a HealthLog nie uruchomi się, jeśli brakuje potrzebnego mu klucza.",
|
||||
"loading": "Wczytywanie zestawu odzyskiwania…",
|
||||
"loadError": "Nie udało się wczytać zestawu odzyskiwania.",
|
||||
"badgeDue": "Do zrobienia",
|
||||
"badgeDone": "Zapisany",
|
||||
"keyId": "ID",
|
||||
"fingerprint": "Odcisk",
|
||||
"noFingerprint": "Brak na tym serwerze",
|
||||
"kind": {
|
||||
"application": "Klucz aplikacji {id}",
|
||||
"backup": "Klucz kopii poza hostem",
|
||||
"backupPrevious": "Poprzedni klucz kopii poza hostem",
|
||||
"token": "Klucz tokenów"
|
||||
},
|
||||
"need": {
|
||||
"active": "Szyfruje nowe dane.",
|
||||
"configured": "Znajduje się na tym serwerze.",
|
||||
"storedCopies": "Potrzebny zapisanym kopiom: {count}.",
|
||||
"offhostUntil": "Potrzebny kopiom poza hostem do {date}.",
|
||||
"offhostUnknown": "Potrzebny kopiom poza hostem, dopóki bucket je przechowuje.",
|
||||
"backupActive": "Pieczętuje każdą nową kopię poza hostem.",
|
||||
"backupPrevious": "Otwiera kopie poza hostem zapisane przed ostatnią zmianą klucza.",
|
||||
"token": "Podpisuje sesje, tokeny i linki udostępniania. Bez niego wszyscy zostaną wylogowani, ale żadne dane nie przepadną."
|
||||
},
|
||||
"source": "Ustawiony w {variable}.",
|
||||
"status": {
|
||||
"confirmed": "Zapisany",
|
||||
"new": "Jeszcze niezapisany",
|
||||
"notConfigured": "Brak na tym serwerze",
|
||||
"unreadable": "Nieprawidłowy klucz"
|
||||
},
|
||||
"notConfiguredHint": "Kopia zapasowa nadal potrzebuje tego klucza, a ten serwer go nie ma. Te kopie zapasowe może odczytać tylko kopia klucza, którą przechowujesz gdzie indziej. Potwierdź zestaw, gdy będziesz mieć tę kopię.",
|
||||
"unreadableHint": "Zmienna jest ustawiona, ale nie zawiera prawidłowego klucza. Popraw ją, zanim zaczniesz polegać na kopiach poza hostem.",
|
||||
"fingerprintHint": "Odcisk to pierwsze 12 znaków skrótu SHA-256 klucza. Rozpoznaje klucz, nie ujawniając go, więc możesz porównać go ze swoją kopią.",
|
||||
"whereTitle": "Gdzie ustawione są klucze",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "Klucze znajdują się w pliku .env obok docker-compose.yml: ENCRYPTION_KEY lub ENCRYPTION_KEYS, API_TOKEN_HMAC_KEY oraz, przy kopiach poza hostem, BACKUP_ENCRYPTION_KEY.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Otwórz Apps, wybierz HealthLog i kliknij Edit. Klucze są w sekcji HealthLog Configuration; w aplikacji zainstalowanej przez YAML to ENCRYPTION_KEY, API_TOKEN_HMAC_KEY i BACKUP_ENCRYPTION_KEY w ustawieniach środowiska.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Otwórz aplikację w Coolify i skopiuj ENCRYPTION_KEY, API_TOKEN_HMAC_KEY i BACKUP_ENCRYPTION_KEY z jej Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "Na karcie Docker edytuj kontener HealthLog i skopiuj ENCRYPTION_KEY, API_TOKEN_HMAC_KEY i BACKUP_ENCRYPTION_KEY z szablonu albo z pliku .env stosu Compose Manager.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Otwórz stos HealthLog w Portainerze i skopiuj ENCRYPTION_KEY, API_TOKEN_HMAC_KEY i BACKUP_ENCRYPTION_KEY z jego zmiennych środowiskowych."
|
||||
},
|
||||
"restoreTitle": "Przywracanie tego serwera",
|
||||
"restoreBody": "Zainstaluj HealthLog z tymi samymi kluczami, przywróć najnowszy zrzut bazy danych do pustej bazy, a potem uruchom aplikację. Przewodnik prowadzi przez każdy krok.",
|
||||
"restoreLink": "Przewodnik odzyskiwania po awarii",
|
||||
"checkLabel": "Sprawdź moją kopię",
|
||||
"checkKeyLabel": "Klucz do sprawdzenia",
|
||||
"checkValueLabel": "Twoja kopia klucza",
|
||||
"checkButton": "Sprawdź",
|
||||
"checkNote": "Wklejony klucz jest porównywany z kluczem na tym serwerze, a potem odrzucany. Nigdy nie jest zapisywany ani logowany.",
|
||||
"checkInsecureNote": "Ta strona nie jest serwowana przez https://, więc wklejony klucz przechodzi przez sieć bez szyfrowania, tak jak Twoje hasło. Jest porównywany i odrzucany, nigdy nie jest zapisywany ani logowany.",
|
||||
"checkMatches": "Twoja kopia pasuje do tego klucza.",
|
||||
"checkNoMatch": "Twoja kopia nie pasuje do tego klucza. Sprawdź, czy skopiowano całą wartość.",
|
||||
"checkFailed": "Sprawdzenie nie powiodło się.",
|
||||
"confirmButton": "Mam kopię każdego klucza",
|
||||
"confirmed": "Zestaw odzyskiwania potwierdzony.",
|
||||
"confirmFailed": "Nie udało się zapisać potwierdzenia.",
|
||||
"stale": "Zestaw zmienił się od wczytania tej strony. Sprawdź klucze, które teraz wymienia, a potem potwierdź ponownie.",
|
||||
"confirmedAt": "Potwierdzono {date}.",
|
||||
"confirmedAtBy": "Potwierdzono {date}, potwierdził(a): {who}.",
|
||||
"dueAgain": "Zestaw potwierdzono {date}. Zawiera teraz także {keys}, czego to potwierdzenie nie obejmowało. Zapisz kopię każdego i potwierdź ponownie."
|
||||
},
|
||||
"recoveryReadiness": {
|
||||
"title": "Gotowość do odzyskiwania",
|
||||
"description": "Czy ten serwer dałoby się odtworzyć po utracie, oceniane na podstawie każdej kopii zapasowej, którą tworzy.",
|
||||
"loading": "Sprawdzanie kopii zapasowych…",
|
||||
"loadError": "Nie udało się wczytać gotowości do odzyskiwania.",
|
||||
"state": {
|
||||
"ok": "Gotowe",
|
||||
"attention": "Wymaga uwagi",
|
||||
"failing": "Zagrożone"
|
||||
},
|
||||
"signal": {
|
||||
"database_dump": "Zrzut bazy danych",
|
||||
"weekly_copy": "Cotygodniowe kopie kont",
|
||||
"offhost_copies": "Kopie poza hostem",
|
||||
"restore_drill": "Próba przywracania",
|
||||
"offhost_setup": "Kopia poza hostem",
|
||||
"recovery_kit": "Zestaw odzyskiwania"
|
||||
},
|
||||
"at": {
|
||||
"database_dump": "Najnowszy zrzut: {date}",
|
||||
"weekly_copy": "Najstarsza kopia konta: {date}",
|
||||
"offhost_copies": "Najstarsza kopia poza hostem: {date}",
|
||||
"restore_drill": "Ostatnia próba: {date}",
|
||||
"recovery_kit": "Potwierdzono: {date}"
|
||||
},
|
||||
"dueBy": "Termin: {date}",
|
||||
"fix": {
|
||||
"database_dump": "Otwórz kopie zapasowe",
|
||||
"weekly_copy": "Otwórz kopie zapasowe",
|
||||
"offhost_copies": "Otwórz kopie zapasowe",
|
||||
"restore_drill": "Otwórz kopie zapasowe",
|
||||
"offhost_setup": "Otwórz kopie zapasowe",
|
||||
"recovery_kit": "Otwórz zestaw"
|
||||
},
|
||||
"reason": {
|
||||
"database_dump": {
|
||||
"not_available": "Ta wersja nie planuje zrzutu bazy danych.",
|
||||
"disabled": "Zaplanowany zrzut bazy danych jest wyłączony, więc żadna kopia nie odtworzy tego serwera. Aby go włączyć, usuń DB_BACKUP_ENABLED lub ustaw na true i uruchom HealthLog ponownie.",
|
||||
"unreadable": "Nie udało się odczytać stanu zrzutu bazy danych.",
|
||||
"never_succeeded": "Żaden zrzut bazy danych jeszcze się nie udał, a ostatnia próba zakończyła się błędem.",
|
||||
"never_ran": "Od włączenia zrzutów minęło {hours} h, a nie zapisano żadnego zrzutu bazy danych.",
|
||||
"first_pending": "Pierwszy zrzut bazy danych jeszcze się nie odbył.",
|
||||
"stale": "Najnowszy zrzut bazy danych ma {hours} h.",
|
||||
"last_failed": "Ostatni zrzut bazy danych nie powiódł się. Poprzedni jest jeszcze aktualny.",
|
||||
"fresh": "Najnowszy zrzut bazy danych jest aktualny.",
|
||||
"nowhere_to_write": "Katalog zrzutów {directory} nie jest zapisywalny i nie skonfigurowano zewnętrznego bucketu, więc żaden zrzut nie może zostać zachowany."
|
||||
},
|
||||
"weekly_copy": {
|
||||
"no_accounts": "Nie ma jeszcze kont do skopiowania.",
|
||||
"stale": "Najstarsza cotygodniowa kopia ma {days} dni. Zaległe konta: {accounts}.",
|
||||
"never": "Jedno z kont od {days} dni nie ma cotygodniowej kopii. Zaległe konta: {accounts}.",
|
||||
"first_pending": "Pierwsza cotygodniowa kopia powstaje wcześnie rano w niedzielę.",
|
||||
"fresh": "Każde konto ma kopię z ostatniego tygodnia."
|
||||
},
|
||||
"offhost_copies": {
|
||||
"not_configured": "Nie skonfigurowano bucketu poza hostem.",
|
||||
"stale": "Kopie poza hostem są opóźnione o ponad dwie noce. Zaległe konta: {accounts}.",
|
||||
"first_pending": "Nocny przebieg kopii poza hostem nie objął jeszcze żadnego konta.",
|
||||
"fresh": "Każde konto ma aktualną kopię w buckecie poza hostem."
|
||||
},
|
||||
"restore_drill": {
|
||||
"not_configured": "Próba odczytuje bucket poza hostem, który nie jest skonfigurowany.",
|
||||
"not_run_yet": "Comiesięczna próba przywracania jeszcze się nie odbyła.",
|
||||
"failed": "Ostatnia próba przywracania nie powiodła się: nie udało się odczytać {failed} z {checked} kopii.",
|
||||
"failed_run": "Ostatnia próba przywracania nie dobiegła końca.",
|
||||
"passed": "Ostatnia próba przywracania odczytała kopie: {checked}."
|
||||
},
|
||||
"offhost_setup": {
|
||||
"configured": "Kopie opuszczają ten serwer każdej nocy.",
|
||||
"no_admin": "Nie ma jeszcze konta administratora.",
|
||||
"not_configured": "Nie skonfigurowano bucketu poza hostem, więc każda kopia jest tylko na tym serwerze.",
|
||||
"not_configured_grace": "Nie skonfigurowano jeszcze bucketu poza hostem, więc każda kopia jest tylko na tym serwerze."
|
||||
},
|
||||
"recovery_kit": {
|
||||
"confirmed": "Istnieje kopia każdego klucza potrzebnego do przywrócenia.",
|
||||
"out_of_date": "Zestaw odzyskiwania zawiera teraz także {keys}, czego ostatnie potwierdzenie nie obejmowało. Nic się nie zepsuło: zapisz kopię i potwierdź zestaw ponownie.",
|
||||
"no_admin": "Nie ma jeszcze konta administratora.",
|
||||
"not_confirmed": "Nikt nie potwierdził kopii kluczy potrzebnych do przywrócenia: {keys}.",
|
||||
"not_confirmed_grace": "Zapisz kopię kluczy potrzebnych do przywrócenia: {keys}."
|
||||
}
|
||||
},
|
||||
"recovered": {
|
||||
"database_dump": "Zrzut bazy danych znów jest aktualny.",
|
||||
"weekly_copy": "Każde konto znów ma aktualną cotygodniową kopię.",
|
||||
"offhost_copies": "Każde konto znów ma aktualną kopię poza hostem.",
|
||||
"restore_drill": "Próba przywracania znów się powiodła.",
|
||||
"offhost_setup": "Kopie poza hostem są już skonfigurowane.",
|
||||
"recovery_kit": "Zestaw odzyskiwania jest znów potwierdzony."
|
||||
}
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
@@ -9065,6 +9200,14 @@
|
||||
"checkupReminders": {
|
||||
"title": "Przypomnienia o badaniach kontrolnych",
|
||||
"description": "Włączaj lub wyłączaj pojedyncze przypomnienia o badaniach."
|
||||
},
|
||||
"eventBackupHealth": "Stan kopii zapasowych",
|
||||
"eventBackupHealthDesc": "Gdy kopia zapasowa przestaje powstawać, zostaje w tyle lub wraca do normy. Wysyłane tylko do administratorów.",
|
||||
"recoveryReadiness": {
|
||||
"titleAttention": "Kopie zapasowe wymagają uwagi",
|
||||
"titleFailing": "Kopie zapasowe są zagrożone",
|
||||
"titleReminder": "Kopie zapasowe są nadal zagrożone",
|
||||
"titleRecovered": "Kopie zapasowe znów działają normalnie"
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
@@ -11792,7 +11935,7 @@
|
||||
"dismiss": "Odrzuć",
|
||||
"empty": "Nie udało się nic odczytać z tego dokumentu.",
|
||||
"unavailableBody": "Skonfiguruj dostawcę AI, aby zaproponować tytuł, typ i datę.",
|
||||
"unavailableLocalOcr": "Włącz lokalne OCR w ustawieniach AI, aby odczytywać obrazy na tym urządzeniu.",
|
||||
"unavailableLocalOcr": "Włącz „Odczytuj skany na tym urządzeniu” w Ustawienia → Moduły → Badania, aby odczytywać obrazy na tym urządzeniu.",
|
||||
"unavailableAction": "Otwórz ustawienia AI",
|
||||
"errorGeneric": "Nie udało się odczytać dokumentu. Spróbuj ponownie.",
|
||||
"errorOcr": "Nie udało się odczytać tekstu z tego obrazu.",
|
||||
@@ -11805,11 +11948,12 @@
|
||||
"errorFileType": "Tego typu pliku nie można odczytać.",
|
||||
"errorNotIndexed": "Najpierw odczytaj dokument, potem wyodrębnij.",
|
||||
"errorOutputLimit": "Dostawca AI osiągnął limit odpowiedzi, zanim zwrócił choć jedną wartość. Spróbuj modelu, który pozwala na dłuższe odpowiedzi.",
|
||||
"errorLocalOcrDisabled": "Najpierw włącz lokalne OCR w ustawieniach AI.",
|
||||
"errorLocalOcrDisabled": "Najpierw włącz „Odczytuj skany na tym urządzeniu” w Ustawienia → Moduły → Badania.",
|
||||
"errorProvider": "Nie skonfigurowano dostawcy AI do tego celu.",
|
||||
"errorTextImageOnly": "Lokalne OCR odczytuje tylko obrazy, nie ten plik.",
|
||||
"errorConsent": "Odczyt dokumentów przez AI wymaga najpierw Twojej zgody.",
|
||||
"errorUnavailable": "Odczyt dokumentów przez AI jest tu teraz niedostępny."
|
||||
"errorUnavailable": "Odczyt dokumentów przez AI jest tu teraz niedostępny.",
|
||||
"unavailableLocalOcrAction": "Otwórz ustawienia badań"
|
||||
},
|
||||
"summary": {
|
||||
"summarise": "Podsumuj",
|
||||
|
||||
+195
-51
@@ -780,13 +780,13 @@
|
||||
"bmi": "kg/m²"
|
||||
}
|
||||
},
|
||||
"keyBackupBanner": {
|
||||
"title": "Faça backup da sua chave de criptografia",
|
||||
"body": "Sem uma cópia da chave, seus dados não podem ser recuperados após uma reinstalação. Leva um minuto.",
|
||||
"action": "Abrir a etapa"
|
||||
},
|
||||
"quickAddSymptom": "Registrar sintoma",
|
||||
"todayCardVisible": "Mostrar o cartão principal"
|
||||
"todayCardVisible": "Mostrar o cartão principal",
|
||||
"recoveryKitBanner": {
|
||||
"title": "Guarde seu kit de recuperação",
|
||||
"body": "Sem cópias das chaves que este servidor usa, os dados dele não podem ser recuperados após uma reinstalação. Leva poucos minutos.",
|
||||
"action": "Abrir o kit"
|
||||
}
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Medições",
|
||||
@@ -8331,7 +8331,23 @@
|
||||
"restoreSkippedEnvironmentLocationCiphertext": "Local de um período ou dia de ambiente selado com uma chave que este servidor não possui (mantido selado, esses dias não são atualizados)",
|
||||
"typePreWipe": "Antes da exclusão de todos os dados",
|
||||
"retentionNote": "A cópia semanal é substituída todo domingo. Uma cópia feita antes de \"Excluir todos os dados\" é excluída depois de {days} dias. As cópias enviadas ficam até a conta delas ser apagada.",
|
||||
"retentionNoteNoGrace": "A cópia semanal é substituída todo domingo. As cópias enviadas ficam até a conta delas ser apagada."
|
||||
"retentionNoteNoGrace": "A cópia semanal é substituída todo domingo. As cópias enviadas ficam até a conta delas ser apagada.",
|
||||
"database": {
|
||||
"title": "Dump do banco de dados",
|
||||
"lastGood": "Último dump válido {when}, {size}, {where}.",
|
||||
"whereBoth": "guardado neste servidor e fora dele",
|
||||
"whereLocal": "guardado neste servidor",
|
||||
"whereOffhost": "somente fora do servidor",
|
||||
"none": "Ainda não há dump do banco de dados. O primeiro é feito esta noite.",
|
||||
"failedTitle": "O último dump do banco de dados falhou",
|
||||
"failedDescription": "{when}: {reason}",
|
||||
"disabled": "Os dumps do banco de dados estão desligados (DB_BACKUP_ENABLED=false). Faça o backup do banco de outra forma.",
|
||||
"noDirectoryOffhost": "Este servidor não consegue gravar em {dir}, então os dumps vão só para fora e nenhum fica aqui. Monte o volume de backups.",
|
||||
"noDirectory": "Este servidor não consegue gravar em {dir} e não há armazenamento externo configurado, então nenhum dump pode ser feito. Monte o volume de backups.",
|
||||
"retentionLocal": "Contas excluídas continuam nos dumps até que eles expirem: este servidor guarda os {nights} mais recentes.",
|
||||
"retentionOffhost": "A regra de ciclo de vida do bucket remove as cópias externas após {days} dias.",
|
||||
"retentionOffhostMissing": "Nenhuma regra de ciclo de vida do bucket cobre database/, então os dumps ali, e as contas excluídas dentro deles, nunca são removidos. Adicione uma regra para esse prefixo."
|
||||
}
|
||||
},
|
||||
"danger-zone": {
|
||||
"title": "Zona de perigo",
|
||||
@@ -8527,47 +8543,6 @@
|
||||
"title": "Esta parte da página não pôde ser exibida",
|
||||
"description": "O resto da página ainda funciona; tente novamente ou recarregue se continuar falhando."
|
||||
},
|
||||
"keyBackup": {
|
||||
"title": "Faça backup da sua chave de criptografia",
|
||||
"description": "Sem uma cópia desta chave, seus dados criptografados e os backups deste servidor não podem ser lidos novamente.",
|
||||
"loading": "Carregando o status do backup da chave…",
|
||||
"loadError": "Não foi possível carregar o status do backup da chave.",
|
||||
"badgeDue": "A fazer",
|
||||
"badgeDone": "Backup feito",
|
||||
"keyId": "ID da chave ativa",
|
||||
"fingerprint": "Impressão digital",
|
||||
"whatIsLost": "Mantenha uma cópia da chave em algum lugar fora deste servidor, por exemplo em um gerenciador de senhas. Se o servidor for reinstalado com uma chave diferente, o HealthLog se recusa a começar a servir até a chave original voltar.",
|
||||
"fingerprintHint": "A impressão digital são os primeiros 12 caracteres do hash SHA-256 da chave. Ela identifica a chave sem revelá-la, então você pode compará-la com sua cópia.",
|
||||
"checkLabel": "Verificar minha cópia",
|
||||
"checkButton": "Verificar",
|
||||
"checkNote": "A chave que você cola é comparada com a chave ativa e depois descartada. Ela nunca é armazenada ou registrada.",
|
||||
"checkInsecureNote": "Esta página não é servida por https://, então a chave que você cola atravessa a rede sem criptografia, assim como sua senha. Ela é comparada e descartada, nunca armazenada ou registrada.",
|
||||
"checkMatches": "Sua cópia corresponde à chave ativa.",
|
||||
"checkNoMatch": "Sua cópia não corresponde à chave ativa. Verifique se você copiou o valor inteiro.",
|
||||
"checkFailed": "A verificação falhou.",
|
||||
"confirmButton": "Eu fiz backup desta chave",
|
||||
"confirmed": "Backup da chave confirmado.",
|
||||
"confirmFailed": "Não foi possível salvar a confirmação.",
|
||||
"stale": "A chave do servidor mudou desde que esta página carregou. Verifique o novo ID e a impressão digital e confirme novamente.",
|
||||
"confirmedAt": "Confirmado em {date}.",
|
||||
"confirmedAtBy": "Confirmado em {date} por {who}.",
|
||||
"dueAgain": "Um backup foi confirmado para a chave {keyId}, mas o servidor agora usa outra. Faça backup da atual.",
|
||||
"tileLabel": "Backup da chave de criptografia",
|
||||
"tileDue": "Não confirmado",
|
||||
"tileDone": "Confirmado",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "A chave é ENCRYPTION_KEY (ou ENCRYPTION_KEYS) no arquivo .env ao lado do docker-compose.yml.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Abra Apps, selecione HealthLog e escolha Editar. A chave está no campo Encryption Key em HealthLog Configuration; em um app instalado via YAML, é ENCRYPTION_KEY nas configurações de ambiente.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Abra o aplicativo no Coolify e copie ENCRYPTION_KEY de suas Variáveis de Ambiente.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "Na aba Docker, edite o contêiner do HealthLog e copie ENCRYPTION_KEY do modelo, ou do .env da sua pilha do Compose Manager.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Abra a stack do HealthLog no Portainer e copie ENCRYPTION_KEY de suas variáveis de ambiente."
|
||||
}
|
||||
},
|
||||
"keyMismatch": {
|
||||
"title": "O HealthLog não consegue abrir seus dados",
|
||||
"body": "A chave de criptografia com que este servidor foi iniciado não é a chave com que seu banco de dados foi gravado. Para proteger seus dados, o HealthLog não serve nada até que isso seja corrigido.",
|
||||
@@ -8598,6 +8573,166 @@
|
||||
"defaultAboveCeiling": "O nível padrão não pode estar acima do nível mais alto.",
|
||||
"gapLabel": "Horas entre atualizações de cartão após uma sincronização (Padrão)",
|
||||
"gapHint": "Horas inteiras de 1 a 48. Deixe em branco para usar AI_SYNC_REGEN_GAP_HOURS, senão 6. Em vigor agora: {hours} h."
|
||||
},
|
||||
"recoveryKit": {
|
||||
"title": "Kit de recuperação",
|
||||
"description": "Todas as chaves de que uma restauração deste servidor precisa, com ID e impressão digital.",
|
||||
"intro": "Mantenha uma cópia de cada chave abaixo fora deste servidor, por exemplo em um gerenciador de senhas. Sem elas, nem um dump do banco de dados nem uma cópia de backup podem ser lidos novamente, e o HealthLog se recusa a iniciar se faltar uma chave de que ele precisa.",
|
||||
"loading": "Carregando o kit de recuperação…",
|
||||
"loadError": "Não foi possível carregar o kit de recuperação.",
|
||||
"badgeDue": "A fazer",
|
||||
"badgeDone": "Guardado",
|
||||
"keyId": "ID",
|
||||
"fingerprint": "Impressão digital",
|
||||
"noFingerprint": "Não está neste servidor",
|
||||
"kind": {
|
||||
"application": "Chave do aplicativo {id}",
|
||||
"backup": "Chave de backup fora do host",
|
||||
"backupPrevious": "Chave anterior de backup fora do host",
|
||||
"token": "Chave de tokens"
|
||||
},
|
||||
"need": {
|
||||
"active": "Criptografa os dados novos.",
|
||||
"configured": "Mantida por este servidor.",
|
||||
"storedCopies": "Necessária para {count} cópias armazenadas.",
|
||||
"offhostUntil": "Necessária para as cópias fora do host até {date}.",
|
||||
"offhostUnknown": "Necessária para as cópias fora do host enquanto o bucket as mantiver.",
|
||||
"backupActive": "Sela cada nova cópia fora do host.",
|
||||
"backupPrevious": "Abre as cópias fora do host gravadas antes da última troca de chave.",
|
||||
"token": "Assina sessões, tokens e links de compartilhamento. Sem ela, todos são desconectados, mas nenhum dado é perdido."
|
||||
},
|
||||
"source": "Definida em {variable}.",
|
||||
"status": {
|
||||
"confirmed": "Guardada",
|
||||
"new": "Ainda não guardada",
|
||||
"notConfigured": "Não está neste servidor",
|
||||
"unreadable": "Não é uma chave válida"
|
||||
},
|
||||
"notConfiguredHint": "Um backup ainda precisa desta chave, e este servidor não a tem. Só uma cópia que você guarda em outro lugar consegue ler esses backups. Confirme o kit quando tiver essa cópia.",
|
||||
"unreadableHint": "A variável está definida, mas não contém uma chave válida. Corrija isso antes de contar com as cópias fora do host.",
|
||||
"fingerprintHint": "A impressão digital são os primeiros 12 caracteres do hash SHA-256 de uma chave. Ela identifica a chave sem revelá-la, então você pode compará-la com sua cópia.",
|
||||
"whereTitle": "Onde as chaves são definidas",
|
||||
"platform": {
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "As chaves ficam no arquivo .env ao lado do docker-compose.yml: ENCRYPTION_KEY ou ENCRYPTION_KEYS, API_TOKEN_HMAC_KEY e, com cópias fora do host, BACKUP_ENCRYPTION_KEY.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Abra Apps, selecione HealthLog e escolha Edit. As chaves ficam em HealthLog Configuration; em um app instalado via YAML, são ENCRYPTION_KEY, API_TOKEN_HMAC_KEY e BACKUP_ENCRYPTION_KEY nas configurações de ambiente.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Abra o aplicativo no Coolify e copie ENCRYPTION_KEY, API_TOKEN_HMAC_KEY e BACKUP_ENCRYPTION_KEY de suas Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
"unraidWhere": "Na aba Docker, edite o contêiner do HealthLog e copie ENCRYPTION_KEY, API_TOKEN_HMAC_KEY e BACKUP_ENCRYPTION_KEY do modelo, ou do .env da sua pilha do Compose Manager.",
|
||||
"portainerLabel": "Portainer",
|
||||
"portainerWhere": "Abra a stack do HealthLog no Portainer e copie ENCRYPTION_KEY, API_TOKEN_HMAC_KEY e BACKUP_ENCRYPTION_KEY de suas variáveis de ambiente."
|
||||
},
|
||||
"restoreTitle": "Restaurar este servidor",
|
||||
"restoreBody": "Instale o HealthLog com as mesmas chaves, restaure o dump do banco de dados mais recente em um banco de dados vazio e depois inicie o app. O guia mostra cada etapa.",
|
||||
"restoreLink": "Guia de recuperação de desastres",
|
||||
"checkLabel": "Verificar minha cópia",
|
||||
"checkKeyLabel": "Chave a verificar",
|
||||
"checkValueLabel": "Sua cópia da chave",
|
||||
"checkButton": "Verificar",
|
||||
"checkNote": "A chave que você cola é comparada com a que este servidor mantém e depois descartada. Ela nunca é armazenada ou registrada.",
|
||||
"checkInsecureNote": "Esta página não é servida por https://, então a chave que você cola atravessa a rede sem criptografia, assim como sua senha. Ela é comparada e descartada, nunca armazenada ou registrada.",
|
||||
"checkMatches": "Sua cópia corresponde a esta chave.",
|
||||
"checkNoMatch": "Sua cópia não corresponde a esta chave. Verifique se você copiou o valor inteiro.",
|
||||
"checkFailed": "A verificação falhou.",
|
||||
"confirmButton": "Tenho uma cópia de cada chave",
|
||||
"confirmed": "Kit de recuperação confirmado.",
|
||||
"confirmFailed": "Não foi possível salvar a confirmação.",
|
||||
"stale": "O kit mudou desde que esta página carregou. Verifique as chaves que ele lista agora e confirme novamente.",
|
||||
"confirmedAt": "Confirmado em {date}.",
|
||||
"confirmedAtBy": "Confirmado em {date} por {who}.",
|
||||
"dueAgain": "O kit foi confirmado em {date}. Agora ele também lista {keys}, que essa confirmação não cobria. Guarde uma cópia de cada uma e confirme de novo."
|
||||
},
|
||||
"recoveryReadiness": {
|
||||
"title": "Prontidão para recuperação",
|
||||
"description": "Se este servidor poderia ser reconstruído após uma perda, avaliado a partir de cada backup que ele faz.",
|
||||
"loading": "Verificando os backups…",
|
||||
"loadError": "Não foi possível carregar a prontidão para recuperação.",
|
||||
"state": {
|
||||
"ok": "Pronto",
|
||||
"attention": "Precisa de atenção",
|
||||
"failing": "Em risco"
|
||||
},
|
||||
"signal": {
|
||||
"database_dump": "Dump do banco de dados",
|
||||
"weekly_copy": "Cópias semanais das contas",
|
||||
"offhost_copies": "Cópias fora do host",
|
||||
"restore_drill": "Teste de restauração",
|
||||
"offhost_setup": "Backup fora do host",
|
||||
"recovery_kit": "Kit de recuperação"
|
||||
},
|
||||
"at": {
|
||||
"database_dump": "Dump mais recente: {date}",
|
||||
"weekly_copy": "Cópia de conta mais antiga: {date}",
|
||||
"offhost_copies": "Cópia fora do host mais antiga: {date}",
|
||||
"restore_drill": "Último teste: {date}",
|
||||
"recovery_kit": "Confirmado: {date}"
|
||||
},
|
||||
"dueBy": "Prazo: {date}",
|
||||
"fix": {
|
||||
"database_dump": "Abrir backups",
|
||||
"weekly_copy": "Abrir backups",
|
||||
"offhost_copies": "Abrir backups",
|
||||
"restore_drill": "Abrir backups",
|
||||
"offhost_setup": "Abrir backups",
|
||||
"recovery_kit": "Abrir o kit"
|
||||
},
|
||||
"reason": {
|
||||
"database_dump": {
|
||||
"not_available": "Esta versão não agenda um dump do banco de dados.",
|
||||
"disabled": "O dump agendado do banco de dados está desligado, então nenhuma cópia consegue reconstruir este servidor. Para religá-lo, remova DB_BACKUP_ENABLED ou defina-o como true e reinicie o HealthLog.",
|
||||
"unreadable": "Não foi possível ler o status do dump do banco de dados.",
|
||||
"never_succeeded": "Nenhum dump do banco de dados foi concluído com sucesso ainda, e a última tentativa falhou.",
|
||||
"never_ran": "Nenhum dump do banco de dados foi gravado nas {hours} horas desde que os dumps foram ativados.",
|
||||
"first_pending": "O primeiro dump do banco de dados ainda não foi executado.",
|
||||
"stale": "O dump do banco de dados mais recente tem {hours} horas.",
|
||||
"last_failed": "O último dump do banco de dados falhou. O anterior ainda é recente.",
|
||||
"fresh": "O dump do banco de dados mais recente está em dia.",
|
||||
"nowhere_to_write": "Não é possível gravar no diretório de dumps {directory} e nenhum bucket externo está configurado, então nenhum dump pode ser guardado."
|
||||
},
|
||||
"weekly_copy": {
|
||||
"no_accounts": "Ainda não há contas para copiar.",
|
||||
"stale": "A cópia semanal mais antiga tem {days} dias. Contas atrasadas: {accounts}.",
|
||||
"never": "Uma conta está sem cópia semanal há {days} dias. Contas atrasadas: {accounts}.",
|
||||
"first_pending": "A primeira cópia semanal é feita na madrugada de domingo.",
|
||||
"fresh": "Todas as contas têm uma cópia da última semana."
|
||||
},
|
||||
"offhost_copies": {
|
||||
"not_configured": "Nenhum bucket fora do host está configurado.",
|
||||
"stale": "As cópias fora do host estão mais de duas noites atrasadas. Contas atrasadas: {accounts}.",
|
||||
"first_pending": "A execução noturna fora do host ainda não chegou a nenhuma conta.",
|
||||
"fresh": "Todas as contas têm uma cópia recente no bucket fora do host."
|
||||
},
|
||||
"restore_drill": {
|
||||
"not_configured": "O teste lê o bucket fora do host, que não está configurado.",
|
||||
"not_run_yet": "O teste mensal de restauração ainda não foi executado.",
|
||||
"failed": "O último teste de restauração falhou: {failed} de {checked} cópias não puderam ser lidas de volta.",
|
||||
"failed_run": "O último teste de restauração não pôde ser concluído.",
|
||||
"passed": "O último teste de restauração leu de volta {checked} cópias."
|
||||
},
|
||||
"offhost_setup": {
|
||||
"configured": "As cópias saem deste servidor todas as noites.",
|
||||
"no_admin": "Ainda não há uma conta de administrador.",
|
||||
"not_configured": "Nenhum bucket fora do host está configurado, então todas as cópias ficam apenas neste servidor.",
|
||||
"not_configured_grace": "Nenhum bucket fora do host foi configurado ainda, então todas as cópias ficam apenas neste servidor."
|
||||
},
|
||||
"recovery_kit": {
|
||||
"confirmed": "Há uma cópia guardada de cada chave de que uma restauração precisa.",
|
||||
"out_of_date": "O kit de recuperação agora também lista {keys}, que a última confirmação não cobria. Nada quebrou: guarde uma cópia e confirme o kit de novo.",
|
||||
"no_admin": "Ainda não há uma conta de administrador.",
|
||||
"not_confirmed": "Ninguém confirmou uma cópia das chaves de que uma restauração precisa: {keys}.",
|
||||
"not_confirmed_grace": "Guarde uma cópia das chaves de que uma restauração precisa: {keys}."
|
||||
}
|
||||
},
|
||||
"recovered": {
|
||||
"database_dump": "O dump do banco de dados está em dia novamente.",
|
||||
"weekly_copy": "Todas as contas têm novamente uma cópia semanal recente.",
|
||||
"offhost_copies": "Todas as contas têm novamente uma cópia recente fora do host.",
|
||||
"restore_drill": "O teste de restauração passou novamente.",
|
||||
"offhost_setup": "As cópias fora do host estão configuradas agora.",
|
||||
"recovery_kit": "O kit de recuperação está confirmado novamente."
|
||||
}
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
@@ -9065,6 +9200,14 @@
|
||||
"checkupReminders": {
|
||||
"title": "Lembretes de check-up",
|
||||
"description": "Ligue ou desligue lembretes de check-up individuais."
|
||||
},
|
||||
"eventBackupHealth": "Saúde dos backups",
|
||||
"eventBackupHealthDesc": "Enviado quando um backup para, fica atrasado ou se recupera. Apenas para administradores.",
|
||||
"recoveryReadiness": {
|
||||
"titleAttention": "Os backups precisam de atenção",
|
||||
"titleFailing": "Os backups estão em risco",
|
||||
"titleReminder": "Os backups continuam em risco",
|
||||
"titleRecovered": "Os backups voltaram ao normal"
|
||||
}
|
||||
},
|
||||
"i18n": {
|
||||
@@ -11792,7 +11935,7 @@
|
||||
"dismiss": "Dispensar",
|
||||
"empty": "Nada pôde ser lido deste documento.",
|
||||
"unavailableBody": "Configure um provedor de IA para sugerir um título, tipo e data.",
|
||||
"unavailableLocalOcr": "Ative o OCR local nas configurações de IA para ler imagens neste dispositivo.",
|
||||
"unavailableLocalOcr": "Ative “Ler digitalizações neste dispositivo” em Configurações → Módulos → Exames para ler imagens neste dispositivo.",
|
||||
"unavailableAction": "Abrir configurações de IA",
|
||||
"errorGeneric": "Não foi possível ler o documento. Tente novamente.",
|
||||
"errorOcr": "Não foi possível ler texto desta imagem.",
|
||||
@@ -11805,11 +11948,12 @@
|
||||
"errorFileType": "Este tipo de arquivo não pode ser lido.",
|
||||
"errorNotIndexed": "Leia o documento primeiro, depois extraia.",
|
||||
"errorOutputLimit": "O provedor de IA atingiu o limite de saída antes de devolver um único valor. Tente um modelo que permita respostas mais longas.",
|
||||
"errorLocalOcrDisabled": "Ative o OCR local nas configurações de IA primeiro.",
|
||||
"errorLocalOcrDisabled": "Ative primeiro “Ler digitalizações neste dispositivo” em Configurações → Módulos → Exames.",
|
||||
"errorProvider": "Nenhum provedor de IA está configurado para isso.",
|
||||
"errorTextImageOnly": "O OCR local lê apenas imagens, não este arquivo.",
|
||||
"errorConsent": "Ler documentos com IA precisa do seu consentimento primeiro.",
|
||||
"errorUnavailable": "Ler documentos com IA não está disponível aqui agora."
|
||||
"errorUnavailable": "Ler documentos com IA não está disponível aqui agora.",
|
||||
"unavailableLocalOcrAction": "Abrir configurações de exames"
|
||||
},
|
||||
"summary": {
|
||||
"summarise": "Resumir",
|
||||
|
||||
@@ -302,6 +302,11 @@ const nextConfig: NextConfig = {
|
||||
outputFileTracingIncludes: {
|
||||
"*": [
|
||||
"./src/lib/ai/prompts/safety-contracts.*.yaml",
|
||||
// PDFs are read and rendered in a child process with a memory ceiling
|
||||
// and a time limit (`src/lib/documents/pdf-isolation.ts`). The child is
|
||||
// started from these two files by path, which the tracer cannot follow.
|
||||
"./src/lib/documents/pdf-child.mjs",
|
||||
"./src/lib/documents/pdf-engine.mjs",
|
||||
// Document AI local text extraction and rasterization both run the
|
||||
// app's one pdfjs-dist (`src/lib/documents/pdfjs.ts`) server-side; the
|
||||
// text read is the provider-free fallback that keeps content search
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
-- The nightly whole-database dump records each run: whether a dump was
|
||||
-- written in DB_BACKUP_DIR and copied to the off-host bucket, and why not.
|
||||
-- Instance metadata with no account; the admin backups card and the backup
|
||||
-- alerts read the newest rows.
|
||||
--
|
||||
-- The table is new, so nothing waits on its lock in practice; the wait is
|
||||
-- bounded in a DO block anyway, and every statement is safe to run again, so
|
||||
-- the boot retries the file on a lock timeout.
|
||||
--
|
||||
-- @retry-on-lock-timeout
|
||||
|
||||
DO $migration$
|
||||
BEGIN
|
||||
PERFORM set_config('lock_timeout', '5s', true);
|
||||
CREATE TABLE IF NOT EXISTS "database_backup_runs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"started_at" TIMESTAMP(3) NOT NULL,
|
||||
"finished_at" TIMESTAMP(3),
|
||||
"status" TEXT NOT NULL,
|
||||
"file_name" TEXT,
|
||||
"dump_bytes" BIGINT,
|
||||
"local_result" TEXT NOT NULL,
|
||||
"offhost_result" TEXT NOT NULL,
|
||||
"error" TEXT,
|
||||
CONSTRAINT "database_backup_runs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "database_backup_runs_started_at_idx"
|
||||
ON "database_backup_runs"("started_at");
|
||||
END
|
||||
$migration$;
|
||||
@@ -0,0 +1,101 @@
|
||||
-- Recovery readiness: what the admins were last told, what the restore drill
|
||||
-- last found, and the recovery kit confirmation.
|
||||
--
|
||||
-- `recovery_readiness_notices` holds, per readiness signal, the state the
|
||||
-- admins were last notified about and when. It is the whole memory of the
|
||||
-- hourly evaluation: a state change notifies once, a failing signal is
|
||||
-- reminded at most once a day, and a recovery is announced once. It also
|
||||
-- remembers since when a signal has applied, so a dump switched on by an
|
||||
-- upgrade is not called failing before its first run had a chance.
|
||||
--
|
||||
-- `restore_drill_outcomes` keeps the monthly drill's last result for as long
|
||||
-- as it is the last one. pg-boss forgets a failed run after seven days and
|
||||
-- the admin failing-jobs card looks back 72 hours, so a failed monthly drill
|
||||
-- was on screen three days in thirty.
|
||||
--
|
||||
-- `recovery_kit_confirmations` replaces the four `encryption_key_backup_*`
|
||||
-- columns on `app_settings`. Those confirmed one key, the active application
|
||||
-- key; the kit confirms the set of every key a restore needs, by id and
|
||||
-- fingerprint. A confirmation given under the old step is carried over as a
|
||||
-- kit that holds that one key, so an operator who confirmed the active key
|
||||
-- keeps that part confirmed and is asked only for what the old step never
|
||||
-- covered.
|
||||
--
|
||||
-- A backup file written by a release that still had the four columns carries
|
||||
-- them in its instance settings; the restore writes an explicit list of
|
||||
-- settings and ignores them.
|
||||
--
|
||||
-- The whole file is one statement, so it applies whole or not at all, and
|
||||
-- running it again changes nothing. The drop takes an ACCESS EXCLUSIVE lock
|
||||
-- on `app_settings` for a catalogue update only; the wait is bounded.
|
||||
--
|
||||
-- Reversibility: re-adding the four nullable columns restores the old shape;
|
||||
-- the old step then reads as due until it is confirmed again.
|
||||
--
|
||||
-- @retry-on-lock-timeout
|
||||
|
||||
DO $migration$
|
||||
BEGIN
|
||||
PERFORM set_config('lock_timeout', '5s', true);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "recovery_readiness_notices" (
|
||||
"signal" TEXT NOT NULL,
|
||||
"notified_state" TEXT,
|
||||
"notified_at" TIMESTAMP(3),
|
||||
"watched_since" TIMESTAMP(3),
|
||||
CONSTRAINT "recovery_readiness_notices_pkey" PRIMARY KEY ("signal"),
|
||||
CONSTRAINT "recovery_readiness_notices_state_check"
|
||||
CHECK ("notified_state" IS NULL OR "notified_state" IN ('ok', 'attention', 'failing'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "restore_drill_outcomes" (
|
||||
"id" TEXT NOT NULL DEFAULT 'singleton',
|
||||
"last_run_at" TIMESTAMP(3) NOT NULL,
|
||||
"last_success_at" TIMESTAMP(3),
|
||||
"last_failure_at" TIMESTAMP(3),
|
||||
"last_failure_reason" TEXT,
|
||||
"accounts_checked" INTEGER NOT NULL DEFAULT 0,
|
||||
"accounts_failed" INTEGER NOT NULL DEFAULT 0,
|
||||
CONSTRAINT "restore_drill_outcomes_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "recovery_kit_confirmations" (
|
||||
"id" TEXT NOT NULL DEFAULT 'singleton',
|
||||
"confirmed_at" TIMESTAMP(3) NOT NULL,
|
||||
"confirmed_by_user_id" TEXT,
|
||||
"entries" JSONB NOT NULL,
|
||||
CONSTRAINT "recovery_kit_confirmations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'app_settings'
|
||||
AND column_name = 'encryption_key_backup_confirmed_at'
|
||||
) THEN
|
||||
INSERT INTO "recovery_kit_confirmations"
|
||||
("id", "confirmed_at", "confirmed_by_user_id", "entries")
|
||||
SELECT
|
||||
'singleton',
|
||||
"encryption_key_backup_confirmed_at",
|
||||
"encryption_key_backup_confirmed_by_user_id",
|
||||
jsonb_build_array(jsonb_build_object(
|
||||
'kind', 'application',
|
||||
'id', "encryption_key_backup_confirmed_key_id",
|
||||
'fingerprint', "encryption_key_backup_confirmed_fingerprint"
|
||||
))
|
||||
FROM "app_settings"
|
||||
WHERE "id" = 'singleton'
|
||||
AND "encryption_key_backup_confirmed_at" IS NOT NULL
|
||||
AND "encryption_key_backup_confirmed_key_id" IS NOT NULL
|
||||
AND "encryption_key_backup_confirmed_fingerprint" IS NOT NULL
|
||||
ON CONFLICT ("id") DO NOTHING;
|
||||
|
||||
ALTER TABLE "app_settings"
|
||||
DROP COLUMN IF EXISTS "encryption_key_backup_confirmed_at",
|
||||
DROP COLUMN IF EXISTS "encryption_key_backup_confirmed_key_id",
|
||||
DROP COLUMN IF EXISTS "encryption_key_backup_confirmed_fingerprint",
|
||||
DROP COLUMN IF EXISTS "encryption_key_backup_confirmed_by_user_id";
|
||||
END IF;
|
||||
END
|
||||
$migration$;
|
||||
+93
-15
@@ -5489,21 +5489,6 @@ model AppSettings {
|
||||
documentMaxFileBytes Int @default(26214400) @map("document_max_file_bytes")
|
||||
documentQuotaBytes BigInt @default(1073741824) @map("document_quota_bytes")
|
||||
|
||||
// The operator's confirmation that the encryption key is backed up, bound to
|
||||
// the key it was given for: the active key id and its fingerprint (the first
|
||||
// twelve hex characters of SHA-256 over the raw key bytes). A rotation or a
|
||||
// re-keyed install changes either one, and the step is due again. Never the
|
||||
// key itself. Written by `POST /api/admin/encryption/key-backup/confirm`;
|
||||
// consumer: `readKeyBackupStatus` (the admin step, the overview tile and the
|
||||
// dashboard banner for admins).
|
||||
encryptionKeyBackupConfirmedAt DateTime? @map("encryption_key_backup_confirmed_at")
|
||||
encryptionKeyBackupConfirmedKeyId String? @map("encryption_key_backup_confirmed_key_id")
|
||||
encryptionKeyBackupConfirmedFingerprint String? @map("encryption_key_backup_confirmed_fingerprint")
|
||||
/// Plain id, not a relation: the instance row must not become user-scoped,
|
||||
/// and a deleted admin leaves the confirmation standing. Consumer: the admin
|
||||
/// step names who confirmed when the account still exists.
|
||||
encryptionKeyBackupConfirmedByUserId String? @map("encryption_key_backup_confirmed_by_user_id")
|
||||
|
||||
/// When this instance first stored a discovery finding under the seasonally
|
||||
/// adjusted correlation engine (v1.42). Stamped once, by the first
|
||||
/// `syncAcceptedPatterns` run of a discovery family, before it writes; null
|
||||
@@ -5926,6 +5911,70 @@ model OffhostBackupKeyUse {
|
||||
@@map("offhost_backup_key_use")
|
||||
}
|
||||
|
||||
/// The monthly restore drill's last result, one row for the instance. pg-boss
|
||||
/// forgets a failed run after seven days and the failing-jobs card looks back
|
||||
/// 72 hours, so the drill's verdict lives here for as long as it is the last
|
||||
/// one. Written by `recordRestoreDrillOutcome` at the end of every drill that
|
||||
/// had something to check; consumer: `readRecoveryReadiness` (the readiness
|
||||
/// tile and the hourly evaluation).
|
||||
model RestoreDrillOutcome {
|
||||
id String @id @default("singleton")
|
||||
/// When the last drill finished. Consumer: the tile's drill row.
|
||||
lastRunAt DateTime @map("last_run_at")
|
||||
/// When a drill last passed every object it checked. A failure stays the
|
||||
/// verdict until a later success. Consumer: the readiness model.
|
||||
lastSuccessAt DateTime? @map("last_success_at")
|
||||
/// When a drill last failed. Consumer: the readiness model.
|
||||
lastFailureAt DateTime? @map("last_failure_at")
|
||||
/// One line naming why, already redacted. Consumer: the tile's drill row.
|
||||
lastFailureReason String? @map("last_failure_reason")
|
||||
/// Objects the last drill checked and how many of them failed. Consumer:
|
||||
/// the tile's drill row.
|
||||
accountsChecked Int @default(0) @map("accounts_checked")
|
||||
accountsFailed Int @default(0) @map("accounts_failed")
|
||||
|
||||
@@map("restore_drill_outcomes")
|
||||
}
|
||||
|
||||
/// Per readiness signal, the state the admins were last notified about. The
|
||||
/// hourly evaluation compares the current state against it: a change to
|
||||
/// attention or failing notifies once, a failing signal is reminded at most
|
||||
/// once a day, and a return to ok is announced once. The update is a
|
||||
/// compare-and-set on both columns, so two workers never send the same
|
||||
/// notice. Written and read by `notifyReadinessChanges`.
|
||||
model RecoveryReadinessNotice {
|
||||
/// The signal's id (`database_dump`, `weekly_copy`, …).
|
||||
signal String @id
|
||||
/// `ok`, `attention` or `failing`; null until anything was announced.
|
||||
notifiedState String? @map("notified_state")
|
||||
notifiedAt DateTime? @map("notified_at")
|
||||
/// Since when the evaluation has seen the signal apply (not `inactive`);
|
||||
/// cleared when it stops applying. Consumer: the readiness model, which
|
||||
/// judges a dump that has never been attempted against it.
|
||||
watchedSince DateTime? @map("watched_since")
|
||||
|
||||
@@map("recovery_readiness_notices")
|
||||
}
|
||||
|
||||
/// The recovery kit an admin confirmed: every key a restore needs, by kind,
|
||||
/// id and fingerprint, never the key itself. The kit is due again as soon as
|
||||
/// a key it needs is not in this set (a rotation, a new backup key). Written
|
||||
/// by `POST /api/admin/recovery-kit/confirm`; consumer: `readRecoveryKit`
|
||||
/// (the kit card, the dashboard banner for admins and the readiness model).
|
||||
model RecoveryKitConfirmation {
|
||||
id String @id @default("singleton")
|
||||
confirmedAt DateTime @map("confirmed_at")
|
||||
/// Plain id, not a relation: the instance row must not become user-scoped,
|
||||
/// and a deleted admin leaves the confirmation standing. Consumer: the kit
|
||||
/// card names who confirmed while the account still exists.
|
||||
confirmedByUserId String? @map("confirmed_by_user_id")
|
||||
/// `[{ kind, id, fingerprint }]`, validated on read
|
||||
/// (`parseKitEntries`). Consumer: `assessRecoveryKit`.
|
||||
entries Json
|
||||
|
||||
@@map("recovery_kit_confirmations")
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -5955,6 +6004,35 @@ model OffhostPurgeRequest {
|
||||
@@map("offhost_purge_requests")
|
||||
}
|
||||
|
||||
/// One run of the nightly whole-database dump (the `database-backup` job):
|
||||
/// whether a dump was written on this server and copied off-host, and why not
|
||||
/// when it was not. Instance metadata, not an account's data. Written by
|
||||
/// `runDatabaseBackup`; read by `getDatabaseBackupHealth`, which feeds the
|
||||
/// database row of the admin backups card and the backup alerts. A row left
|
||||
/// `running` by a process that died is read as a failed run once the job's
|
||||
/// expiry has passed.
|
||||
model DatabaseBackupRun {
|
||||
id String @id @default(cuid())
|
||||
startedAt DateTime @map("started_at")
|
||||
finishedAt DateTime? @map("finished_at")
|
||||
/// `running`, `succeeded`, `failed` or `skipped`.
|
||||
status String
|
||||
/// The dump's file name, once one was written and checked.
|
||||
fileName String? @map("file_name")
|
||||
/// Size of the dump in bytes.
|
||||
dumpBytes BigInt? @map("dump_bytes")
|
||||
/// `written` (kept in DB_BACKUP_DIR), `no_directory` (no writable
|
||||
/// directory, the dump went off-host only), or `failed`.
|
||||
localResult String @map("local_result")
|
||||
/// `uploaded`, `not_configured` or `failed`.
|
||||
offhostResult String @map("offhost_result")
|
||||
/// Why the run failed or was skipped, trimmed. Never key material.
|
||||
error String?
|
||||
|
||||
@@index([startedAt])
|
||||
@@map("database_backup_runs")
|
||||
}
|
||||
|
||||
// ─── Apple Health export.zip import (v1.4.34) ───────────────
|
||||
//
|
||||
// Mirrors a `pg-boss` `apple-health-import` job's lifecycle so the
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* The restore's refusals, which all come before it writes anything.
|
||||
*
|
||||
* The database is a stand-in here: `connect` answers a client whose only use
|
||||
* is to be asked which tables exist, and `runTool` (which would run
|
||||
* `pg_restore`) is a spy that must never be called on a refusal. The real
|
||||
* restore into a real empty database, and its refusal of a real database with
|
||||
* tables, are in `tests/integration/database-backup.test.ts` and in the
|
||||
* compose drill (`scripts/compose-dr-drill.sh`).
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const tablesInTarget = vi.fn(async (): Promise<string[]> => []);
|
||||
const runTool = vi.fn(async () => "");
|
||||
const end = vi.fn(async () => {});
|
||||
|
||||
vi.mock("../../src/lib/database-backup/dump-core.mjs", async (original) => ({
|
||||
...(await original<Record<string, unknown>>()),
|
||||
connect: async () => ({ query: async () => ({ rows: [] }), end }),
|
||||
listUserTables: () => tablesInTarget(),
|
||||
runTool: (...args: unknown[]) => runTool(...(args as [])),
|
||||
}));
|
||||
|
||||
import { buildManifest } from "../../src/lib/database-backup/dump-core.mjs";
|
||||
import { main } from "../db-restore.mjs";
|
||||
|
||||
const KEY_A = "a".repeat(64);
|
||||
const KEY_B = "b".repeat(64);
|
||||
const BASE = "healthlog-20261025T013000Z-daily";
|
||||
|
||||
let dir: string;
|
||||
let errors: string[];
|
||||
let lines: string[];
|
||||
|
||||
function env(extra: Record<string, string> = {}) {
|
||||
return {
|
||||
DATABASE_URL: "postgresql://healthlog:pw@db:5432/healthlog?schema=public",
|
||||
DB_BACKUP_DIR: dir,
|
||||
ENCRYPTION_KEY: KEY_A,
|
||||
API_TOKEN_HMAC_KEY: "hmac",
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/** A dump file and a manifest that describes it, written with KEY_A. */
|
||||
function writeDump(body = Buffer.from("PGDMP not really an archive")) {
|
||||
writeFileSync(join(dir, `${BASE}.dump`), body);
|
||||
const manifest = buildManifest({
|
||||
kind: "daily",
|
||||
file: `${BASE}.dump`,
|
||||
createdAt: new Date("2026-10-25T01:30:00Z"),
|
||||
appVersion: "1.46.0",
|
||||
buildSha: null,
|
||||
latestMigration: "0486_database_backup_runs",
|
||||
server: { version: "18.6", versionNum: 180006 },
|
||||
pgDump: null,
|
||||
archive: {
|
||||
bytes: body.length,
|
||||
sha256: createHash("sha256").update(body).digest("hex"),
|
||||
entries: 1,
|
||||
},
|
||||
rowCounts: { "public.users": 2 },
|
||||
env: { ENCRYPTION_KEY: KEY_A, API_TOKEN_HMAC_KEY: "hmac" },
|
||||
});
|
||||
writeFileSync(
|
||||
join(dir, `${BASE}.manifest.json`),
|
||||
JSON.stringify(manifest, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "hl-db-restore-"));
|
||||
errors = [];
|
||||
lines = [];
|
||||
vi.spyOn(console, "error").mockImplementation((line: string) => {
|
||||
errors.push(String(line));
|
||||
});
|
||||
vi.spyOn(console, "log").mockImplementation((line: string) => {
|
||||
lines.push(String(line));
|
||||
});
|
||||
tablesInTarget.mockResolvedValue([]);
|
||||
runTool.mockClear();
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("db-restore refuses before it writes", () => {
|
||||
it("refuses a target database that already holds tables", async () => {
|
||||
writeDump();
|
||||
tablesInTarget.mockResolvedValue(["public.users", "public.sessions"]);
|
||||
expect(await main(["latest"], env())).toBe(1);
|
||||
expect(errors.join("\n")).toMatch(
|
||||
/already holds 2 tables .*only into an empty database/,
|
||||
);
|
||||
expect(runTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses when the active encryption key is not the dump's, and names it", async () => {
|
||||
writeDump();
|
||||
expect(await main(["latest"], env({ ENCRYPTION_KEY: KEY_B }))).toBe(1);
|
||||
expect(lines.join("\n")).toMatch(/KEY MISMATCH: ENCRYPTION_KEYS\[v1\]/);
|
||||
expect(errors.join("\n")).toMatch(/refusing to restore/);
|
||||
expect(runTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("goes past the key check with --ignore-key-mismatch, and still refuses a full database", async () => {
|
||||
writeDump();
|
||||
tablesInTarget.mockResolvedValue(["public.users"]);
|
||||
expect(
|
||||
await main(
|
||||
["latest", "--ignore-key-mismatch"],
|
||||
env({ ENCRYPTION_KEY: KEY_B }),
|
||||
),
|
||||
).toBe(1);
|
||||
expect(errors.join("\n")).toMatch(/already holds 1 table /);
|
||||
expect(runTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a dump that does not match its manifest", async () => {
|
||||
writeDump();
|
||||
writeFileSync(join(dir, `${BASE}.dump`), "PGDMP altered");
|
||||
expect(await main(["latest"], env())).toBe(1);
|
||||
expect(errors.join("\n")).toMatch(/does not match its manifest/);
|
||||
expect(runTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a dump without its manifest, and a file it did not write", async () => {
|
||||
writeFileSync(join(dir, `${BASE}.dump`), "x");
|
||||
expect(await main([`${BASE}.dump`], env())).toBe(1);
|
||||
expect(errors.join("\n")).toMatch(/manifest.json is missing/);
|
||||
writeFileSync(join(dir, "other.dump"), "x");
|
||||
expect(await main(["other.dump"], env())).toBe(1);
|
||||
expect(errors.join("\n")).toMatch(/not a HealthLog database dump/);
|
||||
expect(runTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says when there is nothing to restore, and how it is used", async () => {
|
||||
expect(await main(["latest"], env())).toBe(1);
|
||||
expect(errors.join("\n")).toMatch(/no restorable dump/);
|
||||
expect(await main([], env())).toBe(2);
|
||||
});
|
||||
|
||||
it("refuses --from-bucket without a configured bucket, before it connects", async () => {
|
||||
expect(await main(["latest", "--from-bucket"], env())).toBe(1);
|
||||
expect(errors.join("\n")).toMatch(/--from-bucket needs BACKUP_S3_ENDPOINT/);
|
||||
expect(runTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists what is there", async () => {
|
||||
writeDump();
|
||||
expect(await main(["list"], env())).toBe(0);
|
||||
expect(lines.join("\n")).toContain(`${BASE}.dump daily`);
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,9 @@ ROOT=$(git rev-parse --show-toplevel)
|
||||
# The last release whose compose file ran Postgres 16.
|
||||
OLD_REF=${OLD_REF:-v1.45.0}
|
||||
PROJECT=hl-db-upgrade-check
|
||||
PORT=${CHECK_DB_PORT:-55416}
|
||||
# A free port unless CHECK_DB_PORT names one, so the check never collides with
|
||||
# another stack on the same machine.
|
||||
PORT=${CHECK_DB_PORT:-$(node -e "const s=require('node:net').createServer().listen(0,'127.0.0.1',()=>{console.log(s.address().port);s.close()})")}
|
||||
WORK=$(mktemp -d)
|
||||
|
||||
export PRISMA_HIDE_UPDATE_MESSAGE=1
|
||||
@@ -63,11 +65,40 @@ run_upgrade() {
|
||||
}
|
||||
upgrade_log() { docker logs healthlog-db-upgrade 2>&1 | grep '^db-upgrade:' || true; }
|
||||
|
||||
# The container can report healthy before its published port forwards (Docker
|
||||
# Desktop and OrbStack set the forward up a moment later), and a migrate
|
||||
# started in that gap failed with "Can't reach database server". So wait
|
||||
# until the port answers, for at most thirty seconds, and retry the migrate
|
||||
# within the same bound.
|
||||
wait_for_port() {
|
||||
local i=0
|
||||
until node -e "
|
||||
const s = require('node:net').connect(Number(process.argv[1]), '127.0.0.1');
|
||||
s.on('connect', () => { s.end(); process.exit(0); });
|
||||
s.on('error', () => process.exit(1));
|
||||
" "$PORT" 2>/dev/null; do
|
||||
i=$((i + 1))
|
||||
[ "$i" -lt 30 ] || return 1
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
migrate_old() {
|
||||
local started=$SECONDS
|
||||
until DATABASE_URL="postgresql://healthlog:$POSTGRES_PASSWORD@127.0.0.1:$PORT/healthlog?schema=public" \
|
||||
node "$ROOT/node_modules/prisma/build/index.js" migrate deploy --schema "$ROOT/prisma/schema.prisma" >"$WORK/migrate.log" 2>&1; do
|
||||
if [ $((SECONDS - started)) -ge 30 ] || ! grep -q "P1001" "$WORK/migrate.log"; then
|
||||
cat "$WORK/migrate.log" >&2
|
||||
return 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
down_all
|
||||
step "a v1.45 database on Postgres 16"
|
||||
old up -d --wait db >/dev/null 2>&1
|
||||
DATABASE_URL="postgresql://healthlog:$POSTGRES_PASSWORD@127.0.0.1:$PORT/healthlog?schema=public" \
|
||||
node "$ROOT/node_modules/prisma/build/index.js" migrate deploy --schema "$ROOT/prisma/schema.prisma" >/dev/null
|
||||
old up -d --wait db >"$WORK/up.log" 2>&1 || { cat "$WORK/up.log" >&2; fail "the v1.45 database did not start"; }
|
||||
wait_for_port || fail "port $PORT did not answer within thirty seconds"
|
||||
migrate_old || fail "the migrations did not apply to the v1.45 database"
|
||||
old exec -T db psql -U healthlog -d healthlog -v ON_ERROR_STOP=1 -q <<'SQL'
|
||||
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
|
||||
CREATE SCHEMA check_probe;
|
||||
|
||||
Executable
+346
@@ -0,0 +1,346 @@
|
||||
#!/usr/bin/env bash
|
||||
# Plays the documented disaster recovery end to end against real containers:
|
||||
# a server is lost with everything on it, and a new one is rebuilt from the
|
||||
# nightly database dump and the keys alone (docs/ops/disaster-recovery.md).
|
||||
#
|
||||
# 1. Build the app image from this checkout and start the bundled compose
|
||||
# stack with fixed throwaway keys, and an S3-compatible bucket outside
|
||||
# it (Versity S3 Gateway, pinned) as the off-host target.
|
||||
# 2. Seed real data: the demo seed (documents, encrypted notes, years of
|
||||
# readings) and a Guardian with a managed profile, written by the app's
|
||||
# own code; then, through the API, a reading with an encrypted note.
|
||||
# 3. Let the scheduled `database-backup` job take the dump (its cron is set
|
||||
# to every minute here) and wait for its ledger row: kept on the server
|
||||
# and uploaded to the bucket.
|
||||
# 4. DESTROY every volume of the stack, the local dumps included. The
|
||||
# bucket lives outside the stack and survives, as an off-host copy does.
|
||||
# 5. Start a new stack and restore with the keys alone:
|
||||
# DRILL_SOURCE=bucket (the default, the disaster path)
|
||||
# `docker compose run --rm db-restore latest --from-bucket`;
|
||||
# DRILL_SOURCE=local (the dump files had been copied off the host)
|
||||
# the files go into the new backups volume, then
|
||||
# `docker compose run --rm db-restore latest`.
|
||||
# 6. Count every table against the manifest, independently of the restore.
|
||||
# 7. Boot the app on the restored database and check: the health endpoint,
|
||||
# the demo login, the encrypted note read back through the API, a
|
||||
# document opened, and the Guardian still seeing the managed profile.
|
||||
#
|
||||
# DRILL_MUTATION breaks one step on purpose, and the drill must then fail
|
||||
# (a check that cannot fail proves nothing):
|
||||
# wrong-key restore with a different active encryption key
|
||||
# wrong-backup-key restore from the bucket with a different
|
||||
# BACKUP_ENCRYPTION_KEY (DRILL_SOURCE=bucket)
|
||||
# skip-table a dump whose archive lacks one table's rows, the
|
||||
# manifest's checksum fixed up and its counts left true
|
||||
# (DRILL_SOURCE=local)
|
||||
# non-empty-target the new app starts and migrates before the restore
|
||||
#
|
||||
# Needs Docker, openssl, pnpm and the repo's node_modules (for the seeds). Run
|
||||
# it from anywhere in the repo: `bash scripts/compose-dr-drill.sh`.
|
||||
# DRILL_IMAGE names an image to use instead of building one. The ports are
|
||||
# free ones picked at start unless DRILL_APP_PORT, DRILL_DB_PORT or
|
||||
# DRILL_BUCKET_PORT name them.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=$(git rev-parse --show-toplevel)
|
||||
PROJECT=hl-dr-drill
|
||||
free_port() {
|
||||
node -e "const s=require('node:net').createServer().listen(0,'127.0.0.1',()=>{console.log(s.address().port);s.close()})"
|
||||
}
|
||||
APP_PORT=${DRILL_APP_PORT:-$(free_port)}
|
||||
DB_PORT=${DRILL_DB_PORT:-$(free_port)}
|
||||
BUCKET_PORT=${DRILL_BUCKET_PORT:-$(free_port)}
|
||||
IMAGE=${DRILL_IMAGE:-}
|
||||
MUTATION=${DRILL_MUTATION:-}
|
||||
SOURCE=${DRILL_SOURCE:-bucket}
|
||||
OFFSITE=$PROJECT-offsite
|
||||
BUCKET=$PROJECT-bucket
|
||||
BUCKET_IMAGE=versity/versitygw:v1.8.0@sha256:30292fc2eeacc67a36993b01f7a7a5e3361a19cced0e80c1d71cfa2a4b0a2499
|
||||
WORK=$(mktemp -d)
|
||||
BASE_URL="http://127.0.0.1:$APP_PORT"
|
||||
|
||||
case "$SOURCE/$MUTATION" in
|
||||
bucket/ | local/ | */wrong-key | */non-empty-target | bucket/wrong-backup-key | local/skip-table) ;;
|
||||
*)
|
||||
echo "DRILL_SOURCE=$SOURCE with DRILL_MUTATION=$MUTATION is not a drill" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
export PRISMA_HIDE_UPDATE_MESSAGE=1
|
||||
export POSTGRES_PASSWORD
|
||||
POSTGRES_PASSWORD=$(openssl rand -hex 16)
|
||||
# Fixed throwaway keys: the restore gets these and nothing else of the lost
|
||||
# host. Two encryption keys, the second active, so a retired key is carried.
|
||||
export ENCRYPTION_KEY=""
|
||||
export ENCRYPTION_KEYS='{"v1":"1111111111111111111111111111111111111111111111111111111111111111","v2":"2222222222222222222222222222222222222222222222222222222222222222"}'
|
||||
export ENCRYPTION_ACTIVE_KEY_ID=v2
|
||||
export API_TOKEN_HMAC_KEY=3333333333333333333333333333333333333333333333333333333333333333
|
||||
export SESSION_COOKIE_SECURE=false
|
||||
export NEXT_PUBLIC_APP_URL=$BASE_URL APP_URL=$BASE_URL
|
||||
export DB_BACKUP_SCHEDULE="* * * * *"
|
||||
# The off-host bucket, reached from the stack by its container name on a
|
||||
# network of its own that outlives the stack, as a real bucket does.
|
||||
export BACKUP_S3_ENDPOINT=http://$BUCKET:7070
|
||||
export BACKUP_S3_BUCKET=healthlog-backups
|
||||
export BACKUP_S3_ACCESS_KEY=drill-access
|
||||
export BACKUP_S3_SECRET_KEY=drill-secret-0123456789
|
||||
export BACKUP_S3_REGION=us-east-1
|
||||
export BACKUP_ENCRYPTION_KEY=5555555555555555555555555555555555555555555555555555555555555555
|
||||
|
||||
step() { echo "== $*"; }
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
docker logs "$PROJECT-app" 2>&1 | tail -40 >&2 || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [ -z "$IMAGE" ]; then
|
||||
IMAGE=healthlog-dr-drill:local
|
||||
step "building the app image from this checkout"
|
||||
docker build -q \
|
||||
--build-arg NEXT_PUBLIC_APP_VERSION="drill-$(git -C "$ROOT" rev-parse --short HEAD)" \
|
||||
--build-arg NEXT_PUBLIC_APP_BUILD_SHA="$(git -C "$ROOT" rev-parse --short HEAD)" \
|
||||
-t "$IMAGE" "$ROOT" >/dev/null
|
||||
fi
|
||||
|
||||
# The bundled compose file, with this image, its own names and ports, so the
|
||||
# drill never meets another stack on the same machine, and the app and the
|
||||
# restore on the bucket's network.
|
||||
cat >"$WORK/drill.yml" <<YAML
|
||||
services:
|
||||
app:
|
||||
image: $IMAGE
|
||||
pull_policy: never
|
||||
container_name: $PROJECT-app
|
||||
ports: !override
|
||||
- "127.0.0.1:$APP_PORT:3000"
|
||||
networks: [default, bucket]
|
||||
db:
|
||||
container_name: $PROJECT-db
|
||||
ports:
|
||||
- "127.0.0.1:$DB_PORT:5432"
|
||||
db-upgrade:
|
||||
container_name: $PROJECT-db-upgrade
|
||||
db-restore:
|
||||
image: $IMAGE
|
||||
pull_policy: never
|
||||
networks: [default, bucket]
|
||||
networks:
|
||||
bucket:
|
||||
external: true
|
||||
name: $BUCKET
|
||||
YAML
|
||||
|
||||
stack() { docker compose -p "$PROJECT" -f "$ROOT/docker-compose.yml" -f "$WORK/drill.yml" "$@"; }
|
||||
sql() { docker exec "$PROJECT-db" psql -U healthlog -d healthlog -v ON_ERROR_STOP=1 -Atqc "$1"; }
|
||||
teardown() {
|
||||
stack down -v --remove-orphans >/dev/null 2>&1 || true
|
||||
docker volume rm "$OFFSITE" >/dev/null 2>&1 || true
|
||||
docker rm -f "$BUCKET" >/dev/null 2>&1 || true
|
||||
docker volume rm "$BUCKET-data" >/dev/null 2>&1 || true
|
||||
docker network rm "$BUCKET" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap 'teardown; rm -rf "$WORK"' EXIT
|
||||
|
||||
wait_healthy() {
|
||||
local i=0
|
||||
until [ "$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL/api/health")" = 200 ]; do
|
||||
i=$((i + 1))
|
||||
[ "$i" -lt 150 ] || fail "the app did not report healthy within five minutes"
|
||||
docker inspect -f '{{.State.Running}}' "$PROJECT-app" 2>/dev/null | grep -q true ||
|
||||
fail "the app container stopped"
|
||||
sleep 2
|
||||
done
|
||||
}
|
||||
|
||||
# POST/GET as a signed-in account, with its own cookie jar.
|
||||
login() {
|
||||
local jar=$1 user=$2 password=$3
|
||||
local code
|
||||
code=$(curl -s -o "$WORK/login.json" -w '%{http_code}' -c "$jar" \
|
||||
-H 'content-type: application/json' \
|
||||
-d "{\"email\":\"$user\",\"password\":\"$password\"}" "$BASE_URL/api/auth/login")
|
||||
[ "$code" = 200 ] || fail "login as $user answered $code: $(cat "$WORK/login.json")"
|
||||
}
|
||||
api() { curl -s -b "$1" -w '\n%{http_code}' "${@:2}"; }
|
||||
json() { node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const v=JSON.parse(s);console.log((${1})(v))})"; }
|
||||
|
||||
# A shell in the app image as root, with volumes, for moving the dump files
|
||||
# the way an operator copies them; `cp -p` keeps the app user as owner.
|
||||
as_root() {
|
||||
local command=$1 mounts=()
|
||||
shift
|
||||
for volume in "$@"; do mounts+=(-v "$volume"); done
|
||||
docker run --rm --user 0:0 --entrypoint sh "${mounts[@]}" "$IMAGE" -c "$command"
|
||||
}
|
||||
|
||||
teardown
|
||||
|
||||
step "an off-host bucket, outside the stack"
|
||||
docker network create "$BUCKET" >/dev/null
|
||||
docker run -d --name "$BUCKET" --network "$BUCKET" -p "127.0.0.1:$BUCKET_PORT:7070" \
|
||||
-v "$BUCKET-data:/data" --entrypoint sh "$BUCKET_IMAGE" -c \
|
||||
"mkdir -p /data/$BACKUP_S3_BUCKET && exec versitygw --access $BACKUP_S3_ACCESS_KEY --secret $BACKUP_S3_SECRET_KEY posix /data" \
|
||||
>/dev/null || fail "the bucket did not start"
|
||||
|
||||
step "a running server with real data"
|
||||
stack up -d app >"$WORK/up.log" 2>&1 || { tail -20 "$WORK/up.log" >&2; fail "the stack did not start"; }
|
||||
wait_healthy
|
||||
DATABASE_URL="postgresql://healthlog:$POSTGRES_PASSWORD@127.0.0.1:$DB_PORT/healthlog?schema=public"
|
||||
export DATABASE_URL
|
||||
(cd "$ROOT" && pnpm dlx tsx@4.23.1 scripts/seed-demo.ts) >"$WORK/seed.log" 2>&1 ||
|
||||
{ tail -20 "$WORK/seed.log" >&2; fail "the demo seed failed"; }
|
||||
IDS=$(cd "$ROOT" && pnpm dlx tsx@4.23.1 scripts/dr-drill-seed.ts | tail -1)
|
||||
PROFILE_ID=$(echo "$IDS" | json 'v=>v.profileId')
|
||||
unset DATABASE_URL
|
||||
|
||||
login "$WORK/demo.jar" demo demo123demo123
|
||||
NOTE="drill note $(openssl rand -hex 6)"
|
||||
OUT=$(api "$WORK/demo.jar" -H 'content-type: application/json' \
|
||||
-d "{\"type\":\"WEIGHT\",\"value\":81.4,\"measuredAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"notes\":\"$NOTE\"}" \
|
||||
"$BASE_URL/api/measurements")
|
||||
[ "$(echo "$OUT" | tail -1)" = 201 ] || [ "$(echo "$OUT" | tail -1)" = 200 ] ||
|
||||
fail "creating a reading with a note answered: $OUT"
|
||||
READING_ID=$(echo "$OUT" | sed '$d' | json 'v=>v.data.id')
|
||||
[ "$(sql "SELECT count(*) FROM measurements WHERE id = '$READING_ID' AND position('$NOTE' in measurements::text) > 0")" = 0 ] ||
|
||||
fail "the note is stored in plain text"
|
||||
DOCUMENTS=$(sql "SELECT count(*) FROM inbound_documents WHERE deleted_at IS NULL")
|
||||
[ "$DOCUMENTS" -gt 0 ] || fail "the seed wrote no documents"
|
||||
SEEDED_AT=$(sql "SELECT now()")
|
||||
|
||||
step "the scheduled job takes the dump and uploads it"
|
||||
i=0
|
||||
until DUMP=$(sql "SELECT file_name FROM database_backup_runs WHERE status = 'succeeded' AND local_result = 'written' AND offhost_result = 'uploaded' AND started_at > '$SEEDED_AT'::timestamptz ORDER BY started_at DESC LIMIT 1") && [ -n "$DUMP" ]; do
|
||||
i=$((i + 1))
|
||||
[ "$i" -lt 120 ] || fail "no succeeded and uploaded database-backup run within four minutes: $(sql "SELECT status, local_result, offhost_result, error FROM database_backup_runs ORDER BY started_at DESC LIMIT 3")"
|
||||
sleep 2
|
||||
done
|
||||
BASE=${DUMP%.dump}
|
||||
echo " $DUMP"
|
||||
|
||||
if [ "$SOURCE" = local ]; then
|
||||
step "the dump files are copied off the host"
|
||||
docker volume create "$OFFSITE" >/dev/null
|
||||
as_root "cp -p /from/$BASE.dump /from/$BASE.manifest.json /to/" \
|
||||
"${PROJECT}_backups:/from:ro" "$OFFSITE:/to" ||
|
||||
fail "could not copy the dump off the host"
|
||||
fi
|
||||
|
||||
step "the host is lost: every volume of the stack is destroyed"
|
||||
stack down -v --remove-orphans >/dev/null 2>&1
|
||||
[ -z "$(docker volume ls -q --filter "name=^${PROJECT}_")" ] || fail "a volume of the old stack survived"
|
||||
|
||||
if [ "$MUTATION" = skip-table ]; then
|
||||
step "MUTATION: the dump loses the rows of public.measurements"
|
||||
# A scratch Postgres restores the real dump and dumps it again without that
|
||||
# table's rows; the files stay the app user's, read and written by root.
|
||||
docker run --rm -v "$OFFSITE:/d" -e BASE="$BASE" --entrypoint sh postgres:18.6-alpine -ec '
|
||||
gosu postgres initdb -D /tmp/pg >/dev/null
|
||||
gosu postgres pg_ctl -D /tmp/pg -o "-c listen_addresses=" -w start >/dev/null
|
||||
gosu postgres createdb scratch
|
||||
gosu postgres pg_restore --no-owner --no-privileges -d scratch <"/d/$BASE.dump"
|
||||
gosu postgres pg_dump -Fc --exclude-table-data=public.measurements scratch >"/d/$BASE.dump"
|
||||
sum=$(sha256sum "/d/$BASE.dump" | cut -d" " -f1)
|
||||
size=$(wc -c <"/d/$BASE.dump")
|
||||
sed -i "s/\"sha256\": \"[0-9a-f]*\"/\"sha256\": \"$sum\"/; s/\"bytes\": [0-9]*/\"bytes\": $size/" "/d/$BASE.manifest.json"
|
||||
chown 1001:1001 "/d/$BASE.dump"' || fail "could not build the mutated dump"
|
||||
fi
|
||||
|
||||
step "a new server: the compose file, the keys and the $SOURCE copy"
|
||||
if [ "$MUTATION" = wrong-key ]; then
|
||||
echo " MUTATION: the restore gets a different active encryption key"
|
||||
ENCRYPTION_KEYS='{"v1":"1111111111111111111111111111111111111111111111111111111111111111","v2":"4444444444444444444444444444444444444444444444444444444444444444"}'
|
||||
fi
|
||||
if [ "$MUTATION" = wrong-backup-key ]; then
|
||||
echo " MUTATION: the restore gets a different BACKUP_ENCRYPTION_KEY"
|
||||
BACKUP_ENCRYPTION_KEY=6666666666666666666666666666666666666666666666666666666666666666
|
||||
fi
|
||||
if [ "$SOURCE" = local ]; then
|
||||
# Mounting the image's directory fills the new volume with the app user's
|
||||
# ownership; then the two files go in.
|
||||
stack run --rm --no-deps --entrypoint true db-restore >/dev/null 2>&1
|
||||
as_root "cp -p /from/* /to/" "$OFFSITE:/from:ro" "${PROJECT}_backups:/to" ||
|
||||
fail "could not put the dump on the new server"
|
||||
RESTORE=(latest)
|
||||
else
|
||||
RESTORE=(latest --from-bucket)
|
||||
fi
|
||||
if [ "$MUTATION" = non-empty-target ]; then
|
||||
echo " MUTATION: the new app starts (and migrates) before the restore"
|
||||
stack up -d app >/dev/null 2>&1
|
||||
wait_healthy
|
||||
fi
|
||||
stack up -d db >/dev/null 2>&1
|
||||
if ! stack run --rm db-restore "${RESTORE[@]}" 2>&1 | tee "$WORK/restore.log"; then
|
||||
fail "the restore failed"
|
||||
fi
|
||||
grep -q "^Restored $DUMP" "$WORK/restore.log" || fail "the restore did not say it restored $DUMP"
|
||||
|
||||
step "every table against the manifest, counted apart from the restore"
|
||||
if [ "$SOURCE" = local ]; then
|
||||
as_root "cat /d/$BASE.manifest.json" "$OFFSITE:/d:ro" >"$WORK/manifest.json"
|
||||
else
|
||||
# From the bucket, opened with the restore's own reader.
|
||||
(cd "$ROOT" && node --input-type=module -e '
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
const core = await import(join(process.cwd(), "src/lib/database-backup/dump-core.mjs"));
|
||||
const [port, base, work] = process.argv.slice(1);
|
||||
const bucket = await core.openBucket({ ...core.bucketConfigFromEnv(process.env), endpoint: `http://127.0.0.1:${port}` });
|
||||
const name = `${base}.manifest.json.enc`;
|
||||
await bucket.download(core.offhostObjectKey(name), join(work, name));
|
||||
await core.decryptOffhostFile({ inPath: join(work, name), outPath: join(work, "manifest.json"), objectKey: core.offhostObjectKey(name), keys: core.offhostKeysFromEnv(process.env) });
|
||||
' "$BUCKET_PORT" "$BASE" "$WORK") || fail "could not read the manifest from the bucket"
|
||||
fi
|
||||
node -e '
|
||||
const m = require(process.argv[1]);
|
||||
const q = String.fromCharCode(39);
|
||||
const lit = (t) => q + t.split(q).join(q + q) + q;
|
||||
console.log(Object.keys(m.rowCounts).map((t) => `SELECT ${lit(t)} || chr(9) || count(*) FROM ${t}`).join(" UNION ALL ") + ";");
|
||||
' "$WORK/manifest.json" >"$WORK/count.sql"
|
||||
docker exec -i "$PROJECT-db" psql -U healthlog -d healthlog -v ON_ERROR_STOP=1 -Atq <"$WORK/count.sql" >"$WORK/counts.tsv"
|
||||
node -e '
|
||||
const fs = require("node:fs");
|
||||
const m = require(process.argv[1]);
|
||||
const got = Object.fromEntries(fs.readFileSync(process.argv[2], "utf8").trim().split("\n").map((l) => l.split("\t")).map(([t, n]) => [t, Number(n)]));
|
||||
const bad = Object.entries(m.rowCounts).filter(([t, n]) => got[t] !== n);
|
||||
if (bad.length) { console.error(bad.map(([t, n]) => `${t}: manifest ${n}, database ${got[t]}`).join("\n")); process.exit(1); }
|
||||
if (m.rowCounts["public.users"] < 3) { console.error("fewer than three accounts in the dump"); process.exit(1); }
|
||||
const c = m.rowCounts;
|
||||
console.log(` ${Object.keys(c).length} tables, ${Object.values(c).reduce((a, b) => a + b, 0)} rows: ` +
|
||||
`${c["public.users"]} accounts, ${c["public.account_grants"]} grants, ${c["public.measurements"]} readings, ` +
|
||||
`${c["public.inbound_documents"]} documents, ${c["pgboss.job"] ?? 0} queued jobs`);
|
||||
' "$WORK/manifest.json" "$WORK/counts.tsv" || fail "the restored row counts differ from the manifest"
|
||||
|
||||
step "the app on the restored database"
|
||||
stack up -d app >/dev/null 2>&1 || fail "the app did not start on the restored database"
|
||||
wait_healthy
|
||||
[ "$(curl -s "$BASE_URL/api/health" | json 'v=>v.status ?? v.data?.status')" = ok ] || fail "health is not ok"
|
||||
|
||||
login "$WORK/demo2.jar" demo demo123demo123
|
||||
OUT=$(api "$WORK/demo2.jar" "$BASE_URL/api/measurements/$READING_ID")
|
||||
[ "$(echo "$OUT" | tail -1)" = 200 ] || fail "reading the note back answered: $OUT"
|
||||
[ "$(echo "$OUT" | sed '$d' | json 'v=>v.data.notes')" = "$NOTE" ] || fail "the encrypted note did not read back"
|
||||
echo " the encrypted note reads back"
|
||||
|
||||
OUT=$(api "$WORK/demo2.jar" "$BASE_URL/api/documents/inbound")
|
||||
[ "$(echo "$OUT" | tail -1)" = 200 ] || fail "listing documents answered: $OUT"
|
||||
DOC_ID=$(echo "$OUT" | sed '$d' | json 'v=>(v.data.documents ?? v.data.items ?? v.data)[0].id')
|
||||
CODE=$(curl -s -b "$WORK/demo2.jar" -o "$WORK/doc.bin" -w '%{http_code}' "$BASE_URL/api/documents/inbound/$DOC_ID/original")
|
||||
[ "$CODE" = 200 ] || fail "opening document $DOC_ID answered $CODE"
|
||||
[ "$(head -c 4 "$WORK/doc.bin")" = "%PDF" ] || fail "document $DOC_ID did not open to a PDF"
|
||||
echo " a document opens ($(wc -c <"$WORK/doc.bin" | tr -d ' ') bytes)"
|
||||
|
||||
login "$WORK/guardian.jar" drill-guardian drill-guardian-password-1
|
||||
OUT=$(api "$WORK/guardian.jar" "$BASE_URL/api/account/grants")
|
||||
[ "$(echo "$OUT" | tail -1)" = 200 ] || fail "listing the Guardian's grants answered: $OUT"
|
||||
echo "$OUT" | sed '$d' | node -e "
|
||||
let s=''; process.stdin.on('data', d => s += d).on('end', () => {
|
||||
const received = JSON.parse(s).data.received ?? [];
|
||||
const found = received.some((g) => JSON.stringify(g).includes('$PROFILE_ID'));
|
||||
process.exit(found ? 0 : 1);
|
||||
});" || fail "the Guardian no longer sees the managed profile"
|
||||
echo " the Guardian still sees the managed profile"
|
||||
|
||||
echo "OK: the server was rebuilt from the $SOURCE copy of its dump and the keys alone"
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* The pre-migration dump: a whole-database dump the entrypoint takes before
|
||||
* `prisma migrate deploy` applies anything.
|
||||
*
|
||||
* node scripts/db-dump.mjs pre-migration
|
||||
*
|
||||
* A migration is not undone by going back to the previous image, and the
|
||||
* bundled compose file pulls `:latest` and migrates on every start. So when
|
||||
* this image brings migrations the database has not applied, it first writes
|
||||
* `healthlog-<timestamp>-pre-migration.dump` with its manifest into
|
||||
* `DB_BACKUP_DIR` (`src/lib/database-backup/dump-core.mjs`), and keeps the
|
||||
* newest two.
|
||||
*
|
||||
* Exit 0: the dump was written, or there was nothing to do (no pending
|
||||
* migration, a new database, `DB_BACKUP_BEFORE_MIGRATE=false`), or there is no
|
||||
* writable directory to put it in, which is said as a warning and is not a
|
||||
* reason to keep the app down. Exit 1: the directory is writable and the dump
|
||||
* failed. The entrypoint then stops the boot and names the switch that skips
|
||||
* the dump, because migrating without the way back the operator was promised
|
||||
* is the one thing this step exists to prevent.
|
||||
*
|
||||
* Fast on purpose: one snapshot, `pg_dump`, a count per table for the
|
||||
* manifest, `pg_restore --list` to prove the file reads. Nothing else.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import {
|
||||
applyRetention,
|
||||
connect,
|
||||
createDatabaseDump,
|
||||
isWritableDirectory,
|
||||
latestMigration,
|
||||
listUserTables,
|
||||
connectionFromUrl,
|
||||
preMigrationDumpEnabled,
|
||||
resolveBackupDir,
|
||||
resolveDailyKeep,
|
||||
} from "../src/lib/database-backup/dump-core.mjs";
|
||||
import { pendingMigrations } from "./pending-migrations.mjs";
|
||||
|
||||
const log = (line) => console.log(`HealthLog: ${line}`);
|
||||
const warn = (line) => console.error(`HealthLog: WARNING — ${line}`);
|
||||
|
||||
/** The image's version: the build argument, else package.json beside it. */
|
||||
function imageVersion(env) {
|
||||
const fromEnv = (env.NEXT_PUBLIC_APP_VERSION ?? "").trim();
|
||||
if (fromEnv) return fromEnv;
|
||||
try {
|
||||
return JSON.parse(readFileSync(join(process.cwd(), "package.json"), "utf8"))
|
||||
.version;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Megabytes as an operator reads them. */
|
||||
function mb(bytes) {
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export async function preMigrationDump(env = process.env) {
|
||||
if (!preMigrationDumpEnabled(env)) {
|
||||
log("DB_BACKUP_BEFORE_MIGRATE=false, no dump before migrating.");
|
||||
return 0;
|
||||
}
|
||||
const databaseUrl = env.DATABASE_URL;
|
||||
if (!databaseUrl) return 0;
|
||||
|
||||
let pending;
|
||||
try {
|
||||
pending = await pendingMigrations({
|
||||
connectionString: databaseUrl,
|
||||
migrationsDir: join(process.cwd(), "prisma", "migrations"),
|
||||
});
|
||||
} catch (error) {
|
||||
warn(
|
||||
`could not count pending migrations (${error instanceof Error ? error.message : String(error)}); no dump before migrating.`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
if (pending.length === 0) return 0;
|
||||
|
||||
// A database no migration has touched holds nothing to lose.
|
||||
const client = await connect(databaseUrl);
|
||||
let applied;
|
||||
let tables;
|
||||
try {
|
||||
applied = await latestMigration(
|
||||
client,
|
||||
connectionFromUrl(databaseUrl).schema,
|
||||
);
|
||||
tables = (await listUserTables(client)).length;
|
||||
} finally {
|
||||
await client.end().catch(() => {});
|
||||
}
|
||||
if (applied === null && tables === 0) {
|
||||
log("new database, no dump before the first migrations.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
const dir = resolveBackupDir(env);
|
||||
if (!isWritableDirectory(dir)) {
|
||||
warn(
|
||||
`${dir} is not writable, so no dump is taken before the ${pending.length} pending migration(s). ` +
|
||||
"Mount the backups volume (docker-compose.yml) to keep a way back; continuing.",
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
log(
|
||||
`taking a database dump before ${pending.length} pending migration(s) into ${dir}...`,
|
||||
);
|
||||
const started = Date.now();
|
||||
try {
|
||||
const { dumpPath, manifest } = await createDatabaseDump({
|
||||
databaseUrl,
|
||||
dir,
|
||||
kind: "pre-migration",
|
||||
env,
|
||||
appVersion: imageVersion(env),
|
||||
buildSha: (env.NEXT_PUBLIC_APP_BUILD_SHA ?? "").trim() || null,
|
||||
});
|
||||
log(
|
||||
`pre-migration dump written: ${basename(dumpPath)} (${mb(manifest.dump.bytes)}, ` +
|
||||
`${Object.keys(manifest.rowCounts).length} tables, ${Math.round((Date.now() - started) / 1000)} s).`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`HealthLog: ERROR — the database dump before migrating failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
await applyRetention(dir, { dailyKeep: resolveDailyKeep(env) });
|
||||
} catch (error) {
|
||||
warn(
|
||||
`could not remove old dumps in ${dir} (${error instanceof Error ? error.message : String(error)}).`,
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const kind = process.argv[2];
|
||||
if (kind !== "pre-migration") {
|
||||
console.error("db-dump: usage: node scripts/db-dump.mjs pre-migration");
|
||||
process.exit(2);
|
||||
}
|
||||
process.exit(await preMigrationDump());
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
await main();
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* Restore a whole-database dump into an empty database.
|
||||
*
|
||||
* docker compose run --rm db-restore list [directory]
|
||||
* docker compose run --rm db-restore list --from-bucket
|
||||
* docker compose run --rm db-restore latest [--from-bucket]
|
||||
* docker compose run --rm db-restore <file> [--ignore-key-mismatch]
|
||||
*
|
||||
* Runs in the app image with plain `node` (no `tsx`, no source checkout), as
|
||||
* the `db-restore` compose service, so a new server needs only the compose
|
||||
* file, the keys and the dump. `<file>` is a name in `DB_BACKUP_DIR` or a
|
||||
* path inside the container: a `.dump` written on a server, or a `.dump.enc`
|
||||
* downloaded from the off-host bucket (`database/` prefix), with its manifest
|
||||
* (`.manifest.json` or `.manifest.json.enc`) beside it. With `--from-bucket`
|
||||
* it fetches the pair from the bucket the `BACKUP_S3_*` variables name (the
|
||||
* newest for `latest`), which is the restore after the host and its local
|
||||
* dumps are gone.
|
||||
*
|
||||
* In this order, and it stops at the first thing that is wrong, before it
|
||||
* writes anything:
|
||||
* 1. an off-host copy is opened with BACKUP_ENCRYPTION_KEY (or a key in
|
||||
* BACKUP_ENCRYPTION_PREVIOUS_KEYS) into a temporary file;
|
||||
* 2. the dump's SHA-256 and size must equal the manifest's;
|
||||
* 3. the keys in this environment are compared with the manifest's
|
||||
* fingerprints; each one missing or different is named, and a mismatch
|
||||
* of the active encryption key refuses (`--ignore-key-mismatch` restores
|
||||
* anyway, for an operator who knows the key is lost);
|
||||
* 4. the target database must hold no table at all. This never overwrites.
|
||||
* Then `pg_restore` in one transaction, `ANALYZE`, and a count of every table
|
||||
* against the manifest. A count that differs empties the database again and
|
||||
* fails, so the app is never started on a restore that does not match.
|
||||
*
|
||||
* Exit 0 restored (or listed), 1 refused or failed, 2 usage.
|
||||
*/
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, dirname, isAbsolute, join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import {
|
||||
compareKeyFingerprints,
|
||||
compareRowCounts,
|
||||
connect,
|
||||
countRows,
|
||||
decryptOffhostFile,
|
||||
emptyDatabase,
|
||||
hashFile,
|
||||
isGoodSet,
|
||||
isWritableDirectory,
|
||||
keyFingerprints,
|
||||
listDumpSets,
|
||||
listUserTables,
|
||||
offhostKeysFromEnv,
|
||||
offhostObjectKey,
|
||||
parseDumpFileName,
|
||||
parseManifest,
|
||||
resolveBackupDir,
|
||||
runTool,
|
||||
statOrNull,
|
||||
connectionFromUrl,
|
||||
bucketConfigFromEnv,
|
||||
openBucket,
|
||||
pickBucketDump,
|
||||
OFFHOST_DATABASE_PREFIX,
|
||||
} from "../src/lib/database-backup/dump-core.mjs";
|
||||
import { shippedMigrations } from "./pending-migrations.mjs";
|
||||
|
||||
const out = (line = "") => console.log(line);
|
||||
const say = (line) => console.log(`db-restore: ${line}`);
|
||||
|
||||
class Refusal extends Error {}
|
||||
|
||||
function mb(bytes) {
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this image knows the dump's newest migration. A dump from a newer
|
||||
* release restored under an older image leaves the app with a schema its code
|
||||
* does not expect, so the restore says which image to run.
|
||||
*/
|
||||
function imageKnowsMigration(name) {
|
||||
if (!name) return true;
|
||||
try {
|
||||
return shippedMigrations(
|
||||
join(process.cwd(), "prisma", "migrations"),
|
||||
).includes(name);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function listCommand(dir) {
|
||||
const sets = listDumpSets(dir);
|
||||
if (sets.length === 0) {
|
||||
say(`no dumps in ${dir}`);
|
||||
return 0;
|
||||
}
|
||||
out(`Dumps in ${dir}, newest first:`);
|
||||
for (const set of sets) {
|
||||
const state = isGoodSet(set)
|
||||
? `${mb(set.dump.bytes)}`
|
||||
: set.encryptedDump
|
||||
? "off-host copy (.enc)"
|
||||
: set.dump
|
||||
? "no manifest, not restorable"
|
||||
: "incomplete";
|
||||
out(
|
||||
` ${set.base}.dump${set.encryptedDump && !set.dump ? ".enc" : ""} ${set.kind} ${set.timestamp.toISOString()} ${state}`,
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dump and manifest to restore: a local pair, or an encrypted pair
|
||||
* opened into `scratch`.
|
||||
*/
|
||||
async function resolveInput(arg, dir, env) {
|
||||
if (arg === "latest") {
|
||||
const set = listDumpSets(dir).find(
|
||||
(s) =>
|
||||
isGoodSet(s) ||
|
||||
(s.encryptedDump !== null &&
|
||||
(s.encryptedManifest !== null || s.manifest !== null)),
|
||||
);
|
||||
if (!set) throw new Refusal(`no restorable dump in ${dir}`);
|
||||
arg = set.dump ? set.dump.path : set.encryptedDump;
|
||||
}
|
||||
const path = isAbsolute(arg) ? arg : join(dir, arg);
|
||||
if (!(await statOrNull(path))) throw new Refusal(`${path} does not exist`);
|
||||
const name = basename(path);
|
||||
const parsed = parseDumpFileName(name);
|
||||
if (!parsed || parsed.role !== "dump" || parsed.variant === "partial") {
|
||||
throw new Refusal(
|
||||
`${name} is not a HealthLog database dump (healthlog-<time>-<kind>.dump or .dump.enc)`,
|
||||
);
|
||||
}
|
||||
const folder = dirname(path);
|
||||
const plainManifest = join(folder, `${parsed.base}.manifest.json`);
|
||||
const encManifest = `${plainManifest}.enc`;
|
||||
|
||||
if (parsed.variant === "final") {
|
||||
if (!(await statOrNull(plainManifest))) {
|
||||
throw new Refusal(
|
||||
`${basename(plainManifest)} is missing beside ${name}; a dump is restored only with its manifest`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
dumpPath: path,
|
||||
manifest: parseManifest(
|
||||
JSON.parse(await readFile(plainManifest, "utf8")),
|
||||
),
|
||||
scratch: null,
|
||||
};
|
||||
}
|
||||
|
||||
// An off-host copy. Open it next to the backups if there is room to write
|
||||
// there, in the container's temp directory otherwise.
|
||||
const keys = offhostKeysFromEnv(env);
|
||||
if (keys.length === 0) {
|
||||
throw new Refusal(
|
||||
"this is an off-host copy and BACKUP_ENCRYPTION_KEY is not set; it cannot be opened without it",
|
||||
);
|
||||
}
|
||||
const scratch = await mkdtemp(
|
||||
join(isWritableDirectory(dir) ? dir : tmpdir(), ".restore-"),
|
||||
);
|
||||
try {
|
||||
let manifest;
|
||||
if (await statOrNull(encManifest)) {
|
||||
const opened = join(scratch, `${parsed.base}.manifest.json`);
|
||||
await decryptOffhostFile({
|
||||
inPath: encManifest,
|
||||
outPath: opened,
|
||||
objectKey: offhostObjectKey(basename(encManifest)),
|
||||
keys,
|
||||
});
|
||||
manifest = parseManifest(JSON.parse(await readFile(opened, "utf8")));
|
||||
} else if (await statOrNull(plainManifest)) {
|
||||
manifest = parseManifest(
|
||||
JSON.parse(await readFile(plainManifest, "utf8")),
|
||||
);
|
||||
} else {
|
||||
throw new Refusal(
|
||||
`${basename(encManifest)} is missing beside ${name}; download it from the bucket too`,
|
||||
);
|
||||
}
|
||||
say(`opening the off-host copy ${name}...`);
|
||||
const dumpPath = join(scratch, `${parsed.base}.dump`);
|
||||
await decryptOffhostFile({
|
||||
inPath: path,
|
||||
outPath: dumpPath,
|
||||
objectKey: offhostObjectKey(name),
|
||||
keys,
|
||||
});
|
||||
return { dumpPath, manifest, scratch };
|
||||
} catch (error) {
|
||||
await rm(scratch, { recursive: true, force: true });
|
||||
throw error instanceof Refusal ? error : new Refusal(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a dump and its manifest from the off-host bucket (`BACKUP_S3_*`) into
|
||||
* a scratch directory: the newest pair for `latest`, the named one otherwise.
|
||||
* Answers the downloaded `.dump.enc` and the directory to remove afterwards.
|
||||
*/
|
||||
async function downloadFromBucket(arg, dir, env) {
|
||||
const config = bucketConfigFromEnv(env);
|
||||
if (!config) {
|
||||
throw new Refusal(
|
||||
"--from-bucket needs BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY and BACKUP_S3_SECRET_KEY",
|
||||
);
|
||||
}
|
||||
const bucket = await openBucket(config);
|
||||
const keys = await bucket.list(OFFHOST_DATABASE_PREFIX);
|
||||
const pick = pickBucketDump(keys, arg === "latest" ? null : basename(arg));
|
||||
if (!pick) {
|
||||
throw new Refusal(
|
||||
arg === "latest"
|
||||
? `no database dump with its manifest under ${OFFHOST_DATABASE_PREFIX} in bucket ${config.bucket}`
|
||||
: `${basename(arg)} and its manifest are not both under ${OFFHOST_DATABASE_PREFIX} in bucket ${config.bucket}`,
|
||||
);
|
||||
}
|
||||
const scratch = await mkdtemp(
|
||||
join(isWritableDirectory(dir) ? dir : tmpdir(), ".download-"),
|
||||
);
|
||||
try {
|
||||
say(
|
||||
`downloading ${pick.dumpKey} and its manifest from ${config.bucket}...`,
|
||||
);
|
||||
const dumpPath = join(scratch, basename(pick.dumpKey));
|
||||
await bucket.download(
|
||||
pick.manifestKey,
|
||||
join(scratch, basename(pick.manifestKey)),
|
||||
);
|
||||
await bucket.download(pick.dumpKey, dumpPath);
|
||||
return { dumpPath, scratch };
|
||||
} catch (error) {
|
||||
await rm(scratch, { recursive: true, force: true });
|
||||
throw new Refusal(
|
||||
`could not download from the bucket: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function listBucketCommand(env) {
|
||||
const config = bucketConfigFromEnv(env);
|
||||
if (!config)
|
||||
throw new Refusal("--from-bucket needs the BACKUP_S3_* variables");
|
||||
const keys = (await (await openBucket(config)).list(OFFHOST_DATABASE_PREFIX))
|
||||
.filter((k) => k.endsWith(".dump.enc"))
|
||||
.sort()
|
||||
.reverse();
|
||||
out(
|
||||
`Dumps in bucket ${config.bucket} under ${OFFHOST_DATABASE_PREFIX}, newest first:`,
|
||||
);
|
||||
for (const key of keys) out(` ${key}`);
|
||||
if (keys.length === 0) say("none");
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function restoreCommand(arg, flags, env) {
|
||||
const dir = resolveBackupDir(env);
|
||||
const databaseUrl = env.DATABASE_URL;
|
||||
if (!databaseUrl) throw new Refusal("DATABASE_URL is not set");
|
||||
|
||||
let input = arg;
|
||||
let download = null;
|
||||
if (flags.has("--from-bucket")) {
|
||||
download = await downloadFromBucket(arg, dir, env);
|
||||
input = download.dumpPath;
|
||||
}
|
||||
let resolved;
|
||||
try {
|
||||
resolved = await resolveInput(input, dir, env);
|
||||
} finally {
|
||||
// The sealed copy is not needed once it is opened (or failed to open).
|
||||
if (download) await rm(download.scratch, { recursive: true, force: true });
|
||||
}
|
||||
const { dumpPath, manifest, scratch } = resolved;
|
||||
try {
|
||||
say(
|
||||
`dump ${manifest.file}: taken ${manifest.createdAt} by HealthLog ${manifest.appVersion ?? "(unknown version)"}, ` +
|
||||
`newest migration ${manifest.latestMigration ?? "(none)"}, Postgres ${manifest.postgres.serverVersion}, ` +
|
||||
`${Object.keys(manifest.rowCounts).length} tables`,
|
||||
);
|
||||
if (!imageKnowsMigration(manifest.latestMigration)) {
|
||||
say(
|
||||
`warning: this image does not know the dump's newest migration ${manifest.latestMigration}; ` +
|
||||
`start the app with HealthLog ${manifest.appVersion ?? "the version that wrote it"} or newer (HEALTHLOG_IMAGE_REF), not this image`,
|
||||
);
|
||||
}
|
||||
if (basename(dumpPath) !== manifest.file) {
|
||||
throw new Refusal(
|
||||
`the manifest describes ${manifest.file}, not ${basename(dumpPath)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. The file is the one the manifest describes.
|
||||
const { sha256, bytes } = await hashFile(dumpPath);
|
||||
if (sha256 !== manifest.dump.sha256 || bytes !== manifest.dump.bytes) {
|
||||
throw new Refusal(
|
||||
`the dump does not match its manifest (SHA-256 ${sha256.slice(0, 12)}…, ${bytes} bytes; ` +
|
||||
`the manifest says ${manifest.dump.sha256.slice(0, 12)}…, ${manifest.dump.bytes} bytes). It is damaged or not this dump.`,
|
||||
);
|
||||
}
|
||||
say(`checksum matches the manifest (${mb(bytes)})`);
|
||||
|
||||
// 3. The keys.
|
||||
const keys = compareKeyFingerprints(manifest.keys, keyFingerprints(env));
|
||||
if (keys.findings.length === 0) {
|
||||
say("every key the dump was written with is configured here");
|
||||
}
|
||||
for (const finding of keys.findings) {
|
||||
say(
|
||||
`${finding.severity === "refuse" ? "KEY MISMATCH" : "warning"}: ${finding.key}: ${finding.detail}`,
|
||||
);
|
||||
}
|
||||
if (keys.refuse && !flags.has("--ignore-key-mismatch")) {
|
||||
throw new Refusal(
|
||||
"refusing to restore: set the encryption keys this dump was written with (see above). " +
|
||||
"Only if that key is lost for good, run again with --ignore-key-mismatch; everything encrypted will stay unreadable.",
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Never over existing data.
|
||||
const client = await connect(databaseUrl);
|
||||
try {
|
||||
const tables = await listUserTables(client);
|
||||
if (tables.length > 0) {
|
||||
throw new Refusal(
|
||||
`the target database already holds ${tables.length} ${tables.length === 1 ? "table" : "tables"} (${tables.slice(0, 3).join(", ")}${tables.length > 3 ? ", …" : ""}). ` +
|
||||
"A restore goes only into an empty database: start it on a new, empty database volume, and do not start the app before the restore.",
|
||||
);
|
||||
}
|
||||
|
||||
say("restoring (one transaction)...");
|
||||
const started = Date.now();
|
||||
await runTool(
|
||||
"pg_restore",
|
||||
[
|
||||
"--no-owner",
|
||||
"--no-privileges",
|
||||
"--single-transaction",
|
||||
"--exit-on-error",
|
||||
"--no-password",
|
||||
`--dbname=${connectionFromUrl(databaseUrl).pgEnv.PGDATABASE}`,
|
||||
dumpPath,
|
||||
],
|
||||
{ env: connectionFromUrl(databaseUrl).pgEnv },
|
||||
);
|
||||
say(`restored in ${Math.round((Date.now() - started) / 1000)} s`);
|
||||
|
||||
await client.query("ANALYZE");
|
||||
say("planner statistics gathered");
|
||||
|
||||
const problems = compareRowCounts(
|
||||
manifest.rowCounts,
|
||||
await countRows(client),
|
||||
);
|
||||
if (problems.length > 0) {
|
||||
for (const line of problems.slice(0, 20)) say(`row count: ${line}`);
|
||||
await emptyDatabase(client);
|
||||
throw new Refusal(
|
||||
`${problems.length} table(s) do not match the manifest; the database was emptied again. Do not start the app on it.`,
|
||||
);
|
||||
}
|
||||
say(
|
||||
`all ${Object.keys(manifest.rowCounts).length} tables hold the row counts the manifest names`,
|
||||
);
|
||||
} finally {
|
||||
await client.end().catch(() => {});
|
||||
}
|
||||
out();
|
||||
out(
|
||||
`Restored ${manifest.file}. Start the app with the image this dump came from or a newer one ` +
|
||||
`(HealthLog ${manifest.appVersion ?? "?"}, newest migration ${manifest.latestMigration ?? "none"}): docker compose up -d`,
|
||||
);
|
||||
return 0;
|
||||
} finally {
|
||||
if (scratch) await rm(scratch, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2), env = process.env) {
|
||||
const flags = new Set(argv.filter((a) => a.startsWith("--")));
|
||||
const [command] = argv.filter((a) => !a.startsWith("--"));
|
||||
if (!command) {
|
||||
console.error(
|
||||
"db-restore: usage: db-restore list [directory] | latest | <file> [--from-bucket] [--ignore-key-mismatch]",
|
||||
);
|
||||
return 2;
|
||||
}
|
||||
try {
|
||||
if (command === "list" && flags.has("--from-bucket")) {
|
||||
return await listBucketCommand(env);
|
||||
}
|
||||
if (command === "list") {
|
||||
const [, other] = argv.filter((a) => !a.startsWith("--"));
|
||||
return listCommand(other ?? resolveBackupDir(env));
|
||||
}
|
||||
return await restoreCommand(command, flags, env);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`db-restore: ${error instanceof Refusal ? "" : "failed: "}${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
process.exit(await main());
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* The accounts the disaster-recovery drill adds beside the demo seed: a
|
||||
* Guardian with a password, and a managed profile that Guardian administers.
|
||||
* Written through the app's own code (`createManagedProfile`,
|
||||
* `hashPassword`), so the rows are the ones a person creating them in the app
|
||||
* would leave.
|
||||
*
|
||||
* DATABASE_URL=… pnpm dlx tsx scripts/dr-drill-seed.ts
|
||||
*
|
||||
* Prints one JSON line: `{ "guardianId": …, "profileId": … }`. The drill
|
||||
* (`scripts/compose-dr-drill.sh`) checks after the restore that the Guardian
|
||||
* still sees that profile. Idempotent: a second run answers the same ids.
|
||||
*/
|
||||
import { hashPassword } from "@/lib/auth/password";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { createManagedProfile } from "@/lib/managed-profiles/create";
|
||||
|
||||
const GUARDIAN = "drill-guardian";
|
||||
const PASSWORD = "drill-guardian-password-1";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
let guardian = await prisma.user.findUnique({
|
||||
where: { username: GUARDIAN },
|
||||
});
|
||||
guardian ??= await prisma.user.create({
|
||||
data: {
|
||||
username: GUARDIAN,
|
||||
email: "drill-guardian@example.test",
|
||||
passwordHash: await hashPassword(PASSWORD),
|
||||
displayName: "Drill Guardian",
|
||||
locale: "en",
|
||||
timezone: "Europe/Berlin",
|
||||
onboardingCompletedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const existing = await prisma.accountGrant.findFirst({
|
||||
where: { granteeId: guardian.id, access: "MANAGE" },
|
||||
select: { grantorId: true },
|
||||
});
|
||||
const profileId =
|
||||
existing?.grantorId ??
|
||||
(
|
||||
await createManagedProfile({
|
||||
creatorId: guardian.id,
|
||||
displayName: "Drill Child",
|
||||
dateOfBirth: new Date("2018-05-04T00:00:00Z"),
|
||||
locale: "en",
|
||||
timezone: "Europe/Berlin",
|
||||
})
|
||||
).profile.id;
|
||||
console.log(JSON.stringify({ guardianId: guardian.id, profileId }));
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error: unknown) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -360,6 +360,38 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Database dumps",
|
||||
"description": "The nightly whole-database dump and the dump before migrations (docs/ops/disaster-recovery.md). Optional: each has a default, and the bundled compose file mounts the `backups` volume at the default directory.",
|
||||
"required": false,
|
||||
"variables": [
|
||||
{
|
||||
"name": "DB_BACKUP_ENABLED",
|
||||
"purpose": "Set false to stop the nightly database dump. On when unset.",
|
||||
"example": "true"
|
||||
},
|
||||
{
|
||||
"name": "DB_BACKUP_DIR",
|
||||
"purpose": "Directory the dumps are written to and restored from. Default /var/lib/healthlog/backups, the `backups` volume. Without a writable directory the nightly dump only goes off-host and no dump is taken before migrations.",
|
||||
"example": "/var/lib/healthlog/backups"
|
||||
},
|
||||
{
|
||||
"name": "DB_BACKUP_SCHEDULE",
|
||||
"purpose": "Five-field cron for the nightly dump, in Europe/Berlin time like every scheduled job. Default 30 1 * * * (01:30).",
|
||||
"example": "30 1 * * *"
|
||||
},
|
||||
{
|
||||
"name": "DB_BACKUP_KEEP",
|
||||
"purpose": "How many nightly dumps stay in DB_BACKUP_DIR (default 7, at least 1). The last two dumps taken before a migration are kept besides.",
|
||||
"example": "7"
|
||||
},
|
||||
{
|
||||
"name": "DB_BACKUP_BEFORE_MIGRATE",
|
||||
"purpose": "Set false to start without the dump the entrypoint takes before applying migrations. On when unset; a failed dump stops the start until it works or this is false.",
|
||||
"example": "true"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Operational windows and cookie flags",
|
||||
"description": "Values the product states back to a user or that decide whether a session cookie is accepted. Optional: each has a documented default. They are listed so `pnpm check-env` reports them, and so the compose whitelist and the example files can be held in step with this manifest.",
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
/**
|
||||
* Boot-time notice before `prisma migrate deploy`: how many migrations this
|
||||
* image is about to apply, and a reminder to have a backup first.
|
||||
* image is about to apply, and that a dump is taken first
|
||||
* (`scripts/db-dump.mjs`, which imports `pendingMigrations` from here).
|
||||
*
|
||||
* Run by `docker-entrypoint.sh` as `node scripts/pending-migrations.mjs`.
|
||||
* It only reports. A migration that changes data cannot be undone by pulling
|
||||
* the previous image, so an operator who reads the count in the log still has
|
||||
* a moment to stop the container and take a `pg_dump`; the entrypoint does
|
||||
* not wait for that.
|
||||
* the previous image; the dump the entrypoint takes next is the way back.
|
||||
*
|
||||
* Best effort by design: any failure to count (no `_prisma_migrations` table
|
||||
* yet is not a failure, it means every migration is pending) prints one line
|
||||
@@ -67,9 +66,9 @@ export function pendingNotice(count) {
|
||||
const noun = count === 1 ? "migration" : "migrations";
|
||||
return (
|
||||
`HealthLog: ${count} database ${noun} pending. ` +
|
||||
"Back up the database before updating (pg_dump, see the Updates section " +
|
||||
"of docs/self-hosting/getting-started.md); a migration is not undone by " +
|
||||
"going back to the previous image."
|
||||
"Unless DB_BACKUP_BEFORE_MIGRATE=false, a pg_dump of the database is " +
|
||||
"written to DB_BACKUP_DIR before they run (docs/ops/disaster-recovery.md); " +
|
||||
"a migration is not undone by going back to the previous image."
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+145
-60
@@ -9,7 +9,9 @@
|
||||
* - 1 admin user (demo/demo123demo123)
|
||||
* - 90 days of measurements (weight, BP, pulse, resting HR, body fat,
|
||||
* sleep, steps, fortnightly waist) — flat-or-improving trends,
|
||||
* most-recent point from today
|
||||
* most-recent point from today; blood pressure has one busy stretch two
|
||||
* to three weeks back (about 144/91 for some ten days) that comes down
|
||||
* after a ramipril dose increase
|
||||
* - A full week of per-stage sleep nights (awake/REM/light(core)/deep),
|
||||
* ~7.5 h each, including last night
|
||||
* - 90 days of body composition (fat/lean/muscle mass, total body water,
|
||||
@@ -42,12 +44,13 @@
|
||||
* temperature, period flow, fertile-window mucus/OPK, per-day symptoms,
|
||||
* and a cached forward prediction
|
||||
* - Two practitioners, a knee arthroscopy with its operative report and
|
||||
* discharge letter linked (small generated PDFs), its follow-up visit, and
|
||||
* a check-up booked for tomorrow morning
|
||||
* discharge letter linked (small generated PDFs), its follow-up visit, the
|
||||
* blood-pressure visit that raised ramipril, and a check-up booked for
|
||||
* tomorrow morning
|
||||
* - An AI-configured Coach with five sample conversations spread from
|
||||
* today back over a few weeks
|
||||
* - App settings (registration disabled, English locale, the key-backup
|
||||
* step confirmed for the key the demo runs with)
|
||||
* - App settings (registration disabled, English locale) and the recovery
|
||||
* kit confirmed for the keys the demo runs with
|
||||
* - A multi-year history before the 90-day window (about four and a half
|
||||
* years) for the timeline and the day view: blood pressure in bursts with
|
||||
* real gaps, weekly weight, phone steps, watch resting heart rate / HRV /
|
||||
@@ -87,8 +90,14 @@ import {
|
||||
encrypt,
|
||||
encryptBytes,
|
||||
getActiveKeyId,
|
||||
getConfiguredKeyIds,
|
||||
getKeyFingerprint,
|
||||
} from "../src/lib/crypto";
|
||||
import {
|
||||
applicationKitKeys,
|
||||
backupKitKeys,
|
||||
tokenKitKeys,
|
||||
} from "../src/lib/recovery/kit-rules";
|
||||
import { ENVIRONMENT_LOCATION_AAD } from "../src/lib/crypto/encrypted-columns";
|
||||
import { withBriefingGeneratedAt } from "../src/lib/insights/briefing-generated-at";
|
||||
import { dailyStatsExternalId } from "../src/lib/measurements/apple-health-mapping";
|
||||
@@ -403,19 +412,45 @@ async function bulkInsert(
|
||||
|
||||
/**
|
||||
* A busy stretch inside the 90-day window, in days before "now": blood
|
||||
* pressure and pulse rise and settle again, and the mood dips with them.
|
||||
* Without it every short-window series was flat noise around its target and a
|
||||
* 7-day trend line over 30 days drew a straight line; with it the line has a
|
||||
* shape to follow. It ends well before today, so the latest readings, the
|
||||
* tiles and the stored texts still describe a settled, optimal band.
|
||||
* pressure climbs over two days to about 144/91 and stays there for some ten
|
||||
* days, pulse rises with it and the mood dips. At the family practice on
|
||||
* `doseUp` ramipril goes from 5 to 10 mg, and over the following days the
|
||||
* readings come back down to the settled band. Without it every short-window
|
||||
* series was flat noise around its target, and on a blood-pressure axis that
|
||||
* has to fit both systolic and diastolic, even a 15 mmHg swing read as a flat
|
||||
* line. It ends a few days before today, so the latest readings, the tiles and
|
||||
* the stored texts describe a settled, optimal band again.
|
||||
*
|
||||
* It is also no longer than this on purpose. The personal usual range is the
|
||||
* median ± 3 MAD of the last 30 days: once the stretch fills about half of that
|
||||
* window, the median lands on the stretch and the band reads something like
|
||||
* 87-181 mmHg. Kept to this length the band stays around 107-132.
|
||||
*/
|
||||
const BUSY_STRETCH = { from: 27, to: 12 } as const;
|
||||
const BUSY_STRETCH = { from: 21, peak: 19, doseUp: 11, settled: 6 } as const;
|
||||
|
||||
/** 0 outside the busy stretch, rising smoothly to 1 at its middle. */
|
||||
/**
|
||||
* Systolic, diastolic and pulse on top of the settled band at the stretch's
|
||||
* height. The chart pads its axis and rounds it to even ticks: with systolic
|
||||
* readings up to about 148 the 30-day axis runs 60 to 160 in steps of 20, one
|
||||
* mmHg more and it jumps to 50 to 200 in steps of 50, which flattens the very
|
||||
* rise the stretch is there to show. So the lift stops short of that.
|
||||
*/
|
||||
const BUSY_STRETCH_LIFT = { sys: 25, dia: 15, pulse: 6 } as const;
|
||||
|
||||
/** Ramipril dose after the stretch, in mg (from 5 mg before it). */
|
||||
const RAMIPRIL_STRETCH_DOSE = 10;
|
||||
|
||||
/**
|
||||
* 0 outside the busy stretch, 1 while it holds: a smooth climb from `from` to
|
||||
* `peak`, level until the dose change on `doseUp`, a smooth fall to `settled`.
|
||||
*/
|
||||
function busyStretch(daysBefore: number): number {
|
||||
const { from, to } = BUSY_STRETCH;
|
||||
if (daysBefore > from || daysBefore < to) return 0;
|
||||
return Math.sin((Math.PI * (from - daysBefore)) / (from - to)) ** 2;
|
||||
const { from, peak, doseUp, settled } = BUSY_STRETCH;
|
||||
const smooth = (t: number) => t * t * (3 - 2 * t);
|
||||
if (daysBefore >= from || daysBefore <= settled) return 0;
|
||||
if (daysBefore > peak) return smooth((from - daysBefore) / (from - peak));
|
||||
if (daysBefore >= doseUp) return 1;
|
||||
return smooth((daysBefore - settled) / (doseUp - settled));
|
||||
}
|
||||
|
||||
/** Pollen season bump: 0 outside `[from, to]`, peaking at `peak` mid-season. */
|
||||
@@ -577,12 +612,14 @@ export async function seed() {
|
||||
|
||||
// Weight: 86.5 → trending down to a stable ~82 (healthy BMI ~24.7 at 182cm)
|
||||
const weights = randomWalk(86.5, 82.0, span, 0.6);
|
||||
// Systolic BP: 128 → settled in the optimal band ~118 (clamped)
|
||||
// Systolic BP: 128 → settled in the optimal band ~118 (clamped). The day-
|
||||
// to-day noise is kept small, so the 7-day trend over the busy stretch
|
||||
// below draws an arc rather than jitter.
|
||||
const sysBP = randomWalk(
|
||||
128,
|
||||
118,
|
||||
span,
|
||||
3,
|
||||
1.2,
|
||||
VALUE_RANGES.BLOOD_PRESSURE_SYS,
|
||||
0.1,
|
||||
);
|
||||
@@ -591,7 +628,7 @@ export async function seed() {
|
||||
82,
|
||||
76,
|
||||
span,
|
||||
2.5,
|
||||
1,
|
||||
VALUE_RANGES.BLOOD_PRESSURE_DIA,
|
||||
0.1,
|
||||
);
|
||||
@@ -637,9 +674,21 @@ export async function seed() {
|
||||
Math.max(range.min, Math.round((series[i] + by) * 10) / 10),
|
||||
);
|
||||
};
|
||||
lift(sysBP, busy * 12 + weekend * 2, VALUE_RANGES.BLOOD_PRESSURE_SYS);
|
||||
lift(diaBP, busy * 7 + weekend * 1, VALUE_RANGES.BLOOD_PRESSURE_DIA);
|
||||
lift(pulse, busy * 6, VALUE_RANGES.PULSE);
|
||||
// A little reading-to-reading scatter on top of the slow walk, as a
|
||||
// cuff has; the 7-day mean averages it out.
|
||||
lift(
|
||||
sysBP,
|
||||
busy * BUSY_STRETCH_LIFT.sys + weekend * 1 + (Math.random() - 0.5) * 3,
|
||||
VALUE_RANGES.BLOOD_PRESSURE_SYS,
|
||||
);
|
||||
lift(
|
||||
diaBP,
|
||||
busy * BUSY_STRETCH_LIFT.dia +
|
||||
weekend * 0.5 +
|
||||
(Math.random() - 0.5) * 2,
|
||||
VALUE_RANGES.BLOOD_PRESSURE_DIA,
|
||||
);
|
||||
lift(pulse, busy * BUSY_STRETCH_LIFT.pulse, VALUE_RANGES.PULSE);
|
||||
}
|
||||
for (const series of [weights, restingHr]) {
|
||||
settleLatest(series);
|
||||
@@ -647,7 +696,7 @@ export async function seed() {
|
||||
// Today's reading settles on the days after the busy stretch only, so it
|
||||
// describes the band the series came back to.
|
||||
for (const series of [sysBP, diaBP, pulse]) {
|
||||
settleLatest(series, BUSY_STRETCH.to - 1);
|
||||
settleLatest(series, BUSY_STRETCH.settled);
|
||||
}
|
||||
|
||||
// Each series carries the source that writes it on a real account, the
|
||||
@@ -1406,19 +1455,21 @@ export async function seed() {
|
||||
console.log("Creating medications...");
|
||||
|
||||
// Medication 1: Ramipril (blood pressure). Started about three years
|
||||
// back at 2.5 mg and raised to 5 mg a year later; the multi-year history
|
||||
// section below writes the dose changes, a short sick-day pause and the
|
||||
// intakes before the 90-day window. `starts_on` is the stated start the
|
||||
// timeline lane draws from.
|
||||
// back at 2.5 mg, raised to 5 mg a year later, and to 10 mg during the
|
||||
// busy stretch inside the 90-day window; the multi-year history section
|
||||
// below writes the dose changes, a short sick-day pause and the intakes
|
||||
// before the 90-day window. `starts_on` is the stated start the timeline
|
||||
// lane draws from.
|
||||
const med1Id = cuid();
|
||||
await client.query(
|
||||
`INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, starts_on, created_at, updated_at)
|
||||
VALUES ($1, $2, 'Ramipril', '5mg', true, true, $3, $4, $4)`,
|
||||
VALUES ($1, $2, 'Ramipril', $5, true, true, $3, $4, $4)`,
|
||||
[
|
||||
med1Id,
|
||||
userId,
|
||||
formatDate(daysAgo(RAMIPRIL_START_DAYS_AGO)),
|
||||
daysAgo(RAMIPRIL_START_DAYS_AGO),
|
||||
`${RAMIPRIL_STRETCH_DOSE}mg`,
|
||||
],
|
||||
);
|
||||
const sched1Id = cuid();
|
||||
@@ -2311,6 +2362,16 @@ export async function seed() {
|
||||
reason: "Follow-up two weeks after the arthroscopy.",
|
||||
outcome: "Healing well, full range of motion. Physiotherapy continues.",
|
||||
});
|
||||
// The visit that turned the busy stretch: ten days of high home readings,
|
||||
// ramipril raised that morning.
|
||||
await insertEncounter({
|
||||
occurredAt: daysAgoAt(BUSY_STRETCH.doseUp, 8, 15),
|
||||
status: "DONE",
|
||||
kind: "ROUTINE",
|
||||
practitionerId: familyPracticeId,
|
||||
reason: "Home blood pressure high for about ten days.",
|
||||
outcome: `Readings around 144/91 during a stressful stretch at work. Ramipril raised to ${RAMIPRIL_STRETCH_DOSE} mg, home readings again in two weeks.`,
|
||||
});
|
||||
await insertEncounter({
|
||||
occurredAt: daysAgoAt(-1, 9, 30),
|
||||
status: "PLANNED",
|
||||
@@ -2633,10 +2694,12 @@ export async function seed() {
|
||||
intakeRows,
|
||||
);
|
||||
|
||||
// The ramipril dose history and its sick-day pause.
|
||||
// The ramipril dose history and its sick-day pause. The last step falls
|
||||
// inside the 90-day window, on the day the busy stretch turns.
|
||||
for (const [n, value] of [
|
||||
[RAMIPRIL_START_DAYS_AGO, 2.5],
|
||||
[RAMIPRIL_DOSE_UP_DAYS_AGO, 5],
|
||||
[BUSY_STRETCH.doseUp, RAMIPRIL_STRETCH_DOSE],
|
||||
] as const) {
|
||||
await client.query(
|
||||
`INSERT INTO medication_dose_changes (id, medication_id, effective_from, dose_value, dose_unit, created_at)
|
||||
@@ -3353,9 +3416,10 @@ export async function seed() {
|
||||
content: "How has my blood pressure been trending lately?",
|
||||
},
|
||||
answer(
|
||||
"Your blood pressure has been settling nicely. After a higher " +
|
||||
"stretch about three weeks ago, the morning readings have come back " +
|
||||
"down into the optimal band, and " +
|
||||
"Your blood pressure has come back down nicely. During the stressful " +
|
||||
"stretch two to three weeks ago your morning readings sat around " +
|
||||
"144/91 for about ten days; since ramipril went up to 10 mg they " +
|
||||
"have eased back into the optimal band within about a week, and " +
|
||||
"your resting heart rate is sitting in a comfortable range too. " +
|
||||
"Keeping up the steady sleep and daily movement looks like it is " +
|
||||
"paying off — worth staying consistent with the morning measurements " +
|
||||
@@ -3633,10 +3697,11 @@ export async function seed() {
|
||||
keyFindings: [
|
||||
{
|
||||
tone: "good",
|
||||
headline: "Blood pressure has settled into the optimal range.",
|
||||
headline: "Blood pressure is back in the optimal range.",
|
||||
detail:
|
||||
"Your 30-day average is well inside the optimal band and lower " +
|
||||
"than where the quarter began.",
|
||||
"After a higher stretch two to three weeks ago and a ramipril " +
|
||||
"dose increase, your readings have come back down below where " +
|
||||
"the quarter began.",
|
||||
delta: "↓ ~10 mmHg",
|
||||
sourceWindow: "30d",
|
||||
sourceMetric: "bp",
|
||||
@@ -3674,7 +3739,7 @@ export async function seed() {
|
||||
],
|
||||
},
|
||||
trendAnnotations: {
|
||||
bp: "Your systolic is trending down into the optimal band — a pattern worth keeping.",
|
||||
bp: "After a higher stretch and a ramipril dose increase, your systolic has come back down into the optimal band.",
|
||||
weight:
|
||||
"Weight has levelled off after an earlier decline — a stable, healthy place to hold.",
|
||||
mood: "Mood has been on a quiet upward run across the quarter.",
|
||||
@@ -3727,11 +3792,10 @@ export async function seed() {
|
||||
{
|
||||
scope: "blood-pressure",
|
||||
text:
|
||||
"Your blood pressure is averaging about 118/76 over the last two " +
|
||||
"weeks, back down after a busier stretch before that. It sits " +
|
||||
"roughly 10 mmHg systolic below where this quarter started, " +
|
||||
"comfortably inside the optimal band. That is a real, earned " +
|
||||
"shift, not day-to-day noise. Keeping the morning " +
|
||||
"Your blood pressure is back around 118/76 this week. Two to three " +
|
||||
"weeks ago it climbed to about 144/91 and held there for about ten " +
|
||||
"days; after ramipril went up to 10 mg it came down over the " +
|
||||
"following days and now sits inside the optimal band again. Keeping the morning " +
|
||||
"readings on a steady schedule will make the trend even easier to " +
|
||||
"follow.",
|
||||
},
|
||||
@@ -3846,8 +3910,9 @@ export async function seed() {
|
||||
period: "month",
|
||||
text:
|
||||
"Over the past month the gains from earlier in the quarter have " +
|
||||
"become your new normal. Blood pressure has held in the optimal " +
|
||||
"band rather than just dipping into it, weight has stabilised at a " +
|
||||
"become your new normal. Blood pressure had one higher stretch, " +
|
||||
"came back down after the ramipril dose went up and now sits in the " +
|
||||
"optimal band again, weight has stabilised at a " +
|
||||
"healthy point, and resting heart rate has stayed low alongside " +
|
||||
"consistent daily activity. Sleep lengthened and steadied, and " +
|
||||
"your mood trend continued its quiet climb. The month reads less " +
|
||||
@@ -3890,24 +3955,44 @@ export async function seed() {
|
||||
|
||||
// ── App Settings ─────────────────────────
|
||||
console.log("Creating app settings...");
|
||||
// The key-backup step is confirmed for the key the demo runs with, the
|
||||
// same fields `POST /api/admin/encryption/key-backup/confirm` writes (key
|
||||
// id + its public fingerprint, never the key). The demo account is an
|
||||
// admin, so an unconfirmed step put a "Back up your encryption key"
|
||||
// banner above the dashboard for every visitor, about a key no visitor
|
||||
// can reach.
|
||||
const activeKeyId = getActiveKeyId();
|
||||
await client.query(
|
||||
`INSERT INTO app_settings (id, registration_enabled, default_locale,
|
||||
encryption_key_backup_confirmed_at, encryption_key_backup_confirmed_key_id,
|
||||
encryption_key_backup_confirmed_fingerprint, encryption_key_backup_confirmed_by_user_id)
|
||||
VALUES ('singleton', false, 'en', NOW(), $1, $2, $3)
|
||||
ON CONFLICT (id) DO UPDATE SET registration_enabled = false, default_locale = 'en',
|
||||
encryption_key_backup_confirmed_at = EXCLUDED.encryption_key_backup_confirmed_at,
|
||||
encryption_key_backup_confirmed_key_id = EXCLUDED.encryption_key_backup_confirmed_key_id,
|
||||
encryption_key_backup_confirmed_fingerprint = EXCLUDED.encryption_key_backup_confirmed_fingerprint,
|
||||
encryption_key_backup_confirmed_by_user_id = EXCLUDED.encryption_key_backup_confirmed_by_user_id`,
|
||||
[activeKeyId, getKeyFingerprint(activeKeyId), userId],
|
||||
`INSERT INTO app_settings (id, registration_enabled, default_locale)
|
||||
VALUES ('singleton', false, 'en')
|
||||
ON CONFLICT (id) DO UPDATE SET registration_enabled = false, default_locale = 'en'`,
|
||||
);
|
||||
// The recovery kit is confirmed for the keys the demo runs with, the same
|
||||
// entries `POST /api/admin/recovery-kit/confirm` writes (key ids and
|
||||
// public fingerprints, never a key). The demo account is an admin, so an
|
||||
// unconfirmed kit put a "Save your recovery kit" banner above the
|
||||
// dashboard for every visitor, about keys no visitor can reach. The seed
|
||||
// writes no backup copies, so no retired key is needed.
|
||||
const kitEntries = [
|
||||
...applicationKitKeys({
|
||||
activeKeyId: getActiveKeyId(),
|
||||
configuredKeyIds: getConfiguredKeyIds(),
|
||||
fingerprintOf: getKeyFingerprint,
|
||||
needs: {
|
||||
stored: [],
|
||||
unrecorded: { copies: 0, oldestAt: null },
|
||||
offhost: [],
|
||||
offhostExpirationDays: null,
|
||||
retiredKeysStillNeeded: [],
|
||||
},
|
||||
now: new Date(),
|
||||
}),
|
||||
...backupKitKeys(
|
||||
process.env.BACKUP_ENCRYPTION_KEY,
|
||||
process.env.BACKUP_ENCRYPTION_PREVIOUS_KEYS,
|
||||
),
|
||||
...tokenKitKeys(process.env.API_TOKEN_HMAC_KEY),
|
||||
].map(({ kind, id, fingerprint }) => ({ kind, id, fingerprint }));
|
||||
await client.query(
|
||||
`INSERT INTO recovery_kit_confirmations (id, confirmed_at, confirmed_by_user_id, entries)
|
||||
VALUES ('singleton', NOW(), $1, $2::jsonb)
|
||||
ON CONFLICT (id) DO UPDATE SET confirmed_at = EXCLUDED.confirmed_at,
|
||||
confirmed_by_user_id = EXCLUDED.confirmed_by_user_id,
|
||||
entries = EXCLUDED.entries`,
|
||||
[userId, JSON.stringify(kitEntries)],
|
||||
);
|
||||
|
||||
// ── Audit Log (some login entries) ──────
|
||||
@@ -3962,7 +4047,7 @@ export async function seed() {
|
||||
` Cycle: ${cycleLengths.length} cycles with BBT, flow, mucus/OPK, symptoms + a forecast`,
|
||||
);
|
||||
console.log(
|
||||
` Visits: knee arthroscopy (2 linked PDFs) + follow-up, check-up booked for tomorrow`,
|
||||
` Visits: knee arthroscopy (2 linked PDFs) + follow-up, a blood-pressure visit, check-up booked for tomorrow`,
|
||||
);
|
||||
console.log(` Coach: ${coachConversations.length} conversations`);
|
||||
console.log(
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"$comment": "safeJson calls per route file under src/app; new body-reading routes use parseJsonBody. Maintained by src/__tests__/route-body-parse-ratchet.test.ts.",
|
||||
"count": 61,
|
||||
"total": 61,
|
||||
"count": 60,
|
||||
"total": 60,
|
||||
"entries": {
|
||||
"app/api/admin/ai-settings/route.ts": 1,
|
||||
"app/api/admin/drain-per-sample-cumulative/route.ts": 1,
|
||||
"app/api/admin/encryption/key-backup/verify/route.ts": 1,
|
||||
"app/api/admin/notifications/reminder-check/route.ts": 1,
|
||||
"app/api/admin/recovery-kit/verify/route.ts": 1,
|
||||
"app/api/admin/rollups/recompute/route.ts": 1,
|
||||
"app/api/admin/users/[id]/reset-password/route.ts": 1,
|
||||
"app/api/ai/test/route.ts": 1,
|
||||
@@ -37,7 +37,6 @@
|
||||
"app/api/insights/generate/route.ts": 1,
|
||||
"app/api/insights/layout/route.ts": 1,
|
||||
"app/api/integrations/healthkit/route.ts": 1,
|
||||
"app/api/internal/deploy-webhook/route.ts": 1,
|
||||
"app/api/labs/ocr/extract/route.ts": 1,
|
||||
"app/api/measurements/batch/route.ts": 1,
|
||||
"app/api/measurements/by-external-ids/route.ts": 1,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 84,
|
||||
"total": 94984,
|
||||
"total": 94948,
|
||||
"entries": {
|
||||
"app/medications/page-client.tsx": 839,
|
||||
"components/charts/mood-chart.tsx": 1166,
|
||||
@@ -23,9 +23,9 @@
|
||||
"lib/ai/anthropic-client.ts": 804,
|
||||
"lib/ai/coach/about-me.ts": 1012,
|
||||
"lib/ai/coach/coach-prose-grounding.ts": 1079,
|
||||
"lib/ai/coach/persistence.ts": 1219,
|
||||
"lib/ai/coach/persistence.ts": 1218,
|
||||
"lib/ai/coach/results/metric-table-tool.ts": 1102,
|
||||
"lib/ai/coach/snapshot.ts": 1347,
|
||||
"lib/ai/coach/snapshot.ts": 1346,
|
||||
"lib/ai/coach/stream-events.ts": 817,
|
||||
"lib/ai/coach/types.ts": 1011,
|
||||
"lib/ai/codex-client.ts": 1045,
|
||||
@@ -66,8 +66,8 @@
|
||||
"lib/jobs/coach-nudge.ts": 1084,
|
||||
"lib/jobs/insight-pregenerate.ts": 1189,
|
||||
"lib/jobs/medication-intake-import.ts": 819,
|
||||
"lib/jobs/offhost-backup.ts": 1127,
|
||||
"lib/jobs/reaction-line.ts": 824,
|
||||
"lib/jobs/offhost-backup.ts": 1114,
|
||||
"lib/jobs/reaction-line.ts": 803,
|
||||
"lib/managed-profiles/handover.ts": 986,
|
||||
"lib/mcp/record-search.ts": 1467,
|
||||
"lib/mcp/rich-reads.ts": 1467,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 348,
|
||||
"total": 112742,
|
||||
"count": 346,
|
||||
"total": 112297,
|
||||
"entries": {
|
||||
"app/achievements/page.tsx :: AchievementsPage": 206,
|
||||
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 286,
|
||||
"app/api/admin/notifications/reminder-check/route.ts :: apiHandler(arg)": 208,
|
||||
"app/api/admin/notifications/reminder-check/route.ts :: apiHandler(arg)": 206,
|
||||
"app/api/admin/settings/route.ts :: apiHandler(arg) #2": 211,
|
||||
"app/api/auth/login/route.ts :: apiHandler(arg)": 262,
|
||||
"app/api/auth/me/route.ts :: apiHandler(arg)": 386,
|
||||
@@ -59,7 +59,6 @@
|
||||
"components/admin/glitchtip-section.tsx :: GlitchtipSection": 233,
|
||||
"components/admin/host-metrics-chart.tsx :: HostMetricsChart": 232,
|
||||
"components/admin/invite-tokens-section.tsx :: InviteTokensSection": 525,
|
||||
"components/admin/key-backup-card.tsx :: KeyBackupCard": 238,
|
||||
"components/admin/login-overview-section.tsx :: LoginOverviewSection": 503,
|
||||
"components/admin/pulse-day-mean-card.tsx :: PulseDayMeanCard": 305,
|
||||
"components/admin/reminders-section.tsx :: RemindersSection": 309,
|
||||
@@ -247,7 +246,7 @@
|
||||
"lib/ai/anthropic-client.ts :: generateCompletion": 294,
|
||||
"lib/ai/coach/cycle-snapshot.ts :: buildCycleSnapshotBlock": 218,
|
||||
"lib/ai/coach/glp1-snapshot.ts :: buildGlp1SnapshotBlock": 218,
|
||||
"lib/ai/coach/snapshot.ts :: buildCoachSnapshotImpl": 881,
|
||||
"lib/ai/coach/snapshot.ts :: buildCoachSnapshotImpl": 880,
|
||||
"lib/ai/coach/tools/loop.ts :: runCoachToolLoop": 438,
|
||||
"lib/ai/coach/turn/model.ts :: runTurnModel": 538,
|
||||
"lib/ai/coach/turn/pipeline.ts :: runTurn": 274,
|
||||
@@ -260,8 +259,7 @@
|
||||
"lib/analytics/intraday-pulse-io.ts :: loadIntradayPulse": 206,
|
||||
"lib/analytics/score/reader.ts :: computeUserHealthScore": 324,
|
||||
"lib/analytics/summaries-slice.ts :: computeFromRollups": 373,
|
||||
"lib/api-handler.ts :: apiHandler": 207,
|
||||
"lib/api-handler.ts :: apiHandler > wrapped": 201,
|
||||
"lib/api-handler.ts :: apiHandler": 204,
|
||||
"lib/auth/profile-update.ts :: applyProfileUpdate": 353,
|
||||
"lib/cache/server-cache.ts :: createServerCaches": 230,
|
||||
"lib/cycle/backup.ts :: restoreCycleData": 224,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
|
||||
"count": 2897,
|
||||
"total": 9243,
|
||||
"total": 9236,
|
||||
"entries": {
|
||||
"__tests__/account-payload-consumer-guard.test.ts": 2,
|
||||
"__tests__/acting-account-boundary-guard.test.ts": 7,
|
||||
@@ -175,9 +175,9 @@
|
||||
"app/api/auth/refresh/route.ts": 2,
|
||||
"app/api/auth/register/__tests__/route.test.ts": 5,
|
||||
"app/api/auth/register/route.ts": 10,
|
||||
"app/api/biomarkers/__tests__/error-contract.test.ts": 1,
|
||||
"app/api/biomarkers/[id]/__tests__/route.test.ts": 2,
|
||||
"app/api/biomarkers/[id]/route.ts": 6,
|
||||
"app/api/biomarkers/__tests__/error-contract.test.ts": 1,
|
||||
"app/api/biomarkers/route.ts": 4,
|
||||
"app/api/c/[token]/unlock/__tests__/route.test.ts": 1,
|
||||
"app/api/c/[token]/unlock/route.ts": 1,
|
||||
@@ -187,12 +187,12 @@
|
||||
"app/api/coach/about-me/questions/__tests__/route-module-gate.test.ts": 1,
|
||||
"app/api/coach/about-me/questions/route.ts": 3,
|
||||
"app/api/coach/about-me/route.ts": 4,
|
||||
"app/api/coach/plans/__tests__/route.test.ts": 1,
|
||||
"app/api/coach/plans/[id]/route.ts": 2,
|
||||
"app/api/coach/plans/__tests__/route.test.ts": 1,
|
||||
"app/api/coach/plans/route.ts": 1,
|
||||
"app/api/coach/reminder-suggestions/route.ts": 1,
|
||||
"app/api/coach/reminders/__tests__/route.test.ts": 1,
|
||||
"app/api/coach/reminders/[id]/route.ts": 1,
|
||||
"app/api/coach/reminders/__tests__/route.test.ts": 1,
|
||||
"app/api/coach/reminders/route.ts": 1,
|
||||
"app/api/coach/suggested-actions/__tests__/route.test.ts": 1,
|
||||
"app/api/coach/suggested-actions/route.ts": 1,
|
||||
@@ -200,9 +200,9 @@
|
||||
"app/api/consent/ai/latest/__tests__/route.test.ts": 3,
|
||||
"app/api/consent/ai/latest/route.ts": 3,
|
||||
"app/api/consent/ai/route.ts": 3,
|
||||
"app/api/custom-metrics/[id]/entries/[entryId]/route.ts": 2,
|
||||
"app/api/custom-metrics/[id]/entries/__tests__/note-encryption.test.ts": 1,
|
||||
"app/api/custom-metrics/[id]/entries/__tests__/soft-delete-restore.test.ts": 1,
|
||||
"app/api/custom-metrics/[id]/entries/[entryId]/route.ts": 2,
|
||||
"app/api/custom-metrics/[id]/entries/route.ts": 4,
|
||||
"app/api/custom-metrics/[id]/route.ts": 1,
|
||||
"app/api/custom-metrics/route.ts": 1,
|
||||
@@ -212,8 +212,8 @@
|
||||
"app/api/cycle/day-logs/[id]/route.ts": 4,
|
||||
"app/api/cycle/day-logs/route.ts": 1,
|
||||
"app/api/cycle/period/route.ts": 4,
|
||||
"app/api/cycle/symptoms/custom/__tests__/custom-crud.test.ts": 1,
|
||||
"app/api/cycle/symptoms/custom/[key]/route.ts": 1,
|
||||
"app/api/cycle/symptoms/custom/__tests__/custom-crud.test.ts": 1,
|
||||
"app/api/cycle/symptoms/custom/route.ts": 3,
|
||||
"app/api/dashboard/chart-overlay-prefs/__tests__/route.test.ts": 1,
|
||||
"app/api/dashboard/chart-overlay-prefs/route.ts": 2,
|
||||
@@ -223,11 +223,10 @@
|
||||
"app/api/dashboard/summary/route.ts": 5,
|
||||
"app/api/dashboard/widgets/__tests__/route.test.ts": 4,
|
||||
"app/api/dashboard/widgets/route.ts": 14,
|
||||
"app/api/devices/__tests__/route.test.ts": 1,
|
||||
"app/api/devices/[id]/__tests__/route.test.ts": 1,
|
||||
"app/api/devices/[id]/route.ts": 1,
|
||||
"app/api/devices/__tests__/route.test.ts": 1,
|
||||
"app/api/devices/route.ts": 4,
|
||||
"app/api/documents/inbound/__tests__/route.test.ts": 1,
|
||||
"app/api/documents/inbound/[id]/__tests__/route.test.ts": 1,
|
||||
"app/api/documents/inbound/[id]/chat/__tests__/route.test.ts": 2,
|
||||
"app/api/documents/inbound/[id]/chat/route.ts": 1,
|
||||
@@ -242,6 +241,7 @@
|
||||
"app/api/documents/inbound/[id]/suggest/route.ts": 2,
|
||||
"app/api/documents/inbound/[id]/summary/route.ts": 2,
|
||||
"app/api/documents/inbound/[id]/thumbnail/route.ts": 1,
|
||||
"app/api/documents/inbound/__tests__/route.test.ts": 1,
|
||||
"app/api/documents/inbound/reindex/route.ts": 1,
|
||||
"app/api/documents/inbound/route.ts": 2,
|
||||
"app/api/documents/inbound/usage/route.ts": 1,
|
||||
@@ -260,10 +260,10 @@
|
||||
"app/api/export/mood/route.ts": 2,
|
||||
"app/api/family-history/[id]/route.ts": 2,
|
||||
"app/api/family-history/route.ts": 1,
|
||||
"app/api/fhir/Patient/$everything/route.ts": 1,
|
||||
"app/api/fhir/__tests__/observation-series.test.ts": 1,
|
||||
"app/api/fhir/__tests__/route.test.ts": 1,
|
||||
"app/api/fhir/metadata/route.ts": 1,
|
||||
"app/api/fhir/Patient/$everything/route.ts": 1,
|
||||
"app/api/fitbit/callback/route.ts": 1,
|
||||
"app/api/fitbit/connect/route.ts": 1,
|
||||
"app/api/fitbit/credentials/route.ts": 1,
|
||||
@@ -292,6 +292,11 @@
|
||||
"app/api/insights/breathing-screening/route.ts": 2,
|
||||
"app/api/insights/cards/__tests__/route.test.ts": 2,
|
||||
"app/api/insights/cards/route.ts": 9,
|
||||
"app/api/insights/chat/[id]/attachments/[documentId]/route.ts": 1,
|
||||
"app/api/insights/chat/[id]/attachments/__tests__/route.test.ts": 1,
|
||||
"app/api/insights/chat/[id]/attachments/route.ts": 1,
|
||||
"app/api/insights/chat/[id]/messages/[messageId]/trail/__tests__/route.test.ts": 1,
|
||||
"app/api/insights/chat/[id]/route.ts": 1,
|
||||
"app/api/insights/chat/__tests__/dialog-harness.ts": 1,
|
||||
"app/api/insights/chat/__tests__/golden-transcripts.test.ts": 1,
|
||||
"app/api/insights/chat/__tests__/route-activity.test.ts": 1,
|
||||
@@ -303,19 +308,14 @@
|
||||
"app/api/insights/chat/__tests__/route-snapshot-once.test.ts": 2,
|
||||
"app/api/insights/chat/__tests__/route-tool-mode.test.ts": 4,
|
||||
"app/api/insights/chat/__tests__/route-workout-scope.test.ts": 1,
|
||||
"app/api/insights/chat/[id]/attachments/__tests__/route.test.ts": 1,
|
||||
"app/api/insights/chat/[id]/attachments/[documentId]/route.ts": 1,
|
||||
"app/api/insights/chat/[id]/attachments/route.ts": 1,
|
||||
"app/api/insights/chat/[id]/messages/[messageId]/trail/__tests__/route.test.ts": 1,
|
||||
"app/api/insights/chat/[id]/route.ts": 1,
|
||||
"app/api/insights/chat/fenced/__tests__/route.test.ts": 1,
|
||||
"app/api/insights/chat/fenced/route.ts": 1,
|
||||
"app/api/insights/chat/messages/[id]/feedback/route.ts": 3,
|
||||
"app/api/insights/chat/route.ts": 9,
|
||||
"app/api/insights/coach-read/route.ts": 2,
|
||||
"app/api/insights/coach/facts/[id]/route.ts": 2,
|
||||
"app/api/insights/coach/facts/__tests__/route-memory.test.ts": 1,
|
||||
"app/api/insights/coach/facts/__tests__/route.test.ts": 1,
|
||||
"app/api/insights/coach/facts/[id]/route.ts": 2,
|
||||
"app/api/insights/coach/facts/route.ts": 1,
|
||||
"app/api/insights/coach/nudge-status/route.ts": 1,
|
||||
"app/api/insights/coach/seen/route.ts": 1,
|
||||
@@ -326,9 +326,9 @@
|
||||
"app/api/insights/derived/batch/__tests__/route.test.ts": 3,
|
||||
"app/api/insights/derived/batch/route.ts": 6,
|
||||
"app/api/insights/derived/route.ts": 5,
|
||||
"app/api/insights/ecg/__tests__/route.test.ts": 1,
|
||||
"app/api/insights/ecg/[id]/__tests__/route.test.ts": 1,
|
||||
"app/api/insights/ecg/[id]/route.ts": 2,
|
||||
"app/api/insights/ecg/__tests__/route.test.ts": 1,
|
||||
"app/api/insights/ecg/route.ts": 1,
|
||||
"app/api/insights/feedback/route.ts": 2,
|
||||
"app/api/insights/generate/__tests__/route.test.ts": 12,
|
||||
@@ -360,15 +360,15 @@
|
||||
"app/api/integrations/healthkit/__tests__/route.test.ts": 4,
|
||||
"app/api/integrations/healthkit/route.ts": 3,
|
||||
"app/api/integrations/status/__tests__/route.test.ts": 2,
|
||||
"app/api/integrations/status/route.ts": 7,
|
||||
"app/api/integrations/status/route.ts": 6,
|
||||
"app/api/integrations/withings/resume/__tests__/route.test.ts": 1,
|
||||
"app/api/integrations/withings/resume/route.ts": 1,
|
||||
"app/api/internal/deploy-webhook/__tests__/route.test.ts": 2,
|
||||
"app/api/internal/deploy-webhook/route.ts": 1,
|
||||
"app/api/internal/web-vitals/__tests__/route.test.ts": 1,
|
||||
"app/api/internal/web-vitals/route.ts": 1,
|
||||
"app/api/labs/__tests__/error-contract.test.ts": 1,
|
||||
"app/api/labs/[id]/route.ts": 7,
|
||||
"app/api/labs/__tests__/error-contract.test.ts": 1,
|
||||
"app/api/labs/ocr/capability/route.ts": 1,
|
||||
"app/api/labs/ocr/commit/__tests__/route.test.ts": 2,
|
||||
"app/api/labs/ocr/commit/route.ts": 4,
|
||||
@@ -388,13 +388,13 @@
|
||||
"app/api/measurement-reminders/[id]/skip/route.ts": 1,
|
||||
"app/api/measurement-reminders/[id]/snooze/route.ts": 1,
|
||||
"app/api/measurement-reminders/route.ts": 3,
|
||||
"app/api/measurements/[id]/__tests__/route.test.ts": 2,
|
||||
"app/api/measurements/[id]/route.ts": 16,
|
||||
"app/api/measurements/__tests__/arrival-wiring.test.ts": 1,
|
||||
"app/api/measurements/__tests__/group-by-day.test.ts": 8,
|
||||
"app/api/measurements/__tests__/put-duplicate-timestamp.test.ts": 1,
|
||||
"app/api/measurements/__tests__/range-aggregation-route.test.ts": 3,
|
||||
"app/api/measurements/__tests__/zod-multi-issue.test.ts": 4,
|
||||
"app/api/measurements/[id]/__tests__/route.test.ts": 2,
|
||||
"app/api/measurements/[id]/route.ts": 16,
|
||||
"app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts": 1,
|
||||
"app/api/measurements/batch/__tests__/duplicate-prefilter.test.ts": 2,
|
||||
"app/api/measurements/batch/__tests__/hr-bucket-observability.test.ts": 1,
|
||||
@@ -411,7 +411,6 @@
|
||||
"app/api/measurements/series-batch/route.ts": 1,
|
||||
"app/api/measurements/series/__tests__/route.test.ts": 5,
|
||||
"app/api/measurements/series/route.ts": 6,
|
||||
"app/api/medications/__tests__/route.test.ts": 10,
|
||||
"app/api/medications/[id]/__tests__/route.test.ts": 8,
|
||||
"app/api/medications/[id]/api-endpoint/route.ts": 2,
|
||||
"app/api/medications/[id]/cadence/route.ts": 10,
|
||||
@@ -423,10 +422,10 @@
|
||||
"app/api/medications/[id]/dose-history/route.ts": 14,
|
||||
"app/api/medications/[id]/glp1/__tests__/route.test.ts": 3,
|
||||
"app/api/medications/[id]/glp1/route.ts": 8,
|
||||
"app/api/medications/[id]/intake/__tests__/route.test.ts": 11,
|
||||
"app/api/medications/[id]/intake/__tests__/source-provenance.test.ts": 1,
|
||||
"app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts": 9,
|
||||
"app/api/medications/[id]/intake/[eventId]/route.ts": 4,
|
||||
"app/api/medications/[id]/intake/__tests__/route.test.ts": 11,
|
||||
"app/api/medications/[id]/intake/__tests__/source-provenance.test.ts": 1,
|
||||
"app/api/medications/[id]/intake/bulk-delete/route.ts": 7,
|
||||
"app/api/medications/[id]/intake/import/__tests__/route.test.ts": 2,
|
||||
"app/api/medications/[id]/intake/import/route.ts": 6,
|
||||
@@ -434,11 +433,12 @@
|
||||
"app/api/medications/[id]/inventory/[itemId]/route.ts": 8,
|
||||
"app/api/medications/[id]/inventory/route.ts": 4,
|
||||
"app/api/medications/[id]/phase-config/route.ts": 4,
|
||||
"app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts": 1,
|
||||
"app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts": 9,
|
||||
"app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts": 1,
|
||||
"app/api/medications/[id]/schedule-revisions/route.ts": 3,
|
||||
"app/api/medications/[id]/side-effects/[logId]/route.ts": 2,
|
||||
"app/api/medications/[id]/side-effects/route.ts": 5,
|
||||
"app/api/medications/__tests__/route.test.ts": 10,
|
||||
"app/api/medications/categories/[key]/route.ts": 1,
|
||||
"app/api/medications/categories/route.ts": 1,
|
||||
"app/api/medications/compliance/__tests__/route.test.ts": 2,
|
||||
@@ -455,9 +455,9 @@
|
||||
"app/api/medications/route.ts": 2,
|
||||
"app/api/mental-health/assessments/__tests__/route.test.ts": 2,
|
||||
"app/api/mental-health/assessments/route.ts": 6,
|
||||
"app/api/mood-entries/__tests__/route.test.ts": 7,
|
||||
"app/api/mood-entries/[id]/__tests__/route.test.ts": 4,
|
||||
"app/api/mood-entries/[id]/route.ts": 22,
|
||||
"app/api/mood-entries/__tests__/route.test.ts": 7,
|
||||
"app/api/mood-entries/bulk-delete/__tests__/route.test.ts": 1,
|
||||
"app/api/mood-entries/bulk-delete/route.ts": 4,
|
||||
"app/api/mood-entries/bulk/__tests__/route.test.ts": 5,
|
||||
@@ -547,12 +547,12 @@
|
||||
"app/api/withings/connect/__tests__/route.test.ts": 1,
|
||||
"app/api/withings/connect/route.ts": 3,
|
||||
"app/api/withings/status/route.ts": 1,
|
||||
"app/api/withings/webhook/__tests__/route.test.ts": 1,
|
||||
"app/api/withings/webhook/[token]/__tests__/route.test.ts": 1,
|
||||
"app/api/withings/webhook/__tests__/route.test.ts": 1,
|
||||
"app/api/withings/webhook/route.ts": 2,
|
||||
"app/api/workouts/[id]/route.ts": 2,
|
||||
"app/api/workouts/__tests__/batch-create.test.ts": 3,
|
||||
"app/api/workouts/__tests__/canonical-dedup.test.ts": 3,
|
||||
"app/api/workouts/[id]/route.ts": 2,
|
||||
"app/api/workouts/batch/route.ts": 3,
|
||||
"app/api/workouts/route.ts": 2,
|
||||
"app/auth/claim/page.tsx": 1,
|
||||
@@ -654,20 +654,20 @@
|
||||
"app/loading.tsx": 2,
|
||||
"app/mcp/__tests__/route.test.ts": 5,
|
||||
"app/measurements/page.tsx": 5,
|
||||
"app/medications/[id]/history/page.tsx": 1,
|
||||
"app/medications/[id]/page.tsx": 2,
|
||||
"app/medications/__tests__/category-filter.test.tsx": 2,
|
||||
"app/medications/__tests__/detail-page-history-surface.test.ts": 1,
|
||||
"app/medications/__tests__/list-view-toggle.test.tsx": 5,
|
||||
"app/medications/__tests__/page-prefetch.test.tsx": 1,
|
||||
"app/medications/__tests__/take-all-due-affordance.test.tsx": 1,
|
||||
"app/medications/[id]/history/page.tsx": 1,
|
||||
"app/medications/[id]/page.tsx": 2,
|
||||
"app/medications/new/page.tsx": 1,
|
||||
"app/medications/page-client.tsx": 50,
|
||||
"app/mental-wellbeing/page.tsx": 1,
|
||||
"app/mood/page-client.tsx": 2,
|
||||
"app/not-found.tsx": 2,
|
||||
"app/onboarding/__tests__/page.test.tsx": 1,
|
||||
"app/onboarding/[step]/page.tsx": 1,
|
||||
"app/onboarding/__tests__/page.test.tsx": 1,
|
||||
"app/onboarding/handover/page.tsx": 1,
|
||||
"app/onboarding/page.tsx": 2,
|
||||
"app/privacy/__tests__/page.test.tsx": 1,
|
||||
@@ -1381,7 +1381,7 @@
|
||||
"components/settings/integrations/connections-panel.tsx": 5,
|
||||
"components/settings/integrations/garmin-info-note.tsx": 1,
|
||||
"components/settings/integrations/notification-channels-panel.tsx": 1,
|
||||
"components/settings/integrations/setup-guide-link.tsx": 5,
|
||||
"components/settings/integrations/setup-guide-link.tsx": 2,
|
||||
"components/settings/integrations/shared.tsx": 3,
|
||||
"components/settings/labs-section.tsx": 1,
|
||||
"components/settings/layout-groups.ts": 2,
|
||||
@@ -1737,7 +1737,7 @@
|
||||
"lib/ai/coach/turn/reply-guards.ts": 18,
|
||||
"lib/ai/coach/turn/reuse-turn.ts": 1,
|
||||
"lib/ai/coach/turn/sse.ts": 10,
|
||||
"lib/ai/coach/turn/steps.ts": 3,
|
||||
"lib/ai/coach/turn/steps.ts": 1,
|
||||
"lib/ai/coach/turn/types.ts": 17,
|
||||
"lib/ai/coach/types.ts": 58,
|
||||
"lib/ai/coach/visits-snapshot.ts": 1,
|
||||
@@ -2703,7 +2703,7 @@
|
||||
"lib/queries/use-medication-compliance-summary.ts": 3,
|
||||
"lib/queries/use-medication-list-layout.ts": 3,
|
||||
"lib/queries/use-profile-claim.ts": 4,
|
||||
"lib/query-keys/admin.ts": 13,
|
||||
"lib/query-keys/admin.ts": 12,
|
||||
"lib/query-keys/ai-runs.ts": 1,
|
||||
"lib/query-keys/auth.ts": 9,
|
||||
"lib/query-keys/coach.ts": 13,
|
||||
|
||||
@@ -58,7 +58,9 @@ describe("the Postgres 16 -> 18 move in docker-compose.yml", () => {
|
||||
it("keeps the new data where the Postgres 18 image expects it", () => {
|
||||
// Mounted at .../data, as 16 had it, the 18 image refuses to start.
|
||||
expect(db.volumes).toEqual(["postgres-data:/var/lib/postgresql"]);
|
||||
// `backups` holds the app's database dumps, not database files.
|
||||
expect(Object.keys(compose.volumes).sort()).toEqual([
|
||||
"backups",
|
||||
"pgdata",
|
||||
"postgres-data",
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Structural guard: no lab value reaches a model while the labs module is off.
|
||||
*
|
||||
* ## The failure this freezes
|
||||
*
|
||||
* The Coach snapshot carried a labs block "intentionally not module-gated", a
|
||||
* decision from before Labs could be switched off. Once it could, the Coach
|
||||
* prompt, its `get_labs` tool, the daily briefing, the biomarker status note,
|
||||
* the reaction line to a new panel and the MCP lab-trend prompt all kept
|
||||
* reading lab values for the model after the owner had turned the module
|
||||
* off, while the snapshot's own prelude promised that "the model never sees
|
||||
* a disabled domain". Each read was a separate module with its own
|
||||
* `prisma.labResult` query, and nothing tied them to the switch.
|
||||
*
|
||||
* ## What it proves and what it does not
|
||||
*
|
||||
* Every module under the paths that build model input (`src/lib/ai`,
|
||||
* `src/lib/insights`, `src/lib/jobs`, `src/lib/mcp`) that reads the lab
|
||||
* table either asks the labs module itself (`isModuleEnabled(…, "labs")`,
|
||||
* the `correlation:LAB_DRAWS` surface, or the MCP `whenModuleOn` wrapper) or
|
||||
* is listed below with the reason its read cannot reach a prompt. A new
|
||||
* reader fails here until it does one or the other. The data routes are not
|
||||
* in scope: a module decides what a model is given, not what the owner can
|
||||
* read (`CLAUDE.md`, "An AI capability gates model work").
|
||||
*
|
||||
* It is grep-shaped: it sees a read, not where the rows go, and a reader in
|
||||
* another directory is outside it. The behaviour is pinned per path by the
|
||||
* module-off cases in the suites of the readers themselves.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { readSourceFolder, stripComments } from "./helpers/source-files";
|
||||
|
||||
const ROOTS = ["src/lib/ai", "src/lib/insights", "src/lib/jobs", "src/lib/mcp"];
|
||||
|
||||
/** A read of the lab table: the Prisma delegate or raw SQL naming it. */
|
||||
const LAB_READ =
|
||||
/\.labResult\s*\.\s*(?:findMany|findFirst|findUnique|count|aggregate|groupBy)\s*\(|\blab_results\b/;
|
||||
|
||||
/** What counts as asking the labs module. */
|
||||
const LABS_GATE =
|
||||
/isModuleEnabled\(\s*[\w.]+\s*,\s*"labs"\s*\)|"correlation:LAB_DRAWS"|whenModuleOn\(/;
|
||||
|
||||
/** Readers whose rows never reach a model, each with the reason. */
|
||||
const NOT_MODEL_INPUT: Record<string, string> = {
|
||||
"src/lib/jobs/lab-biomarker-backfill.ts":
|
||||
"links stored rows to their biomarker; no model is involved",
|
||||
"src/lib/jobs/soft-delete-retention.ts":
|
||||
"purges rows past the trash window; no model is involved",
|
||||
"src/lib/insights/correlation-channel-series.ts":
|
||||
"`fetchLabDraws`: both callers gate it, the Coach on correlation:LAB_DRAWS and the correlations data route on the same surface",
|
||||
"src/lib/mcp/rich-reads.ts":
|
||||
"`getLabHistory`: reached only through the MCP get_labs tool, which runs behind whenModuleOn",
|
||||
"src/lib/ai/coach/tools/availability.ts":
|
||||
"counts lab rows for the Coach's empty-read answer; the only lab subject comes from get_labs, which answers module_disabled first",
|
||||
};
|
||||
|
||||
function labReaders(): Array<{ path: string; gated: boolean }> {
|
||||
const out: Array<{ path: string; gated: boolean }> = [];
|
||||
for (const root of ROOTS) {
|
||||
for (const [rel, source] of readSourceFolder(root, {
|
||||
floor: 20,
|
||||
include: (r) =>
|
||||
!r.split("/").includes("__tests__") &&
|
||||
!/\.(?:test|spec)\.tsx?$/.test(r),
|
||||
})) {
|
||||
const code = stripComments(source);
|
||||
if (!LAB_READ.test(code)) continue;
|
||||
out.push({ path: `${root}/${rel}`, gated: LABS_GATE.test(code) });
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
|
||||
describe("labs reach a model only with the labs module on", () => {
|
||||
const readers = labReaders();
|
||||
|
||||
it("finds the readers it exists to check", () => {
|
||||
// An empty match set would pass every assertion below vacuously.
|
||||
expect(readers.length).toBeGreaterThanOrEqual(8);
|
||||
expect(readers.filter((r) => r.gated).length).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
|
||||
it("every reader asks the labs module or is listed as not model input", () => {
|
||||
const open = readers
|
||||
.filter((r) => !r.gated && !(r.path in NOT_MODEL_INPUT))
|
||||
.map((r) => r.path);
|
||||
expect(open).toEqual([]);
|
||||
});
|
||||
|
||||
it("lists no reader that has since gained the gate or gone", () => {
|
||||
const paths = new Map(readers.map((r) => [r.path, r.gated]));
|
||||
const stale = Object.keys(NOT_MODEL_INPUT).filter(
|
||||
(path) => paths.get(path) !== false,
|
||||
);
|
||||
expect(stale).toEqual([]);
|
||||
});
|
||||
|
||||
it("the matchers catch what they exist to catch", () => {
|
||||
expect(LAB_READ.test("await prisma.labResult.findMany({")).toBe(true);
|
||||
expect(LAB_READ.test("tx.labResult\n .count({")).toBe(true);
|
||||
expect(LAB_READ.test("SELECT 1 FROM lab_results")).toBe(true);
|
||||
expect(LAB_READ.test("prisma.labResultDocumentLink.findMany(")).toBe(false);
|
||||
expect(LABS_GATE.test('await isModuleEnabled(userId, "labs")')).toBe(true);
|
||||
expect(LABS_GATE.test('await isModuleEnabled(ctx.userId, "labs")')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(LABS_GATE.test('isModuleEnabled(userId, "medications")')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -242,18 +242,6 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
},
|
||||
"/api/admin/encryption/key-backup": {
|
||||
kind: "adminConsole",
|
||||
methods: ["GET"],
|
||||
},
|
||||
"/api/admin/encryption/key-backup/confirm": {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
},
|
||||
"/api/admin/encryption/key-backup/verify": {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
},
|
||||
"/api/admin/encryption/rotate": { kind: "adminConsole", methods: ["POST"] },
|
||||
"/api/admin/encryption/status": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/host-metrics": { kind: "adminConsole", methods: ["GET"] },
|
||||
@@ -276,6 +264,19 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
|
||||
"/api/admin/provider-health": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/notifications/health": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/notifications/test": { kind: "adminConsole", methods: ["POST"] },
|
||||
"/api/admin/recovery-kit": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/recovery-kit/confirm": {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
},
|
||||
"/api/admin/recovery-kit/verify": {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
},
|
||||
"/api/admin/recovery-readiness": {
|
||||
kind: "adminConsole",
|
||||
methods: ["GET"],
|
||||
},
|
||||
"/api/admin/rollups/recompute": { kind: "adminConsole", methods: ["POST"] },
|
||||
"/api/admin/settings": { kind: "adminConsole", methods: ["GET", "PUT"] },
|
||||
"/api/admin/settings/assistant-flags": {
|
||||
|
||||
@@ -102,9 +102,18 @@ describe("proxy matcher", () => {
|
||||
"/api/import/csv/preview",
|
||||
// The composition PUT beside the label upload stays behind the proxy.
|
||||
"/api/medications/abc/ingredients",
|
||||
// An API path ending in a static-file extension is still an API call:
|
||||
// the last segment is the caller's choice.
|
||||
"/api/mood/tags/custom/x.png",
|
||||
"/api/documents/inbound/abc.txt",
|
||||
"/api/measurements/x.svg",
|
||||
]) {
|
||||
expect(re.test(path), path).toBe(true);
|
||||
}
|
||||
// Static files outside the API still skip it.
|
||||
for (const path of ["/icons/icon-192.png", "/og.jpg", "/robots.txt"]) {
|
||||
expect(re.test(path), path).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -26,9 +26,10 @@
|
||||
* which is how the server finds it.
|
||||
* - No runtime module under `src/` imports pdf-parse, and none imports
|
||||
* pdfjs-dist (a type-only import included) except
|
||||
* `src/lib/documents/pdfjs.ts`, so the text read and the rasterizer open
|
||||
* documents with the same options. Comments are stripped first, so prose
|
||||
* that names a package does not count.
|
||||
* `src/lib/documents/pdf-engine.mjs`, so the text read and the rasterizer
|
||||
* open documents with the same options, under the same image limit, in
|
||||
* the bounded child process. Comments are stripped first, so prose that
|
||||
* names a package does not count.
|
||||
*
|
||||
* It does not prove the image carries one copy; the Dockerfile hoists the
|
||||
* pinned version by name and the tracing glob can only find what the lockfile
|
||||
@@ -49,7 +50,7 @@ import {
|
||||
|
||||
const ROOT = process.cwd();
|
||||
/** Relative to `src/`. */
|
||||
const LOADER = "lib/documents/pdfjs.ts";
|
||||
const LOADER = "lib/documents/pdf-engine.mjs";
|
||||
|
||||
const pkg = JSON.parse(readSourceText(join(ROOT, "package.json"))) as {
|
||||
dependencies?: Record<string, string>;
|
||||
|
||||
@@ -135,6 +135,13 @@ const PINNED_AFFORDANCES: Record<
|
||||
// off-host copy is, and the state it paints green came out of the database
|
||||
// rather than out of a request this card just made.
|
||||
"src/components/admin/offhost-backups-section.tsx": { "text-success": 1 },
|
||||
// Reads the server's verdict, writes nothing: the check and the green mark
|
||||
// say a backup signal is ok as the readiness model judged it, not that a
|
||||
// request this card made succeeded.
|
||||
"src/components/admin/recovery-readiness-card.tsx": {
|
||||
CheckCircle2: 2,
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/admin/recent-audit-preview.tsx": {
|
||||
CheckCircle2: 2,
|
||||
"text-success": 1,
|
||||
|
||||
@@ -28,6 +28,7 @@ import { ApiTokenOverviewSection } from "@/components/admin/api-token-overview-s
|
||||
import { AppLogPreviewSection } from "@/components/admin/app-log-preview-section";
|
||||
import { BackupsSection } from "@/components/admin/backups-section";
|
||||
import { OffhostBackupsSection } from "@/components/admin/offhost-backups-section";
|
||||
import { RecoveryReadinessCard } from "@/components/admin/recovery-readiness-card";
|
||||
import { DangerZoneSection } from "@/components/admin/danger-zone-section";
|
||||
import { EncryptionSection } from "@/components/admin/encryption-section";
|
||||
import { GeneralSettingsSection } from "@/components/admin/general-settings-section";
|
||||
@@ -166,10 +167,12 @@ export function AdminSectionRenderer({
|
||||
</SectionFrame>
|
||||
);
|
||||
case "backups":
|
||||
// Two cards, two independent failure modes: the copies held in this
|
||||
// database, then the copies that reached the operator's bucket.
|
||||
// The verdict across every backup first, then two cards, two
|
||||
// independent failure modes: the copies held in this database, then
|
||||
// the copies that reached the operator's bucket.
|
||||
return (
|
||||
<SectionFrame>
|
||||
<RecoveryReadinessCard />
|
||||
<BackupsSection />
|
||||
<OffhostBackupsSection />
|
||||
</SectionFrame>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { AdminShell } from "@/components/admin/admin-shell";
|
||||
import { RecoveryReadinessCard } from "@/components/admin/recovery-readiness-card";
|
||||
import { SystemStatusSummary } from "@/components/admin/system-status-summary";
|
||||
import { VersionTileSection } from "@/components/admin/version-tile-section";
|
||||
import { RecentAuditPreview } from "@/components/admin/recent-audit-preview";
|
||||
@@ -49,6 +50,9 @@ export default function AdminOverviewPage() {
|
||||
|
||||
<SystemStatusSummary />
|
||||
|
||||
{/* v1.46 — every backup signal, the recovery kit included. */}
|
||||
<RecoveryReadinessCard />
|
||||
|
||||
<RecentAuditPreview />
|
||||
</div>
|
||||
</AdminShell>
|
||||
|
||||
@@ -54,9 +54,29 @@ vi.mock("@/lib/jobs/offhost-backup", () => ({
|
||||
probeOffhostLifecycle: async () => ({
|
||||
state: "missing",
|
||||
expirationDays: null,
|
||||
database: { state: "missing", expirationDays: null },
|
||||
}),
|
||||
}));
|
||||
|
||||
const databaseHealth = {
|
||||
enabled: true,
|
||||
directory: "/var/lib/healthlog/backups",
|
||||
keepNightly: 7,
|
||||
localDirWritable: true,
|
||||
offhostConfigured: true,
|
||||
lastGood: {
|
||||
at: "2026-09-21T23:31:40.000Z",
|
||||
fileName: "healthlog-20260921T233000Z-daily.dump",
|
||||
sizeBytes: 52_428_800,
|
||||
local: true,
|
||||
offhost: true,
|
||||
},
|
||||
lastFailure: null,
|
||||
};
|
||||
vi.mock("@/lib/database-backup/health", () => ({
|
||||
getDatabaseBackupHealth: async () => databaseHealth,
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import type { BackupsList } from "@/types/backups";
|
||||
|
||||
@@ -176,7 +196,11 @@ describe("GET /api/admin/backups — off-host freshness", () => {
|
||||
periodHours: 24,
|
||||
rows: [],
|
||||
// No bucket to ask on a host that is not configured.
|
||||
lifecycle: { state: "unknown", expirationDays: null },
|
||||
lifecycle: {
|
||||
state: "unknown",
|
||||
expirationDays: null,
|
||||
database: { state: "unknown", expirationDays: null },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -239,6 +263,7 @@ describe("GET /api/admin/backups — off-host freshness", () => {
|
||||
expect(body.offhost.lifecycle).toEqual({
|
||||
state: "missing",
|
||||
expirationDays: null,
|
||||
database: { state: "missing", expirationDays: null },
|
||||
});
|
||||
expect(body.offhost.pendingDeletions).toEqual({
|
||||
count: 2,
|
||||
@@ -295,3 +320,10 @@ describe("GET /api/admin/backups — a run in progress and one a process died un
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/admin/backups — the database dump", () => {
|
||||
it("carries the dump's health beside the account copies", async () => {
|
||||
const body = await read();
|
||||
expect(body.database).toEqual(databaseHealth);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,10 @@
|
||||
* stopped six weeks ago — every row it lists has a perfectly ordinary
|
||||
* timestamp either way.
|
||||
*
|
||||
* The database row comes first in what an operator needs and last in this
|
||||
* file: the nightly whole-database dump (`getDatabaseBackupHealth`), the
|
||||
* one copy that rebuilds a lost server, read from its own ledger.
|
||||
*
|
||||
* And alongside both, the off-host leg: one row per account saying when this
|
||||
* host last put that account's encrypted copy in the operator's bucket and how
|
||||
* stale that is against the nightly schedule. It comes from the ledger the
|
||||
@@ -31,6 +35,7 @@ import {
|
||||
} from "@/lib/jobs/job-failures";
|
||||
import { readBackupPassActivity } from "@/lib/jobs/backup-pass-attempts";
|
||||
import { summariseBackupSchedule } from "@/lib/jobs/backup-schedule-status";
|
||||
import { getDatabaseBackupHealth } from "@/lib/database-backup/health";
|
||||
import {
|
||||
offhostBackupConfigured,
|
||||
probeOffhostLifecycle,
|
||||
@@ -39,6 +44,7 @@ import { OFFHOST_BACKUP_QUEUE } from "@/lib/jobs/offhost-backup-queue";
|
||||
import { offhostPurgeDue } from "@/lib/jobs/offhost-purge-queue";
|
||||
import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account";
|
||||
import { accountDeletionGraceDays } from "@/lib/account-deletion/grace";
|
||||
import { LIFECYCLE_UNKNOWN } from "@/lib/jobs/offhost-lifecycle";
|
||||
import {
|
||||
classifyOffhostBackup,
|
||||
OFFHOST_BACKUP_PERIOD_HOURS,
|
||||
@@ -200,16 +206,15 @@ export const GET = apiHandler(async () => {
|
||||
readBackupPassActivity(prisma, DATA_BACKUP_QUEUE, weeklyRunningSince),
|
||||
readBackupPassActivity(prisma, OFFHOST_BACKUP_QUEUE, offhostRunningSince),
|
||||
]);
|
||||
const [purgeCount, oldestPurge, lifecycle] = await Promise.all([
|
||||
const [purgeCount, oldestPurge, lifecycle, database] = 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 }),
|
||||
configured ? probeOffhostLifecycle() : Promise.resolve(LIFECYCLE_UNKNOWN),
|
||||
getDatabaseBackupHealth(now),
|
||||
]);
|
||||
|
||||
const payload: BackupsList = {
|
||||
@@ -241,6 +246,7 @@ export const GET = apiHandler(async () => {
|
||||
},
|
||||
lifecycle,
|
||||
},
|
||||
database,
|
||||
};
|
||||
|
||||
return apiSuccess(payload);
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* POST /api/admin/encryption/key-backup/confirm { keyId, fingerprint }
|
||||
*
|
||||
* The admin states that the active encryption key is backed up. The body
|
||||
* names the key the admin was shown; when that is no longer the active key
|
||||
* (rotated, or the server restarted with another key since the page loaded)
|
||||
* the answer is 409 `encryption.keyBackup.stale` and nothing is recorded.
|
||||
* No step-up: confirming is a low-risk write that reveals nothing.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { parseJsonBody } from "@/lib/api/body";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
confirmKeyBackup,
|
||||
KEY_BACKUP_STALE_CODE,
|
||||
} from "@/lib/crypto/key-backup";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const confirmSchema = z
|
||||
.object({
|
||||
keyId: z.string().min(1).max(32),
|
||||
fingerprint: z.string().regex(/^[0-9a-f]{12}$/),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "admin.encryption.keyBackup.confirm" } });
|
||||
|
||||
const parsed = await parseJsonBody(request, confirmSchema, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (!parsed.ok) return parsed.error;
|
||||
|
||||
const result = await confirmKeyBackup({
|
||||
keyId: parsed.data.keyId,
|
||||
fingerprint: parsed.data.fingerprint,
|
||||
userId: user.id,
|
||||
});
|
||||
if (!result.ok) {
|
||||
annotate({ meta: { key_backup_stale: true } });
|
||||
return apiError(
|
||||
"The encryption key changed since this page was loaded. Reload and confirm the backup of the current key.",
|
||||
409,
|
||||
{
|
||||
errorCode: KEY_BACKUP_STALE_CODE,
|
||||
activeKeyId: result.activeKeyId,
|
||||
fingerprint: result.fingerprint,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await auditLog("encryption.keyBackup.confirmed", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
keyId: result.status.activeKeyId,
|
||||
fingerprint: result.status.fingerprint,
|
||||
},
|
||||
});
|
||||
return apiSuccess(result.status);
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* GET /api/admin/encryption/key-backup
|
||||
*
|
||||
* Whether the "Back up your encryption key" step is due, for the admin step,
|
||||
* the overview tile and the dashboard banner. A light read (one settings row),
|
||||
* separate from `/status`, which walks the whole encrypted corpus. Key id and
|
||||
* fingerprint only, never key material. Cookie-only admin.
|
||||
*/
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { readKeyBackupStatus } from "@/lib/crypto/key-backup";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.encryption.keyBackup.status" } });
|
||||
const status = await readKeyBackupStatus();
|
||||
annotate({ meta: { key_backup_due: status.due } });
|
||||
return apiSuccess(status);
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* POST /api/admin/encryption/key-backup/verify { encryptionKey }
|
||||
*
|
||||
* "Check my copy": the admin pastes the backed-up key and learns whether it
|
||||
* is the active key. The value is compared in memory (constant time on the
|
||||
* decoded bytes) and dropped. It is never stored, never logged, never echoed:
|
||||
* the Zod issue list is not returned for this body (a validation failure is a
|
||||
* plain 422), and the field name is on the observability denylist.
|
||||
*
|
||||
* A boolean answer is no oracle against a 256-bit key; the 5 per minute limit
|
||||
* per admin is there to keep the route from being anyone's busy loop. Over
|
||||
* plain HTTP the value crosses the network in clear, as the admin's password
|
||||
* does; the client says so before sending.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { candidateMatchesKey, getActiveKeyId } from "@/lib/crypto";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const KEY_BACKUP_VERIFY_LIMIT = 5;
|
||||
const KEY_BACKUP_VERIFY_WINDOW_MS = 60 * 1000;
|
||||
|
||||
const verifySchema = z
|
||||
.object({ encryptionKey: z.string().min(1).max(256) })
|
||||
.strict();
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "admin.encryption.keyBackup.verify" } });
|
||||
|
||||
const limit = await checkRateLimit(
|
||||
`key-backup-verify:${user.id}`,
|
||||
KEY_BACKUP_VERIFY_LIMIT,
|
||||
KEY_BACKUP_VERIFY_WINDOW_MS,
|
||||
);
|
||||
if (!limit.allowed) {
|
||||
return apiError("Too many checks. Try again in a minute.", 429);
|
||||
}
|
||||
|
||||
const body = await safeJson(request, { maxBytes: 1024 });
|
||||
if (body.error) return body.error;
|
||||
const parsed = verifySchema.safeParse(body.data);
|
||||
if (!parsed.success) {
|
||||
// Deliberately not `returnAllZodIssues`: nothing derived from this body
|
||||
// goes back over the wire.
|
||||
return apiError("Paste the key as 64 hex characters.", 422);
|
||||
}
|
||||
|
||||
const activeKeyId = getActiveKeyId();
|
||||
const matches = candidateMatchesKey(parsed.data.encryptionKey, activeKeyId);
|
||||
annotate({ meta: { key_backup_verify_matches: matches } });
|
||||
await auditLog("encryption.keyBackup.verified", {
|
||||
userId: user.id,
|
||||
details: { keyId: activeKeyId, matches },
|
||||
});
|
||||
return apiSuccess({ matches, keyId: activeKeyId });
|
||||
});
|
||||
@@ -17,13 +17,9 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { readKeyBackupStatus } from "@/lib/crypto/key-backup";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getConfiguredKeyIds } from "@/lib/crypto";
|
||||
import {
|
||||
offhostBackupConfigured,
|
||||
probeOffhostLifecycle,
|
||||
} from "@/lib/jobs/offhost-backup";
|
||||
import { readBackupKeyNeeds } from "@/lib/recovery/backup-key-needs";
|
||||
import {
|
||||
scanCorpus,
|
||||
type CorpusClient,
|
||||
@@ -105,100 +101,6 @@ 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" } });
|
||||
@@ -214,7 +116,6 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
const rotation = await readRotationState();
|
||||
const backups = await readBackupKeyNeeds(scan.activeKeyId, new Date());
|
||||
const keyBackup = await readKeyBackupStatus();
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
@@ -235,8 +136,6 @@ export const GET = apiHandler(async () => {
|
||||
columns: scan.columns,
|
||||
rotation,
|
||||
backups,
|
||||
// The "Back up your encryption key" step, bound to the active key.
|
||||
keyBackup,
|
||||
// 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
|
||||
|
||||
@@ -3,7 +3,12 @@ import type { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
isJsonContentType,
|
||||
} from "@/lib/api-response";
|
||||
import { adminReminderCheckSchema } from "@/lib/validations/notifications";
|
||||
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
|
||||
@@ -76,9 +81,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// body and no content-type), so the parse only runs when a JSON body is
|
||||
// actually presented. An empty object is the same instance-wide sweep.
|
||||
let input: unknown = {};
|
||||
if (
|
||||
(request.headers.get("content-type") ?? "").includes("application/json")
|
||||
) {
|
||||
if (isJsonContentType(request.headers.get("content-type"))) {
|
||||
const { data, error } = await safeJson(request, { maxBytes: 4 * 1024 });
|
||||
if (error) return error;
|
||||
input = data ?? {};
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* POST /api/admin/recovery-kit/confirm { entries: [{ kind, id, fingerprint }] }
|
||||
*
|
||||
* The admin states that a copy of every key in the kit is kept outside the
|
||||
* server. The body is the kit as the admin was shown it; when that is no
|
||||
* longer the kit (a rotation, a new backup key, a restart with other keys
|
||||
* since the page loaded) the answer is 409 `recovery.kit.stale` with the
|
||||
* current kit, and nothing is recorded. No step-up: confirming is a low-risk
|
||||
* write that reveals nothing.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { parseJsonBody } from "@/lib/api/body";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { confirmRecoveryKit } from "@/lib/recovery/kit";
|
||||
import {
|
||||
kitConfirmationSchema,
|
||||
RECOVERY_KIT_STALE_CODE,
|
||||
} from "@/lib/recovery/kit-rules";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "admin.recoveryKit.confirm" } });
|
||||
|
||||
const parsed = await parseJsonBody(request, kitConfirmationSchema, {
|
||||
maxBytes: 8 * 1024,
|
||||
});
|
||||
if (!parsed.ok) return parsed.error;
|
||||
|
||||
const result = await confirmRecoveryKit({
|
||||
shown: parsed.data.entries,
|
||||
userId: user.id,
|
||||
});
|
||||
if (!result.ok) {
|
||||
annotate({ meta: { recovery_kit_stale: true } });
|
||||
return apiError(
|
||||
"The recovery kit changed since this page was loaded. Reload, check the keys it lists now, and confirm again.",
|
||||
409,
|
||||
{ errorCode: RECOVERY_KIT_STALE_CODE },
|
||||
);
|
||||
}
|
||||
|
||||
await auditLog("recovery.kit.confirmed", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
keys: result.kit.keys.map(({ kind, id, fingerprint }) => ({
|
||||
kind,
|
||||
id,
|
||||
fingerprint,
|
||||
})),
|
||||
},
|
||||
});
|
||||
return apiSuccess(result.kit);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* GET /api/admin/recovery-kit
|
||||
*
|
||||
* The recovery kit: every key a restore of this instance needs (the
|
||||
* application keys, including retired ones a backup still lists, the off-host
|
||||
* backup keys and the token key), each with its fingerprint and why it is
|
||||
* needed, and whether the admin's confirmation covers it. Ids and
|
||||
* fingerprints only, never key material. Cookie-only admin.
|
||||
*/
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { readRecoveryKit } from "@/lib/recovery/kit";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.recoveryKit.status" } });
|
||||
const kit = await readRecoveryKit();
|
||||
annotate({
|
||||
meta: {
|
||||
recovery_kit_due: kit.due,
|
||||
recovery_kit_keys: kit.keys.length,
|
||||
recovery_kit_unconfirmed: kit.unconfirmed.length,
|
||||
},
|
||||
});
|
||||
return apiSuccess(kit);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* POST /api/admin/recovery-kit/verify { kind, id, keyCopy }
|
||||
*
|
||||
* "Check my copy": the admin pastes the copy of one key the kit lists and
|
||||
* learns whether it is that key. The value is compared in memory (constant
|
||||
* time on the compared bytes) and dropped. It is never stored, never logged,
|
||||
* never echoed: the Zod issue list is not returned for this body (a
|
||||
* validation failure is a plain 422), and the field name is on the
|
||||
* observability denylist.
|
||||
*
|
||||
* A boolean answer is no oracle against a 256-bit key; the 5 per minute limit
|
||||
* per admin is there to keep the route from being anyone's busy loop. Over
|
||||
* plain HTTP the value crosses the network in clear, as the admin's password
|
||||
* does; the client says so before sending.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { verifyKitKey } from "@/lib/recovery/kit";
|
||||
import { KIT_KEY_KINDS } from "@/lib/recovery/kit-rules";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const KIT_VERIFY_LIMIT = 5;
|
||||
const KIT_VERIFY_WINDOW_MS = 60 * 1000;
|
||||
|
||||
const verifySchema = z
|
||||
.object({
|
||||
kind: z.enum(KIT_KEY_KINDS),
|
||||
id: z.string().min(1).max(64),
|
||||
keyCopy: z.string().min(1).max(512),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "admin.recoveryKit.verify" } });
|
||||
|
||||
const limit = await checkRateLimit(
|
||||
`recovery-kit-verify:${user.id}`,
|
||||
KIT_VERIFY_LIMIT,
|
||||
KIT_VERIFY_WINDOW_MS,
|
||||
);
|
||||
if (!limit.allowed) {
|
||||
return apiError("Too many checks. Try again in a minute.", 429);
|
||||
}
|
||||
|
||||
const body = await safeJson(request, { maxBytes: 2048 });
|
||||
if (body.error) return body.error;
|
||||
const parsed = verifySchema.safeParse(body.data);
|
||||
if (!parsed.success) {
|
||||
// Deliberately not `returnAllZodIssues`: nothing derived from this body
|
||||
// goes back over the wire.
|
||||
return apiError("Paste the key exactly as it is set.", 422);
|
||||
}
|
||||
|
||||
const { kind, id } = parsed.data;
|
||||
const matches = verifyKitKey({ kind, id, value: parsed.data.keyCopy });
|
||||
annotate({ meta: { recovery_kit_verify_matches: matches } });
|
||||
await auditLog("recovery.kit.verified", {
|
||||
userId: user.id,
|
||||
details: { kind, keyId: id, matches },
|
||||
});
|
||||
return apiSuccess({ matches, kind, id });
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* GET /api/admin/recovery-readiness
|
||||
*
|
||||
* Every backup signal with its verdict, its time and where to fix it: the
|
||||
* database dump, the weekly copy, the off-host copies, the last restore drill
|
||||
* (no time window: a failed drill stays failed until a later one passes), the
|
||||
* off-host setup and the recovery kit. The same judgement the hourly
|
||||
* evaluation pushes to the admins (`src/lib/recovery/readiness-model.ts`).
|
||||
* Cookie-only admin.
|
||||
*/
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { readRecoveryReadiness } from "@/lib/recovery/readiness";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.recoveryReadiness.status" } });
|
||||
const readiness = await readRecoveryReadiness();
|
||||
annotate({ meta: { recovery_readiness_state: readiness.state } });
|
||||
return apiSuccess(readiness);
|
||||
});
|
||||
@@ -113,7 +113,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
// The per-account half of the throttle (`@/lib/auth/login-throttle`): a
|
||||
// place this account has signed in from is neither counted nor held back,
|
||||
// remembered browser or a phone this account signed in on is neither
|
||||
// counted nor held back,
|
||||
// anywhere else waits longer after each failure past the first few, up to
|
||||
// fifteen minutes. Decided before the Argon2id verification, so a waiting
|
||||
// attempt costs no hashing.
|
||||
@@ -121,7 +122,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
user,
|
||||
identifier,
|
||||
request,
|
||||
ip: rl.ip,
|
||||
});
|
||||
annotate({ meta: { login_known_source: attempt.source ?? "none" } });
|
||||
if (attempt.waiting) {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { createAuthenticationOptions } from "@/lib/auth/passkey";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
safeJson,
|
||||
isJsonContentType,
|
||||
} from "@/lib/api-response";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
@@ -35,7 +40,7 @@ export const POST = apiHandler(async (request: Request) => {
|
||||
// The body is optional (the web client sends its page transport; nothing
|
||||
// else needs to send anything). A missing or unreadable body is no body, as
|
||||
// before; only a stated plain-http page refuses, before a challenge exists.
|
||||
if (request.headers.get("content-type")?.includes("application/json")) {
|
||||
if (isJsonContentType(request.headers.get("content-type"))) {
|
||||
const { data: body } = await safeJson(request, { maxBytes: 4 * 1024 });
|
||||
const transportRefusal = refuseWhenCookieCannotStick(body);
|
||||
if (transportRefusal) return transportRefusal;
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
isJsonContentType,
|
||||
} from "@/lib/api-response";
|
||||
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -32,7 +33,7 @@ const proofRequired = () =>
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user, session } = await requireCookieAuth();
|
||||
|
||||
if (!request.headers.get("content-type")?.includes("application/json")) {
|
||||
if (!isJsonContentType(request.headers.get("content-type"))) {
|
||||
return proofRequired();
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* grants.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { apiError, apiSuccess, isJsonContentType } from "@/lib/api-response";
|
||||
import { parseJsonBody } from "@/lib/api/body";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -47,7 +47,7 @@ export const POST = apiHandler(async (req: Request) => {
|
||||
// empty object, the retired `{ intent }` shape) is not the consent act.
|
||||
let confirmed = false;
|
||||
const contentType = req.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
if (isJsonContentType(contentType)) {
|
||||
const parsed = await parseJsonBody(req, webConsentGrantBody, {
|
||||
maxBytes: 1024,
|
||||
action: "consent.ai.web-grant.invalid_shape",
|
||||
|
||||
@@ -7,7 +7,12 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
isJsonContentType,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { markDisconnected } from "@/lib/integrations/status";
|
||||
@@ -23,7 +28,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
// The body is optional: no body is `{ deleteData: false }`.
|
||||
let body: unknown = {};
|
||||
if (request.headers.get("content-type")?.includes("application/json")) {
|
||||
if (isJsonContentType(request.headers.get("content-type"))) {
|
||||
const read = await safeJson(request, { maxBytes: 1024 });
|
||||
if (read.error) return read.error;
|
||||
body = read.data;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
isJsonContentType,
|
||||
} from "@/lib/api-response";
|
||||
import { loadDawarichConnection } from "@/lib/dawarich/connection";
|
||||
import { yesterdayKey } from "@/lib/dawarich/sync";
|
||||
@@ -34,7 +35,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
// The body is optional: no body is the nightly window.
|
||||
let body: unknown = {};
|
||||
if (request.headers.get("content-type")?.includes("application/json")) {
|
||||
if (isJsonContentType(request.headers.get("content-type"))) {
|
||||
const read = await safeJson(request, { maxBytes: 1024 });
|
||||
if (read.error) return read.error;
|
||||
body = read.data;
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
getClientIp,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
isJsonContentType,
|
||||
} from "@/lib/api-response";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
@@ -152,7 +153,7 @@ export const POST = apiHandler(
|
||||
|
||||
const background = prefersRespondAsync(request);
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
if (isJsonContentType(contentType)) {
|
||||
// One body read for both JSON modes, BEFORE any bucket charge — a
|
||||
// malformed body never needs a refund because nothing was charged yet.
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
getClientIp,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
isJsonContentType,
|
||||
} from "@/lib/api-response";
|
||||
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import {
|
||||
@@ -92,7 +93,7 @@ export const POST = apiHandler(
|
||||
|
||||
const background = prefersRespondAsync(request);
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
if (isJsonContentType(contentType)) {
|
||||
return handleTextIndex(request, user.id, document, background);
|
||||
}
|
||||
return handleVisionIndex(request, user.id, document, background);
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
getClientIp,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
isJsonContentType,
|
||||
} from "@/lib/api-response";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
@@ -94,7 +95,7 @@ export const POST = apiHandler(
|
||||
|
||||
const background = prefersRespondAsync(request);
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
if (isJsonContentType(contentType)) {
|
||||
return handleTextSuggest(request, user.id, document, background);
|
||||
}
|
||||
return handleVisionSuggest(request, user.id, document, background);
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
getClientIp,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
isJsonContentType,
|
||||
} from "@/lib/api-response";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
@@ -165,7 +166,7 @@ export const POST = apiHandler(
|
||||
background: prefersRespondAsync(request),
|
||||
};
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
if (isJsonContentType(contentType)) {
|
||||
return handleTextSummary(request, ask);
|
||||
}
|
||||
return handleVisionSummary(request, ask);
|
||||
|
||||
@@ -348,6 +348,7 @@ describe("POST /api/ingest/medication — slot attribution + convergence (v1.16.
|
||||
// The replay probe finds the original event.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
|
||||
id: "evt-original",
|
||||
medicationId: "med-1",
|
||||
} as never);
|
||||
const res = await POST(
|
||||
postReq({
|
||||
@@ -359,6 +360,53 @@ describe("POST /api/ingest/medication — slot attribution + convergence (v1.16.
|
||||
expect(res.status).toBe(200);
|
||||
expect(consumeForIntake).not.toHaveBeenCalled();
|
||||
});
|
||||
/**
|
||||
* A token handed to an automation for one medication used to tell a name
|
||||
* it may not touch (403) from one that does not exist (404), and to replay
|
||||
* any of the owner's doses by key before checking its scope.
|
||||
*/
|
||||
function narrowToken(scopes: string[]) {
|
||||
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
||||
id: "tok-2",
|
||||
userId: "user-1",
|
||||
permissions: ["medication:ingest", ...scopes],
|
||||
revoked: false,
|
||||
expiresAt: null,
|
||||
lastUsedAt: null,
|
||||
} as Awaited<ReturnType<typeof prisma.apiToken.findUnique>>);
|
||||
}
|
||||
|
||||
it("gives a one-medication token the same 403 for an unknown and an out-of-scope name", async () => {
|
||||
wireHappyPath();
|
||||
narrowToken(["medication:med-other:ingest"]);
|
||||
const known = await POST(
|
||||
postReq({ medicationName: "Metformin", idempotencyKey: "k-known-1" }),
|
||||
);
|
||||
vi.mocked(prisma.medication.findFirst).mockResolvedValue(null);
|
||||
const unknown = await POST(
|
||||
postReq({ medicationName: "Nonexistent", idempotencyKey: "k-unknown-1" }),
|
||||
);
|
||||
expect(known.status).toBe(403);
|
||||
expect(unknown.status).toBe(403);
|
||||
expect((await unknown.json()).error).toBe((await known.json()).error);
|
||||
// No replay was looked up for a medication outside the scope.
|
||||
expect(prisma.medicationIntakeEvent.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never replays another medication's dose to a scoped token", async () => {
|
||||
wireHappyPath();
|
||||
narrowToken(["medication:med-1:ingest"]);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
|
||||
id: "evt-other",
|
||||
medicationId: "med-other",
|
||||
takenAt: new Date(),
|
||||
} as Awaited<ReturnType<typeof prisma.medicationIntakeEvent.findFirst>>);
|
||||
const res = await POST(
|
||||
postReq({ medicationName: "Metformin", idempotencyKey: "k-spent-1" }),
|
||||
);
|
||||
expect(res.status).toBe(409);
|
||||
expect(JSON.stringify(await res.json())).not.toContain("evt-other");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/ingest/medication — idempotency-key stability floor", () => {
|
||||
|
||||
@@ -186,17 +186,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const { medicationName, takenAt, idempotencyKey } = parsed.data;
|
||||
|
||||
// Idempotency check (scoped to token owner to prevent cross-user lookups)
|
||||
const existing = await prisma.medicationIntakeEvent.findFirst({
|
||||
where: {
|
||||
idempotencyKey,
|
||||
userId: apiToken.userId,
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
return apiSuccess(existing);
|
||||
}
|
||||
|
||||
// Find medication by name for this user
|
||||
const medication = await prisma.medication.findFirst({
|
||||
where: {
|
||||
@@ -206,16 +195,27 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
});
|
||||
|
||||
if (!medication) {
|
||||
return apiError("Medication not found", 404);
|
||||
// A scoped token gets one answer for unknown and out-of-scope names; only a
|
||||
// full-access token, which can list them anyway, learns a name is unknown.
|
||||
const fullAccess = apiToken.permissions.includes("*");
|
||||
if (
|
||||
!medication ||
|
||||
(!fullAccess &&
|
||||
!apiToken.permissions.includes(`medication:${medication.id}:ingest`))
|
||||
) {
|
||||
return !medication && fullAccess
|
||||
? apiError("Medication not found", 404)
|
||||
: apiError("API endpoint for this medication is disabled", 403);
|
||||
}
|
||||
|
||||
const medicationScope = `medication:${medication.id}:ingest`;
|
||||
if (
|
||||
!apiToken.permissions.includes("*") &&
|
||||
!apiToken.permissions.includes(medicationScope)
|
||||
) {
|
||||
return apiError("API endpoint for this medication is disabled", 403);
|
||||
// Replay after the scope check, for this medication only (key is per user).
|
||||
const existing = await prisma.medicationIntakeEvent.findFirst({
|
||||
where: { idempotencyKey, userId: apiToken.userId },
|
||||
});
|
||||
if (existing) {
|
||||
return existing.medicationId === medication.id
|
||||
? apiSuccess(existing)
|
||||
: apiError("Idempotency key already used for another medication", 409);
|
||||
}
|
||||
|
||||
// A record-only medication takes no new dose — the same refusal every
|
||||
|
||||
@@ -502,8 +502,8 @@ export const GET = apiHandler(async () => {
|
||||
} satisfies IntegrationViewModel & NightscoutExtras,
|
||||
{
|
||||
...publish(dawarichStatus),
|
||||
// v1.43 — Dawarich, read by the card through `health`. The ledger core
|
||||
// rides along like on every entry, and goes with the rest in v1.44.
|
||||
// Dawarich, read by the card through `health`. The ledger core rides
|
||||
// along like on every entry.
|
||||
connected: dawarichConfigured,
|
||||
configured: dawarichConfigured,
|
||||
health: health["dawarich"],
|
||||
@@ -524,8 +524,9 @@ interface IntegrationViewModel {
|
||||
* health-shaped field on this entry (`state`, `lastError`,
|
||||
* `consecutiveFailuresByKind`, `syncHealth`, `tokenExpired`, `needsReauth`,
|
||||
* `hasActivityScope`, `planUnavailable`, `scopeReconnect`) is folded into
|
||||
* it and goes once the settings cards render `health`; no other client
|
||||
* reads this envelope.
|
||||
* it. Those stay on the wire, deprecated in the published document; the
|
||||
* cards render `health`, and only their copied diagnostics still read
|
||||
* `consecutiveFailuresByKind`. No native client reads this envelope.
|
||||
*/
|
||||
health: ConnectionHealth | null;
|
||||
state: string;
|
||||
|
||||
@@ -436,3 +436,71 @@ describe("POST /api/internal/deploy-webhook (timestamp replay protection)", () =
|
||||
expect(dispatchLocalisedNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The secret header cannot stop a replay: a captured request carries the
|
||||
* secret, and the timestamp beside it was covered by nothing. A signed
|
||||
* request carries no secret and binds its timestamp to its body.
|
||||
*/
|
||||
describe("POST /api/internal/deploy-webhook (signed requests)", () => {
|
||||
const ORIGINAL_REQUIRE = process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP;
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_REQUIRE === undefined) {
|
||||
delete process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP;
|
||||
} else {
|
||||
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP = ORIGINAL_REQUIRE;
|
||||
}
|
||||
});
|
||||
|
||||
async function signed(
|
||||
body: string,
|
||||
opts: { ts?: string; sign?: string; secret?: string } = {},
|
||||
): Promise<NextRequest> {
|
||||
const { createHmac } = await import("node:crypto");
|
||||
const ts = opts.ts ?? String(Math.floor(Date.now() / 1000));
|
||||
const mac = createHmac("sha256", opts.secret ?? "test-secret")
|
||||
.update(`${ts}.${opts.sign ?? body}`)
|
||||
.digest("hex");
|
||||
return new NextRequest("https://app.example/api/internal/deploy-webhook", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-deploy-webhook-timestamp": ts,
|
||||
"x-deploy-webhook-signature": `sha256=${mac}`,
|
||||
},
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
it("accepts a signed request without the secret header", async () => {
|
||||
const res = await POST(await signed('{"status":"success"}'));
|
||||
expect(res.status).toBe(200);
|
||||
expect(auditLog).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("refuses a signature over another body, an old timestamp, or another key", async () => {
|
||||
for (const req of [
|
||||
await signed('{"status":"failed"}', { sign: '{"status":"success"}' }),
|
||||
await signed('{"status":"success"}', {
|
||||
ts: String(Math.floor(Date.now() / 1000) - 600),
|
||||
}),
|
||||
await signed('{"status":"success"}', { secret: "other-secret" }),
|
||||
]) {
|
||||
expect((await POST(req)).status).toBe(401);
|
||||
}
|
||||
expect(auditLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("in strict mode accepts only a signed request, not the bare secret with a fresh timestamp", async () => {
|
||||
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP = "true";
|
||||
const unsigned = jsonRequest(
|
||||
{ status: "success" },
|
||||
{
|
||||
"x-deploy-webhook-secret": "test-secret",
|
||||
"x-deploy-webhook-timestamp": String(Math.floor(Date.now() / 1000)),
|
||||
},
|
||||
);
|
||||
expect((await POST(unsigned)).status).toBe(401);
|
||||
expect((await POST(await signed('{"status":"success"}'))).status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,16 @@
|
||||
import { envOr } from "@/lib/env";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { getClientIp, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
getClientIp,
|
||||
isJsonContentType,
|
||||
readBodyText,
|
||||
} from "@/lib/api-response";
|
||||
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
@@ -87,23 +92,27 @@ function normalizePayload(payload: CoolifyDeployPayload): NormalizedEvent {
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay protection (v1.16.4). A captured webhook request carries a
|
||||
* static shared secret, so without a freshness check it can be replayed
|
||||
* indefinitely — re-paging admins with stale failure alerts and
|
||||
* polluting the audit log with forged deploy outcomes.
|
||||
* Freshness and signature.
|
||||
*
|
||||
* The sender attaches `X-Deploy-Webhook-Timestamp` (unix seconds or
|
||||
* ISO-8601). Requests whose timestamp falls outside a ±5-minute window
|
||||
* are rejected. Two modes:
|
||||
* The secret header alone cannot stop a replay: whoever holds a captured
|
||||
* request holds the secret it carries, and can send a fresh request of any
|
||||
* content. A timestamp beside it changed nothing, because the timestamp was
|
||||
* covered by nothing and could be rewritten with the rest. Earlier versions
|
||||
* called it replay protection; it was a staleness hint.
|
||||
*
|
||||
* - Default (tolerant): a request WITHOUT the header passes — Coolify's
|
||||
* stock notification sender does not attach one, and breaking the
|
||||
* live integration would cost more than the residual replay window.
|
||||
* A header that IS present but stale/invalid is always rejected, so
|
||||
* a replayed capture of a timestamped request never lands.
|
||||
* - `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true`: the header becomes
|
||||
* mandatory. For operators whose sender (or reverse proxy) can
|
||||
* attach the timestamp; closes the replay window completely.
|
||||
* A sender that can compute a signature authenticates without sending the
|
||||
* secret at all: `X-Deploy-Webhook-Signature: sha256=<hex>` is the
|
||||
* HMAC-SHA256, under `DEPLOY_WEBHOOK_SECRET`, of the timestamp header's
|
||||
* value, a dot, and the raw body, and the timestamp must lie within five
|
||||
* minutes. A capture of such a request is useless after the window and
|
||||
* reveals no secret.
|
||||
*
|
||||
* - Default: a signed request is verified as above; an unsigned one
|
||||
* authenticates with the secret header, as Coolify's stock sender does,
|
||||
* and a timestamp it carries must still be fresh.
|
||||
* - `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true`: only a signed request is
|
||||
* accepted. The name predates the signature; requiring a timestamp
|
||||
* without one would not make a replay any harder.
|
||||
*
|
||||
* See docs/ops/deploy.md ("Deploy-status webhook") for the operator
|
||||
* guidance.
|
||||
@@ -123,13 +132,7 @@ function checkTimestampFreshness(
|
||||
now: number = Date.now(),
|
||||
): { ok: boolean; reason?: string } {
|
||||
const raw = request.headers.get("x-deploy-webhook-timestamp");
|
||||
const required =
|
||||
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP === "true" ||
|
||||
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP === "1";
|
||||
if (raw === null || raw === "") {
|
||||
if (required) return { ok: false, reason: "timestamp_missing" };
|
||||
return { ok: true };
|
||||
}
|
||||
if (raw === null || raw === "") return { ok: true };
|
||||
const ts = parseTimestamp(raw);
|
||||
if (ts === null) return { ok: false, reason: "timestamp_invalid" };
|
||||
if (Math.abs(now - ts) > TIMESTAMP_WINDOW_MS) {
|
||||
@@ -138,6 +141,44 @@ function checkTimestampFreshness(
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function strictMode(): boolean {
|
||||
return (
|
||||
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP === "true" ||
|
||||
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP === "1"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A signed request: the HMAC of `timestamp.body` under the shared secret,
|
||||
* with a fresh timestamp. Null when the request carries no signature.
|
||||
*/
|
||||
function verifySignature(
|
||||
request: NextRequest,
|
||||
rawBody: string,
|
||||
now: number = Date.now(),
|
||||
): { ok: boolean; reason?: string } | null {
|
||||
const header = request.headers.get("x-deploy-webhook-signature");
|
||||
if (header === null) return null;
|
||||
const expected = process.env.DEPLOY_WEBHOOK_SECRET;
|
||||
if (!expected) return { ok: false, reason: "secret_unset" };
|
||||
const timestamp = request.headers.get("x-deploy-webhook-timestamp");
|
||||
if (!timestamp) return { ok: false, reason: "timestamp_missing" };
|
||||
const ts = parseTimestamp(timestamp);
|
||||
if (ts === null) return { ok: false, reason: "timestamp_invalid" };
|
||||
if (Math.abs(now - ts) > TIMESTAMP_WINDOW_MS) {
|
||||
return { ok: false, reason: "timestamp_outside_window" };
|
||||
}
|
||||
const mac = createHmac("sha256", expected)
|
||||
.update(`${timestamp}.${rawBody}`, "utf8")
|
||||
.digest("hex");
|
||||
const a = Buffer.from(`sha256=${mac}`, "utf8");
|
||||
const b = Buffer.from(header.trim(), "utf8");
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
return { ok: false, reason: "signature_mismatch" };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function hasValidSecret(request: NextRequest): boolean {
|
||||
const expected = process.env.DEPLOY_WEBHOOK_SECRET;
|
||||
if (!expected) {
|
||||
@@ -238,27 +279,45 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const ip = getClientIp(request);
|
||||
|
||||
if (!hasValidSecret(request)) {
|
||||
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
|
||||
if (!isJsonContentType(request.headers.get("content-type"))) {
|
||||
return apiError("Content-Type must be application/json", 415);
|
||||
}
|
||||
// The raw body, because a signature covers the exact bytes sent.
|
||||
const read = await readBodyText(request, 64 * 1024).catch(() => null);
|
||||
if (!read) return apiError("Invalid request body", 400);
|
||||
if (read.tooLarge) return apiError("Request body exceeds 65536 bytes", 413);
|
||||
|
||||
const signed = verifySignature(request, read.text);
|
||||
if (signed) {
|
||||
if (!signed.ok) {
|
||||
annotate({ meta: { deploy_webhook_auth_reason: signed.reason } });
|
||||
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
getEvent()?.setAuth({ auth_method: "webhook_secret" });
|
||||
annotate({ meta: { deploy_webhook_auth: "signature" } });
|
||||
} else {
|
||||
if (strictMode() || !hasValidSecret(request)) {
|
||||
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
// Freshness AFTER the secret check: an unauthenticated caller learns
|
||||
// nothing about the timestamp policy.
|
||||
const freshness = checkTimestampFreshness(request);
|
||||
if (!freshness.ok) {
|
||||
annotate({
|
||||
meta: { deploy_webhook_replay_reason: freshness.reason ?? "unknown" },
|
||||
});
|
||||
return NextResponse.json({ status: "stale" }, { status: 401 });
|
||||
}
|
||||
getEvent()?.setAuth({ auth_method: "webhook_secret" });
|
||||
}
|
||||
|
||||
// Freshness AFTER the secret check: an unauthenticated caller learns
|
||||
// nothing about the timestamp policy, and a replayed-but-stale request
|
||||
// from a captured legitimate call is rejected here.
|
||||
const freshness = checkTimestampFreshness(request);
|
||||
if (!freshness.ok) {
|
||||
annotate({
|
||||
meta: { deploy_webhook_replay_reason: freshness.reason ?? "unknown" },
|
||||
});
|
||||
return NextResponse.json({ status: "stale" }, { status: 401 });
|
||||
let payload: CoolifyDeployPayload;
|
||||
try {
|
||||
payload = JSON.parse(read.text) as CoolifyDeployPayload;
|
||||
} catch {
|
||||
return apiError("Invalid JSON body", 400);
|
||||
}
|
||||
|
||||
getEvent()?.setAuth({ auth_method: "webhook_secret" });
|
||||
|
||||
const { data: payload, error: jsonError } =
|
||||
await safeJson<CoolifyDeployPayload>(request, { maxBytes: 64 * 1024 });
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const event = normalizePayload(payload);
|
||||
|
||||
annotate({
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
apiValidationError,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
isJsonContentType,
|
||||
} from "@/lib/api-response";
|
||||
import { requireAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import {
|
||||
@@ -91,7 +92,7 @@ export const POST = apiHandler(async (request: Request) => {
|
||||
// Dispatch on the body shape: a JSON body is the local-OCR text mode; a
|
||||
// multipart body is the native-vision image/PDF mode.
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
if (isJsonContentType(contentType)) {
|
||||
return handleTextExtract(request, user.id);
|
||||
}
|
||||
return handleVisionExtract(request, user.id);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Authorize endpoint — consent gate + audience + PKCE + redirect-URI binding.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
process.env.API_TOKEN_HMAC_KEY = "x".repeat(48);
|
||||
@@ -496,3 +497,58 @@ describe("authorize CSP — the consent form may lead only to the validated redi
|
||||
expect(csp(unavailable)).toContain("default-src 'none'");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Dynamic registration lets anyone register "Claude" with a callback on a
|
||||
* host of their own and send a signed-in person the authorize link. The
|
||||
* consent page used to show the borrowed name and "Unverified application",
|
||||
* never where the code would go.
|
||||
*/
|
||||
describe("GET /authorize — where the access goes (M3)", () => {
|
||||
it("names the redirect host for a self-registered client, on the page and on Allow", async () => {
|
||||
const phish = registerDcrClient({
|
||||
clientName: "Claude",
|
||||
redirectUris: ["https://collector.example.net/cb"],
|
||||
});
|
||||
signedIn();
|
||||
const body = await (
|
||||
await GET(
|
||||
new NextRequest(
|
||||
authorizeUrl({
|
||||
client_id: phish.clientId,
|
||||
redirect_uri: "https://collector.example.net/cb",
|
||||
}),
|
||||
),
|
||||
)
|
||||
).text();
|
||||
expect(body).toMatch(/Check where this access goes/);
|
||||
expect(body).toMatch(
|
||||
/Access goes to:<\/strong> <code>collector\.example\.net<\/code>/,
|
||||
);
|
||||
expect(body).toMatch(
|
||||
/value="allow">Allow access for collector\.example\.net</,
|
||||
);
|
||||
// The warning comes before the borrowed name.
|
||||
expect(body.indexOf("Check where")).toBeLessThan(body.indexOf("Claude"));
|
||||
});
|
||||
|
||||
it("says a loopback redirect stays on this device", async () => {
|
||||
const desktop = registerDcrClient({
|
||||
clientName: "Desktop client",
|
||||
redirectUris: ["http://127.0.0.1:4000/callback"],
|
||||
});
|
||||
signedIn();
|
||||
const body = await (
|
||||
await GET(
|
||||
new NextRequest(
|
||||
authorizeUrl({
|
||||
client_id: desktop.clientId,
|
||||
redirect_uri: "http://127.0.0.1:4000/callback",
|
||||
}),
|
||||
),
|
||||
)
|
||||
).text();
|
||||
expect(body).toMatch(/an application running on this device/);
|
||||
expect(body).not.toMatch(/Check where this access goes/);
|
||||
});
|
||||
});
|
||||
@@ -420,6 +420,7 @@ async function authorizeGet(request: NextRequest): Promise<Response> {
|
||||
v.clientSource === "cimd"
|
||||
? `<p>Verified origin: <code>${htmlEscape(v.clientOrigin)}</code></p>`
|
||||
: `<p><strong>Unverified application</strong> (dynamically registered, the name above is self-reported and not verified).</p>`;
|
||||
const destination = consentDestination(v.redirectUri, v.clientSource);
|
||||
// Plain-language access summary. When write is granted, say so explicitly —
|
||||
// the app will be able to READ and LOG/WRITE health data, not just read.
|
||||
const writeGranted = v.scope.split(/\s+/).includes(SCOPE_HEALTH_WRITE);
|
||||
@@ -436,8 +437,10 @@ async function authorizeGet(request: NextRequest): Promise<Response> {
|
||||
html(
|
||||
`<main>
|
||||
<h1>Authorize access</h1>
|
||||
${destination.warning}
|
||||
<p><strong>${htmlEscape(v.clientName)}</strong> is requesting access to your HealthLog data.</p>
|
||||
${provenance}
|
||||
${destination.line}
|
||||
<p>Scope: <code>${htmlEscape(v.scope)}</code></p>
|
||||
${accessSummary}
|
||||
${readSummary}
|
||||
@@ -450,7 +453,7 @@ async function authorizeGet(request: NextRequest): Promise<Response> {
|
||||
${hidden("scope", v.scope)}
|
||||
${hidden("resource", v.resource)}
|
||||
${v.state !== undefined ? hidden("state", v.state) : ""}
|
||||
<button type="submit" name="decision" value="allow">Allow</button>
|
||||
<button type="submit" name="decision" value="allow">${destination.allowLabel}</button>
|
||||
<button type="submit" name="decision" value="deny">Deny</button>
|
||||
</form>
|
||||
</main>`,
|
||||
@@ -460,6 +463,55 @@ async function authorizeGet(request: NextRequest): Promise<Response> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an Allow sends the access, said on the consent page in words a person
|
||||
* checks: the redirect URI's host, or "an application on this device" for a
|
||||
* loopback redirect.
|
||||
*
|
||||
* Dynamic registration is open by specification and the client names itself,
|
||||
* so anyone can register "Claude" with a redirect to a host of their own and
|
||||
* send a signed-in person the authorize link; the page used to show the
|
||||
* borrowed name and never the host the code would go to. The host is now on
|
||||
* every consent page and on the Allow button. For an unverified client whose
|
||||
* redirect leaves this device, a warning leads the page.
|
||||
*
|
||||
* Registration itself stays open: the hosted assistants register this way
|
||||
* with their own https callback, so closing it or allowlisting hosts would
|
||||
* break them on every instance, and a switch left at its default would
|
||||
* protect nobody. Naming where the access goes is what a phished person can
|
||||
* act on.
|
||||
*/
|
||||
function consentDestination(
|
||||
redirectUri: string,
|
||||
clientSource: string,
|
||||
): { warning: string; line: string; allowLabel: string } {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(redirectUri);
|
||||
} catch {
|
||||
return { warning: "", line: "", allowLabel: "Allow" };
|
||||
}
|
||||
const loopback =
|
||||
url.protocol === "http:" &&
|
||||
["localhost", "127.0.0.1", "[::1]", "::1"].includes(url.hostname);
|
||||
if (loopback) {
|
||||
return {
|
||||
warning: "",
|
||||
line: `<p><strong>Access goes to:</strong> an application running on this device (<code>${htmlEscape(url.host)}</code>).</p>`,
|
||||
allowLabel: "Allow",
|
||||
};
|
||||
}
|
||||
const host = htmlEscape(url.host);
|
||||
return {
|
||||
warning:
|
||||
clientSource === "cimd"
|
||||
? ""
|
||||
: `<p><strong>Check where this access goes before you allow it.</strong> Whoever runs <code>${host}</code> will be able to use it. Allow only if you started this connection from <code>${host}</code> yourself; the application's name below is its own claim.</p>`,
|
||||
line: `<p><strong>Access goes to:</strong> <code>${host}</code></p>`,
|
||||
allowLabel: `Allow access for ${host}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function authorizePost(request: NextRequest): Promise<Response> {
|
||||
return withBackgroundEvent("mcp.oauth.authorize", async () => {
|
||||
if (!(await surfaceAvailable())) {
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
readBodyText,
|
||||
isJsonContentType,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -127,7 +128,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
const contentType = (request.headers.get("content-type") ?? "").toLowerCase();
|
||||
const isJson = contentType.includes("application/json");
|
||||
const isJson = isJsonContentType(contentType);
|
||||
const isText =
|
||||
contentType.includes("text/csv") || contentType.includes("text/plain");
|
||||
if (!isJson && !isText) {
|
||||
|
||||
@@ -6,6 +6,7 @@ vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(h: T): T =>
|
||||
h,
|
||||
requireAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
@@ -29,6 +30,7 @@ import { POST } from "../route";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getGlitchtipSettings } from "@/lib/monitoring-settings";
|
||||
import { sendGlitchtipEvent } from "@/lib/monitoring/glitchtip";
|
||||
import { requireAuth } from "@/lib/api-handler";
|
||||
|
||||
function jsonReq(body: unknown, contentType = "application/json"): NextRequest {
|
||||
return new NextRequest("http://localhost/api/monitoring/glitchtip", {
|
||||
@@ -40,6 +42,9 @@ function jsonReq(body: unknown, contentType = "application/json"): NextRequest {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireAuth).mockResolvedValue({
|
||||
user: { id: "user-1" },
|
||||
} as Awaited<ReturnType<typeof requireAuth>>);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 19,
|
||||
@@ -58,6 +63,20 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("POST /api/monitoring/glitchtip", () => {
|
||||
it("relays nothing for a caller without a session", async () => {
|
||||
vi.mocked(requireAuth).mockRejectedValue(new Error("Unauthorized"));
|
||||
await expect(POST(jsonReq({ message: "injected" }))).rejects.toThrow();
|
||||
expect(sendGlitchtipEvent).not.toHaveBeenCalled();
|
||||
expect(checkRateLimit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("limits the relay per account", async () => {
|
||||
await POST(jsonReq({ message: "boom" }));
|
||||
expect(vi.mocked(checkRateLimit).mock.calls[0]![0]).toBe(
|
||||
"glitchtip:user:user-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 429 when rate limit is exceeded", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
@@ -169,20 +188,9 @@ describe("POST /api/monitoring/glitchtip", () => {
|
||||
expect(res.status).toBe(502);
|
||||
});
|
||||
|
||||
it("rate-limits per IP — uses x-forwarded-for as the bucket key", async () => {
|
||||
const r = new NextRequest("http://localhost/api/monitoring/glitchtip", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-forwarded-for": "10.0.0.42",
|
||||
},
|
||||
body: JSON.stringify({ message: "boom" }),
|
||||
});
|
||||
await POST(r);
|
||||
it("keeps the documented 20 per minute window", async () => {
|
||||
await POST(jsonReq({ message: "boom" }));
|
||||
const callArgs = vi.mocked(checkRateLimit).mock.calls[0];
|
||||
// First arg is the bucket key — must include the resolved client IP.
|
||||
expect(callArgs?.[0]).toContain("10.0.0.42");
|
||||
// 20 requests / 60 000 ms — guard the documented sliding window.
|
||||
expect(callArgs?.[1]).toBe(20);
|
||||
expect(callArgs?.[2]).toBe(60_000);
|
||||
});
|
||||
|
||||
@@ -4,13 +4,12 @@ import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
apiValidationError,
|
||||
getClientIp,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getGlitchtipSettings } from "@/lib/monitoring-settings";
|
||||
import { sendGlitchtipEvent } from "@/lib/monitoring/glitchtip";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
|
||||
const clientErrorSchema = z
|
||||
@@ -24,11 +23,17 @@ const clientErrorSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* The browser's error reporter relays here into the operator's GlitchTip.
|
||||
* It needs a signed-in session: the relay used to be anonymous, so anyone
|
||||
* could write events into the incident list, limited only per address. A
|
||||
* page that fails before sign-in goes unreported, which is the price.
|
||||
*/
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "monitoring.glitchtip.report" } });
|
||||
|
||||
const ip = getClientIp(request);
|
||||
const rl = await checkRateLimit(`glitchtip:${ip}`, 20, 60 * 1000);
|
||||
const { user } = await requireAuth();
|
||||
const rl = await checkRateLimit(`glitchtip:user:${user.id}`, 20, 60 * 1000);
|
||||
if (!rl.allowed) return apiError("Rate limit exceeded", 429);
|
||||
|
||||
const settings = await getGlitchtipSettings();
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* POST /api/notifications/action/{token} — a quick answer from a
|
||||
* notification. Pins the order of the gates and every refusal class: rate
|
||||
* limit before the token is read, then signature, expiry, account, module,
|
||||
* and the single-use write.
|
||||
* notification. Pins the order of the gates and every refusal class: the
|
||||
* signature (an HMAC, no database read), then the rate limit (per account for
|
||||
* a genuine token, per address in a bucket of its own for any other), expiry,
|
||||
* account, module, and the single-use write.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
@@ -18,6 +19,7 @@ vi.mock("@/lib/db", () => ({
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAuthSurfaceRateLimit: vi.fn(),
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({ "Retry-After": "60" })),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn() }));
|
||||
@@ -31,7 +33,7 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { checkAuthSurfaceRateLimit, checkRateLimit } from "@/lib/rate-limit";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import { logTelegramMood } from "@/lib/mood/create-from-telegram";
|
||||
import {
|
||||
@@ -65,6 +67,12 @@ beforeEach(() => {
|
||||
resetAt: new Date(),
|
||||
ip: "203.0.113.5",
|
||||
} as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 20,
|
||||
remaining: 19,
|
||||
resetAt: Date.now(),
|
||||
});
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "user-1",
|
||||
timezone: "Europe/Berlin",
|
||||
@@ -98,17 +106,39 @@ describe("POST /api/notifications/action/{token}", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("is rate-limited before the token is read", async () => {
|
||||
it("limits a genuine token per account, before any database read", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
limit: 20,
|
||||
remaining: 0,
|
||||
resetAt: Date.now(),
|
||||
});
|
||||
const res = await call(token());
|
||||
expect(res.status).toBe(429);
|
||||
expect(vi.mocked(checkRateLimit).mock.calls[0]![0]).toBe(
|
||||
"notification-action:user:user-1",
|
||||
);
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
expect(logTelegramMood).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("charges junk to a bucket of its own, so it cannot hold genuine answers back", async () => {
|
||||
// The junk bucket is spent, as it is for everyone when the address
|
||||
// cannot be read and all junk lands in one bucket.
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: new Date(),
|
||||
ip: "203.0.113.5",
|
||||
ip: null,
|
||||
} as never);
|
||||
const res = await call(token());
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
expect(logTelegramMood).not.toHaveBeenCalled();
|
||||
const junk = await call("hla_not-a-token");
|
||||
expect(junk.status).toBe(429);
|
||||
expect(vi.mocked(checkAuthSurfaceRateLimit).mock.calls[0]![1]).toBe(
|
||||
"notification-action-invalid",
|
||||
);
|
||||
|
||||
const genuine = await call(token());
|
||||
expect(genuine.status).toBe(200);
|
||||
});
|
||||
|
||||
it("refuses a tampered token with 401 and writes nothing", async () => {
|
||||
|
||||
@@ -42,7 +42,11 @@ import {
|
||||
quickActionExternalId,
|
||||
verifyActionToken,
|
||||
} from "@/lib/notifications/action-token";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import {
|
||||
checkAuthSurfaceRateLimit,
|
||||
checkRateLimit,
|
||||
rateLimitHeaders,
|
||||
} from "@/lib/rate-limit";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -64,12 +68,27 @@ export const POST = apiHandler(
|
||||
async (request: NextRequest, context: RouteContext) => {
|
||||
annotate({ action: { name: "notification.action.invoke" } });
|
||||
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
"notification-action",
|
||||
RATE_LIMIT,
|
||||
RATE_WINDOW_MS,
|
||||
);
|
||||
// The token is verified first: it is an HMAC, so checking it costs one
|
||||
// hash and no database read. A forged or stale token is limited per
|
||||
// address in a bucket of its own, and a genuine one per account. The one
|
||||
// shared bucket this used to charge first, keyed on the address, became
|
||||
// a single bucket for everyone whenever the address could not be read,
|
||||
// and a couple of junk requests a minute switched off every person's
|
||||
// quick answers.
|
||||
const { token } = await context.params;
|
||||
const verdict = verifyActionToken(token);
|
||||
const rl = verdict.ok
|
||||
? await checkRateLimit(
|
||||
`notification-action:user:${verdict.userId}`,
|
||||
RATE_LIMIT,
|
||||
RATE_WINDOW_MS,
|
||||
)
|
||||
: await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
"notification-action-invalid",
|
||||
RATE_LIMIT,
|
||||
RATE_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
annotate({
|
||||
action: { name: "notification.action.refused" },
|
||||
@@ -82,8 +101,6 @@ export const POST = apiHandler(
|
||||
return response;
|
||||
}
|
||||
|
||||
const { token } = await context.params;
|
||||
const verdict = verifyActionToken(token);
|
||||
if (!verdict.ok) {
|
||||
annotate({
|
||||
action: { name: "notification.action.refused" },
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { notificationPreferenceSchema } from "@/lib/validations/notifications";
|
||||
import { EVENT_TYPES, CHANNEL_TYPE_LABELS } from "@/lib/notifications/types";
|
||||
import {
|
||||
CHANNEL_TYPE_LABELS,
|
||||
eventTypesForRole,
|
||||
} from "@/lib/notifications/types";
|
||||
import type { ChannelType } from "@/lib/notifications/types";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import { resolveEventDefaults } from "@/lib/notifications/event-defaults";
|
||||
@@ -92,7 +95,9 @@ export const GET = apiHandler(async () => {
|
||||
return apiSuccess({
|
||||
channels: channelData,
|
||||
preferences,
|
||||
eventTypes: EVENT_TYPES as unknown as string[],
|
||||
// Admin-only events (backup health) are listed for admins only: nobody
|
||||
// else is ever sent one, so a switch for it would switch nothing.
|
||||
eventTypes: eventTypesForRole(user.role),
|
||||
// What an event does on a channel with no stored row, resolved for this
|
||||
// account by the same function the dispatcher asks. A client renders a
|
||||
// switch without a row from this map, never from its own copy of the
|
||||
|
||||
@@ -49,6 +49,9 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
isModuleEnabled: vi.fn(async () => true),
|
||||
}));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
@@ -70,6 +73,7 @@ import {
|
||||
import { verifyUnlockValue } from "@/lib/clinician-share/unlock-cookie";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { encryptDocumentToBytes } from "@/lib/documents/store";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
const TOKEN = "hls_000000000000000000000000000000000000000000000000";
|
||||
const OWNER = "owner-1";
|
||||
@@ -164,6 +168,17 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("GET /c/[token]/d/[id] — share document serve", () => {
|
||||
it("answers 404 once the owner switched the documents module off", async () => {
|
||||
seedMembership({ mimeType: "application/pdf", bytes: PDF_BYTES });
|
||||
vi.mocked(isModuleEnabled).mockResolvedValueOnce(false);
|
||||
|
||||
const res = await callGet(makeReq("doc-1"), ctx("doc-1"));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(isModuleEnabled).toHaveBeenCalledWith(OWNER, "inboundDocuments");
|
||||
expect(prisma.clinicianShareLinkDocument.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serves a PDF inline through the token, confined to the frozen set", async () => {
|
||||
seedMembership({ mimeType: "application/pdf", bytes: PDF_BYTES });
|
||||
|
||||
|
||||
Loaded 100 of 254 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user