49 Commits
Author SHA1 Message Date
Marc-André Bombeck a80fcf5276 docs: name the per-database role and describe the owner-owned queue schema 2026-11-03 23:31:38 +01:00
Marc-André Bombeck c7ff22ab00 chore(env): whitelist DB_APP_ROLE and document it beside the database settings 2026-11-03 20:37:48 +01:00
Marc-André Bombeck 70df2009d9 fix(deploy-webhook): keep DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP's meaning, add a signature-only flag
DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP had been changed to mean signed requests
only, so an operator who set it for a sender that adds a timestamp but cannot
sign would have had every deploy notification refused after the upgrade. It
means the secret header plus a fresh timestamp again. Signature-only, the
setting that stops a replay, is DEPLOY_WEBHOOK_REQUIRE_SIGNATURE, on the
compose whitelist, in the env manifest and examples, and in the deploy
guide.
2026-10-25 17:44:31 +01:00
Marc-André Bombeck d87718407e fix(http): let operators name their proxy, and survive a bad TRUST_PROXY_HOPS
Forwarding headers were believed from any connection on a private address.
Behind Docker Desktop, OrbStack and docker-proxy every connection to a
published port arrives from the gateway, a private address, so a caller
reaching the port directly could again choose the client address that rate
limits, the audit log and sign-in notices record. TRUSTED_PROXY_CIDRS
(comma-separated CIDRs) now names the proxies: when set, only those peers may
report a client address. The private-range default stays when it is unset,
the boot log states the rule in force in one line, and the reverse-proxy
guide says what to set for a proxy on the same host, on a Docker network, or
on a public address (which, unlisted, puts all clients in one bucket).

An invalid TRUST_PROXY_HOPS threw on every request that resolved the client
address, so every route answered 500, the health probe included. It now
resolves no address, warns once, and is named at boot.
2026-10-25 17:44:31 +01:00
Marc-André Bombeck aebd7ea612 docs(deploy): describe the deploy webhook's signature and strict mode
The compose comment still called DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP a
requirement for a fresh timestamp header, which the route stopped
accepting in strict mode once requests could be signed, and the env
manifest named only the secret header and lacked the strict switch. Both
now say what the route checks: the secret header, which has no replay
protection, or X-Deploy-Webhook-Signature over the timestamp and body,
and that the strict setting takes signed requests only. The development
example says the same and points at the deploy runbook instead of a page
that no longer exists.
2026-10-25 16:00:35 +01:00
Marc-André Bombeck 877db9a5b7 docs(proxy): say TRUST_CF_CONNECTING_IP needs a Cloudflare-only origin
The flag makes the app believe CF-Connecting-IP from every connection,
before the peer check and whatever TRUST_PROXY_HOPS says, which is safe
only when nothing but Cloudflare can reach the origin. The reverse-proxy
page now says so with the ways to get there, the compose comment and both
example files carry the same condition, and the variable is in the env
manifest. The page's list of peers whose forwarding headers are read now
matches the code: loopback, 0.0.0.0/8, private, CGNAT, link-local,
unique-local and site-local, the reserved blocks and multicast. Those last
two stay trusted, since no internet host can connect from them; a test
pins every listed range and the Cloudflare precedence.
2026-10-25 16:00:35 +01:00
Marc-André Bombeck 351e7d1fe0 fix(backup): keep a dump locally only where it survives the container
The image creates DB_BACKUP_DIR owned by the app user, so an install that
runs it without the backups volume could write there, and every nightly and
pre-migration dump landed in the container's own layer. The next update
threw them away while the admin page, the boot line and the readiness card
called them kept on this server.

A dump directory now counts as kept only when it survives the container:
inside a container (Docker, Podman, Kubernetes, a container cgroup or an
overlay root) the directory must sit on a mount of its own that is not a
tmpfs, read from /proc/self/mountinfo with a device-number fallback;
outside one, a writable directory is a disk. Otherwise no dump is written
there: the nightly run uploads off-host only and records not_persistent,
the pre-migration dump is skipped with a warning, the boot line says the
directory is inside the container, the backups page stops promising local
dumps and ignores local-only ledger rows, and readiness fails with no
bucket and notes the bucket-only setup otherwise.

The Unraid template gains a path for the dumps, the Unraid and TrueNAS
pages say why it is needed, and the restore drill first runs the stack
without the volume and checks that nothing is kept or claimed.
2026-10-25 16:00:34 +01:00
Marc-André Bombeck 9db5e755e6 Merge the release into the deletion grace work
The translation files merge key by key (no key changed on both sides), the
backups list carries both the database dump and the pre-wipe copy window,
and the backup-restore doc keeps the shorter rewrite plus the dump in the
bucket and a lifecycle rule that covers database/.
2026-10-25 15:18:46 +01:00
Marc-André Bombeck a69666fa16 feat(account): hold account deletion and data wipe for a grace window
Deleting an account, a guardian deleting a managed profile, and "Delete
all data" were final the moment they were confirmed: the account row
cascaded into every table and every stored copy, and the off-host objects
were purged in the same breath. Nothing short of rolling the whole
instance back could return one account.

ACCOUNT_DELETION_GRACE_DAYS (14 by default, at most 30, 0 erases at once
as before) now sets a window:

