Merge branch 'fix/compose-env-and-claims' into release/v1.39.3

This commit is contained in:
Marc-André Bombeck committed 2026-09-26 17:00:46 +02:00
commit 026e8fc1ed
64 files changed
+1059 -158

No files matched your search

+15 -4
View File
@@ -4,6 +4,9 @@
# Copy this file to .env and fill in the values.
# $ cp .env.example .env
#
# This is a short starter. Every variable the app reads, with what it does,
# is documented in .env.production.example.
#
# Required variables: DATABASE_URL or POSTGRES_PASSWORD,
# ENCRYPTION_KEY, API_TOKEN_HMAC_KEY
#
@@ -76,10 +79,9 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
# -----------------------------------------------------------------------------
# Withings OAuth (optional — for automatic scale/BP/activity sync)
# -----------------------------------------------------------------------------
# Register an app at https://developer.withings.com/ and paste the credentials
# here. Per-user OAuth credentials can also be configured in Settings.
# WITHINGS_CLIENT_ID=""
# WITHINGS_CLIENT_SECRET=""
# Register an app at https://developer.withings.com/ and enter its client id
# and secret in Settings -> Integrations -> Withings. There is no environment
# variable for them; only the redirect override and webhook secret live here.
# WITHINGS_REDIRECT_URI="https://healthlog.example.com/api/withings/callback"
# WITHINGS_WEBHOOK_SECRET="" # any random string — used to harden the webhook URL
@@ -306,6 +308,15 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
# DEPLOY_WEBHOOK_SECRET=""
# -----------------------------------------------------------------------------
# Outbound requests HealthLog makes on its own (optional switches)
# -----------------------------------------------------------------------------
# Each accepts "1", "true" or "yes". See .env.production.example for detail.
# PASSWORD_BREACH_CHECK_DISABLED="1" # no HaveIBeenPwned range query when a password is set
# UPDATE_CHECK_DISABLED="1" # no daily GitHub releases request
# IP_GEO_LOOKUP_DISABLED="1" # no online IP geolocation for login events
# -----------------------------------------------------------------------------
# IP geolocation (optional)
# -----------------------------------------------------------------------------
+23 -2
View File
@@ -471,6 +471,26 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# TRUST_PROXY_HOPS="1"
# TRUST_CF_CONNECTING_IP="1"
# -----------------------------------------------------------------------------
# Outbound requests HealthLog makes on its own -- optional switches
# -----------------------------------------------------------------------------
# Besides the services you connect yourself (AI provider, integrations,
# notification channels, off-host backups), the server contacts three third
# parties by default. Each has an off-switch that accepts "1", "true" or "yes":
#
# - HaveIBeenPwned, when a password is set (registration, change, admin
# reset). Only the first five hex characters of the password's SHA-1 are
# sent (k-anonymity); the password and its full hash never leave. With the
# switch on, known-breached passwords are no longer refused.
# PASSWORD_BREACH_CHECK_DISABLED="1"
#
# - The GitHub releases API, at most once a day per browser, for the "update
# available" badge on the About page and the admin version tile.
# UPDATE_CHECK_DISABLED="1"
#
# - The online IP geolocation provider for login events; see
# IP_GEO_LOOKUP_DISABLED in the next section.
# -----------------------------------------------------------------------------
# IP geolocation lookup -- optional
# -----------------------------------------------------------------------------
@@ -490,8 +510,9 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# IP_GEO_LOOKUP_URL="http://ip-api.com/json"
# IP_GEO_ALLOW_INSECURE="true"
#
# Set to "1" to disable all third-party IP egress (GDPR Art. 44). With egress
# off the resolver uses only the offline GeoLite2 MMDB tier, if present.
# Set to "1" (or "true" / "yes") to disable all third-party IP egress (GDPR
# Art. 44). With egress off the resolver uses only the offline GeoLite2 MMDB
# tier, if present, and the monthly MaxMind download is skipped too.
# IP_GEO_LOOKUP_DISABLED="1"
# -----------------------------------------------------------------------------
+3
View File
@@ -55,6 +55,9 @@ next-env.d.ts
# the cache-version baseline.
/public/sw-version.js
/public/i18n/
# In-browser OCR engine and language data, copied from node_modules by
# scripts/stage-tesseract-assets.mjs at prebuild/predev.
/public/tesseract/
# v1.4.27 B3 — MaxMind GeoLite2 databases are fetched at build time
# via `scripts/fetch-geolite2.sh`. Too large for git; license key
+22
View File
@@ -170,6 +170,24 @@ COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
COPY --from=builder /app/src/generated ./src/generated
# Operator maintenance scripts that a runbook tells the operator to run inside
# this container (`docs/ops/password-reset.md`, `docs/ops/intake-repair.md`).
# The standalone output carries none of `scripts/`, so without these lines the
# documented `docker compose exec app node scripts/reset-password.mjs` failed
# with "Cannot find module". Only self-contained scripts belong here: plain
# `node` or the `healthlog-tsx` launcher below, resolving `pg` and
# `@node-rs/argon2` from the traced runtime tree. Scripts that import the
# application graph (`@/…`, the generated Prisma client) run from a source
# checkout instead. `src/__tests__/container-scripts-ship-guard.test.ts` holds
# the runbooks and this list in step. Root-owned on purpose: the app user can
# run them but not rewrite them.
COPY --from=builder /app/scripts/reset-password.mjs ./scripts/reset-password.mjs
COPY --from=builder /app/src/lib/auth/argon2-params.mjs ./src/lib/auth/argon2-params.mjs
COPY --from=builder /app/scripts/repair-intake-anomalies.ts ./scripts/repair-intake-anomalies.ts
# Prove the reset CLI's imports resolve in this tree: with every import found
# it stops at its own usage line; a missing module throws before reaching it.
RUN node scripts/reset-password.mjs 2>&1 | grep -q "reset-password: usage:"
# Install the migration CLI, its config dependency, and a pinned launcher for
# the maintenance scripts already shipped in the standalone tree. The Prisma
# config loads dotenv from /app, so expose the isolated copy there before
@@ -213,6 +231,10 @@ RUN mkdir -p /opt/prisma-cli && \
rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /root/.cache/node/corepack /root/.npm && \
rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack /usr/local/bin/pnpm /usr/local/bin/pnpx
# Same proof for the intake-repair script under the launcher it is documented
# with: its `pg` import resolves, so it reaches its own DATABASE_URL check.
RUN env -u DATABASE_URL healthlog-tsx scripts/repair-intake-anomalies.ts 2>&1 | grep -q "DATABASE_URL must be set"
# v1.4.27 B3 — offline GeoLite2 databases for IP→location and IP→ASN
# lookups. The MMDB files live in `/opt/geolite2/` and are read by
# `src/lib/geo.ts` via `mmdb-lib`. They are downloaded outside the
+6 -6
View File
@@ -42,7 +42,7 @@
## Why HealthLog
HealthLog is a self-hosted health tracking app (PWA + native iOS client) that runs from a single `docker compose up` on a NAS, homelab, or small VPS. It brings weight, blood pressure, pulse, blood glucose, body composition, sleep, mood, menstrual cycle, and medications onto one timeline — synced from the devices you already own — and keeps everything encrypted at rest on hardware you control. No vendor cloud, no subscription, no telemetry.
HealthLog is a self-hosted health tracking app (PWA + native iOS client) that runs from a single `docker compose up` on a NAS, homelab, or small VPS. It brings weight, blood pressure, pulse, blood glucose, body composition, sleep, mood, menstrual cycle, and medications onto one timeline, synced from the devices you already own, and keeps it in a Postgres database on hardware you control. No vendor cloud, no subscription, no usage telemetry. The few requests the server makes on its own (an update check, a password breach check, an IP geolocation lookup for logins) can each be switched off.
HealthLog is a personal health record-keeping and wellness tool. It is not a medical device and does not diagnose, treat, cure, or prevent any disease — it helps you log, visualise, and export your own data, and leaves clinical judgement to a clinician.
@@ -52,14 +52,14 @@ Try the [live demo](https://demo.healthlog.dev) to see a working install — sig
- **Every vital on one timeline.** Weight, blood pressure, pulse, glucose, body composition, sleep, SpO₂, respiratory rate, mood, and cycle tracking — charted with trends, moving averages, and clinical reference ranges (ESH 2023, ADA 2024), overridable with the targets your clinician set.
- **Clinical signals, opt-in and additive.** Validated self-checks for mental wellbeing and sleep (WHO-5, PHQ-9, GAD-7, Sleep Condition Indicator — official questionnaire wording, calm crisis-resource handling), grip strength, a 0–10 pain score, waist and body measurements, and a longevity lab panel — each added through one signal registry, so a new signal wires into charts, correlations, the coach, the FHIR map, and the API at once.
- **Records, structured.** Allergies and family history as encrypted structured records that flow into the doctor report, plus lab values you key in or scan from a printed report — OCR extracts the stated values, units, and dates for you to review and confirm before anything is saved. HealthLog transcribes what a report says; it never interprets or diagnoses from it. A vaccination record keeps a lifetime immunization log, doses grouped under the disease they protect against, and prints in the doctor report.
- **Records, structured.** Allergies and family history as structured records that flow into the doctor report (reactions and notes are encrypted, the substance and condition names are not), plus lab values you key in or scan from a printed report — OCR extracts the stated values, units, and dates for you to review and confirm before anything is saved. HealthLog transcribes what a report says; it never interprets or diagnoses from it. A vaccination record keeps a lifetime immunization log, doses grouped under the disease they protect against, and prints in the doctor report.
- **A document vault for the paper trail.** An opt-in module stores every letter, report, and scan encrypted at rest — PDFs and photos preview inline, Office and text files stay download-only, and nothing user-uploaded can ever execute in the app. One searchable timeline with type, condition, and year filters, drag-and-drop and paste-to-upload, bulk filing, undo-able delete with a 30-day grace, and per-file/per-account limits the operator tunes.
- **Context that explains the numbers.** An opt-in environmental module folds weather, daylight, and temperature in as influence signals (winter darkness against mood, heat against blood pressure) using a home location you set, so a pattern has somewhere to point.
- **Your devices, one server.** Withings, WHOOP, Oura and Polar sync over OAuth2, Google Health connects Fitbit and Pixel Watch through your own Google Cloud client, Nightscout streams CGM glucose, an Apple Health `export.zip` folds your full history in, and the native iOS app streams HealthKit live. A per-metric source priority decides which reading is canonical when wearables overlap.
- **Medication tracking that tells the truth.** Flexible schedules (weekly injections, weekday-only, intervals, PRN, cyclic), a configurable intake window per dose, and a traceable dose history where every slot reads taken, late, skipped, or missed — the same ledger the compliance rate is computed from, so the percentage can never disagree with the timeline.
- **AI insights you own.** Daily briefing, health scores, correlations, and a conversational coach grounded in your own measurements and interpreted against cited guideline reference bands — where a value sits and what that means in plain words, never a diagnosis. The coach proposes concrete if-then plans you accept or decline, and suppresses proactive check-ins when there's nothing genuine to say. Bring your own OpenAI or Anthropic key, or point at a local endpoint (Ollama, LM Studio, vLLM) so nothing leaves your network.
- **Clinician-ready output.** A doctor-report PDF generated client-side, a read-only HL7 FHIR R4 API, and scoped, time-limited share links you revoke after the visit.
- **Private by construction.** AES-256-GCM encryption at rest with zero-downtime key rotation, passkey login, server-side sessions, strict CSP — and no third-party tracking anywhere.
- **AI insights you own.** Daily briefing, health scores, correlations, and a conversational coach grounded in your own measurements and interpreted against cited guideline reference bands — where a value sits and what that means in plain words, never a diagnosis. The coach proposes concrete if-then plans you accept or decline, and suppresses proactive check-ins when there's nothing genuine to say. Bring your own OpenAI or Anthropic key, or point at a local endpoint (Ollama, LM Studio, vLLM) so AI requests stay on your network.
- **Clinician-ready output.** A doctor-report PDF rendered on your server, a read-only HL7 FHIR R4 API, and scoped, time-limited share links you revoke after the visit.
- **Private by construction.** AES-256-GCM encryption at rest for notes and other free text, documents, questionnaire answers, AI-written text and credentials, with zero-downtime key rotation. Measurement values, lab values, medication names and mood scores are stored unencrypted in the database, so the database volume and its backups need the same protection as the host. Passkey login, server-side sessions, strict CSP, and no third-party tracking anywhere.
- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, Polish, and Korean end to end.
The full feature tour, integration guides, and API reference live at [docs.healthlog.dev](https://docs.healthlog.dev).
@@ -97,7 +97,7 @@ NAS and homelab users have ready-made paths: [Unraid Community Applications](doc
## Self-hosting
One container hosts both the web server and the job worker by default; split them via `HEALTHLOG_PROCESS_TYPE=web|worker` for horizontal scale. Encrypted daily backups to any S3-compatible bucket are opt-in via the admin panel, new users join by invite link or QR code, and the stack works out of the box behind Caddy, Traefik, Nginx, or [Coolify](https://coolify.io/).
One container hosts both the web server and the job worker by default; split them via `HEALTHLOG_PROCESS_TYPE=web|worker` for horizontal scale. Encrypted nightly backups to any S3-compatible bucket are opt-in through five `BACKUP_*` environment variables (the admin panel shows their status), new users join by invite link or QR code, and the stack works out of the box behind Caddy, Traefik, Nginx, or [Coolify](https://coolify.io/).
HealthLog ships releases roughly weekly. Pin a tag, back up before upgrades, and skim the [CHANGELOG](CHANGELOG.md) before pulling `latest`. The operator manual — reverse proxy, migrations, encryption-key rotation, backup and restore — lives at [docs.healthlog.dev](https://docs.healthlog.dev).
+8
View File
@@ -102,6 +102,14 @@ x-healthlog-env: &healthlog_env
IP_GEO_LOOKUP_URL: "${IP_GEO_LOOKUP_URL:-}"
IP_GEO_LOOKUP_DISABLED: "${IP_GEO_LOOKUP_DISABLED:-}"
IP_GEO_ALLOW_INSECURE: "${IP_GEO_ALLOW_INSECURE:-}"
# Outbound requests an operator can turn off (1 / true / yes). Both are on
# by default. PASSWORD_BREACH_CHECK_DISABLED stops the HaveIBeenPwned range
# query made when a password is set (only the first five characters of the
# password's SHA-1 are sent). UPDATE_CHECK_DISABLED stops the daily request
# to the GitHub releases API behind the About page and the admin version
# tile. Listed here so an operator value in .env reaches the container.
PASSWORD_BREACH_CHECK_DISABLED: "${PASSWORD_BREACH_CHECK_DISABLED:-}"
UPDATE_CHECK_DISABLED: "${UPDATE_CHECK_DISABLED:-}"
# Directory the offline GeoLite2 databases are read from. Default
# `/opt/geolite2` — the path the published image bakes its own copy
# into, if the build had a MaxMind licence key. Point this at a
+4
View File
@@ -833,6 +833,10 @@ paths:
"unknown"` and a machine-readable `reason`, so the UI can offer a retry instead of a red banner nobody can act
on. The one genuine error is a feed that answers successfully with no version tag in it. Nothing is cached
server-side; the client is expected to hold the result.
An operator who sets `UPDATE_CHECK_DISABLED` turns the outbound request off: the route then answers `status:
"unknown"` with `reason: "disabled"` and contacts nobody.
responses:
"200":
description: Up to date, an update is available, or the check could not be completed.
+4 -3
View File
@@ -70,10 +70,11 @@ worker`. Default `all` keeps the single-container behaviour.
- **`ENCRYPTION_KEY`** _(unchanged)_ — still honoured as a fallback
when `ENCRYPTION_KEYS` is empty. No action needed for existing
installs.
- Rotation: `pnpm dlx tsx scripts/rotate-encryption-key.ts <newKeyId>`
re-encrypts every ciphertext under the new key without downtime.
- Rotation: **Admin → Encryption → Rotate now** re-encrypts every
ciphertext under the new key without downtime; the script
`scripts/rotate-encryption-key.ts` does the same from a source checkout.
Walk-through in `docs/ops/encryption-key-rotation.md` (the current
playbook; the production image strips `tsx`, hence `pnpm dlx`).
playbook).
### Off-host backup target _(optional)_
+34 -14
View File
@@ -41,15 +41,33 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
Restart the app. New writes are now encrypted under `v2`; existing
`v1`-keyed and legacy bare rows still decrypt because the `v1` entry is
retained.
3. **Run the rotation script** to re-encrypt every registered encrypted column
under the new active key:
3. **Re-encrypt the stored data under the new key.** Open **Admin →
Encryption** and press **Rotate now**. The button needs an admin account
with a second factor, confirmed recently; it queues one background run
(`POST /api/admin/encryption/rotate`) on the app's own worker, so it
works on the published image with nothing else installed. The same page
shows the per-column counts and the result of the last run.
Without a second factor on any admin account, run the rotation script
instead. It imports the application code and the generated database
client, so it runs from a source checkout of the same release, not
inside the production container (the image has no package manager and
does not contain `scripts/rotate-encryption-key.ts`). It needs the same
`ENCRYPTION_KEYS` and `ENCRYPTION_ACTIVE_KEY_ID` as the app and a
`DATABASE_URL` that reaches the database:
```
git checkout vX.Y.Z # the release the instance runs
pnpm install && pnpm db:generate
pnpm dlx tsx scripts/rotate-encryption-key.ts v2
```
The script is idempotent — running it again is a no-op for rows already
prefixed with `v2.`. It rotates every column in the canonical registry
The bundled `db` service publishes no port. On a stock compose stack,
either publish `5432` for the duration, or run the checkout in a
throwaway `node:22` container attached to the compose network.
Both paths are idempotent: running again is a no-op for rows already
prefixed with `v2.`. Both rotate every column in the canonical registry
(`src/lib/crypto/encrypted-columns.ts`), which covers the `*Encrypted`
columns plus the ones whose names say nothing about their contents:
`IntegrationStatus.lastError`, `CoachMessage.encryptedContent`,
@@ -58,7 +76,8 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
`IdempotencyKey.responseBody`, and `DataBackup.data` — the whole-account
backup blob.
Read three things off the output before going further:
When you used the script, read three things off its output before going
further:
- the per-column line, `scanned` / `rotated` / `errors` / `dropped`.
`scanned` counts the rows that hold ciphertext in that column, not every
@@ -78,10 +97,10 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
4. **Confirm nothing is left on the old key, THEN drop it.** This is the step
that destroys data if it is taken on a bad signal, so check the corpus
rather than the absence of complaints. Either re-run the script — a clean
second pass reports `rotated=0`, `errors=0` and a full `Columns walked`
line — or open **Admin → Encryption** and confirm the status view reports
zero rows outside the active key. Both read the same registry the rotation
rather than the absence of complaints. Open **Admin → Encryption** and
confirm the status view reports zero rows outside the active key, or
re-run the script: a clean second pass reports `rotated=0`, `errors=0` and
a full `Columns walked` line. Both read the same registry the rotation
walks.
A zero only means "nothing left" when the run also says it walked every
@@ -117,13 +136,14 @@ remaining.
```
ENCRYPTION_KEYS='{"v2":"<old>","v3":"<new>"}'
ENCRYPTION_ACTIVE_KEY_ID="v3"
pnpm dlx tsx scripts/rotate-encryption-key.ts v3
# restart, then Admin → Encryption → Rotate now
# (or: pnpm dlx tsx scripts/rotate-encryption-key.ts v3 from a checkout)
ENCRYPTION_KEYS='{"v3":"<new>"}'
```
## Rollback
> **Important.** Once the rotation script has run, ciphertexts in the
> **Important.** Once a rotation has run, ciphertexts in the
> database start with `v2.` (or whatever the active id is). The pre-PR
> v1.3.x image cannot read that prefix — it expects bare base64 — and
> calling `decrypt()` on those rows will throw.
@@ -133,7 +153,7 @@ If you need to revert to the pre-rotation image:
- Either keep the new image. The new code reads both formats, so most
rollback scenarios don't need to undo rotation.
- Or, if you must run the old code, restore a database backup taken
_before_ the rotation script ran. There is no script to convert
_before_ the rotation ran. There is no script to convert
`v2.`-prefixed rows back to legacy format — by design, rotation is a
forward-only operation.
@@ -145,10 +165,10 @@ at least 24 hours.
- `Encryption key id 'v1' is not configured` — the database still contains
`v1.`-prefixed rows but `v1` was removed from `ENCRYPTION_KEYS`. Re-add
the key, run the rotation script, then remove again.
the key, run the rotation again, then remove it.
- `Found a legacy-format ciphertext but no v1 key is configured` — same
cause for legacy bare-base64 rows. Restore `ENCRYPTION_KEY` (or add a
`v1` entry to `ENCRYPTION_KEYS`) and run the rotation script before
`v1` entry to `ENCRYPTION_KEYS`) and run the rotation again before
removing it.
- `Refusing to rotate: argv key id ... does not match the currently active
id ...` — pass the same id you set in `ENCRYPTION_ACTIVE_KEY_ID`. The
+11 -7
View File
@@ -18,10 +18,12 @@ instance).
## Run it
The reset CLI ships in the image and runs under plain `node` against the built
runtime (the standalone image strips `tsx`, so this is a `.mjs` that uses the
in-image `pg` and `@node-rs/argon2` directly and reuses the app's exact Argon2id
parameters):
The reset CLI ships in the image from v1.39.3 on. It runs under plain `node`
against the built runtime: a `.mjs` file that uses the in-image `pg` and
`@node-rs/argon2` directly and reuses the app's exact Argon2id parameters.
Images up to and including v1.39.2 did not contain the script, so on those
the command below fails with "Cannot find module"; use the source-checkout
form at the end of this page instead.
```sh
docker compose exec app node scripts/reset-password.mjs <username-or-email>
@@ -32,7 +34,7 @@ confirmation that names only the user — never the password:
```
New password (input hidden):
reset-password: password updated for "alice"
reset-password: password updated for "alice"; revoked 2 sessions, 0 API tokens, 1 refresh tokens, 0 trusted devices, and 0 step-up elevations
```
You can also pass the password as a second argument for non-interactive use,
@@ -57,8 +59,10 @@ docker compose exec app node scripts/reset-password.mjs alice 'a-strong-passphra
## Source checkout
From a source checkout (not the production image) the same script runs under
Node directly:
From a source checkout of the same release (after `pnpm install`) the same
script runs under Node directly. It needs a `DATABASE_URL` that reaches the
database; the bundled `db` service publishes no port, so on a stock compose
stack run it inside the compose network or publish the port temporarily:
```sh
DATABASE_URL='postgres://…' node scripts/reset-password.mjs <username-or-email>
+3
View File
@@ -127,6 +127,9 @@ addresses they could not place. To remove that remainder too:
IP_GEO_LOOKUP_DISABLED="1"
```
`true` and `yes` work as well. Any other value, including an empty one, leaves
the lookup on.
The resolver then uses only the local databases and returns nothing when
they miss, rather than asking anyone. An instance handling other people's
health data is a reasonable place to make that trade.
+12 -3
View File
@@ -21,6 +21,13 @@ already installed, longer if a TLS-fronted public hostname is in scope
weather and daylight; it stays off until you
enable it and set a home location, and every outbound call goes
through the SSRF-guarded egress wrapper.
Three requests happen by default without anything connected: the
HaveIBeenPwned range query when a password is set (only the first five
characters of the password's SHA-1 are sent), a daily check of the
GitHub releases API for the update badge, and the online IP
geolocation lookup for login events. `PASSWORD_BREACH_CHECK_DISABLED`,
`UPDATE_CHECK_DISABLED` and `IP_GEO_LOOKUP_DISABLED` turn each off; see
`.env.production.example`.
- **Optional but recommended:** a reverse proxy that terminates TLS
and forwards to `http://localhost:3000` (Caddy, Traefik, Nginx
Proxy Manager, Coolify, or bare Nginx — see `reverse-proxy.md`).
@@ -33,9 +40,11 @@ cd HealthLog
cp .env.example .env
```
`.env.example` is the canonical reference for every env var the app
reads. Anything you do not uncomment falls back to a sane default or
disables the optional subsystem.
`.env.example` is a short starter with the variables most self-hosts
touch. The complete reference, with every variable the app reads and what
it does, is `.env.production.example`; CI checks that file against
`scripts/env-manifest.json`. Anything you do not set falls back to a sane
default or leaves the optional subsystem off.
## 2. Generate the three required secrets
+3
View File
@@ -29,6 +29,9 @@ const eslintConfig = defineConfig([
"coverage/**",
"playwright-report/**",
"test-results/**",
// Third-party OCR engine copied from node_modules at prebuild/predev by
// scripts/stage-tesseract-assets.mjs; gitignored, minified, not ours.
"public/tesseract/**",
]),
// Factory enforcement for every client module: all components/hooks plus
// `"use client"` app/lib modules. Tests and `src/lib/query-keys/**` own
+9 -8
View File
@@ -4591,7 +4591,7 @@
"sampleBody": "Gewicht in zwei Wochen um 1,2 kg gesunken. Ruhepuls stabil bei 58 bpm. Blutdruck an 6 von 7 Tagen im Zielbereich — der einzige hohe Wert folgte auf ein spätes, salziges Abendessen.",
"sampleCaption": "Nur ein Beispiel — nicht deine eigenen Daten. So liest sich ein Briefing, sobald Insights aktiv sind.",
"ladderTitle": "Drei Wege, es zu aktivieren",
"ladderLocalTitle": "Lokales Modell — nichts verlässt dein Netzwerk",
"ladderLocalTitle": "Lokales Modell — KI-Anfragen bleiben in deinem Netzwerk",
"ladderLocalBody": "Richte HealthLog auf Ollama oder LM Studio auf deinem eigenen Rechner aus. Kostenlos und vollständig privat — der ruhige Standard.",
"ladderByokTitle": "Eigenen Schlüssel verwenden",
"ladderByokBody": "Nutze deinen eigenen OpenAI- oder Anthropic-Schlüssel. Er läuft über dein Konto und deine Abrechnung, und die API-Stufe hält deine Daten standardmäßig aus dem Training heraus.",
@@ -5655,10 +5655,10 @@
"codexConnected": "ChatGPT erfolgreich verbunden! Insights sind jetzt aktiv.",
"codexDisconnected": "ChatGPT-Verbindung getrennt.",
"codexConnectionFailed": "ChatGPT-Verbindung fehlgeschlagen. Bitte versuche es erneut.",
"rawData": "Rohdaten mitsenden",
"rawDataOnDescription": "Es werden aggregierte Kennzahlen plus anonymisierte einzelne Messpunkte der letzten 30 Tage mit Zeitbezug gesendet — ohne Name, E-Mail oder direkte Kontodaten.",
"rawDataOffDescription": "Es werden nur zusammengefasste Werte gesendet (z. B. Durchschnitt, Trend, Minimum/Maximum) — ohne einzelne Messpunkte und exakte Zeitbezüge.",
"rawDataWarning": "Rohdaten aktiv: Der Provider erhält zusätzlich anonymisierte Messpunkte der letzten 30 Tage für eine präzisere Auswertung.",
"rawData": "Ausführlichen Verlauf senden",
"rawDataOnDescription": "Zusätzlich zu den Zusammenfassungen erhält der Anbieter Durchschnittswerte deiner Messungen: pro Tag für die letzten 90 Tage, pro Woche für das Jahr davor und pro Monat bis zu 5 Jahre zurück, jeweils mit Datum und Anzahl der Messungen. Einzelne Messwerte, dein Name und deine E-Mail werden nicht gesendet.",
"rawDataOffDescription": "Es werden nur Zusammenfassungen gesendet: der letzte Wert, Durchschnitte, Trends, Minimum und Maximum über feste Zeiträume. Keine Messreihen.",
"rawDataWarning": "Ausführlicher Verlauf ist an: Jede Auswertung sendet zusätzlich Tagesdurchschnitte der letzten 90 Tage, Wochendurchschnitte für das Jahr davor und Monatsdurchschnitte bis zu 5 Jahre zurück.",
"regenerateInsights": "Reports neu generieren",
"regenerateSuccess": "Reports wurden neu generiert",
"regenerateRateLimit": "Bitte warte — du hast das stündliche Limit für Analysen erreicht.",
@@ -7092,6 +7092,7 @@
"versionTileBuiltAt": "Gebaut am",
"versionTileUpdateAvailable": "Update verfügbar: {tag}",
"versionTileUpToDate": "Aktuell",
"versionTileCheckOff": "Update-Prüfung aus",
"snapshotLoadError": "System-Snapshot konnte nicht geladen werden.",
"snapshotLoading": "Snapshot wird geladen…",
"auditTitle": "Letzte Aktivitäten",
@@ -7296,7 +7297,7 @@
"description": "Wann dieser Host zuletzt die verschlüsselte Kopie jedes Kontos in deinen Bucket gelegt hat.",
"loadError": "Der Status außer Haus konnte nicht geladen werden.",
"notConfiguredTitle": "Sicherung außer Haus ist nicht eingerichtet",
"notConfiguredDescription": "Es sind weder Bucket noch Schlüssel gesetzt, also verlässt nichts diesen Host. Setze BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY und BACKUP_ENCRYPTION_KEY, um den nächtlichen Upload einzuschalten.",
"notConfiguredDescription": "Es sind weder Bucket noch Schlüssel gesetzt, also verlässt keine Sicherung diesen Host. Setze BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY und BACKUP_ENCRYPTION_KEY, um den nächtlichen Upload einzuschalten.",
"stateFresh": "Frisch",
"stateDue": "Fällig",
"stateStale": "Veraltet",
@@ -7377,7 +7378,7 @@
"rotateEnqueued": "Rotation gestartet.",
"rotateFailed": "Rotation konnte nicht gestartet werden.",
"stepUpNeeded": "Ein frischer zweiter Faktor ist erforderlich. Bestätige deinen zweiten Faktor oder nutze das Kommandozeilen-Werkzeug, falls kein zweiter Faktor eingerichtet ist.",
"cliNote": "Für diesen Knopf ist ein zweiter Faktor nötig. Betreiber ohne zweiten Faktor rotieren mit scripts/rotate-encryption-key.ts.",
"cliNote": "Für diesen Knopf ist ein zweiter Faktor nötig. Ohne ihn führen Betreiber scripts/rotate-encryption-key.ts aus einem Quellcode-Checkout dieser Version aus; die Schritte stehen in docs/ops/encryption-key-rotation.md.",
"columnsTitle": "Abdeckung pro Spalte",
"columnsDescription": "Jede verschlüsselte Spalte mit ihren Zeilenzahlen auf dem aktiven Schlüssel gegenüber älteren Schlüsseln.",
"colColumn": "Spalte",
@@ -10243,7 +10244,7 @@
}
},
"selfDescription": {
"dataPosture": "HealthLog wird selbst gehostet: Deine Gesundheitsdaten bleiben auf deiner eigenen Instanz und sind im Ruhezustand verschlüsselt. KI-Funktionen nutzen einen von dir mitgebrachten Anbieter-Schlüssel oder ein lokales Modell — nichts verlässt deine Instanz über den von dir konfigurierten Anbieter hinaus, und es gibt keine Drittanbieter-Analyse deiner Gesundheitsdaten."
"dataPosture": "HealthLog wird selbst gehostet: Deine Gesundheitsdaten liegen in der Datenbank deiner eigenen Instanz. Notizen und andere Freitexte, Dokumente, Fragebogen-Antworten, von der KI geschriebene Texte und Zugangs-Tokens sind im Ruhezustand verschlüsselt; Messwerte, Laborwerte, Medikamentennamen und Stimmungswerte werden unverschlüsselt gespeichert und sind so sicher wie dein Server. KI-Funktionen senden Daten nur an den KI-Anbieter, der für dein Konto eingerichtet ist, und es gibt keine Drittanbieter-Analyse deiner Gesundheitsdaten."
},
"customMetrics": {
"sectionTitle": "Eigene Metriken",
+9 -8
View File
@@ -4591,7 +4591,7 @@
"sampleBody": "Weight down 1.2 kg over two weeks. Resting pulse steady at 58 bpm. Blood pressure in range on 6 of 7 days — the one high reading followed a late, salty dinner.",
"sampleCaption": "Example only — not your own data. This is what a briefing reads like once insights are on.",
"ladderTitle": "Three ways to turn it on",
"ladderLocalTitle": "Local model — nothing leaves your network",
"ladderLocalTitle": "Local model — AI requests stay on your network",
"ladderLocalBody": "Point HealthLog at Ollama or LM Studio on your own machine. Free and fully private — the calm default.",
"ladderByokTitle": "Bring your own key",
"ladderByokBody": "Use your own OpenAI or Anthropic key. It runs under your account and billing, and the API tier keeps your data out of training by default.",
@@ -5655,10 +5655,10 @@
"codexConnected": "ChatGPT connected successfully! Insights are now active.",
"codexDisconnected": "ChatGPT connection removed.",
"codexConnectionFailed": "ChatGPT connection failed. Please try again.",
"rawData": "Send raw data",
"rawDataOnDescription": "Aggregated metrics plus anonymized individual points from the last 30 days with time context are sent — never your name, email, or direct account identifiers.",
"rawDataOffDescription": "Only summarized metrics are sent (for example averages, trends, minimum/maximum) — no individual points and no exact time context.",
"rawDataWarning": "Raw mode enabled: the provider receives additional anonymized points from the last 30 days for higher analysis accuracy.",
"rawData": "Send detailed history",
"rawDataOnDescription": "Besides the summaries, the provider receives averages of your readings: per day for the last 90 days, per week for the year before, and per month back to 5 years, each with its date and number of readings. Single readings, your name and your email are not sent.",
"rawDataOffDescription": "Only summaries are sent: the latest value, averages, trends, minimum and maximum over fixed windows. No series of readings.",
"rawDataWarning": "Detailed history is on: every analysis also sends daily averages for the last 90 days, weekly averages for the year before, and monthly averages back to 5 years.",
"regenerateInsights": "Regenerate reports",
"regenerateSuccess": "Reports regenerated successfully",
"regenerateRateLimit": "Please wait — you've hit the hourly limit for analyses.",
@@ -7092,6 +7092,7 @@
"versionTileBuiltAt": "Built at",
"versionTileUpdateAvailable": "Update available: {tag}",
"versionTileUpToDate": "Up to date",
"versionTileCheckOff": "Update check off",
"snapshotLoadError": "Failed to load system snapshot.",
"snapshotLoading": "Loading snapshot…",
"auditTitle": "Recent activity",
@@ -7296,7 +7297,7 @@
"description": "When this host last put each account's encrypted copy in your bucket.",
"loadError": "Could not load the off-host status.",
"notConfiguredTitle": "Off-host backup is not configured",
"notConfiguredDescription": "No bucket and no key are set, so nothing leaves this host. Set BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY and BACKUP_ENCRYPTION_KEY to turn the nightly upload on.",
"notConfiguredDescription": "No bucket and no key are set, so no backup leaves this host. Set BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY and BACKUP_ENCRYPTION_KEY to turn the nightly upload on.",
"stateFresh": "Fresh",
"stateDue": "Due",
"stateStale": "Stale",
@@ -7377,7 +7378,7 @@
"rotateEnqueued": "Rotation started.",
"rotateFailed": "Could not start the rotation.",
"stepUpNeeded": "A fresh second factor is required. Confirm your second factor, or use the command-line tool if you have no second factor enrolled.",
"cliNote": "A second factor is required to use this button. Operators without one rotate with scripts/rotate-encryption-key.ts.",
"cliNote": "A second factor is required to use this button. Without one, run scripts/rotate-encryption-key.ts from a source checkout of this release; the steps are in docs/ops/encryption-key-rotation.md.",
"columnsTitle": "Per-column coverage",
"columnsDescription": "Each encrypted column with its row counts on the active key versus older keys.",
"colColumn": "Column",
@@ -10243,7 +10244,7 @@
}
},
"selfDescription": {
"dataPosture": "HealthLog is self-hosted: your health data stays on your own instance, encrypted at rest. AI features use a provider key you bring or a local model, so nothing leaves your instance beyond the provider you configure, and there is no third-party analytics on your health data."
"dataPosture": "HealthLog is self-hosted: your health data lives in your own instance's database. Notes and other free text, documents, questionnaire answers, AI-written text and access tokens are encrypted at rest; measurement values, lab values, medication names and mood scores are stored unencrypted and rely on the security of your server. AI features send data only to the AI provider set up for your account, and there is no third-party analytics on your health data."
},
"customMetrics": {
"sectionTitle": "Custom metrics",
+9 -8
View File
@@ -4591,7 +4591,7 @@
"sampleBody": "Peso 1,2 kg menos en dos semanas. Pulso en reposo estable en 58 lpm. Tensión dentro del rango 6 de 7 días — la única lectura alta llegó tras una cena tardía y salada.",
"sampleCaption": "Solo un ejemplo — no son tus datos. Así se lee un resumen cuando los insights están activos.",
"ladderTitle": "Tres formas de activarlo",
"ladderLocalTitle": "Modelo local — nada sale de tu red",
"ladderLocalTitle": "Modelo local — las solicitudes de IA no salen de tu red",
"ladderLocalBody": "Apunta HealthLog a Ollama o LM Studio en tu propia máquina. Gratis y totalmente privado — la opción tranquila por defecto.",
"ladderByokTitle": "Usa tu propia clave",
"ladderByokBody": "Usa tu propia clave de OpenAI o Anthropic. Funciona con tu cuenta y facturación, y el nivel de API mantiene tus datos fuera del entrenamiento por defecto.",
@@ -5655,10 +5655,10 @@
"codexConnected": "¡ChatGPT conectado correctamente! Insights ya está activo.",
"codexDisconnected": "Conexión con ChatGPT eliminada.",
"codexConnectionFailed": "Falló la conexión con ChatGPT. Inténtalo de nuevo.",
"rawData": "Enviar datos en bruto",
"rawDataOnDescription": "Se envían métricas agregadas más puntos individuales anonimizados de los últimos 30 días con contexto temporal — nunca el nombre, el correo electrónico ni los identificadores directos de la cuenta.",
"rawDataOffDescription": "Solo se envían métricas resumidas (por ejemplo, promedios, tendencias, mínimo/máximo) — sin puntos individuales ni contexto temporal exacto.",
"rawDataWarning": "Modo en bruto activado: el proveedor recibe puntos anonimizados adicionales de los últimos 30 días para un análisis más preciso.",
"rawData": "Enviar historial detallado",
"rawDataOnDescription": "Además de los resúmenes, el proveedor recibe promedios de tus mediciones: por día de los últimos 90 días, por semana del año anterior y por mes hasta 5 años atrás, cada uno con su fecha y número de mediciones. No se envían mediciones sueltas, tu nombre ni tu correo electrónico.",
"rawDataOffDescription": "Solo se envían resúmenes: el último valor, promedios, tendencias, mínimo y máximo en ventanas fijas. Ninguna serie de mediciones.",
"rawDataWarning": "Historial detallado activado: cada análisis envía además promedios diarios de los últimos 90 días, promedios semanales del año anterior y promedios mensuales hasta 5 años atrás.",
"regenerateInsights": "Regenerar informes",
"regenerateSuccess": "Informes regenerados correctamente",
"regenerateRateLimit": "Espera un momento — has alcanzado el límite por hora de análisis.",
@@ -7092,6 +7092,7 @@
"versionTileBuiltAt": "Compilado el",
"versionTileUpdateAvailable": "Actualización disponible: {tag}",
"versionTileUpToDate": "Actualizado",
"versionTileCheckOff": "Comprobación de actualizaciones desactivada",
"snapshotLoadError": "No se pudo cargar la instantánea del sistema.",
"snapshotLoading": "Cargando instantánea...",
"auditTitle": "Actividad reciente",
@@ -7296,7 +7297,7 @@
"description": "Cuándo puso este host por última vez la copia cifrada de cada cuenta en tu bucket.",
"loadError": "No se pudo cargar el estado fuera del host.",
"notConfiguredTitle": "La copia fuera del host no está configurada",
"notConfiguredDescription": "No hay bucket ni clave definidos, así que nada sale de este host. Define BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY y BACKUP_ENCRYPTION_KEY para activar la subida nocturna.",
"notConfiguredDescription": "No hay bucket ni clave definidos, así que ninguna copia de seguridad sale de este host. Define BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY y BACKUP_ENCRYPTION_KEY para activar la subida nocturna.",
"stateFresh": "Reciente",
"stateDue": "Pendiente",
"stateStale": "Obsoleta",
@@ -7377,7 +7378,7 @@
"rotateEnqueued": "Rotación iniciada.",
"rotateFailed": "No se pudo iniciar la rotación.",
"stepUpNeeded": "Se requiere un segundo factor reciente. Confirma tu segundo factor o usa la herramienta de línea de comandos si no tienes ninguno configurado.",
"cliNote": "Se necesita un segundo factor para usar este botón. Los operadores sin él rotan con scripts/rotate-encryption-key.ts.",
"cliNote": "Se necesita un segundo factor para usar este botón. Sin él, ejecuta scripts/rotate-encryption-key.ts desde una copia del código fuente de esta versión; los pasos están en docs/ops/encryption-key-rotation.md.",
"columnsTitle": "Cobertura por columna",
"columnsDescription": "Cada columna cifrada con sus recuentos de filas en la clave activa frente a claves anteriores.",
"colColumn": "Columna",
@@ -10243,7 +10244,7 @@
}
},
"selfDescription": {
"dataPosture": "HealthLog es autoalojado: tus datos de salud permanecen en tu propia instancia, cifrados en reposo. Las funciones de IA usan una clave de proveedor que tú aportas o un modelo local, así que nada sale de tu instancia más allá del proveedor que configures, y no hay analítica de terceros sobre tus datos de salud."
"dataPosture": "HealthLog es autoalojado: tus datos de salud están en la base de datos de tu propia instancia. Las notas y otros textos libres, los documentos, las respuestas a cuestionarios, los textos escritos por la IA y los tokens de acceso se cifran en reposo; los valores de mediciones, los valores de laboratorio, los nombres de medicamentos y las puntuaciones de ánimo se guardan sin cifrar y dependen de la seguridad de tu servidor. Las funciones de IA envían datos solo al proveedor de IA configurado para tu cuenta, y no hay analítica de terceros sobre tus datos de salud."
},
"customMetrics": {
"sectionTitle": "Métricas personalizadas",
+9 -8
View File
@@ -4591,7 +4591,7 @@
"sampleBody": "Poids en baisse de 1,2 kg en deux semaines. Pouls au repos stable à 58 bpm. Tension dans la plage 6 jours sur 7 — la seule valeur élevée a suivi un dîner tardif et salé.",
"sampleCaption": "Exemple uniquement — pas vos données. Voilà à quoi ressemble un briefing une fois les insights activés.",
"ladderTitle": "Trois façons de l'activer",
"ladderLocalTitle": "Modèle local — rien ne quitte votre réseau",
"ladderLocalTitle": "Modèle local — les requêtes d’IA restent sur votre réseau",
"ladderLocalBody": "Pointez HealthLog vers Ollama ou LM Studio sur votre propre machine. Gratuit et totalement privé — le choix calme par défaut.",
"ladderByokTitle": "Apportez votre clé",
"ladderByokBody": "Utilisez votre propre clé OpenAI ou Anthropic. Elle fonctionne avec votre compte et votre facturation, et le niveau API exclut vos données de l'entraînement par défaut.",
@@ -5655,10 +5655,10 @@
"codexConnected": "ChatGPT connecté ! Insights est maintenant actif.",
"codexDisconnected": "Connexion à ChatGPT supprimée.",
"codexConnectionFailed": "Échec de la connexion à ChatGPT. Veuillez réessayer.",
"rawData": "Envoyer les données brutes",
"rawDataOnDescription": "Des métriques agrégées et des points individuels anonymisés des 30 derniers jours avec contexte temporel sont envoyés — jamais le nom, l’e-mail ni les identifiants de compte directs.",
"rawDataOffDescription": "Seules des métriques résumées sont envoyées (par exemple moyennes, tendances, minimum/maximum) — aucun point individuel ni contexte temporel exact.",
"rawDataWarning": "Mode brut activé : le fournisseur reçoit des points anonymisés supplémentaires des 30 derniers jours pour une analyse plus précise.",
"rawData": "Envoyer l’historique détaillé",
"rawDataOnDescription": "En plus des résumés, le fournisseur reçoit des moyennes de vos mesures : par jour sur les 90 derniers jours, par semaine sur l’année précédente et par mois jusqu’à 5 ans en arrière, chacune avec sa date et son nombre de mesures. Les mesures individuelles, votre nom et votre e-mail ne sont pas envoyés.",
"rawDataOffDescription": "Seuls des résumés sont envoyés : la dernière valeur, des moyennes, des tendances, le minimum et le maximum sur des fenêtres fixes. Aucune série de mesures.",
"rawDataWarning": "Historique détaillé activé : chaque analyse envoie aussi les moyennes journalières des 90 derniers jours, les moyennes hebdomadaires de l’année précédente et les moyennes mensuelles jusqu’à 5 ans en arrière.",
"regenerateInsights": "Régénérer les rapports",
"regenerateSuccess": "Rapports régénérés",
"regenerateRateLimit": "Veuillez patienter — vous avez atteint la limite horaire d’analyses.",
@@ -7092,6 +7092,7 @@
"versionTileBuiltAt": "Compilé le",
"versionTileUpdateAvailable": "Mise à jour disponible : {tag}",
"versionTileUpToDate": "À jour",
"versionTileCheckOff": "Vérification des mises à jour désactivée",
"snapshotLoadError": "Échec du chargement de l'instantané du système.",
"snapshotLoading": "Chargement de l'instantané...",
"auditTitle": "Activité récente",
@@ -7296,7 +7297,7 @@
"description": "Quand cet hôte a déposé pour la dernière fois la copie chiffrée de chaque compte dans votre bucket.",
"loadError": "Impossible de charger l'état hors hôte.",
"notConfiguredTitle": "La sauvegarde hors hôte n'est pas configurée",
"notConfiguredDescription": "Aucun bucket ni clé ne sont définis, donc rien ne quitte cet hôte. Définissez BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY et BACKUP_ENCRYPTION_KEY pour activer l'envoi nocturne.",
"notConfiguredDescription": "Aucun bucket ni clé ne sont définis, donc aucune sauvegarde ne quitte cet hôte. Définissez BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY et BACKUP_ENCRYPTION_KEY pour activer l'envoi nocturne.",
"stateFresh": "Récente",
"stateDue": "Attendue",
"stateStale": "Périmée",
@@ -7377,7 +7378,7 @@
"rotateEnqueued": "Rotation démarrée.",
"rotateFailed": "Impossible de démarrer la rotation.",
"stepUpNeeded": "Un second facteur récent est requis. Confirmez votre second facteur, ou utilisez l'outil en ligne de commande si aucun n'est configuré.",
"cliNote": "Un second facteur est requis pour ce bouton. Les opérateurs sans second facteur utilisent scripts/rotate-encryption-key.ts.",
"cliNote": "Un second facteur est requis pour ce bouton. Sans lui, lancez scripts/rotate-encryption-key.ts depuis une copie des sources de cette version ; les étapes sont dans docs/ops/encryption-key-rotation.md.",
"columnsTitle": "Couverture par colonne",
"columnsDescription": "Chaque colonne chiffrée avec ses décomptes de lignes sur la clé active par rapport aux clés plus anciennes.",
"colColumn": "Colonne",
@@ -10243,7 +10244,7 @@
}
},
"selfDescription": {
"dataPosture": "HealthLog est auto-hébergé : vos données de santé restent sur votre propre instance, chiffrées au repos. Les fonctions d'IA utilisent une clé de fournisseur que vous apportez ou un modèle local, donc rien ne quitte votre instance au-delà du fournisseur que vous configurez, et il n'y a aucune analyse tierce de vos données de santé."
"dataPosture": "HealthLog est auto-hébergé : vos données de santé se trouvent dans la base de données de votre propre instance. Les notes et autres textes libres, les documents, les réponses aux questionnaires, les textes rédigés par l'IA et les jetons d'accès sont chiffrés au repos ; les valeurs de mesure, les résultats de laboratoire, les noms de médicaments et les scores d'humeur sont stockés en clair et dépendent de la sécurité de votre serveur. Les fonctions d'IA n'envoient des données qu'au fournisseur d'IA configuré pour votre compte, et il n'y a aucune analyse tierce de vos données de santé."
},
"customMetrics": {
"sectionTitle": "Mesures personnalisées",
+9 -8
View File
@@ -4591,7 +4591,7 @@
"sampleBody": "Peso in calo di 1,2 kg in due settimane. Battito a riposo stabile a 58 bpm. Pressione nel range in 6 giorni su 7 — l'unico valore alto è arrivato dopo una cena tardiva e salata.",
"sampleCaption": "Solo un esempio — non i tuoi dati. Ecco come si legge un briefing quando gli insight sono attivi.",
"ladderTitle": "Tre modi per attivarlo",
"ladderLocalTitle": "Modello locale — nulla lascia la tua rete",
"ladderLocalTitle": "Modello locale — le richieste all’IA restano nella tua rete",
"ladderLocalBody": "Punta HealthLog su Ollama o LM Studio sulla tua macchina. Gratis e del tutto privato — la scelta tranquilla di default.",
"ladderByokTitle": "Usa la tua chiave",
"ladderByokBody": "Usa la tua chiave OpenAI o Anthropic. Gira sul tuo account e sulla tua fatturazione, e il piano API tiene i tuoi dati fuori dall'addestramento in modo predefinito.",
@@ -5655,10 +5655,10 @@
"codexConnected": "ChatGPT connesso correttamente! Insights è ora attivo.",
"codexDisconnected": "Connessione a ChatGPT rimossa.",
"codexConnectionFailed": "Connessione a ChatGPT non riuscita. Riprova.",
"rawData": "Invia dati grezzi",
"rawDataOnDescription": "Vengono inviate metriche aggregate più punti individuali anonimizzati degli ultimi 30 giorni con contesto temporale — mai nome, e-mail o identificatori diretti dell’account.",
"rawDataOffDescription": "Vengono inviate solo metriche riassuntive (ad esempio medie, tendenze, minimo/massimo) — nessun punto individuale e nessun contesto temporale preciso.",
"rawDataWarning": "Modalità grezza attiva: il provider riceve ulteriori punti anonimizzati degli ultimi 30 giorni per un’analisi più precisa.",
"rawData": "Invia cronologia dettagliata",
"rawDataOnDescription": "Oltre ai riepiloghi, il provider riceve le medie delle tue misurazioni: per giorno negli ultimi 90 giorni, per settimana nell’anno precedente e per mese fino a 5 anni fa, ciascuna con data e numero di misurazioni. Le singole misurazioni, il tuo nome e la tua e-mail non vengono inviati.",
"rawDataOffDescription": "Vengono inviati solo riepiloghi: l’ultimo valore, medie, tendenze, minimo e massimo su finestre fisse. Nessuna serie di misurazioni.",
"rawDataWarning": "Cronologia dettagliata attiva: ogni analisi invia anche le medie giornaliere degli ultimi 90 giorni, le medie settimanali dell’anno precedente e le medie mensili fino a 5 anni fa.",
"regenerateInsights": "Rigenera i report",
"regenerateSuccess": "Report rigenerati correttamente",
"regenerateRateLimit": "Attendi — hai raggiunto il limite orario di analisi.",
@@ -7092,6 +7092,7 @@
"versionTileBuiltAt": "Compilato il",
"versionTileUpdateAvailable": "Aggiornamento disponibile: {tag}",
"versionTileUpToDate": "Aggiornato",
"versionTileCheckOff": "Controllo aggiornamenti disattivato",
"snapshotLoadError": "Impossibile caricare l'istantanea del sistema.",
"snapshotLoading": "Caricamento istantanea...",
"auditTitle": "Attività recente",
@@ -7296,7 +7297,7 @@
"description": "Quando questo host ha messo per l'ultima volta la copia cifrata di ogni account nel tuo bucket.",
"loadError": "Impossibile caricare lo stato fuori host.",
"notConfiguredTitle": "Il backup fuori host non è configurato",
"notConfiguredDescription": "Non sono impostati né bucket né chiave, quindi nulla lascia questo host. Imposta BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY e BACKUP_ENCRYPTION_KEY per attivare il caricamento notturno.",
"notConfiguredDescription": "Non sono impostati né bucket né chiave, quindi nessun backup lascia questo host. Imposta BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY e BACKUP_ENCRYPTION_KEY per attivare il caricamento notturno.",
"stateFresh": "Recente",
"stateDue": "Attesa",
"stateStale": "Obsoleta",
@@ -7377,7 +7378,7 @@
"rotateEnqueued": "Rotazione avviata.",
"rotateFailed": "Impossibile avviare la rotazione.",
"stepUpNeeded": "È richiesto un secondo fattore recente. Conferma il secondo fattore oppure usa lo strumento da riga di comando se non ne hai configurato uno.",
"cliNote": "Per questo pulsante serve un secondo fattore. Gli operatori senza secondo fattore ruotano con scripts/rotate-encryption-key.ts.",
"cliNote": "Per questo pulsante serve un secondo fattore. Senza, esegui scripts/rotate-encryption-key.ts da una copia dei sorgenti di questa versione; i passaggi sono in docs/ops/encryption-key-rotation.md.",
"columnsTitle": "Copertura per colonna",
"columnsDescription": "Ogni colonna cifrata con i conteggi di righe sulla chiave attiva rispetto alle chiavi più vecchie.",
"colColumn": "Colonna",
@@ -10243,7 +10244,7 @@
}
},
"selfDescription": {
"dataPosture": "HealthLog è self-hosted: i tuoi dati di salute restano sulla tua istanza, cifrati a riposo. Le funzioni di IA usano una chiave del provider che fornisci tu o un modello locale, quindi nulla lascia la tua istanza oltre il provider che configuri, e non c'è alcuna analisi di terze parti sui tuoi dati di salute."
"dataPosture": "HealthLog è self-hosted: i tuoi dati di salute si trovano nel database della tua istanza. Note e altri testi liberi, documenti, risposte ai questionari, testi scritti dall'IA e token di accesso sono cifrati a riposo; valori delle misurazioni, valori di laboratorio, nomi dei farmaci e punteggi dell'umore sono salvati in chiaro e dipendono dalla sicurezza del tuo server. Le funzioni di IA inviano dati solo al provider di IA configurato per il tuo account, e non c'è alcuna analisi di terze parti sui tuoi dati di salute."
},
"customMetrics": {
"sectionTitle": "Metriche personalizzate",
+9 -8
View File
@@ -4591,7 +4591,7 @@
"sampleBody": "2주 동안 체중 1.2kg 감소. 안정 시 맥박은 58bpm으로 꾸준해요. 혈압은 7일 중 6일 범위 안 — 높았던 하루는 늦은 시간의 짠 저녁 식사 다음이었어요.",
"sampleCaption": "예시일 뿐 내 데이터가 아니에요. 인사이트를 켜면 브리핑이 이런 식으로 읽혀요.",
"ladderTitle": "켜는 방법 세 가지",
"ladderLocalTitle": "로컬 모델 — 내 네트워크 밖으로 나가지 않아요",
"ladderLocalTitle": "로컬 모델 — AI 요청이 내 네트워크 안에 머물러요",
"ladderLocalBody": "내 컴퓨터의 Ollama나 LM Studio를 HealthLog에 연결해요. 무료이고 완전히 비공개 — 가장 마음 편한 기본값이에요.",
"ladderByokTitle": "내 API 키 사용",
"ladderByokBody": "본인의 OpenAI나 Anthropic 키를 쓰면 돼요. 내 계정과 결제로 돌아가고, API 등급은 기본적으로 데이터를 학습에 쓰지 않아요.",
@@ -5655,10 +5655,10 @@
"codexConnected": "ChatGPT를 연결했어요! 이제 인사이트를 쓸 수 있어요.",
"codexDisconnected": "ChatGPT 연결을 해제했어요.",
"codexConnectionFailed": "ChatGPT 연결에 실패했어요. 다시 시도해 주세요.",
"rawData": "원본 데이터 보내기",
"rawDataOnDescription": "집계 지표와 함께 최근 30일의 익명화된 개별 값이 시간 맥락과 함께 전송돼요 — 이름, 이메일, 계정 식별자는 절대 보내지 않아요.",
"rawDataOffDescription": "요약 지표만 전송돼요(예: 평균, 추이, 최소/최대) — 개별 값이나 정확한 시간 맥락은 보내지 않아요.",
"rawDataWarning": "원본 모드가 켜져 있어요. 분석 정확도를 높이려고 제공자에게 최근 30일의 익명화된 값이 추가로 전달돼요.",
"rawData": "자세한 기록 보내기",
"rawDataOnDescription": "요약과 함께 측정값의 평균이 제공자에게 전송돼요. 최근 90일은 하루 단위, 그 이전 1년은 주 단위, 최대 5년 전까지는 월 단위이며 각각 날짜와 측정 횟수가 포함돼요. 개별 측정값, 이름, 이메일은 보내지 않아요.",
"rawDataOffDescription": "요약만 전송돼요. 최근 값, 평균, 추이, 정해진 기간의 최소/최대가 포함되며 측정값 시계열은 보내지 않아요.",
"rawDataWarning": "자세한 기록이 켜져 있어요. 분석할 때마다 최근 90일의 일별 평균, 그 이전 1년의 주별 평균, 최대 5년 전까지의 월별 평균이 추가로 전송돼요.",
"regenerateInsights": "리포트 다시 생성",
"regenerateSuccess": "리포트를 다시 생성했어요",
"regenerateRateLimit": "잠시만 기다려 주세요 — 시간당 분석 한도에 도달했어요.",
@@ -7071,6 +7071,7 @@
"versionTileBuiltAt": "빌드 시각",
"versionTileUpdateAvailable": "업데이트 있음: {tag}",
"versionTileUpToDate": "최신 상태",
"versionTileCheckOff": "업데이트 확인 꺼짐",
"snapshotLoadError": "시스템 스냅샷을 불러오지 못했어요.",
"snapshotLoading": "스냅샷 불러오는 중…",
"auditTitle": "최근 활동",
@@ -7275,7 +7276,7 @@
"description": "이 호스트가 각 계정의 암호화 사본을 버킷에 마지막으로 올린 시점입니다.",
"loadError": "외부 보관 상태를 불러오지 못했습니다.",
"notConfiguredTitle": "외부 보관 백업이 설정되지 않았습니다",
"notConfiguredDescription": "버킷과 키가 모두 설정되지 않아 이 호스트를 벗어나는 것이 없습니다. 야간 업로드를 켜려면 BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY, BACKUP_ENCRYPTION_KEY를 설정하세요.",
"notConfiguredDescription": "버킷과 키가 모두 설정되지 않아 이 호스트를 벗어나는 백업이 없습니다. 야간 업로드를 켜려면 BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY, BACKUP_ENCRYPTION_KEY를 설정하세요.",
"stateFresh": "최신",
"stateDue": "지연",
"stateStale": "오래됨",
@@ -7356,7 +7357,7 @@
"rotateEnqueued": "교체를 시작했어요.",
"rotateFailed": "교체를 시작하지 못했어요.",
"stepUpNeeded": "2단계 인증을 다시 확인해야 해요. 2단계 인증을 확인하거나, 등록한 수단이 없으면 명령줄 도구를 써 주세요.",
"cliNote": "이 버튼을 쓰려면 2단계 인증이 필요해요. 등록하지 않은 운영자는 scripts/rotate-encryption-key.ts로 교체해 주세요.",
"cliNote": "이 버튼을 쓰려면 2단계 인증이 필요해요. 등록하지 않았다면 이 버전의 소스 코드 체크아웃에서 scripts/rotate-encryption-key.ts를 실행해 주세요. 절차는 docs/ops/encryption-key-rotation.md에 있어요.",
"columnsTitle": "열별 적용 범위",
"columnsDescription": "암호화된 열마다 활성 키와 이전 키의 행 수를 보여줘요.",
"colColumn": "열",
@@ -10243,7 +10244,7 @@
}
},
"selfDescription": {
"dataPosture": "HealthLog는 셀프 호스팅이에요. 건강 데이터는 본인 인스턴스에 암호화되어 저장돼요. AI 기능은 직접 준비한 제공자 키나 로컬 모델을 쓰기 때문에 설정한 제공자 외에는 아무것도 인스턴스 밖으로 나가지 않고, 건강 데이터에 대한 서드파티 분석도 없어요."
"dataPosture": "HealthLog는 셀프 호스팅이에요. 건강 데이터는 본인 인스턴스의 데이터베이스에 저장돼요. 메모와 기타 자유 입력 텍스트, 문서, 설문 응답, AI가 작성한 텍스트, 접근 토큰은 저장 시 암호화돼요. 측정값, 검사 수치, 약 이름, 기분 점수는 암호화되지 않은 채 저장되며 서버 자체의 보안에 의존해요. AI 기능은 계정에 설정된 AI 제공자에게만 데이터를 보내고, 건강 데이터에 대한 서드파티 분석은 없어요."
},
"customMetrics": {
"sectionTitle": "사용자 지표",
+9 -8
View File
@@ -4591,7 +4591,7 @@
"sampleBody": "Waga niższa o 1,2 kg w ciągu dwóch tygodni. Tętno spoczynkowe stabilne na 58 bpm. Ciśnienie w normie przez 6 z 7 dni — jedyny wysoki odczyt nastąpił po późnej, słonej kolacji.",
"sampleCaption": "Tylko przykład — to nie są Twoje dane. Tak czyta się briefing, gdy insighty są włączone.",
"ladderTitle": "Trzy sposoby, by to włączyć",
"ladderLocalTitle": "Model lokalny — nic nie opuszcza Twojej sieci",
"ladderLocalTitle": "Model lokalny — zapytania do AI zostają w Twojej sieci",
"ladderLocalBody": "Skieruj HealthLog na Ollama lub LM Studio na własnej maszynie. Za darmo i w pełni prywatnie — spokojny wybór domyślny.",
"ladderByokTitle": "Użyj własnego klucza",
"ladderByokBody": "Użyj własnego klucza OpenAI lub Anthropic. Działa na Twoim koncie i rozliczeniu, a poziom API domyślnie wyklucza Twoje dane z treningu.",
@@ -5655,10 +5655,10 @@
"codexConnected": "ChatGPT połączony pomyślnie! Insights jest teraz aktywny.",
"codexDisconnected": "Połączenie z ChatGPT usunięte.",
"codexConnectionFailed": "Połączenie z ChatGPT nie powiodło się. Spróbuj ponownie.",
"rawData": "Wysyłaj surowe dane",
"rawDataOnDescription": "Wysyłane są zagregowane metryki oraz zanonimizowane pojedyncze punkty z ostatnich 30 dni z kontekstem czasowym — nigdy imię, adres e-mail ani bezpośrednie identyfikatory konta.",
"rawDataOffDescription": "Wysyłane są tylko podsumowane metryki (np. średnie, trendy, minimum/maksimum) — bez pojedynczych punktów i bez dokładnego kontekstu czasowego.",
"rawDataWarning": "Tryb surowych danych włączony: dostawca otrzymuje dodatkowe zanonimizowane punkty z ostatnich 30 dni dla dokładniejszej analizy.",
"rawData": "Wysyłaj szczegółową historię",
"rawDataOnDescription": "Oprócz podsumowań dostawca otrzymuje średnie z Twoich pomiarów: dzienne z ostatnich 90 dni, tygodniowe z roku wcześniej i miesięczne do 5 lat wstecz, każdą z datą i liczbą pomiarów. Pojedyncze pomiary, Twoje imię i adres e-mail nie są wysyłane.",
"rawDataOffDescription": "Wysyłane są tylko podsumowania: ostatnia wartość, średnie, trendy, minimum i maksimum w stałych okresach. Bez serii pomiarów.",
"rawDataWarning": "Szczegółowa historia jest włączona: każda analiza wysyła dodatkowo średnie dzienne z ostatnich 90 dni, średnie tygodniowe z roku wcześniej i średnie miesięczne do 5 lat wstecz.",
"regenerateInsights": "Wygeneruj raporty ponownie",
"regenerateSuccess": "Raporty wygenerowane ponownie",
"regenerateRateLimit": "Poczekaj — osiągnięto godzinny limit analiz.",
@@ -7092,6 +7092,7 @@
"versionTileBuiltAt": "Skompilowano",
"versionTileUpdateAvailable": "Dostępna aktualizacja: {tag}",
"versionTileUpToDate": "Aktualne",
"versionTileCheckOff": "Sprawdzanie aktualizacji wyłączone",
"snapshotLoadError": "Nie udało się załadować migawki systemu.",
"snapshotLoading": "Ładowanie migawki...",
"auditTitle": "Ostatnia aktywność",
@@ -7296,7 +7297,7 @@
"description": "Kiedy ten host ostatnio umieścił zaszyfrowaną kopię każdego konta w Twoim buckecie.",
"loadError": "Nie udało się wczytać stanu poza hostem.",
"notConfiguredTitle": "Kopia poza hostem nie jest skonfigurowana",
"notConfiguredDescription": "Nie ustawiono ani bucketu, ani klucza, więc nic nie opuszcza tego hosta. Ustaw BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY i BACKUP_ENCRYPTION_KEY, aby włączyć nocne wysyłanie.",
"notConfiguredDescription": "Nie ustawiono ani bucketu, ani klucza, więc żadna kopia zapasowa nie opuszcza tego hosta. Ustaw BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_ACCESS_KEY, BACKUP_S3_SECRET_KEY i BACKUP_ENCRYPTION_KEY, aby włączyć nocne wysyłanie.",
"stateFresh": "Świeża",
"stateDue": "Zaległa",
"stateStale": "Przeterminowana",
@@ -7377,7 +7378,7 @@
"rotateEnqueued": "Rotacja rozpoczęta.",
"rotateFailed": "Nie udało się rozpocząć rotacji.",
"stepUpNeeded": "Wymagany jest świeży drugi składnik. Potwierdź drugi składnik lub użyj narzędzia wiersza poleceń, jeśli nie masz skonfigurowanego żadnego.",
"cliNote": "Do tego przycisku wymagany jest drugi składnik. Operatorzy bez niego rotują przez scripts/rotate-encryption-key.ts.",
"cliNote": "Do tego przycisku wymagany jest drugi składnik. Bez niego uruchom scripts/rotate-encryption-key.ts z kopii kodu źródłowego tej wersji; kroki opisuje docs/ops/encryption-key-rotation.md.",
"columnsTitle": "Pokrycie wg kolumn",
"columnsDescription": "Każda zaszyfrowana kolumna z liczbą wierszy na aktywnym kluczu względem starszych kluczy.",
"colColumn": "Kolumna",
@@ -10243,7 +10244,7 @@
}
},
"selfDescription": {
"dataPosture": "HealthLog jest hostowany samodzielnie: Twoje dane zdrowotne pozostają na Twojej instancji, zaszyfrowane w spoczynku. Funkcje AI korzystają z dostarczonego przez Ciebie klucza dostawcy lub modelu lokalnego, więc nic nie opuszcza Twojej instancji poza skonfigurowanym dostawcą i nie ma analityki firm trzecich na Twoich danych zdrowotnych."
"dataPosture": "HealthLog jest hostowany samodzielnie: Twoje dane zdrowotne znajdują się w bazie danych Twojej instancji. Notatki i inne teksty swobodne, dokumenty, odpowiedzi w kwestionariuszach, teksty napisane przez AI i tokeny dostępu są zaszyfrowane w spoczynku; wartości pomiarów, wyniki badań laboratoryjnych, nazwy leków i oceny nastroju są przechowywane bez szyfrowania i zależą od bezpieczeństwa Twojego serwera. Funkcje AI wysyłają dane tylko do dostawcy AI skonfigurowanego dla Twojego konta i nie ma analityki firm trzecich na Twoich danych zdrowotnych."
},
"customMetrics": {
"sectionTitle": "Metryki własne",
+4 -2
View File
@@ -26,8 +26,8 @@
"scripts": {
"prepare": "node scripts/prepare.mjs",
"dev": "next dev",
"predev": "node scripts/generate-i18n-boot.mjs",
"prebuild": "node scripts/generate-sw-version.mjs && node scripts/generate-i18n-boot.mjs",
"predev": "node scripts/generate-i18n-boot.mjs && node scripts/stage-tesseract-assets.mjs",
"prebuild": "node scripts/generate-sw-version.mjs && node scripts/generate-i18n-boot.mjs && node scripts/stage-tesseract-assets.mjs",
"build": "NODE_OPTIONS=--max-old-space-size=8192 next build",
"analyze": "ANALYZE=1 next build",
"bundle-report": "node scripts/check-bundle-budget.mjs",
@@ -112,6 +112,8 @@
"@next/bundle-analyzer": "^16.3.3",
"@playwright/test": "^1.63.0",
"@tailwindcss/postcss": "^4",
"@tesseract.js-data/deu": "1.0.0",
"@tesseract.js-data/eng": "1.0.0",
"@testcontainers/postgresql": "^12.1.0",
"@types/node": "26.5.1",
"@types/pg": "^8.23.1",
+16
View File
@@ -204,6 +204,12 @@ importers:
'@tailwindcss/postcss':
specifier: ^4
version: 4.3.3
'@tesseract.js-data/deu':
specifier: 1.0.0
version: 1.0.0
'@tesseract.js-data/eng':
specifier: 1.0.0
version: 1.0.0
'@testcontainers/postgresql':
specifier: ^12.1.0
version: 12.1.0(supports-color@7.2.0)
@@ -2819,6 +2825,12 @@ packages:
'@tanstack/virtual-core@3.17.10':
resolution: {integrity: sha512-eMorKWIi2nekElu+8DgSMJR4iR+sTDeh+W8VjKiMH79yUfEDJ3W9aL/96/wwbxNjyV6EfN39HKszSxjd90zAqQ==}
'@tesseract.js-data/deu@1.0.0':
resolution: {integrity: sha512-3ter1p2gFAiCd5CMC+PodZ9YY2UDomkKj+ytlKeeZqOUmhLx1SDnYPf5zog0g5MBbxYveW+8omI3PUflqHfZ4Q==}
'@tesseract.js-data/eng@1.0.0':
resolution: {integrity: sha512-mbTumm6KQPUHyzTPQaF3ObXYnx0SqqfV2nabqFVQBwD6Kl7PhGSLSzOlfFTWy0P3BjghaSKA2W9GB19Jk+ZcTg==}
'@testcontainers/postgresql@12.1.0':
resolution: {integrity: sha512-Pjf2VSVNirEPfz36nidyrVAnZvc2YhajOznY4VgyEsvfTd5qiMNOuPq96drREvxAUtXl5SFLX7vXj7sSq4aTcA==}
@@ -9508,6 +9520,10 @@ snapshots:
'@tanstack/virtual-core@3.17.10': {}
'@tesseract.js-data/deu@1.0.0': {}
'@tesseract.js-data/eng@1.0.0': {}
'@testcontainers/postgresql@12.1.0(supports-color@7.2.0)':
dependencies:
testcontainers: 12.1.0(supports-color@7.2.0)
+19
View File
@@ -163,6 +163,25 @@
}
]
},
{
"name": "Outbound requests",
"description": "Switches for the requests HealthLog makes to third parties on its own. All default to on; set any of them to 1, true or yes to turn that request off.",
"required": false,
"variables": [
{
"name": "IP_GEO_LOOKUP_DISABLED",
"purpose": "Stops the online IP-to-location lookup for login events and the monthly MaxMind download. Only the local GeoLite2 databases are used."
},
{
"name": "PASSWORD_BREACH_CHECK_DISABLED",
"purpose": "Stops the HaveIBeenPwned range query made when a password is set. The query carries the first five hex characters of the password's SHA-1, never the password or the full hash."
},
{
"name": "UPDATE_CHECK_DISABLED",
"purpose": "Stops the daily request to the GitHub releases API behind the About page and the admin version tile."
}
]
},
{
"name": "APNs (iOS push)",
"description": "All four core APNS_* vars together. Missing any disables APNs silently \u2014 the v1.4.40 AP-2 gap that motivated this manifest. allOrNone trips the synthetic <all-or-none> required row whenever 1-3 of the 4 are set.",
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env node
/**
* Stage the in-browser OCR engine under `public/tesseract/`.
*
* `src/lib/labs/local-ocr.ts` reads a lab-report photo on the device with
* tesseract.js. Left at its defaults, tesseract.js fetches its worker, its
* WebAssembly core and the language data from cdn.jsdelivr.net: the browser
* of every user who scans a report contacts a third-party CDN, and the
* production CSP (`worker-src 'self'`, `connect-src 'self'`) refuses those
* requests, so the feature could not load at all. Serving the same files
* from the app's own origin removes both problems.
*
* What is copied, all from pinned packages in node_modules:
* - tesseract.js `dist/worker.min.js`
* - the three LSTM-only core builds of tesseract.js-core (plain, SIMD,
* relaxed SIMD); the worker picks one by feature detection. LSTM-only
* because `createWorker` defaults to OEM 1.
* - `deu` and `eng` `4.0.0_best_int` language data, gzipped, which is what
* OEM 1 loads.
*
* Runs as a `prebuild`/`predev` step; the output is gitignored.
*/
import { copyFileSync, mkdirSync, rmSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const require = createRequire(import.meta.url);
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
const outDir = join(repoRoot, "public", "tesseract");
const pkgDir = (name) => dirname(require.resolve(`${name}/package.json`));
// tesseract.js-core is a dependency of tesseract.js, not of this project;
// resolve it from tesseract.js's own location so pnpm's strict layout finds it.
const tesseractDir = pkgDir("tesseract.js");
const coreDir = dirname(
createRequire(join(tesseractDir, "package.json")).resolve(
"tesseract.js-core/package.json",
),
);
const files = [
[join(tesseractDir, "dist", "worker.min.js"), "worker.min.js"],
...[
"tesseract-core-lstm.wasm.js",
"tesseract-core-simd-lstm.wasm.js",
"tesseract-core-relaxedsimd-lstm.wasm.js",
].map((file) => [join(coreDir, file), join("core", file)]),
// Literal specifiers, so the dependency is visible to static analysis.
...[
["deu", require.resolve("@tesseract.js-data/deu/package.json")],
["eng", require.resolve("@tesseract.js-data/eng/package.json")],
].map(([lang, manifest]) => [
join(dirname(manifest), "4.0.0_best_int", `${lang}.traineddata.gz`),
join("lang", `${lang}.traineddata.gz`),
]),
];
rmSync(outDir, { recursive: true, force: true });
for (const [from, to] of files) {
const target = join(outDir, to);
mkdirSync(dirname(target), { recursive: true });
copyFileSync(from, target);
}
console.log(
`stage-tesseract-assets: ${files.length} files -> public/tesseract`,
);
@@ -46,6 +46,13 @@ const SRC = join(ROOT, "src");
const DIRECT_READ =
/process\.env(?:\.([A-Za-z_][A-Za-z0-9_]*)|\[\s*["'`]([A-Za-z_][A-Za-z0-9_]*)["'`]\s*\])/g;
/**
* `envValue("NAME")`, `envOr("NAME", …)` and `envFlag("NAME")` from
* `src/lib/env.ts` — the one helper every read with a fallback goes through,
* so its call shape is a read wherever it appears. Asserted non-empty in T2.
*/
const HELPER_READ = /\benv(?:Value|Or|Flag)\(\s*["'`]([A-Z][A-Z0-9_]*)["'`]/g;
/**
* Modules that index `process.env` through a variable rather than a literal.
* The direct matcher cannot see their reads at all, so each names the exact
@@ -154,6 +161,7 @@ function runtimeReads(): Set<string> {
for (const rel of sourceFiles()) {
const src = read(rel);
for (const m of src.matchAll(DIRECT_READ)) names.add(m[1] ?? m[2]);
for (const m of src.matchAll(HELPER_READ)) names.add(m[1]);
}
for (const reader of INDIRECT_READERS) {
for (const m of read(reader.file).matchAll(reader.pattern)) names.add(m[1]);
@@ -191,6 +199,13 @@ describe("compose env whitelist", () => {
});
it("T2 — every indirect reader still matches its call shape", () => {
const helperReads = sourceFiles().flatMap((rel) =>
[...read(rel).matchAll(HELPER_READ)].map((m) => m[1]),
);
// The env helper is the common path for reads with a fallback; a matcher
// that stopped seeing it would drop those names from the read set.
expect(helperReads.length).toBeGreaterThan(10);
expect(helperReads).toContain("IP_GEO_LOOKUP_URL");
for (const reader of INDIRECT_READERS) {
const found = [...read(reader.file).matchAll(reader.pattern)].map(
(m) => m[1],
@@ -0,0 +1,111 @@
/**
* Structural guard: a script a runbook tells the operator to run inside the
* container is actually in the image, together with every local file it
* imports.
*
* ## The failure this freezes
*
* `docs/ops/password-reset.md` told operators to run
* `docker compose exec app node scripts/reset-password.mjs`, and the runner
* stage of the Dockerfile copied nothing from `scripts/`: the standalone
* output does not carry it. The recovery path for a locked-out admin failed
* with "Cannot find module" at the moment it was needed. The intake-repair
* runbook had the same gap under the `healthlog-tsx` launcher.
*
* ## What it proves and what it does not
*
* Every `docker compose exec … node scripts/…` or `healthlog-tsx scripts/…`
* command in `docs/` names
* a file the runner stage copies, and every relative import of that file is
* copied too. Whether the package imports resolve is proved at image build by
* the `RUN` checks next to the copies; this file cannot see the traced tree.
* A command written in some other form (a different launcher) is outside the
* matcher.
*/
import { describe, expect, it } from "vitest";
import { readFileSync, readdirSync } from "node:fs";
import { dirname, join, normalize } from "node:path";
const ROOT = process.cwd();
const DOCKERFILE = readFileSync(join(ROOT, "Dockerfile"), "utf8");
/**
* In-container command forms: `docker compose exec <service> node scripts/…`
* or the image's own `healthlog-tsx scripts/…` launcher. A bare
* `node scripts/…` is a workstation or source-checkout command and is out of
* scope.
*/
const CONTAINER_COMMAND =
/(?:docker compose exec\s+(?:-\S+\s+)*\S+\s+node|\bhealthlog-tsx)\s+(scripts\/[\w.-]+)/g;
function markdownFiles(dir: string): string[] {
return readdirSync(dir, { recursive: true, encoding: "utf8" })
.filter((rel) => rel.endsWith(".md"))
.map((rel) => join(dir, rel));
}
function runnerStage(): string {
const start = DOCKERFILE.search(/^FROM .* AS runner$/m);
expect(start, "the Dockerfile has a runner stage").toBeGreaterThan(0);
return DOCKERFILE.slice(start);
}
function copiedIntoRunner(path: string): boolean {
const escaped = path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(
`^COPY --from=builder (?:--chown=\\S+ )?/app/${escaped} \\./${escaped}$`,
"m",
).test(runnerStage());
}
/** Scripts named by a container command anywhere under docs/. */
function documentedContainerScripts(): string[] {
const found = new Set<string>();
for (const file of markdownFiles(join(ROOT, "docs"))) {
for (const m of readFileSync(file, "utf8").matchAll(CONTAINER_COMMAND)) {
found.add(m[1]);
}
}
return [...found].sort();
}
/** Relative import specifiers of a script, resolved against its location. */
function localImports(script: string): string[] {
const src = readFileSync(join(ROOT, script), "utf8");
const specs = [
...src.matchAll(/^\s*import\s[^;]*?from\s+["'](\.{1,2}\/[^"']+)["']/gm),
].map((m) => m[1]);
return specs.map((spec) => normalize(join(dirname(script), spec)));
}
describe("scripts the runbooks run inside the container ship in the image", () => {
it("finds the commands it is meant to check", () => {
const scripts = documentedContainerScripts();
// A matcher that found nothing would agree with any Dockerfile.
expect(scripts).toContain("scripts/reset-password.mjs");
expect(scripts).toContain("scripts/repair-intake-anomalies.ts");
expect(localImports("scripts/reset-password.mjs")).toContain(
"src/lib/auth/argon2-params.mjs",
);
expect(copiedIntoRunner("scripts/not-a-real-script.mjs")).toBe(false);
});
it("copies every documented script into the runner stage", () => {
const missing = documentedContainerScripts().filter(
(script) => !copiedIntoRunner(script),
);
expect(
missing,
"A runbook runs these inside the container, but the Dockerfile runner " +
"stage does not COPY them. Add the COPY (and its local imports), or " +
"change the runbook to a source-checkout command.",
).toEqual([]);
});
it("copies every local file those scripts import", () => {
const missing = documentedContainerScripts()
.flatMap((script) => localImports(script))
.filter((path) => !copiedIntoRunner(path));
expect(missing).toEqual([]);
});
});
@@ -0,0 +1,107 @@
/**
* Structural guard: an environment variable with a fallback is read through
* `src/lib/env.ts`, never as `process.env.NAME ?? fallback`.
*
* ## The failure this freezes
*
* `docker-compose.yml` forwards its whitelist as `"${NAME:-}"`, so every
* listed variable the operator left unset arrives as the empty string. `??`
* only replaces `null` and `undefined`, so the empty string wins and the
* fallback never applies. On a stock compose stack that sent Withings an
* OAuth `redirect_uri=""`, handed the weather client an empty base URL
* (`ERR_INVALID_URL` on every fetch) and pointed the IP geo lookup at a
* relative path. The same shape with a method in between,
* `process.env.NAME?.replace(...) ?? fallback`, fails the same way.
*
* ## What it proves and what it does not
*
* It proves no source file under `src/` writes a nullish fallback directly
* against a `process.env` read. It does not see a read stored in a local
* first and defaulted later (`const x = process.env.NAME; x ?? y`); those
* stay a review question. `||` is allowed: it already treats the empty
* string as unset, and client components need a literal
* `process.env.NEXT_PUBLIC_X` for build-time inlining.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
/**
* `process.env.NAME` or `process.env["NAME"]`, optionally followed by
* optional-chained calls such as `?.trim()` or `?.replace(/x/, "")`, then
* `??` across any whitespace including a line break.
*/
const NULLISH_ENV_FALLBACK =
/process\.env(?:\.[A-Za-z_][A-Za-z0-9_]*|\[\s*["'`][A-Za-z_][A-Za-z0-9_]*["'`]\s*\])(?:\?\.[A-Za-z_]+\((?:[^()]|\([^()]*\))*\))*\s*\?\?/g;
/**
* Files that still carry the pattern because another change owns them.
* Each entry fails T3 once the file no longer matches, so the list can only
* shrink.
*/
const PENDING: Readonly<Record<string, string>> = {
"lib/withings/client.ts":
"WITHINGS_REDIRECT_URI ?? derived callback; the integration clients are being reworked in a parallel change that owns this file",
};
function sourceFiles(): string[] {
return walkSourceFiles(SRC, { floor: 3000 })
.filter((rel) => !rel.startsWith("generated/"))
.filter((rel) => !rel.includes("__tests__"))
.filter((rel) => !rel.endsWith(".test.ts") && !rel.endsWith(".test.tsx"))
.filter((rel) => rel !== "lib/env.ts");
}
function offenders(): string[] {
return sourceFiles().filter((rel) => {
const src = readFileSync(join(SRC, rel), "utf8");
return [...src.matchAll(NULLISH_ENV_FALLBACK)].length > 0;
});
}
describe("an empty environment variable counts as unset", () => {
it("T1 — the matcher catches every shape it claims to", () => {
const shapes = [
`const a = process.env.FOO ?? "x";`,
`const b = process.env.FOO\n ?? "x";`,
`const c = process.env["FOO"] ?? "x";`,
`const d = process.env.FOO?.replace(/\\/$/, "") ?? "x";`,
`const e = process.env.FOO?.trim()?.toLowerCase() ?? "x";`,
];
for (const shape of shapes) {
expect(
[...shape.matchAll(NULLISH_ENV_FALLBACK)].length,
shape,
).toBeGreaterThan(0);
}
for (const allowed of [
`const f = process.env.FOO || "x";`,
`const g = envOr("FOO", "x");`,
`const h = process.env.FOO === "1" ? a ?? b : c;`,
]) {
expect([...allowed.matchAll(NULLISH_ENV_FALLBACK)], allowed).toEqual([]);
}
});
it("T2 — no source file defaults a process.env read with ??", () => {
const found = offenders().filter((rel) => !(rel in PENDING));
expect(
found,
"Read these through envValue/envOr/envFlag from @/lib/env. Compose " +
"passes an unset variable as the empty string, and ?? keeps it.",
).toEqual([]);
});
it("T3 — every pending entry still matches, so the list only shrinks", () => {
const current = new Set(offenders());
const stale = Object.keys(PENDING).filter((rel) => !current.has(rel));
expect(
stale,
"No longer carries the pattern; drop the entry from PENDING.",
).toEqual([]);
});
});
@@ -0,0 +1,66 @@
/**
* The HaveIBeenPwned check sits behind one operator switch, and nothing
* reaches it except through that switch.
*
* `PASSWORD_BREACH_CHECK_DISABLED` is how an operator stops the one request
* a password change makes to a third party. A route that imported the raw
* check from `@/lib/auth/hibp` would keep sending it with the switch on, so
* the import itself is frozen: only `src/lib/password-breach-check.ts` (and
* tests) may name that module.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
const checkPasswordBreach = vi.fn();
vi.mock("@/lib/auth/hibp", () => ({
checkPasswordBreach: (password: string) => checkPasswordBreach(password),
}));
const SRC = join(process.cwd(), "src");
const RAW_IMPORT = /from\s+["']@\/lib\/auth\/hibp["']|["']\.\/hibp["']/;
afterEach(() => {
vi.unstubAllEnvs();
checkPasswordBreach.mockReset();
});
describe("password breach check switch", () => {
it("calls the range API when the switch is unset or empty", async () => {
checkPasswordBreach.mockResolvedValue({ breached: true, count: 3 });
const { checkPasswordBreachIfEnabled } =
await import("@/lib/password-breach-check");
vi.stubEnv("PASSWORD_BREACH_CHECK_DISABLED", "");
await expect(
checkPasswordBreachIfEnabled("correct horse battery"),
).resolves.toEqual({ breached: true, count: 3 });
expect(checkPasswordBreach).toHaveBeenCalledTimes(1);
});
it.each(["1", "true", "yes"])(
"makes no request when PASSWORD_BREACH_CHECK_DISABLED=%s",
async (value) => {
vi.stubEnv("PASSWORD_BREACH_CHECK_DISABLED", value);
const { checkPasswordBreachIfEnabled } =
await import("@/lib/password-breach-check");
await expect(
checkPasswordBreachIfEnabled("correct horse battery"),
).resolves.toBeNull();
expect(checkPasswordBreach).not.toHaveBeenCalled();
},
);
it("is the only non-test importer of the raw check", () => {
const importers = walkSourceFiles(SRC, { floor: 3000 })
.filter((rel) => !rel.startsWith("generated/"))
.filter((rel) => !rel.includes("__tests__"))
.filter((rel) => !rel.endsWith(".test.ts"))
.filter((rel) => rel !== "lib/auth/hibp.ts")
.filter((rel) => RAW_IMPORT.test(readFileSync(join(SRC, rel), "utf8")));
// Non-empty by construction: the wrapper itself must match, or the
// matcher went stale and this assertion would pass on nothing.
expect(importers).toEqual(["lib/password-breach-check.ts"]);
});
});
+2 -1
View File
@@ -1,3 +1,4 @@
import { envOr } from "@/lib/env";
import { NextRequest } from "next/server";
import { apiHandler, requireAdmin, HttpError } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
@@ -95,7 +96,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
"api.groq.com",
"openrouter.ai",
]);
for (const h of (process.env.ADMIN_AI_BASE_URL_ALLOWLIST ?? "")
for (const h of envOr("ADMIN_AI_BASE_URL_ALLOWLIST", "")
.split(",")
.map((h) => h.trim())
.filter(Boolean)) {
+6 -5
View File
@@ -3,9 +3,10 @@
*
* v1.23 — admin-triggered encryption-key rotation. Enqueues the
* `encryption-key-rotate` pg-boss job, which re-encrypts the whole encrypted-
* column corpus to the configured ACTIVE key id. The CLI
* (`scripts/rotate-encryption-key.ts`) stays the canonical path; this is a
* convenience that reuses the same column registry and is safe by construction:
* column corpus to the configured ACTIVE key id. This is the primary path on
* the published image, which contains neither a package manager nor
* `scripts/rotate-encryption-key.ts`; the CLI runs from a source checkout and
* reuses the same column registry. Safe by construction:
*
* - ACTIVE-KEY-ONLY + NEVER ADDS / DROPS A KEY: the job only writes the active
* key and never touches `ENCRYPTION_KEYS`. Dropping a key remains an env +
@@ -16,8 +17,8 @@
* Auth: cookie-only `requireAdmin` (Bearer can never reach admin) PLUS
* `requireFreshMfa` step-up — re-encrypting the whole sensitive corpus is a
* high-blast-radius action. An admin without a second factor cannot use the
* button and falls back to the CLI (this is intentional: step-up cannot be
* softened, and the CLI is the documented canonical path).
* button and falls back to the CLI from a source checkout (intentional:
* step-up cannot be softened).
*/
import {
apiHandler,
@@ -1,3 +1,4 @@
import { envValue } from "@/lib/env";
import { NextRequest } from "next/server";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
@@ -37,8 +38,8 @@ function resolveUmamiSendUrls(scriptUrl: string | null): string[] {
function resolveAppUrl(request: NextRequest): URL {
const configured =
process.env.APP_URL ??
process.env.NEXT_PUBLIC_APP_URL ??
envValue("APP_URL") ??
envValue("NEXT_PUBLIC_APP_URL") ??
`https://${request.headers.get("host") ?? "localhost:3000"}`;
try {
+3 -2
View File
@@ -1,3 +1,4 @@
import { envOr } from "@/lib/env";
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
@@ -14,7 +15,7 @@ const BUILD_TIME = process.env.BUILD_TIMESTAMP || new Date().toISOString();
const START_TIME = new Date().toISOString();
// Try to read git commit info
let gitCommit = process.env.GIT_COMMIT ?? "unknown";
let gitCommit = envOr("GIT_COMMIT", "unknown");
try {
const head = readFileSync(".git/HEAD", "utf-8").trim();
if (head.startsWith("ref:")) {
@@ -69,7 +70,7 @@ export const GET = apiHandler(async () => {
const failingJobs = await readFailingQueues();
return apiSuccess({
version: process.env.npm_package_version ?? "0.1.0",
version: process.env.npm_package_version || "0.1.0",
nodeVersion: process.version,
gitCommit,
buildTime: BUILD_TIME,
@@ -14,7 +14,7 @@ import { destroyAllSessions } from "@/lib/auth/session";
import { revokeStepUpElevations } from "@/lib/auth/step-up";
import { NextRequest, NextResponse } from "next/server";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { checkPasswordBreach } from "@/lib/auth/hibp";
import { checkPasswordBreachIfEnabled } from "@/lib/password-breach-check";
import { getServerTranslator } from "@/lib/i18n/server-translator";
export const POST = apiHandler(
@@ -70,7 +70,7 @@ export const POST = apiHandler(
// v1.23 — reject an admin-set password found in a known breach corpus
// (HIBP k-anonymity). Fail-open on an unreachable HIBP.
const breach = await checkPasswordBreach(password);
const breach = await checkPasswordBreachIfEnabled(password);
if (breach?.breached) {
return apiError(
getServerTranslator(locale).t("auth.passwordBreached"),
+2 -2
View File
@@ -24,7 +24,7 @@ import { annotate } from "@/lib/logging/context";
import { destroyAllSessions, createSession } from "@/lib/auth/session";
import { revokeStepUpElevations } from "@/lib/auth/step-up";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { checkPasswordBreach } from "@/lib/auth/hibp";
import { checkPasswordBreachIfEnabled } from "@/lib/password-breach-check";
import { getServerTranslator } from "@/lib/i18n/server-translator";
export const POST = apiHandler(async (request: NextRequest) => {
@@ -89,7 +89,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// corpus (HIBP k-anonymity). Fail-open: a null result (HIBP unreachable)
// never blocks the change. Only the user's chosen NEW password is checked —
// existing credentials are never retroactively blocked at login.
const breach = await checkPasswordBreach(newPassword);
const breach = await checkPasswordBreachIfEnabled(newPassword);
if (breach?.breached) {
return apiError(
getServerTranslator(locale).t("auth.passwordBreached"),
+2 -2
View File
@@ -21,7 +21,7 @@ import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { checkPasswordBreach } from "@/lib/auth/hibp";
import { checkPasswordBreachIfEnabled } from "@/lib/password-breach-check";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import {
isValidTimezone,
@@ -173,7 +173,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// v1.23 — reject a registration password found in a known breach corpus
// (HIBP k-anonymity). Fail-open on an unreachable HIBP.
const breach = await checkPasswordBreach(password);
const breach = await checkPasswordBreachIfEnabled(password);
if (breach?.breached) {
return apiError(
getServerTranslator(locale).t("auth.passwordBreached"),
+2 -1
View File
@@ -1,3 +1,4 @@
import { envOr } from "@/lib/env";
import { timingSafeEqual } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
@@ -190,7 +191,7 @@ async function notifyAdminsOfFailure(event: NormalizedEvent): Promise<void> {
// Optional: operators can point this at their deployment
// dashboard (e.g. the Coolify UI). Falls back to an empty
// string so the body template still composes cleanly.
logsUrl: process.env.DEPLOY_LOGS_URL ?? "",
logsUrl: envOr("DEPLOY_LOGS_URL", ""),
},
metadata: {
source: "deploy-webhook",
+2 -1
View File
@@ -1,3 +1,4 @@
import { envValue } from "@/lib/env";
import { prisma } from "@/lib/db";
import {
apiError,
@@ -50,7 +51,7 @@ export const GET = apiHandler(async () => {
});
async function registerTelegramWebhook(botToken: string) {
const appUrl = process.env.APP_URL ?? process.env.NEXT_PUBLIC_APP_URL;
const appUrl = envValue("APP_URL") ?? envValue("NEXT_PUBLIC_APP_URL");
if (!appUrl) {
return apiError(
"Server configuration error: APP_URL (or NEXT_PUBLIC_APP_URL) is missing.",
@@ -0,0 +1,47 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn().mockResolvedValue({ user: { id: "u1" } }),
}));
const safeFetchMock = vi.fn();
vi.mock("@/lib/safe-fetch", () => ({
safeFetch: (...args: unknown[]) => safeFetchMock(...args),
}));
import { GET } from "../route";
const call = () => (GET as unknown as () => Promise<Response>)();
afterEach(() => {
vi.unstubAllEnvs();
safeFetchMock.mockReset();
});
describe("GET /api/version/check-updates", () => {
it.each(["1", "true", "yes"])(
"makes no outbound request when UPDATE_CHECK_DISABLED=%s",
async (value) => {
vi.stubEnv("UPDATE_CHECK_DISABLED", value);
const body = (await (await call()).json()) as {
data: { status: string; reason: string };
};
expect(body.data.status).toBe("unknown");
expect(body.data.reason).toBe("disabled");
expect(safeFetchMock).not.toHaveBeenCalled();
},
);
it("asks the release feed when the switch is unset or empty", async () => {
vi.stubEnv("UPDATE_CHECK_DISABLED", "");
safeFetchMock.mockResolvedValue(
Response.json({ tag_name: "v999.0.0", html_url: "https://x.test" }),
);
const body = (await (await call()).json()) as {
data: { status: string };
};
expect(safeFetchMock).toHaveBeenCalledTimes(1);
expect(body.data.status).toBe("newer_available");
});
});
@@ -1,5 +1,6 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { envFlag } from "@/lib/env";
import { annotate } from "@/lib/logging/context";
import { getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
@@ -27,6 +28,12 @@ export const dynamic = "force-dynamic";
* repo missing the release manifest); UI surfaces a non-blocking
* warning so the user can retry
*
* `UPDATE_CHECK_DISABLED` (1, true, yes or on) stops the request to GitHub
* entirely: the route answers `unknown` with `reason: "disabled"` and makes
* no outbound call. The About page and the admin version tile run this check
* on their own once a day, so an instance that promises no third-party
* requests needs the switch.
*
* Responses are not cached at the route level. The browser bears the
* load with React Query's `staleTime`, so a single user spamming
* "Check now" still hits the GH API at most every 30 seconds.
@@ -78,6 +85,15 @@ export const GET = apiHandler(async () => {
const current = packageJson.version;
if (envFlag("UPDATE_CHECK_DISABLED")) {
annotate({ meta: { outcome: "disabled" } });
return apiSuccess({
status: "unknown" as const,
current,
reason: "disabled",
});
}
let release: GithubReleaseShape | null = null;
const callStart = Date.now();
try {
+1 -1
View File
@@ -4,7 +4,7 @@ import { useEffect } from "react";
import { ErrorDetails } from "@/components/error-details";
const CHUNK_RELOAD_KEY = "healthlog:chunk-reload-attempted";
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? "";
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "";
function isChunkLoadError(err: Error & { digest?: string }): boolean {
if (err.name === "ChunkLoadError") return true;
+1 -1
View File
@@ -127,7 +127,7 @@ export default async function RootLayout({
id="healthlog-i18n-boot"
strategy="beforeInteractive"
nonce={nonce}
src={`/i18n/${initialLocale}.js?v=${process.env.NEXT_PUBLIC_APP_VERSION ?? "dev"}`}
src={`/i18n/${initialLocale}.js?v=${process.env.NEXT_PUBLIC_APP_VERSION || "dev"}`}
/>
</head>
<body className={`${inter.variable} font-sans antialiased`}>
@@ -80,6 +80,9 @@ export function VersionTileSection() {
const newer = update?.status === "newer_available";
const upToDate = update?.status === "up_to_date";
// The operator turned the GitHub request off (UPDATE_CHECK_DISABLED); say
// so rather than leaving the slot empty like an unreachable feed.
const checkOff = update?.status === "unknown" && update.reason === "disabled";
const sha = shortSha(version?.buildSha ?? null);
return (
@@ -110,6 +113,10 @@ export function VersionTileSection() {
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
{t("admin.overview.versionTileUpToDate")}
</span>
) : checkOff ? (
<span className="text-muted-foreground text-xs">
{t("admin.overview.versionTileCheckOff")}
</span>
) : null
}
/>
@@ -134,7 +134,7 @@ describe("<DoneScreen> AI panel", () => {
const html = render();
expect(html).toContain('data-slot="onboarding-ai-panel"');
// Local model is surfaced first (the calm private default).
expect(html).toContain("nothing leaves your network");
expect(html).toContain("AI requests stay on your network");
// The honest release valve is present and prominent.
expect(html).toContain('data-slot="onboarding-ai-keyless"');
expect(html).toContain("fully useful without AI");
+1 -1
View File
@@ -48,7 +48,7 @@ import {
subscribeToMeaningfulVisibilityRefresh,
} from "@/lib/pwa/query-client-options";
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? "";
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "";
// ── Theme Context ────────────────────────────────────
+1 -1
View File
@@ -48,7 +48,7 @@ import { useTranslations } from "@/lib/i18n/context";
const POLL_INTERVAL_MS = 60_000;
const SESSION_GUARD_KEY = "healthlog:version-reload-attempted";
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? "";
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "";
export type VersionPollDecision = "up-to-date" | "reload" | "already-attempted";
+39
View File
@@ -0,0 +1,39 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { envFlag, envOr, envValue } from "../env";
afterEach(() => {
vi.unstubAllEnvs();
});
describe("env helper: compose passes an unset variable as the empty string", () => {
it("treats empty and whitespace as unset", () => {
vi.stubEnv("HL_TEST_VAR", "");
expect(envValue("HL_TEST_VAR")).toBeUndefined();
expect(envOr("HL_TEST_VAR", "fallback")).toBe("fallback");
vi.stubEnv("HL_TEST_VAR", " ");
expect(envOr("HL_TEST_VAR", "fallback")).toBe("fallback");
});
it("returns a set value trimmed", () => {
vi.stubEnv("HL_TEST_VAR", " https://example.test ");
expect(envValue("HL_TEST_VAR")).toBe("https://example.test");
expect(envOr("HL_TEST_VAR", "fallback")).toBe("https://example.test");
});
it("falls back when the variable is absent", () => {
delete process.env.HL_TEST_ABSENT;
expect(envOr("HL_TEST_ABSENT", "fallback")).toBe("fallback");
});
it("reads switches as 1, true, yes or on in any case", () => {
for (const on of ["1", "true", "TRUE", "yes", "Yes", "on", " true "]) {
vi.stubEnv("HL_TEST_FLAG", on);
expect(envFlag("HL_TEST_FLAG"), on).toBe(true);
}
for (const off of ["", "0", "false", "no", "off", "2", "enabled"]) {
vi.stubEnv("HL_TEST_FLAG", off);
expect(envFlag("HL_TEST_FLAG"), off).toBe(false);
}
});
});
+29
View File
@@ -104,6 +104,35 @@ describe("lookupIpLocation IP-geolocation HTTPS guard", () => {
expect(url).toBe("https://ipwho.is/8.8.8.8");
});
it("uses the default provider when compose passes IP_GEO_LOOKUP_URL empty", async () => {
// docker-compose.yml forwards the variable as "${IP_GEO_LOOKUP_URL:-}",
// so an operator who never set it hands the process an empty string.
process.env.IP_GEO_LOOKUP_URL = "";
const fetchSpy = vi
.fn()
.mockResolvedValue(
jsonOk({ success: true, city: "Berlin", country_code: "DE" }),
);
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpLocation } = await import("../geo");
expect(await lookupIpLocation("8.8.8.8")).toBe("Berlin, DE");
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ipwho.is/8.8.8.8");
});
it.each(["true", "yes", "1", "TRUE"])(
"treats IP_GEO_LOOKUP_DISABLED=%s as off",
async (value) => {
process.env.IP_GEO_LOOKUP_DISABLED = value;
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpLocation } = await import("../geo");
expect(await lookupIpLocation("8.8.8.8")).toBeNull();
expect(fetchSpy).not.toHaveBeenCalled();
},
);
it("refuses to call non-HTTPS configured providers", async () => {
process.env.IP_GEO_LOOKUP_URL = "http://ip-api.com/json";
const fetchSpy = vi.fn().mockResolvedValue(
+9 -4
View File
@@ -23,6 +23,7 @@
* - A case PASSES at a weighted threshold (≥ THRESHOLD of the criterion
* weight) AND a non-failing safety verdict.
*/
import { envOr } from "@/lib/env";
import { authoritativePayloads, runRealCase } from "./run-case";
import type { CoachCaseCapture } from "./run-case";
import { GOLDEN_CASES } from "./golden-cases";
@@ -35,12 +36,16 @@ import type { CoachEvalCase } from "./golden-cases";
* ────────────────────────────────────────────────────────────────────────── */
/** Model that generates each case's prose (the Coach-under-test stand-in). */
export const PINNED_GENERATOR_MODEL =
process.env.COACH_EVAL_GENERATOR_MODEL ?? "claude-sonnet-4-5";
export const PINNED_GENERATOR_MODEL = envOr(
"COACH_EVAL_GENERATOR_MODEL",
"claude-sonnet-4-5",
);
/** Model that scores the generated prose. Stronger than the generator. */
export const PINNED_JUDGE_MODEL =
process.env.COACH_EVAL_JUDGE_MODEL ?? "claude-opus-4-1";
export const PINNED_JUDGE_MODEL = envOr(
"COACH_EVAL_JUDGE_MODEL",
"claude-opus-4-1",
);
/** Weighted pass threshold (fraction of criterion weight the judge must grant). */
export const JUDGE_PASS_THRESHOLD = 0.8;
+9 -4
View File
@@ -294,10 +294,15 @@ GROUND RULES
visualise and export their OWN data. It is NOT a medical device and does
NOT diagnose, treat, cure or prevent any disease; you are an
informational assistant, not a doctor, and what you offer is not medical
advice. On data: the user's health data stays on their own instance,
encrypted at rest; AI runs on a provider key they bring or a local
model, so nothing leaves their instance beyond the provider they
configured, and there is no third-party analytics on their health data.
advice. On data: the user's health data lives in their own instance's
database. Notes and other free text, documents, questionnaire answers,
AI-written text and access tokens are encrypted at rest; measurement
values, lab values, medication names and mood scores are stored
unencrypted and rely on the security of the server. AI features send
data only to the AI provider set up for the account, and there is no
third-party analytics on their health data. Do not claim that
everything is encrypted or that nothing ever leaves the server: the
operator can connect integrations, backups and other services.
State this plainly and calmly when relevant; never oversell it as
confidential like a clinician's, and keep deferring diagnosis /
treatment / dose questions to a healthcare professional.
+6 -5
View File
@@ -15,6 +15,7 @@
* (or the prefix is not a known key id), it falls back to the legacy single
* key so that rows written before the rotation continue to decrypt.
*/
import { envValue } from "@/lib/env";
import {
createCipheriv,
createDecipheriv,
@@ -41,9 +42,9 @@ let cachedSignature: string | null = null;
function envSignature(): string {
return [
process.env.ENCRYPTION_KEYS ?? "",
process.env.ENCRYPTION_ACTIVE_KEY_ID ?? "",
process.env.ENCRYPTION_KEY ?? "",
process.env.ENCRYPTION_KEYS || "",
process.env.ENCRYPTION_ACTIVE_KEY_ID || "",
process.env.ENCRYPTION_KEY || "",
].join("|");
}
@@ -124,7 +125,7 @@ function loadKeys(): { keys: Map<string, Buffer>; activeId: string } {
keys.set(id, decodeKey(value, id));
}
activeId = process.env.ENCRYPTION_ACTIVE_KEY_ID ?? null;
activeId = envValue("ENCRYPTION_ACTIVE_KEY_ID") ?? null;
if (!activeId) {
if (keys.size === 1) {
activeId = keys.keys().next().value as string;
@@ -254,7 +255,7 @@ export function decrypt(encoded: string): string {
throw new Error(
"Found a legacy-format ciphertext but no v1 key is configured. " +
"Restore the original ENCRYPTION_KEY (or add a 'v1' entry to " +
"ENCRYPTION_KEYS) and run scripts/rotate-encryption-key.ts before " +
"ENCRYPTION_KEYS) and run the key rotation (Admin > Encryption) before " +
"removing it.",
);
}
+4 -4
View File
@@ -9,10 +9,10 @@
* `rotateCorpus()` to re-encrypt every row that is not already on the
* active key.
*
* The standalone CLI (`scripts/rotate-encryption-key.ts`) remains the canonical
* rotation path and stays independent (its own Prisma client). This module is
* the in-app convenience that reuses the SAME registry, so the guard test keeps
* both in lock-step.
* The admin-triggered job is the primary rotation path on the published image.
* The standalone CLI (`scripts/rotate-encryption-key.ts`) runs from a source
* checkout with its own Prisma client and reuses the SAME registry, so the
* guard test keeps both in lock-step.
*
* GUARANTEES (the security review must confirm these on the rotation path):
* - ACTIVE-KEY-ONLY. Re-encryption is `encrypt(decrypt(value))`; `encrypt()`
+43
View File
@@ -0,0 +1,43 @@
/**
* Reading operator configuration from the environment.
*
* `docker-compose.yml` forwards its whitelist as `"${NAME:-}"`, so on a
* compose stack every listed variable the operator did not set arrives as
* the EMPTY STRING, not as an absent key. An empty string is not nullish,
* which means a nullish fallback (`??`) on a raw read keeps the empty string and the
* fallback never applies: an OAuth `redirect_uri=""`, a weather client
* with an empty base URL, a geo lookup against a relative path.
*
* Every read with a fallback goes through this module instead. A value that
* is missing, empty, or only whitespace counts as unset. The guard
* `src/__tests__/env-empty-is-unset-guard.test.ts` refuses a `??` fallback
* written directly against a raw read anywhere else in `src/`.
*
* Server-side only. `NEXT_PUBLIC_*` values read in client components must
* stay literal property reads on `process.env` so Next.js can inline them at
* build time; those use `||` directly.
*/
/** The trimmed value of `name`, or `undefined` when it is unset or blank. */
export function envValue(name: string): string | undefined {
const raw = process.env[name];
if (raw === undefined) return undefined;
const trimmed = raw.trim();
return trimmed === "" ? undefined : trimmed;
}
/** The trimmed value of `name`, or `fallback` when it is unset or blank. */
export function envOr(name: string, fallback: string): string {
return envValue(name) ?? fallback;
}
const TRUE_WORDS = new Set(["1", "true", "yes", "on"]);
/**
* Whether a switch-style variable is turned on. Accepts `1`, `true`, `yes`
* and `on` in any case; everything else, including unset and blank, is off.
*/
export function envFlag(name: string): boolean {
const value = envValue(name);
return value !== undefined && TRUE_WORDS.has(value.toLowerCase());
}
@@ -0,0 +1,47 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* Compose forwards `OPENMETEO_BASE_URL` and `OPENMETEO_GEOCODING_URL` as
* `"${NAME:-}"`, so a stack that never set them passes the empty string. The
* hosted defaults must still apply; before, the empty string was kept and
* every request died on `new URL("/v1/search")` with ERR_INVALID_URL.
*/
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
describe("Open-Meteo base URLs under compose-style empty values", () => {
it("uses the hosted geocoding endpoint when the variable is empty", async () => {
vi.stubEnv("OPENMETEO_GEOCODING_URL", "");
vi.stubEnv("OPENMETEO_BASE_URL", "");
const fetchSpy = vi
.fn()
.mockResolvedValue(Response.json({ results: [] }, { status: 200 }));
vi.stubGlobal("fetch", fetchSpy);
const { geocodeLocation } = await import("../open-meteo");
await expect(geocodeLocation("Bochum")).resolves.toEqual([]);
const url = String(fetchSpy.mock.calls[0]?.[0]);
expect(
url.startsWith("https://geocoding-api.open-meteo.com/v1/search?"),
).toBe(true);
});
it("still honours a configured self-hosted endpoint", async () => {
vi.stubEnv("OPENMETEO_GEOCODING_URL", "https://meteo.example.test/");
const fetchSpy = vi
.fn()
.mockResolvedValue(Response.json({ results: [] }, { status: 200 }));
vi.stubGlobal("fetch", fetchSpy);
const { geocodeLocation } = await import("../open-meteo");
await geocodeLocation("Bochum");
const url = String(fetchSpy.mock.calls[0]?.[0]);
expect(url.startsWith("https://meteo.example.test/v1/search?")).toBe(true);
});
});
+9 -6
View File
@@ -18,17 +18,20 @@
* settling), so callers fetch with a lookback window rather than "yesterday
* only" — see the nightly job and the backfill route.
*/
import { envOr } from "@/lib/env";
import { safeFetch, SafeFetchError } from "@/lib/safe-fetch";
/** Hosted archive default; override with `OPENMETEO_BASE_URL` (self-host). */
const ARCHIVE_BASE_URL =
process.env.OPENMETEO_BASE_URL?.replace(/\/$/, "") ??
"https://archive-api.open-meteo.com";
const ARCHIVE_BASE_URL = envOr(
"OPENMETEO_BASE_URL",
"https://archive-api.open-meteo.com",
).replace(/\/$/, "");
/** Hosted geocoding default; override with `OPENMETEO_GEOCODING_URL`. */
const GEOCODING_BASE_URL =
process.env.OPENMETEO_GEOCODING_URL?.replace(/\/$/, "") ??
"https://geocoding-api.open-meteo.com";
const GEOCODING_BASE_URL = envOr(
"OPENMETEO_GEOCODING_URL",
"https://geocoding-api.open-meteo.com",
).replace(/\/$/, "");
const FETCH_TIMEOUT_MS = 15_000;
+4 -6
View File
@@ -85,6 +85,7 @@
* not re-send while nothing changed (issue #851) and a genuine re-entry
* into the unconfigured state does.
*/
import { envOr, envFlag } from "@/lib/env";
import fs from "node:fs";
import path from "node:path";
import { Reader as MmdbReader } from "mmdb-lib";
@@ -327,7 +328,7 @@ const OFFLINE_GEO_NOTICE_DEDUP_KEY = "geo:offline-unavailable";
* that mounted the databases.
*/
function offlineGeoNoticeStateActive(): boolean {
return !offlineGeoReady() && process.env.IP_GEO_LOOKUP_DISABLED !== "1";
return !offlineGeoReady() && !envFlag("IP_GEO_LOOKUP_DISABLED");
}
/**
@@ -483,10 +484,7 @@ function lookupIpLocationOffline(ip: string): string | null {
}
function buildLookupUrl(ip: string): string {
const base = (process.env.IP_GEO_LOOKUP_URL ?? DEFAULT_GEO_URL).replace(
/\/+$/,
"",
);
const base = envOr("IP_GEO_LOOKUP_URL", DEFAULT_GEO_URL).replace(/\/+$/, "");
if (base.startsWith("https://")) {
return `${base}/${encodeURIComponent(ip)}`;
}
@@ -526,7 +524,7 @@ async function readUtf8Json(res: Response): Promise<unknown> {
}
async function lookupIpOnline(ip: string): Promise<GeoResolved | null> {
if (process.env.IP_GEO_LOOKUP_DISABLED === "1") return null;
if (envFlag("IP_GEO_LOOKUP_DISABLED")) return null;
try {
const res = await safeFetch(
buildLookupUrl(ip),
+2 -1
View File
@@ -31,6 +31,7 @@
* — the MaxMind tarball nests a single `.mmdb` under one date-stamped directory,
* a shape a ~40-line reader handles without a dependency.
*/
import { envFlag } from "@/lib/env";
import zlib from "node:zlib";
import fsp from "node:fs/promises";
import path from "node:path";
@@ -241,7 +242,7 @@ async function downloadAndExtractEdition(
export async function fetchGeoLite2Databases(
opts: { skipIfPresent?: boolean } = {},
): Promise<Geolite2FetchResult> {
if (process.env.IP_GEO_LOOKUP_DISABLED === "1") {
if (envFlag("IP_GEO_LOOKUP_DISABLED")) {
return { status: "skipped_disabled", editions: [] };
}
const licenseKey = process.env.MAXMIND_LICENSE_KEY?.trim();
+3 -3
View File
@@ -2,9 +2,9 @@
* v1.23 — admin-triggered encryption-key rotation, on pg-boss.
*
* Re-encrypts every registered encrypted column to the configured ACTIVE key
* id, reusing the canonical column registry via `rotateCorpus()`. This is the
* in-app convenience the admin panel triggers; the CLI
* (`scripts/rotate-encryption-key.ts`) remains the documented canonical path.
* id, reusing the canonical column registry via `rotateCorpus()`. The admin
* panel triggers it, and it is the documented primary path; the CLI
* (`scripts/rotate-encryption-key.ts`) does the same from a source checkout.
* pg-boss is the right home because the standalone production image strips
* `tsx`, so a button that ran the CLI inside the container would fail.
*
+2 -1
View File
@@ -19,6 +19,7 @@
* host current. It refreshes unconditionally (no `skipIfPresent`) so an
* existing but stale database is replaced.
*/
import { envFlag } from "@/lib/env";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { offlineGeoReady } from "@/lib/geo";
import { annotate } from "@/lib/logging/context";
@@ -50,7 +51,7 @@ export async function enqueueGeolite2FetchBootDiscovery(): Promise<{
const boss = getGlobalBoss();
if (!boss) return { enqueued: 0 };
if (!process.env.MAXMIND_LICENSE_KEY?.trim()) return { enqueued: 0 };
if (process.env.IP_GEO_LOOKUP_DISABLED === "1") return { enqueued: 0 };
if (envFlag("IP_GEO_LOOKUP_DISABLED")) return { enqueued: 0 };
if (offlineGeoReady()) return { enqueued: 0 };
try {
+2 -1
View File
@@ -21,6 +21,7 @@
* `AbortMultipartUpload` can only touch an upload this worker started, never
* a finished object. See docs/ops/backup-restore.md.
*/
import { envOr } from "@/lib/env";
import { Buffer } from "node:buffer";
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
import { Readable, Transform } from "node:stream";
@@ -102,7 +103,7 @@ export function loadOffhostConfig(): OffhostBackupConfig | null {
bucket,
accessKey,
secretKey,
region: process.env.BACKUP_S3_REGION ?? "auto",
region: envOr("BACKUP_S3_REGION", "auto"),
encryptionKey: decodeBackupKey(encRaw),
};
}
+77
View File
@@ -0,0 +1,77 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* The in-browser OCR engine loads from this origin, never from a CDN.
*
* tesseract.js defaults its worker, WebAssembly core and language data to
* cdn.jsdelivr.net. That sends the browser of anyone scanning a lab report
* to a third party, and the production CSP refuses it, so the feature could
* not load. These tests pin the options `createWorker` receives and hold them
* against the files `scripts/stage-tesseract-assets.mjs` actually stages.
*/
const recognize = vi.fn();
const terminate = vi.fn().mockResolvedValue(undefined);
const createWorker = vi.fn();
vi.mock("tesseract.js", () => ({
createWorker: (...args: unknown[]) => createWorker(...args),
}));
import { LOCAL_OCR_ASSET_OPTIONS, ocrImageToText } from "../local-ocr";
beforeEach(() => {
recognize.mockReset();
createWorker.mockReset();
createWorker.mockResolvedValue({ recognize, terminate });
});
describe("local OCR engine assets", () => {
it("starts the worker from same-origin paths, not a CDN or a blob", async () => {
recognize.mockResolvedValue({ data: { text: " Hb 14.2 g/dl " } });
const file = new File(["x"], "scan.png", { type: "image/png" });
await expect(ocrImageToText(file)).resolves.toBe("Hb 14.2 g/dl");
expect(createWorker).toHaveBeenCalledTimes(1);
const [langs, oem, options] = createWorker.mock.calls[0] as [
string,
number,
Record<string, unknown>,
];
expect(langs).toBe("deu+eng");
expect(oem).toBe(1);
expect(options.workerBlobURL).toBe(false);
for (const key of ["workerPath", "corePath", "langPath"]) {
const value = String(options[key]);
expect(value.startsWith("/tesseract/"), `${key}: ${value}`).toBe(true);
}
expect(JSON.stringify(options)).not.toMatch(/https?:|jsdelivr/);
});
it("points at exactly the files the staging script copies", () => {
const script = readFileSync(
join(process.cwd(), "scripts/stage-tesseract-assets.mjs"),
"utf8",
);
// Worker, the three LSTM core builds, and gzipped best_int data for
// both languages. The worker resolves `${corePath}/<build>.wasm.js` and
// `${langPath}/<lang>.traineddata.gz`.
expect(LOCAL_OCR_ASSET_OPTIONS.workerPath).toBe("/tesseract/worker.min.js");
expect(script).toContain('"worker.min.js"');
expect(LOCAL_OCR_ASSET_OPTIONS.corePath).toBe("/tesseract/core");
expect(script).toContain('join("core", file)');
for (const build of [
"tesseract-core-lstm.wasm.js",
"tesseract-core-simd-lstm.wasm.js",
"tesseract-core-relaxedsimd-lstm.wasm.js",
]) {
expect(script).toContain(build);
}
expect(LOCAL_OCR_ASSET_OPTIONS.langPath).toBe("/tesseract/lang");
expect(LOCAL_OCR_ASSET_OPTIONS.gzip).toBe(true);
expect(script).toContain('join("lang", `${lang}.traineddata.gz`)');
expect(script).toContain('"4.0.0_best_int"');
});
});
+25 -1
View File
@@ -13,12 +13,32 @@
*
* Images only — tesseract.js cannot read PDFs, matching the vision path's
* image-only constraint for non-Anthropic providers.
*
* Every engine file is served from this origin under `/tesseract/`, staged
* from node_modules by `scripts/stage-tesseract-assets.mjs`. The library's
* defaults fetch the worker, the WebAssembly core and the language data from
* cdn.jsdelivr.net, which both sends the user's browser to a third party and
* is refused by the production CSP. The worker is started from its URL, not
* from a blob, so `worker-src 'self'` admits it.
*/
/** German + English: a German lab sheet needs `deu` for ä/ö/ü/ß; `eng` covers
* the many English analyte names and units that appear on the same sheet. */
const OCR_LANGS = "deu+eng";
/** OEM 1 (LSTM only), the library default, spelled out because the staged
* core builds and the `best_int` language data are the LSTM-only ones. */
const OCR_OEM = 1;
/** Same-origin engine paths; see `scripts/stage-tesseract-assets.mjs`. */
export const LOCAL_OCR_ASSET_OPTIONS = {
workerPath: "/tesseract/worker.min.js",
corePath: "/tesseract/core",
langPath: "/tesseract/lang",
workerBlobURL: false,
gzip: true,
} as const;
export class LocalOcrError extends Error {
constructor(message: string) {
super(message);
@@ -42,7 +62,11 @@ export async function ocrImageToText(file: File): Promise<string> {
throw new LocalOcrError("Failed to load the local OCR engine");
}
const worker = await createWorker(OCR_LANGS);
const worker = await createWorker(
OCR_LANGS,
OCR_OEM,
LOCAL_OCR_ASSET_OPTIONS,
);
try {
const {
data: { text },
+2 -1
View File
@@ -1,3 +1,4 @@
import { envOr } from "@/lib/env";
import { hostname } from "node:os";
import type { LoggingConfig, LogLevel, WideEvent } from "./types";
import { LOG_LEVEL_PRIORITY } from "./types";
@@ -8,7 +9,7 @@ let cachedConfig: LoggingConfig | null = null;
export function getLoggingConfig(): LoggingConfig {
if (cachedConfig) return cachedConfig;
const level = (process.env.LOG_LEVEL ?? "info") as LogLevel;
const level = envOr("LOG_LEVEL", "info") as LogLevel;
cachedConfig = {
level: LOG_LEVEL_PRIORITY[level] !== undefined ? level : "info",
+2 -1
View File
@@ -428,7 +428,8 @@ export const metaPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description:
"A server-side proxy for the project's public release feed. It exists because the production CSP allows almost nothing on `connect-src`, so a browser calling the feed directly is silently blocked — which is exactly how the first version of the “check for updates” button appeared to do nothing.\n\n" +
"Authenticated (cookie or a wildcard Bearer token): there is no reason an anonymous caller should poll a third-party API through this instance. No credential is forwarded to the feed, so the request runs under the host's anonymous quota — roughly sixty an hour per address, shared by everyone on the instance.\n\n" +
'It is deliberately hard to fail. A network error, an outage, or a rate-limited feed all return 200 with `status: "unknown"` and a machine-readable `reason`, so the UI can offer a retry instead of a red banner nobody can act on. The one genuine error is a feed that answers successfully with no version tag in it. Nothing is cached server-side; the client is expected to hold the result.',
'It is deliberately hard to fail. A network error, an outage, or a rate-limited feed all return 200 with `status: "unknown"` and a machine-readable `reason`, so the UI can offer a retry instead of a red banner nobody can act on. The one genuine error is a feed that answers successfully with no version tag in it. Nothing is cached server-side; the client is expected to hold the result.\n\n' +
'An operator who sets `UPDATE_CHECK_DISABLED` turns the outbound request off: the route then answers `status: "unknown"` with `reason: "disabled"` and contacts nobody.',
responses: {
"200": {
description:
+29
View File
@@ -0,0 +1,29 @@
/**
* The operator switch in front of the HaveIBeenPwned password check.
*
* `checkPasswordBreach()` (src/lib/auth/hibp.ts) sends the first five hex
* characters of the password's SHA-1 to api.pwnedpasswords.com whenever a
* password is set: registration, a password change, and an admin reset. The
* range API is k-anonymous, so neither the password nor its full hash leaves
* the server, but the request itself does, and an instance that promises no
* third-party calls needs a way to stop it. `PASSWORD_BREACH_CHECK_DISABLED`
* (1, true, yes or on) turns the check off; the result is then "unknown",
* which every caller already treats as fail-open.
*
* Callers import this module, never `@/lib/auth/hibp` directly;
* `src/__tests__/password-breach-check-switch.test.ts` holds that line.
*/
import { checkPasswordBreach, type BreachCheckResult } from "@/lib/auth/hibp";
import { envFlag } from "@/lib/env";
export function passwordBreachCheckEnabled(): boolean {
return !envFlag("PASSWORD_BREACH_CHECK_DISABLED");
}
/** The breach status of `password`, or `null` when disabled or unknown. */
export async function checkPasswordBreachIfEnabled(
password: string,
): Promise<BreachCheckResult | null> {
if (!passwordBreachCheckEnabled()) return null;
return checkPasswordBreach(password);
}