- Account and managed-profile deletion lock the account (migration 0488:
  deletion_requested_at, deletion_purge_at, deletion_requested_by_id).
  Every session, refresh token, API token, connector, share link and
  step-up elevation is revoked in the same transaction, and the record,
  its stored copies and its off-host objects are left as they are.
- Sign-in is refused with 403 auth.account.deletion_pending and the
  erasure date, after the credential was proved and before a second
  factor is asked for. The session and Bearer resolvers and the acting-
  account resolver refuse a pending account too, so a credential minted
  in the moment before the lock is useless.
- POST /api/admin/users/{id}/undo-deletion (cookie-only, audit-logged)
  clears the lock. The admin users list reports the pending state.
- The hourly account-deletion-purge job erases accounts whose window has
  ended, exactly as the immediate deletion did, with the off-host purge
  request committed alongside. The Guardian invariant is decided again;
  a refused erasure stays pending and is reported.
- A wipe first stores the whole record as a PRE_WIPE_<ms> copy through
  the writer the weekly copy uses, keeps it out of the wipe, and holds
  its off-host purge back with a new not_before on the request. The
  ordinary restore brings the record back; restoring that copy withdraws
  the held purge. The purge job deletes the copy when its window ends.

Background work skips accounts pending deletion through one shared
predicate (src/lib/account-deletion/active-account.ts): every reminder,
digest, Coach, sync, backfill, rollup, score and backup enumeration
filters with it, and account-deletion-enumeration-guard.test.ts fails on
a query in background work that neither filters nor says why it need
not. Notification delivery and the AI capability loader refuse a pending
account as chokepoints behind it, and the Withings, WHOOP and Telegram
webhooks and the notification quick actions ignore one.

The privacy summary publishes the window. Two pieces of the reminder tick
and the off-host pass moved into modules of their own to keep the files
inside their size ratchet.
2026-10-25 13:50:14 +01:00
Marc-André Bombeck e665f17f52 feat(backup): dump before migrating, and restore a dump into an empty database
The image now carries postgresql18-client (pg_dump and pg_restore 18.6)
and a /var/lib/healthlog/backups directory owned by the app user, which
the bundled compose file backs with a new `backups` volume for `app` and
the commented `app-worker`; a fresh named volume inherits that owner.

Before `migrate deploy`, when migrations are pending on a database that
has applied some, the entrypoint writes healthlog-<time>-pre-migration.dump
with its manifest and keeps the last two. A failed dump on a writable
directory stops the boot and names DB_BACKUP_BEFORE_MIGRATE=false; without
a writable directory it warns and continues.

`docker compose run --rm db-restore <file|latest|list>` runs
scripts/db-restore.mjs in the app image with plain node. It opens an
off-host .dump.enc with BACKUP_ENCRYPTION_KEY (or a previous key), checks
the archive against the manifest's SHA-256, compares the keys in the
environment with the manifest's fingerprints and refuses on a different
active encryption key unless --ignore-key-mismatch, refuses a database
that holds any table, restores in one transaction, runs ANALYZE, and
compares every table's row count with the manifest, emptying the database
again on a difference.

DB_BACKUP_ENABLED, DB_BACKUP_DIR, DB_BACKUP_SCHEDULE, DB_BACKUP_KEEP and
DB_BACKUP_BEFORE_MIGRATE join the compose whitelist, both example files
and the env manifest.
2026-10-25 12:56:33 +01:00
Marc-André Bombeck 16024397c8 feat(observability): log heap samples and a one-time pressure report
runtime.heap.sampled records heap, old space, RSS, external memory and
the work in flight every five minutes, every minute at or above 70 % of
the heap limit. At 85 % runtime.heap.pressure lists the oldest requests
and jobs in flight and the routes most of them share, once, re-armed
only after the heap falls below 70 % and thirty minutes pass. A
heap-limit crash leaves no other record of what the process was doing.

HEALTHLOG_HEAP_SNAPSHOT=1 adds one heap snapshot at that moment into
HEALTHLOG_DIAG_DIR (0700 directory, 0600 file), off by default.
docs/ops/heap-diagnostics.md warns that a snapshot holds decrypted
health data and keys and says how to delete it.
2026-10-19 16:00:53 +02:00
Marc-André Bombeck b838ed4403 feat(dawarich): fetch daily movement values from a Dawarich instance
Connect, status, places, settings, test, sync and disconnect replace their
stubs. A nightly job (03:20) and the connect backfill read the timeline,
visits and residency endpoints through safeFetch and store one row of daily
values per local day: distances, places visited, minutes away from the home
place and the sealed country with its travel-day verdict. A private instance
needs its exact origin in DAWARICH_PRIVATE_ORIGINS.

The connection joins the shared ledger and the connection health model,
watched for errors only. The day carries a movement block, the model
projection drops the travel country, the two movement channels join the
correlation matrix under the environment module, travel days become trip
suggestions, and the days travel in the environment backup section.
2026-10-16 01:20:53 +02:00
Marc-André Bombeck ae8b614dd0 feat(insights): choose the status regeneration gap by ingest source
A sync from a watch or an integration now waits the instance's sync gap
(AppSettings.aiSyncRegenGapHours, else AI_SYNC_REGEN_GAP_HOURS, else 6 h)
before it may regenerate a status card again; a hand entry keeps the hour.
Every caller of afterMeasurementMutation and invalidateStatusInsightsForTypes
names its source, so a new ingest path cannot skip the choice.

Refs #1217
2026-10-15 22:49:00 +02:00
Marc-André Bombeck 7c6510eba9 fix(db): let a PgBouncer host turn the session startup options off
The statement timeout and, since v1.42, work_mem reach Postgres as the
libpq options startup parameter, which PgBouncer refuses unless it is in
ignore_startup_parameters. A host behind it could drop the string by
setting the timeout to 0; work_mem is now always sent, so that no longer
worked. DATABASE_SESSION_OPTIONS_DISABLED sends no session settings,
documented in the example env, the env manifest, the compose whitelist
and the scaling guide.
2026-10-08 22:37:57 +02:00
Marc-André Bombeck e63be67b5a feat: lay down the shared v1.42 contract
Land every file the v1.42 work shares before any of it is built, so the
parts built side by side never edit the same file.

Schema and migrations 0379-0386: the HEALTH_CONNECT measurement source,
ImportJob.kind, the air-quality columns and the per-user switch on the
environment tables, sealed environment locations with nullable plaintext,
managed-profile handover links, the per-type HealthKit arrival ledger, lab
unit-conversion provenance, and user-defined vaccines. Every new column is
annotated for the column-reader sweep.

HEALTH_CONNECT reaches every exhaustive list: the source labels, the
read enum (not the write allowlist), the source-priority ladder cap, now
the number of sources rather than a fixed eight, and the write-path smoke
test. RMSSD gets its own priority class and a 300 ms ceiling. The batch
syncTrigger gains `manual`, with its label on the Apple Health card.

Queues for the Health Connect import, the compaction-tombstone purge and
the measurement-table maintenance are registered with stub handlers;
host-metric sampling moves to every five minutes. Environment and work_mem
variables reach the compose whitelist, the production example and the
manifest. The proxy admits the handover link and redirects it at the edge,
and the Health Connect upload bypasses the proxy body limit.

The new routes answer 501 behind their final authentication and are
published in the contract; backup, wipe and key rotation classify the new
models and columns; the seven message bundles carry the base copy. Guards
that list release-pending keys and columns fail once an entry is wired, so
those lists empty themselves before the tag.

Also re-anchor a login-throttle fixture to the wall clock, where its
expiry is compared, and re-base the aggregate bundle cap on a measured
12 KB of new locale strings.
2026-10-08 07:56:59 +02:00
Marc-André Bombeck 69bae8bdb4 Add ENCRYPTION_KEY_CHECK=warn as an escape hatch for the boot key check
At a first start there is no recorded key yet, so an operator facing a
refusal they are certain is false has no row to delete. With
ENCRYPTION_KEY_CHECK=warn (default enforce) the check still runs and logs
its full outcome, /api/health names a mismatch as a warning field without
failing, requests are served, and no key is recorded unless every key id
passes.

The variable is on the compose whitelist, in both example files, the env
manifest, the Unraid and Portainer templates and the published-env guard.
The log block, the mismatch page and the self-hosting pages offer it as the
last resort after the three real fixes, and say that it disables a safety
check.
2026-10-03 23:46:40 +02:00
Marc-André Bombeck 308470f229 feat(encryption): back up the key, and refuse to serve when it cannot open the database
Every configured key id now has a canary in encryption_key_canaries,
checked at boot. A key that cannot open its canary, or existing data
written before the canary existed, puts the process in a refusal state:
every API route answers 503 encryption.key_mismatch, /api/health reports
reason encryption_key_mismatch, the pages render one explanation, and
neither the queue producer nor the worker starts. The process stays up
so the reason is visible instead of hiding in a crash loop.

The admin Encryption page gains a "Back up your encryption key" step:
key id and fingerprint (never the key), where the key lives per
platform, a check of a pasted copy that is compared and discarded, and
a confirmation bound to the active key id and fingerprint. The overview
gets a tile and admins get a dashboard banner while it is due.

HEALTHLOG_PLATFORM picks the platform the step opens on. Adds the
TrueNAS self-hosting page and key backup sections in the docs.
2026-10-03 18:06:28 +02:00
Marc-André Bombeck 66b1aef40c Merge branch 'fix/encrypt-free-text-and-local-ai' into release/v1.39.3
# Conflicts:
#	src/app/api/import/csv/route.ts
2026-09-26 21:50:05 +02:00
Marc-André Bombeck 4409529c89 Merge branch 'fix/backup-rotation-and-deletion' into release/v1.39.3
# Conflicts:
#	docs/api/openapi.yaml
#	src/lib/jobs/apple-health-import-worker.ts
2026-09-26 21:48:20 +02:00
Marc-André Bombeck c86f23c2c0 fix(security): keep ALLOW_LOCAL_AI_PRIVATE_HOSTS=true for the operator's own AI, deprecated
Making `=true` grant nothing would stop a single-user self-host's local
model on a patch update. It is deprecated for one release cycle instead,
in a narrowed and safe form: it still grants a private host, but only to
the operator's own configurations (the instance-wide admin provider and
AI settings saved on an admin account), and only through the pinned
operator-approved dispatcher, so redirects stay forbidden and metadata,
link-local and unspecified addresses stay refused. A non-admin account's
own base URL needs an exact origin in AI_PRIVATE_ORIGINS.

Provider resolution passes the owning account's role to the client
(`operatorTrusted`); the save route and the connection test apply the
same rule to the account being configured. The boot summary now says the
setting is deprecated and names the replacement, and the admin AI
settings page shows the exact AI_PRIVATE_ORIGINS line for the private
origins saved on the instance (every account's Local and gateway URL and
the admin base URL, scheme, host and port only), so the operator can copy
it and drop the old setting.
2026-09-26 20:59:10 +02:00
Marc-André Bombeck 849748c4cd fix(backup): bind new off-host objects to their key and account, and cap what an uploaded backup may inflate to
Off-host objects are now written as envelope version 4: the header names
the off-host key by fingerprint, and the object's key in the bucket
(<date>/user-<id>.json.enc) is authenticated as GCM associated data, so
a copy moved to another account or another night no longer opens.
Objects of versions 1 to 3 still read. BACKUP_ENCRYPTION_PREVIOUS_KEYS
(comma-separated, on the compose whitelist and in the env manifest)
keeps retired off-host keys readable, which is the rotation path the
off-host key did not have.

The admin backup upload counted the bytes that arrived and none after
the gunzip, so a small gzip could expand without limit into the JSON
scanner and take the app down. The inflated bytes are now held to 100
times the compressed bytes read (at least 256 MB, at most 4 GB) and a
file past that is refused with 413 before anything is stored.
2026-09-26 20:18:55 +02:00
Marc-André Bombeck 1a674cf4b0 fix(security): grant private AI endpoints by exact origin, dialled pinned
ALLOW_LOCAL_AI_PRIVATE_HOSTS=true let any user of the instance point the
Local provider or the OpenAI-compatible gateway at any private address
the server could reach, the metadata range included, and a granted host
was dialled with no connect-time pin at all.

AI_PRIVATE_ORIGINS grants a private AI endpoint with the exact-origin
grammar NOTIFICATION_PRIVATE_ORIGINS and NIGHTSCOUT_PRIVATE_ORIGINS use.
A granted call goes through the pinned operator-approved dispatcher:
redirects forbidden, and metadata, link-local and unspecified answers
dropped at dial time even for a granted name. Everything else stays on
the public pin. The Local client (buffered and streaming) and the
gateway and admin-key tags of the OpenAI client all take their policy
from one function.

The host-list form of ALLOW_LOCAL_AI_PRIVATE_HOSTS keeps working with the
same pinning; a never-grantable literal or a host:port entry is refused
with a one-time warning. Its "true" form grants nothing any more and is
reported in the boot readiness summary with the replacement.

A failed lab-report scan no longer puts the endpoint's response body into
the wide event; status and model stay. The response to the caller never
carried it.
2026-09-26 19:46:11 +02:00
Marc-André Bombeck 026e8fc1ed Merge branch 'fix/compose-env-and-claims' into release/v1.39.3 2026-09-26 17:00:46 +02:00
Marc-André Bombeck afcb37e76c feat(config): let the operator turn off the breach and update checks
Two requests left the server without anything being connected: the
HaveIBeenPwned range query on every password set (registration, change,
admin reset) and the daily GitHub releases request behind the About page
and the admin version tile. Neither could be turned off, which made
"nothing leaves" untrue for an instance that wanted it to be.

PASSWORD_BREACH_CHECK_DISABLED and UPDATE_CHECK_DISABLED (1, true, yes or
on) stop each request. Both stay on by default. The breach check keeps
its fail-open contract: switched off, a password is simply not looked up.
The update route answers status "unknown" with reason "disabled" and
the admin tile says the check is off. The password routes reach the
check only through the switch, and a test holds that import line.

Both are on the compose whitelist, in the env manifest under a new
"Outbound requests" group with IP_GEO_LOOKUP_DISABLED, and documented in
both env examples and the getting-started guide.
2026-09-26 15:33:34 +02:00
Marc-André Bombeck fdc60f69c2 feat(documents): search and import from Paperless-ngx and Papra on request
A person stores one connection per system (base address, Papra
organization id, API token encrypted at rest) and can search it by
document name with tag and date filters, then import picked documents
one per request through the same ingest path as an upload: source-key
answer before any download, content dedup, deleted stays deleted, quota,
provenance, and the person's own AI reading setting. An import can link
the document to the visit, condition or vaccination it was picked for.

The operator lists the instances in DOCUMENT_SOURCE_ORIGINS; unset, the
feature is off. Only listed origins are contacted, through safeFetch with
the origin pinned, redirects refused, timeouts and byte caps. The routes
are cookie-only and owner-only. The token is registered for rotation,
removed by Delete all data, and not carried in backups.
2026-09-26 13:02:36 +02:00
Marc-André Bombeck d5a39603ca Merge branch 'fix/backup-chunked-storage' into release/v1.39.2 2026-09-26 01:03:25 +02:00
Marc-André Bombeck 1e09f30eff fix(backup): store a backup in sealed pieces instead of one value
A stored backup was one value in data_backups.data. v1.39.1 wrote it a
piece at a time but still joined the pieces into that one value, and one
value has to pass through the app whole, so a copy was capped at a fifth of
the heap: 105 MB in the default 1 GB container. An account with 1.75
million readings is past that and could not be backed up at all (#1031).

A copy is now kept as ordered pieces of about a megabyte in the new
data_backup_chunks table (migration 0352). Each piece is sealed on its own
with AES-256-GCM, and its sealed header names the copy (a random stream id
kept on the row), its position and whether it is the last. A reader checks
every piece before it releases a byte, so a piece that is missing, moved,
altered, taken from another copy, or a copy cut short is refused with
backup.payload.undecryptable before a restore deletes anything, and the
second read during the restore checks each piece again.

The write is one transaction, so the previous copy stays whole and readable
until the new one commits. Pieces are written through the model API: a raw
query with a large parameter is kept alive by Prisma's query plan cache,
which left 160 MB of heap behind per hundred one-megabyte inserts.

Restore, preview, download, the restore job and the admin list read both
forms; copies written before this change keep their single value and still
restore. The heap-derived limit is gone. The one limit left is on storage,
BACKUP_MAX_STORED_MB (default 2048), and its message names the setting. Key
rotation covers the pieces, the wipe and account deletion take them with
their backup, and the backup plan lists the table as not carried.

Measured on an account of 2.6 million readings in a 1 GB container with web
and worker in one process: backup 87 s, 97 MiB in 96 pieces, preview 20 s,
download 25 s, restore 264 s with every reading back exactly, peak 567 MiB,
no restart.
2026-09-26 01:02:48 +02:00
Marc-André Bombeck 55a32ff7d1 feat(documents): add a document upload token for imports from other systems
A narrow `documents:write` token, minted cookie-only at
`POST /api/tokens/documents` and accepted by the vault upload route alone.
A scoped caller draws on its own hourly bucket per token
(`DOCUMENT_UPLOAD_LIMIT_PER_HOUR`, default 120) and gets a receipt
`{ id, duplicate }` instead of the stored row.

Uploads take optional `sourceSystem` / `sourceId` fields. The source key is
unique per account across live and deleted documents, and the tombstone
purge carries it to a small ledger, so re-sending a document the owner
deleted answers 200 `deleted: true` and stores nothing. `aiRead=defer`
indexes locally and skips the summary and lab staging.

The detail sheet shows where an imported document came from; backup and
restore carry both fields; Delete All Data clears the ledger.

Refs #1038, #938
2026-09-25 21:48:04 +02:00
Marc-André Bombeck ed8a93afc2 feat(labs): make the lab-scan limit operator-tunable
A self-hoster scanning a stack of lab reports ran into a fixed six scans
an hour with no way to raise it. LABS_OCR_LIMIT_PER_HOUR now sets the
per-user ceiling (default 6, clamped to 1-1000, falling back to the
default when unset or not a number), the same shape as
DOCUMENT_AI_LIMIT_PER_HOUR. It is on the compose whitelist, in the
production example, the env manifest and the AI provider guide.

The route also charged the slot before it had looked at the request, so
a malformed body, an unsupported or oversized file, a PDF that could not
be rendered or a scan the daily budget refused still used up the hour.
Those failures hand the slot back now; a scan that reached the provider
keeps it. The 429 carries meta.retryAt. Refs #985
2026-09-15 23:01:10 +02:00
Marc-André Bombeck 94bed12edc fix(egress): keep loopback grantable under an exact operator origin
A Nightscout self-host on host networking with
NIGHTSCOUT_PRIVATE_ORIGINS=http://localhost:1337 worked before and must
keep working unchanged; an operator who lists an exact loopback origin
with its port has made the same deliberate decision as one who lists a
LAN origin. The never-grantable floor is now the unspecified address,
link-local and the metadata endpoint only. Loopback (127/8, ::1,
localhost) is grantable when listed exactly, still resolved and pinned
at connect time with redirects forbidden, and still refused when not
listed.

::1 is answered before the embedded-IPv4 unwrap, which read it as the
IPv4-compatible spelling of 0.0.0.1 and refused it as unspecified. The
pinned Nightscout transport test runs the real floor again. Each refused
range keeps its own test; loopback moves to the grantable-when-listed
set with a test that an unlisted loopback is still refused, and a
Nightscout grant of http://localhost:1337 is asserted to parse.

Refs #947
2026-09-10 19:08:04 +02:00
Marc-André Bombeck 9630b59973 chore(compose): forward and document NOTIFICATION_PRIVATE_ORIGINS
Put the variable on the compose environment whitelist (without it a
value in .env never reaches the process), on both example files, and in
the env manifest so check-env reports it. The self-hosting notifications
page gains a section on notification targets on a private network: the
exact-origin grammar, what stays refused even when listed, and a table
of which target is refused where so an operator knows which lever to
pull.

Refs #947
2026-09-10 16:47:55 +02:00
Marc-André Bombeck 0037cc1bcd Write restored PHI 0o600 and let TRUST_PROXY_HOPS reach the container
Two findings from the docs truth audit, both operator-facing.

scripts/restore-backup.ts wrote the decrypted backup, which is health
data in plaintext, with the process default file mode, leaving it
world-readable on the operator's disk while it sat there. It is now
written 0o600.

TRUST_PROXY_HOPS is read from the environment to size the trusted
X-Forwarded-For chain, but it was absent from the compose environment
whitelist, so an operator value in .env never reached the container and
was silently ignored. It now rides all four surfaces: the compose
whitelist, both env examples, and the manifest that pnpm check-env
reads.
2026-08-27 15:21:50 +02:00
Marc-André Bombeck 8e16df6894 feat(documents): make the document-AI rate limit an operator setting with honest accounting
The document-AI actions (read/index, extract, suggest, summarise) shared a
hard-coded 6/hour bucket that lied twice: the 429 said "try again in a few
minutes" while the window is an hour, and several failure paths burned a
slot before any provider was dispatched.

- DOCUMENT_AI_LIMIT_PER_HOUR configures the shared per-user ceiling
  (default 6, clamped 1-1000), wired end to end: compose environment
  whitelist, .env.production.example, env manifest, operator docs.
- INSIGHTS_RATE_LIMIT_PER_HOUR joins the compose whitelist and manifest;
  it was documented but never listed, so an operator value could not reach
  the container.
- The 429 mirrors the bucket's reset instant into meta.retryAt (plus the
  existing X-RateLimit-Reset header); the client derives "try again in
  about N minutes" from it in all six locales, with a plural-aware key.
- A slot is consumed only when a request actually reaches the provider (or
  the local index/echo work): malformed bodies, unreadable originals,
  refused consent, and exhausted daily budgets refund the charged slot via
  a window-scoped refundRateLimit primitive. Consistent across all four
  routes sharing the bucket.
2026-08-13 00:33:14 +02:00
Marc-André Bombeck 0a2cacb8b3 Document the runtime GeoLite2 fetch path
Cover the new MAXMIND_LICENSE_KEY route in the self-hosting guide as the
first of the three ways to get the offline databases (fetch, mount, bake),
and note that the monthly runtime refresh keeps a keyed host current on its
own. Add the variable to both env examples and to the env-check manifest so
the optional group lists it.

Refs #659
2026-08-09 22:09:12 +02:00
Marc-André Bombeck e4870f3b5f fix: close two gaps the last audit round left open
- give the delegated-action inventory a leg that can falsify its level
  axis: the walked module set must strictly exceed the manage-only one,
  anchored on a route that never asks for manage
- declare AUDIT_LOG_RETENTION_DAYS and SESSION_COOKIE_SECURE in the env
  manifest, so the pre-deploy check reports the two settings operators
  are told to set, and hold manifest, compose whitelist and both example
  files in step
2026-08-07 02:26:29 +02:00
Marc-André Bombeck 189024cc00 fix(geo): make GEOLITE2_DIR reachable and tell self-hosters what they can do
The runtime has always read GEOLITE2_DIR, but the variable was absent
from the compose `environment:` whitelist, from both env examples, and
from the docs. A variable that is not on that whitelist never reaches
the container even when the operator's `.env` sets it, so the override
existed in code and was unreachable from the documented path.

Put it on the whitelist, document it in both examples and the manifest,
and add `docs/self-hosting/geolite2.md`: fetch the databases with a free
MaxMind account, mount the directory read-only, point the variable at
it. The compose file carries the bind-mount line commented out beside
the app service.

The admin notification told the reader to set MAXMIND_LICENSE_KEY in
this repository's Actions secrets — something nobody running the
published image can do, about a fallback that sends their sign-in
addresses to a third party. It now names the mount, names the online
provider it is actually querying, and links the runbook. Six locales.
It also no longer claims the carrier column stays empty; the online
tier has filled the carrier from the ISP field since v1.25.8.

A blank GEOLITE2_DIR now resolves to the default instead of the process
working directory: compose substitutes an unset variable to the empty
string, which is not nullish.

Refs #659
2026-07-27 14:47:55 +02:00
Marc-André Bombeck 62eb431d66 release: v1.32.0 — hardening and scalability (#580)
* release: prepare v1.32.0

* test: stabilize water capture re-entry
2026-07-22 04:14:15 +02:00
Marc-André Bombeck a8b0365ca5 fix(auth): carry a generated secret in the session cookie, not the row id
The cookie was the Session row's cuid. cuid is built for collision-
resistant identity across distributed writers, not for unguessability —
it encodes a millisecond timestamp and a process-local counter and never
touches a CSPRNG. A session cookie is a bearer credential and has to be
unguessable, so it now carries 32 random bytes and only the HMAC of that
value is stored, matching how api_tokens and refresh_tokens already hold
their secrets.

Compatibility is passive, and deliberately so. A backfill cannot help:
the cookie already sitting in a browser is the cuid, and nothing we
generate server-side can be pushed into it — backfilling would sign
everyone out at deploy. Upgrading a row in place on first use loses to
the parallel requests a single page load fires: each mints its own
secret, one write wins, and the losers cannot re-emit the winner's
secret because only a hash is stored, so the browser can end up holding
a value the row does not honour. Instead, rows minted before the column
keep a NULL token_hash and stay resolvable by id, and are denied the
sliding-expiry extension so they drain within the 30-day session
lifetime. Every login from this release forward is on a secret at once,
and an id stops authenticating the moment its row holds one.

Note for operators: API_TOKEN_HMAC_KEY now also keys session cookies, so
rotating it signs browser sessions out as well as invalidating tokens.
It is already Core-required, so no deployment gains a new prerequisite.
2026-07-19 11:40:56 +02:00
Marc-André Bombeck 7592c73947 fix(auth): harden the OIDC token path and state-cookie lifecycle
Token verification: jwtVerify now carries a closed asymmetric algorithm
allowlist (RS256/PS256/ES256/EdDSA — an HMAC alg would let anyone with
the client secret forge identities) and a 60 s clock tolerance for the
usual self-host drift between IdP and app box. The JWKS fetch — jose's
own, outside safeFetch by design — is disciplined instead by pinning
the jwks_uri host to the configured issuer's host byte-exact and
passing the same 15 s timeout safeFetch defaults to. requirePublicHost
stays deliberately unused on this surface: LAN/Tailscale-only IdPs are
a legitimate topology and the issuer is operator config, not user
input.

Discovery: the issuer comparison now tolerates exactly one trailing
slash on either side (a recurring IdP/operator mismatch); token
verification still matches the provider-sent issuer byte-exact.

State cookie: the cookie is set under Path=/api/auth/oidc but was
deleted without a path — RFC 6265 keys cookies by (name, domain,
path), so the deletion never matched and the single-use blob survived
its whole TTL. Every delete now repeats the exact path, via a shared
constant.

Also: the IdP error code recorded in the wide event is whitelisted
against the closed OAuth error-code set (unknown values collapse to
"other"); the status route drops an unreachable try/catch and a
duplicated annotate; the passkey conditional-UI autofill stays unarmed
under OIDC_ONLY (passkey login is a server-enforced dead end there);
env-manifest gains the three optional OIDC_* tuning vars; the env
example notes why the issuer should be HTTPS.
2026-07-14 11:53:53 +02:00
Balaji Vijayakumar f815e4a83f feat(auth): add OIDC SSO login
Adds an OpenID Connect relying-party login path (PKCE authorization-code
flow) against a single env-configured identity provider — additive to
password/passkey login by default, with an operator opt-in (OIDC_ONLY,
enforced server-side on every auth route) to disable them entirely.
First-time sign-in auto-provisions or links an account by verified email.

Closes an open redirect in the post-login `next` parameter (both the new
OIDC path and the pre-existing password/passkey flow shared the same
backslash-normalization bypass) by resolving the target through a real
URL parse and comparing origins instead of a prefix check.
2026-07-13 16:33:13 +05:30
Marc-André Bombeck a4feabec81 chore(release): v1.28.30 — the daily briefing stops silently skipping days
The recurring no-briefing day had a chain of causes, closed end to end:

Every failure path in the nightly pregenerate + the comprehensive generator
now annotates a queryable cause (several classes — features-error,
payload-too-large, aborted, generator-failed — previously bumped a counter
with zero trace). A failed nightly warm enqueues one bounded retry ~45
minutes later (singleton per user, existing budgets apply), so a transient
provider hiccup heals before morning. A cached payload that carries NO
briefing no longer satisfies the 24h POST short-circuit nor the
unchanged-content hash gate — a once-stripped briefing could previously
block its own regeneration indefinitely. The card surfaces the omission
reason persistently (grounding strip, generation failure, not attempted)
with a forcing retry.

Documentation corrected against code truth: Withings credentials are
per-user (the documented env path never existed; the dead example entries
and manifest rows are gone, and WITHINGS_WEBHOOK_SECRET is now actually
whitelisted in compose — stock deploys could never deliver it); the classic
Fitbit portal no longer accepts new registrations; the backup-restore
example no longer mints a fresh encryption key and uses pnpm dlx tsx; the
TLS guide describes the real client model (two CA-level SPKI pins,
any-match, leaf renewals need no build); scaling/admin references fixed;
the operator-shared subscription provider is documented.
2026-07-12 11:08:59 +02:00
Marc-André Bombeck 1f4ba5939d style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
2026-06-19 22:20:31 +02:00
Marc-André Bombeck c04bdc8f4e feat(notifications): document SMTP env and translate channel strings
Add the webhook + email Settings copy and the admin notification-health
panel labels across all six locales. Add the SMTP_* operator vars to the
env manifest (all-or-none: HOST + PORT + FROM enable the channel,
USER/PASS optional), to .env.production.example, and to the docker-compose
environment whitelist so an operator's .env values actually reach the
container.
2026-06-15 00:46:28 +02:00
Marc-André Bombeck 88139fb981 feat(integrations): add Oura Cloud v2 OAuth sync
Connect an Oura account over the Cloud v2 API: an authorization-code OAuth
flow (shared app from OURA_CLIENT_ID / OURA_CLIENT_SECRET) on the same signed
state machinery as Polar. Unlike Polar, Oura issues refresh tokens; the sync
refreshes reactively on a 401, rotating and re-encrypting both tokens, and a
403 stays a hard reauth rather than a refresh.

An hourly poll-cohort job maps daily readiness to RECOVERY_SCORE, sleep to
per-stage durations plus efficiency / HRV / resting heart rate / respiratory
rate, and daily activity to steps and active energy — all tagged source OURA,
idempotent on (userId, type, source, externalId). Readiness flows through the
canonical recovery ladder above the computed proxy. Settings gains a connect /
disconnect card. Tokens are never logged or returned; every egress goes
through safeFetch.
2026-06-14 20:10:35 +02:00
Marc-André Bombeck 31abd307fd feat(integrations): add Polar AccessLink OAuth sync
Connect a Polar account over AccessLink v3: an authorization-code OAuth
flow (shared app from POLAR_CLIENT_ID / POLAR_CLIENT_SECRET) backed by a
stateless HMAC-signed state token doubling as an httpOnly cookie for CSRF.
The callback verifies the signature, expiry, provider binding, and the
byte-exact cookie match, then encrypts the access token plus member id onto
the user row.

An hourly poll-cohort job maps Nightly Recharge recovery, sleep stages,
and daily activity to canonical measurements tagged source POLAR, idempotent
on (userId, type, source, externalId). The recovery band feeds RECOVERY_SCORE
through the canonical ladder, which now ranks native readiness above the
computed proxy. Settings gains a connect / disconnect card. Tokens are never
logged or returned; every egress goes through safeFetch.
2026-06-14 20:08:50 +02:00
Marc-André Bombeck 76012bf8e2 chore: scrub instance details and personal data from committed artefacts
Blood-pressure fixtures in the in-target integration suite were copied
from live readings; they are now synthetic with the same in/out split so
every assertion holds. Hard-coded operator hostnames move behind env vars
(DEPLOY_LOGS_URL, HEALTHLOG_PROD_URL), the codex client identifies via
the public repository URL, the OpenAPI servers entry points at a neutral
example host, and remaining workflow, docs and comment references to the
operator's instance or working notes are generalised. The compose
whitelist also gains DEPLOY_WEBHOOK_SECRET, which was missing and made
the deploy webhook unusable on compose installs.
2026-06-10 11:00:26 +02:00
Antonius Moritz 13f6252298 fix(docs): generate URL-safe Postgres passwords
openssl rand -base64 24 can emit URI-reserved characters (/, +, =). Since POSTGRES_PASSWORD is interpolated unescaped into DATABASE_URL, such a password breaks the connection URL and blocks boot. Switch every documented example to openssl rand -hex 32 (URL-safe, 256-bit).
2026-06-07 16:13:37 +02:00
Marc-André Bombeck 540abebf09 feat(ops): alarm on a TLS leaf SPKI change for pinned native clients
The native client SPKI-pins the server's TLS leaf certificate. When the
leaf auto-renews (GTS re-issues it roughly every 90 days) the leaf keypair
— and so the SPKI pin — changes, and a client shipped only the old pin
refuses to connect on the next renewal: a silent outage. Nothing
server-side signalled that a client pins the served leaf.

Add a pg-boss monitor that probes the served leaf every 6 hours, computes
its iOS SPKI pin (base64(sha256(DER subjectPublicKeyInfo)) via a raw TLS
socket + X509Certificate), and compares it against the operator's
known-good set in TLS_LEAF_SPKI_PINS (comma-separated for the dual-pin
renewal window). On a served pin outside the set it fails loud: a
tls.pin.leaf_changed wide-event annotation, a system.tls.pin_changed audit
row, and a high-priority SYSTEM_ALERT fanned out to admins through the
existing dispatcher idiom. Transient TLS/network failures annotate
tls.pin.probe_failed and never alarm.

The baseline is an env var, not a persisted last-seen row — a self-learning
baseline would silently adopt the first rotation and suppress the alarm the
pinned client needs; an unset baseline logs the served pin and warns rather
than auto-adopting. The runbook documents extraction, the re-pin + dual-pin
procedure (≥11 days before expiry, ship to TestFlight), and the assumption
that the probed host is the host the client connects to.

Wires the queue into the worker (allQueues + cron + worker), adds the
optional env to the manifest, compose whitelist, and .env example, and
covers the SPKI computation + change detection with a fixture-cert unit
test plus a queue-registration guard.

(cherry picked from commit cdb88cd77b88aec71ad2f21caf2b15c185dfc01e)
2026-06-05 08:10:13 +02:00
Marc-André Bombeck 9b6e237f5f chore(release): v1.4.42 — Knip enforcing, queryKey factory closed, iOS Workouts dedup, Withings off-response classification
Six touch-disjoint waves landed on develop before this release:

- W1-KNIP — exports/types tiers flipped to enforcing-mode on main;
  35 unused exports + 52 unused types triaged to 0/0; shadcn surface
  + zod-infer contract types scoped via knip.json ignore-block.
- W2-ZOD-MULTI-ISSUE — `returnAllZodIssues` shared helper +
  `/api/dashboard/widgets` 422 now returns every Zod issue + worker
  audit-ledger breadcrumb. 41 sibling routes documented for v1.4.43.
- W3-QUERYKEY-LONGTAIL — 40 settings/medications/admin/hooks files
  migrated to the queryKeys factory; 9 new factory entries; ESLint
  rule + test-guard scope extended in lockstep.
- W4-ARCH-HYGIENE — BERLIN_DAY_FORMATTER consolidated across nine
  sites (seven status helpers + bucket-series + bp-in-target);
  Suspense double-comment consolidated; doctor-report sanitiser regex
  rewritten to escape-form so diffs are reviewable; pr-detection-
  worker soft-delete filter; offhost-backup DR-intent comment.
- W5-IOS-WORKOUTS — `dedupeWorkoutBatch` write-time cross-source
  dedup helper for the v1.5 iOS HealthKit observer ingest; dashboard
  tile-strip placeholder parity with the live TrendCard chrome.
- W6-WITHINGS-OFF + ENV-CHECK — Withings off-response classifier
  (transient/reauth/persistent) + WithingsApiError typed throw + the
  `pnpm check-env` CLI. APNs `allOrNone: true` closes the AP-2
  silent-disable gap the wave was conceived to close.

No migration, no env-var change, no API contract break for iOS v0.5.4.
The new `/api/dashboard/widgets` 422 envelope is additively extended.

pnpm test --run green at 4815 passing / 1 skipped.
pnpm typecheck, pnpm lint, pnpm knip (no --include flag) green.
2026-05-21 19:33:07 +02:00