100 Commits
Author SHA1 Message Date
Marc-André Bombeck e9886ee554 fix(security): let the app frame its own blob previews
A blob: document inherits the policy of the page that minted it, and
WebKit applies the inherited frame-ancestors to the blob frame's own
parent. With 'none', Safari on macOS and iOS refused the PDF preview
iframe and showed an empty box; Chromium does not apply it there and
rendered the preview. 'self' keeps every foreign origin out.

Refs #1311
2026-11-05 00:52:02 +01:00
Marc-André Bombeck 6800ff2620 fix(i18n): translate the stray word in the Russian connect-source hint 2026-11-05 00:00:03 +01:00
Marc-André Bombeck e9ae024035 fix(onboarding): connect a Fitbit answer through Google Health
Fitbit shut its classic Web API down on 2026-10-30 and Fitbit data now
arrives through Google Health, yet answering "Fitbit" still ended the setup
on the classic card, read the classic connection's status and named it.

Fitbit stays an answer, because people still own the devices. Its task now
opens the Google Health card, reads that connection, is named after it, and
says on every tile that Fitbit data comes through Google Health. The
question carries the same hint under the Fitbit choice, and the checklist
names the connection the same way.

The onboarding-source guard keeps Google Health out of the questions, now
with a reason a test holds: the Fitbit answer is the way there.
2026-11-04 23:59:18 +01:00
Marc-André Bombeck 341061de5c feat(measurements): store resting energy as its own metric
HealthKit's basalEnergyBurned sat in the deferred set, so the batch route
refused it per entry and acceptedTypes left it out. It now maps to a new
BASAL_ENERGY_BURNED type in kcal (migration 0493), beside active energy
and never added to it.

It is cumulative like active energy: summed per day on the read path and
in the rollups, drained into one daily row, imported from export.zip,
listed and charted with its own label in every locale, part of the report
selection and the backup, and readable over MCP. It carries no personal
record direction and feeds no score, insight or Coach snapshot.

Health Connect is left unmapped on purpose: its basal metabolic rate is a
rate at one instant, not a day's energy. No other integration reports a
resting figure of its own.

Refs #1340
2026-11-04 23:45:55 +01:00
Marc-André Bombeck 4894f5be5b test(labs): seed the marker behind the lab line in the GET contract
The contract walk had no fixture for `/api/biomarkers/{id}/series`, so
the new route was reported instead of parsed. It now takes the seeded
biomarker, which already carries a lab value, and is checked against its
published schema like `/api/biomarkers/{id}`.

Refs #1342
2026-11-04 22:07:34 +01:00
Marc-André Bombeck 27a1cd6c2f feat(integrations): list imported cycle days in the details
The details of the Garmin and Oura cards listed measurement types and
workouts only, so cycle days a wearable imported appeared nowhere, and a
bug report could not say whether any had arrived. The status envelope now
carries a `CYCLE_DAYS` entry per cycle-importing source with the newest
day and the count, and the freshness list renders it. It counts as a
sparse type, so a month between periods never reads as a dead pipe.

Refs #1333
2026-11-04 22:03:18 +01:00
Marc-André Bombeck afdb8163ce fix(onboarding): offer Garmin as a source
The setup question about where readings come from listed every other
connection on Settings and not Garmin, so the flow could neither record it
nor end on connecting it. Garmin is now a Q4 source and a browser
connectable one: the key is pasted on its card, so nothing can be missing
up front.

A guard now pins that every connection the health read covers is either an
onboarding source or named with the reason it is not.

Refs #1333
2026-11-04 22:03:08 +01:00
Marc-André Bombeck 148af765c9 fix(cycle): open cycles from Garmin and Oura period days
Both wearables wrote their period days with a bare create and so skipped
the seam every other cycle writer goes through: a first bleeding day never
opened its cycle and no day was attributed to one. An account whose
periods came from a watch or a ring held day-logs only, with no cycle
start, no lengths and no forecast.

The create now goes through one shared helper that keeps the
create-only-if-absent rule and then runs the same attribution and
`ensureCycleForBleedingDay` inference as the shared upsert. The hourly
legs and the newest-first Garmin backfill both use it; a finished Garmin
period also stamps its last day on a cycle that has no period end yet.

Rows imported before this change are repaired by a one-shot job sent from
boot discovery while candidates exist. It opens or attributes cycles
oldest first per account, touches no day-log content, and converges to an
empty candidate set.

Refs #1333
2026-11-04 22:02:48 +01:00
Marc-André Bombeck a35b77c147 feat(labs): serve one marker's lab line under the labs module
GET /api/biomarkers/{id}/series answers the line labSeries on
/api/timeline draws for that marker, gated on the labs module instead of
the opt-in timeline. The window rules move into resolveTimelineWindow and
the shared query rules into withWindowRules, so both routes cut the same
window and read the same loader; all starts at the marker's first reading.

Refs #1342
2026-11-04 21:24:12 +01:00
Marc-André Bombeck a79fc4168a feat(sync): let a client start the delta feed at the present
GET /api/sync/changes?start=now answers an empty page whose cursor sits at
serverNow in every domain, in the same opaque format the feed hands out.
A client that only needs what changes from now on, such as the Apple
Health write-back, pages forward from it instead of draining the whole
history first. start and cursor together are refused with 422.

Refs #1339
2026-11-04 21:24:12 +01:00
Marc-André Bombeck 59434cd463 feat(healthkit): list the accepted sample types on the integration read
GET /api/integrations/healthkit carries acceptedTypes, the quantity and
category identifiers the batch route maps today. The list is derived from
APPLE_HEALTH_TYPE_MAP alone, so a deferred type is never in it and the
client no longer needs a release to follow a mapping change.

Refs #1340
2026-11-04 21:18:30 +01:00
Marc-André Bombeck 66533497de fix(auth): carry purgeAt on the native OIDC deletion-pending redirect
The web leg of the OIDC callback already names the purge date when the
account is pending deletion; the native leg redirected with the bare
reason. It now adds the same purgeAt parameter to the custom-scheme
redirect, and only the date.

Refs #1341
2026-11-04 21:18:30 +01:00
Marc-André Bombeck a812fb699f fix(mental-health): read the country from the region subtag for every language
A tag such as en-BE, fr-CH or en-AT fell through to the international
crisis set because region detection hung on the language. The region
subtag now decides first for every language; bare language codes keep the
combined sets, and the web's folded pt-BR bundle stays ambiguous.

Refs #1338
2026-11-04 21:02:02 +01:00
Marc-André Bombeck d3401b5b89 refactor(records): move the life circumstances field primitives into their own file
The draft state for the typed fields pushed the form past its size
baseline. The primitives own only their input state and read nothing from
the form, so they move out and the baselines tighten.
2026-11-04 17:59:54 +01:00
Marc-André Bombeck 41f90bac3f chore(deps): move @hono/node-server to 2.x and lift systeminformation
The serveStatic double decode has no fix on the 1.x line, and the MCP
SDK accepts 2.x. The app uses the web-standard transport and never loads
it. systeminformation sits under the shadcn CLI at dev time.
2026-11-04 16:56:20 +01:00
Marc-André Bombeck d98e16557b chore(deps): keep Node majors out of the image PRs
Refs #1334
2026-11-04 16:54:29 +01:00
Marc-André Bombeck 40200b55a7 fix(records): let the smoking history fields take keystrokes
The quit month only accepted text that was already a whole YYYY-MM
month and the start year clamped every keystroke into 1900..now, so
neither could be typed into. Both keep a draft while typing and commit a
valid value. Dependabot also stops proposing Node majors for the image.

Refs #1334
2026-11-04 16:54:14 +01:00
Marc-André Bombeck 27b054d8c4 fix(image): take scheme-less health-check URLs, anchor the compose pin, say which tools left 2026-11-04 14:32:58 +01:00
Marc-André Bombeck fb143a2d47 docs(release): screenshots for v1.47.0 2026-11-04 14:21:49 +01:00
Marc-André Bombeck bc4b9f4350 chore(release): v1.47.0 2026-11-04 14:13:49 +01:00
Marc-André Bombeck 3149805592 Merge branch 'fix/v147-debian-slim' into release/v1.47.0 2026-11-04 13:15:49 +01:00
Marc-André Bombeck 849fe4b2c6 build: stop tracing the musl canvas prebuilts the glibc image never loads 2026-11-04 13:15:49 +01:00
Marc-André Bombeck e547a77683 docs: describe the image as the official Node 22 Debian slim image
The unreleased changelog entry said the image moved to Wolfi. It now
names the official Node image, why (Raspberry Pi 4 rendering, Node
security releases arriving with the image), that the image carries no
package manager, and that psql is no longer in the app image. The README
and the getting-started prerequisites say the same.
2026-11-04 13:14:04 +01:00
Marc-André Bombeck 09d932ce2a ci: fail the container scan only on findings with a fix
The Debian base carries HIGH advisories the distribution has not fixed
(util-linux, ncurses, systemd, acl, sqlite, perl-base), which no change
here can remove. The blocking image scan now runs with --ignore-unfixed,
so a finding with a published fix still fails the job, and a separate
step before it lists every HIGH and CRITICAL finding without failing.
The scheduled scan's critical gate follows the same rule; its SARIF
report keeps everything.
2026-11-04 13:14:04 +01:00
Marc-André Bombeck b6b693717c build: run the image on the official Node 22 Debian slim image
The runner moves from Wolfi to node:22-trixie-slim, the same image and
digest as the dependency and build stages. Wolfi's free repository keeps
only its newest Node stream current, so nodejs-22 stayed at 22.23.2 and
missed two releases with HIGH fixes; the official image carries 22.23.4.

The runner removes npm, npx, corepack and yarn before anything else and
fails the build if one is left. The Prisma CLI install moves to its own
prisma-cli stage on the same base and is copied over, so npm never runs
in the runner. pg_dump and pg_restore 18.6 come from a pgclient stage on
postgres:18.6-trixie, pinned by digest, with the libpq they link; the
Kerberos, LDAP and SASL libraries come from apt. The PGDG package would
bring Perl and the wrapper scripts, and its repository drops a pinned
minor once the next one is out.

The native ABI and glibc check between builder and runner stays as a
guard. runtime-package-pins now holds every stage to a digest, the
runner to the builder's image, the runner free of package managers, and
the pgclient minor to the bundled database's. Dependabot ignores a
PostgreSQL major in the Dockerfile as it does in the compose file.
2026-11-04 13:13:56 +01:00
Marc-André Bombeck 5dd6453773 fix(garmin): compare the cached token's key in memory, drop an unused export 2026-11-04 12:02:46 +01:00
Marc-André Bombeck de47cb7b31 test(analytics): list the Garmin and training types in the parity goldens 2026-11-04 11:15:03 +01:00
Marc-André Bombeck 8a89d5cfc3 Merge branch 'feat/v147-garmin' into release/v1.47.0 2026-11-04 10:58:59 +01:00
Marc-André Bombeck 2348073eeb docs(garmin): describe the service choice, key validity and skipped data
The Garmin page says the key goes only to the service picked, how to
correct a key's validity from the card, that a refused key is retried
daily, that weigh-ins reach back past the first watch, that removed
nights disappear, and where data Garmin does not provide is listed. The
changelog entry names the service choice.
2026-11-04 10:56:52 +01:00
Marc-André Bombeck 83c06051fe fix(garmin): read weigh-ins in both shapes, keep refused resources out of the way
Weigh-ins: Garmin's document has no samplePk and gives kilograms, the live
service sends a samplePk and grams. A weigh-in now takes its id from
samplePk or else its own GMT instant, reads each mass by magnitude (above
1000 is grams), and is written whole or not at all: a missing weight, a
missing instant or one implausible value drops every row of it, counted
on the wide event by reason.

Refused resources: a lasting 4xx on one resource (a cycle summary for an
account that tracks no cycle) is noted once on the progress column,
counts as done for the backfill, is skipped by the hourly sync and is
listed in the card's details. It no longer holds the backfill open or
turns the connection red. The cycle leg never parks the connection,
whatever it is refused with.

Backfill: weigh-ins walk back to the start of Garmin Connect on their
own, so history before the first watch day and a scale-only account
arrive. Progress and the completion stamp are written only while the
connection the slice started on is still the stored one.

Key: connect sends the key to the one service the person picks (Garmin
Connect or Garmin Connect China), never to both. A parked key is asked
about again once a day with a single token exchange and reconnected when
Garmin accepts it. The key's end is read from the token's own iat and
exp, an estimate the key outlived is cleared after an accepted exchange,
and the validity can be corrected from the card's menu (PATCH
/api/garmin/key-expiry, with the day the key was created) without
pasting it again.

Sync: an incremental run reaches back to the last successful sync, up to
a year, so an outage longer than the 30-day window leaves no gap; a night
Garmin no longer reports (deleted, or turned into a nap) is retired.

Card: connect errors are translated from meta.errorCode in every locale.

Types: BODY_BATTERY, STRESS_LEVEL, TRAINING_READINESS and
ACUTE_TRAINING_LOAD get source ladders of their own (the device or the
bridge first, then the hand-entered sources in their usual order), join
the ranked types and the provenance picker, and the recovery-empty
description names them.
2026-11-04 10:56:52 +01:00
Marc-André Bombeck 73f16e5389 docs(garmin): document the Personal API integration
The Garmin page now explains how to create a key, what syncs and where
it lands, how key expiry is handled, and what the Personal API does not
carry. The Home Assistant guide shows how to push Training Readiness and
the acute training load, the Apple Health and Google Health pages point
at the direct integration, and the changelog records the release notes.
2026-11-04 10:14:17 +01:00
Marc-André Bombeck 13e232c836 test(garmin): pin the key exchange, the mapping and a sync against a fake Garmin
Unit tests cover the token cache, the single re-exchange on a refused
token, the key's end read from a capped token, the 429 wait, the range
chunking, and every resource's mapping over made-up records shaped from
Garmin's document. The card test pins the shared anatomy, the menu and
the expiry sentences. The integration test runs the sync, the backfill,
connect and disconnect against a fake Garmin served over HTTP and the
real schema: rows per resource under GARMIN, no second row on a re-sync,
a refused key parked after one retry, a 429 that stops the run, a
history walked to its start across slices, and a purge on disconnect
that keeps the imported history.
2026-11-04 10:14:17 +01:00
Marc-André Bombeck e2ba86b12f feat(garmin): sync Garmin Connect through the Personal API
Garmin Connect now offers a Personal API: a person creates a key for
their own account and any client can buy short-lived tokens with it.
HealthLog takes that key in a new Garmin card, checks it with Garmin
before storing it sealed on the user row, and polls every hour.

- Transport: key exchange with a per-process token cache keyed on the
  key, one re-exchange on a refused token and then reauth_required, the
  429 Retry-After carried to the caller, the China host tried when the
  global one does not know the key, answers read with a byte cap, all
  egress through safeFetch to the two fixed hosts.
- Sync: daily summaries, sleep summaries, HRV, weigh-ins and the
  activity search, chunked to each resource's documented range, read one
  after another and stopped at the first rate limit; today's menstrual
  cycle summary adds logged period days the person has not logged.
- Backfill: a resumable walk through the shared admission lane that
  learns where the history starts from the daily summaries and the
  activities, waits out Garmin's rate limit and stamps its marker.
- Types: GARMIN source; BODY_BATTERY and STRESS_LEVEL for Garmin's own
  day scores, kept apart from the computed STRESS_SCORE; TRAINING_READINESS
  and ACUTE_TRAINING_LOAD for figures a bridge pushes through the ingest
  token. All four chart on the recovery page and sit on the ladders,
  units, ranges, labels and report groups like the device-native scores.
- Key expiry: learned from a token capped short of a day, else estimated
  from the validity picked at connect; the card warns a week ahead and
  asks for a new key once Garmin refuses the old one.
- Ledger, health, freshness, watchdog, backup classes, wipe plan,
  encrypted-column registry, rotation script, OpenAPI and all ten
  locales carry the new integration. The old "no Garmin connector" note
  is gone.
2026-11-04 10:14:10 +01:00
Marc-André Bombeck 67909f2d9d fix(db): derive the app role's password with scrypt
The key it comes from is one the operator chose, and a fast hash over a
weak one would be cheap to walk back from a leaked verifier.
2026-11-04 01:46:59 +01:00
Marc-André Bombeck f266555fe8 build: hold each message catalog to 250 KB gz, for the Russian one
Cyrillic is two bytes per letter in UTF-8, so the Russian catalog is
240 KB gz against under 210 for the Latin-script ones. The reason and the
numbers are in the budget file.
2026-11-04 01:46:58 +01:00
Marc-André Bombeck 4762ca43c2 docs(changelog): v1.47.0 entries 2026-11-04 00:56:27 +01:00
Marc-André Bombeck a162990d18 test(medications): drop the timeout the BYSETPOS property no longer needs 2026-11-04 00:45:53 +01:00
Marc-André Bombeck 65dc9751e0 test(medications): generate BYSETPOS rules that always have an occurrence
A rule with no occurrence (BYSETPOS=2 over one weekday) makes rrule walk
to its year 9999 on every call, seconds per case, which no schedule the
app accepts can produce. The reviewed counterexample is a fixed case.
Also formats the locale settings file.
2026-11-04 00:45:28 +01:00
Marc-André Bombeck 1bbd2d97c7 Merge branch 'fix/v147-screen-negations' into release/v1.47.0 2026-11-04 00:17:01 +01:00
Marc-André Bombeck 1a4703f4fe feat(insights): name the part of the day on the blood pressure stats while narrowed
The stat strip reads the chart's visible window, so with the chart
narrowed to the morning its figures were morning figures under a plain
Systolic label. The column label now says so. Russian texts for the
time-of-day card.
2026-11-04 00:17:00 +01:00
Marc-André Bombeck 62d1f57de8 fix(ai): block a negated take, a pause, an increment and a liquid dose
Four instruction shapes passed the outbound dose screen in every language:
a negated intake ("Don't take atorvastatin tonight", "Nehmen Sie kein
Atorvastatin"), a pause ("Take a break from enalapril"), an increment with
no target ("Add 4 more units of insulin"), and a measured volume of a liquid
drug form ("Take 10 ml of the syrup").

Each bank gains the shapes, anchored on a medication noun, a drug unit or a
liquid drug form rather than on the verb. A negated intake counts as a hold
only when the clause closes or a time word follows and no protective
qualifier sits in the object ("a double dose", "more than", "half"), so the
standard warnings still pass. `ml` stays out of the drug units: the liquid
shape needs a syrup, solution, drops or medication noun beside it, so
"drink 500 ml of water" passes.

The Russian and Korean banks now know the generic-name stems in their own
script (INN_STEM_CYRILLIC, INN_STEM_HANGUL), and the English object slot no
longer crosses another language's negation, which had blocked the Dutch
warning "Stop niet met ramipril zonder overleg".
2026-11-04 00:14:56 +01:00
Marc-André Bombeck 22f83aed6e Merge branch 'feat/v147-bp-time-of-day' into release/v1.47.0 2026-11-04 00:13:45 +01:00
Marc-André Bombeck b93241aa87 feat(insights): blood pressure by time of day
Home monitoring reads morning and evening readings as separate sets, so
the blood pressure page now shows the morning, midday and evening means
side by side over the chart's range, each with its count and pulse, and
the morning-minus-evening difference. A control under them narrows the
chart to one part of the day; the choice lives in `?partOfDay=` so it
survives a reload and a shared link, and the whole day keeps a bare URL.

The split is resolved on the server in the record's own time zone and
published as one response, `GET /api/measurements/blood-pressure/time-of-day`,
so every client renders the same numbers. Morning is 04:00 to 11:59,
midday 12:00 to 17:59, evening 18:00 to 03:59: the guidelines define the
sets by waking and bedtime rather than by the clock, so the windows are
wide, and a reading after midnight is a late evening one. Pairs follow
the blood-pressure series' rule (same source within five minutes); the
pulse is the reading's own. A part shows a mean from three readings on.

`GET /api/measurements` takes `partOfDay` on the blood-pressure and pulse
series read: each local day folds only that part's readings, a day runs
from 04:00, and long ranges fold weekly instead of reading rollups that
cannot tell the hour.

Refs #1331
2026-11-04 00:12:49 +01:00
Marc-André Bombeck cac0668119 test: read the Dockerfile through readSource in the wget shim test
Plus six Russian strings smoothed in review.
2026-11-03 23:52:57 +01:00
Marc-André Bombeck b2fb295d43 Merge pull request #1322 from l1137l/feat/ru-locale 2026-11-03 23:50:35 +01:00
Marc-André Bombeck 0007973b78 fix(ai): tighten the ru dose bank
End a dose unit at a Cyrillic letter, so «3 месяца» no longer reads as
"3 МЕ". Keep ml out of the drug units as the other banks do, and stand
the target-dose shapes down on a dietary noun (water, sodium, caffeine),
which blocked plain nutrition advice. Cover the stop and inject verbs
(перестаньте, бросить, колите) the English bank already blocks.
2026-11-03 23:45:36 +01:00
Marc-André Bombeck 3e2e2ad56b Merge branch 'pr-1322' into review/pr-1322 2026-11-03 23:37:22 +01:00
Marc-André Bombeck 2d8b782f34 Merge branch 'fix/v147-review-dbrole' into release/v1.47.0 2026-11-03 23:35:54 +01:00
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 a10bebdc43 fix(db): give each database its own app role and keep the queue schema the owner's
Roles are cluster-wide. Two HealthLog databases on one server shared
healthlog_app: each start set its own password, locking the other
instance out, and the role held grants on both databases. The role is
now healthlog_app_<database>_<hash>, marked with a comment naming its
database, its password is derived per database, and a role holding an
explicit CONNECT on another database is not used.

The role owned the pgboss schema, so it could plant a trigger, a rule or
a function body that a later owner session (a rollback, DB_APP_ROLE=off,
a fallback start) would run with the owner's rights. pg-boss changes its
schema only on install, on migration and for partitioned queues, which
the app does not use. The start now installs and migrates the schema as
the owner with pg-boss's own plans, and the worker runs pg-boss with
migrate: false when it does not own the schema. The role gets rows only,
read-only on pgboss.bam (SQL the migration runner executes) and on the
version column. Every start, on every path, takes back what a
HealthLog role owns there, drops triggers and rules, and after a
take-back restores the queue functions to pg-boss's own definitions and
retires the legacy role. Bloated queue indexes, which pg-boss only
reports when it does not own them, are rebuilt at start.
2026-11-03 23:31:37 +01:00
Marc-André Bombeck 41e75b5bde Merge branch 'fix/v147-review-pdf' into release/v1.47.0 2026-11-03 23:30:26 +01:00
Marc-André Bombeck da48f1af9e Merge branch 'fix/v147-review-wolfi' into release/v1.47.0 2026-11-03 23:30:25 +01:00
Marc-André Bombeck 7af9ebe480 docs(stats): say what the r² clamp does and does not fix 2026-11-03 23:30:21 +01:00
Marc-André Bombeck 0e7134a2ea fix(labs): let a deleted number not hold a text marker's unit, and read the winner's unit after a race
Refs #1313
2026-11-03 23:30:21 +01:00
Marc-André Bombeck 55a3a9b53d fix(medications): keep the rrule anchor for a rule with BYSETPOS
The library drops a period's candidates before DTSTART before BYSETPOS
picks among them, so a start moved into the middle of a week chose a
different day. The property now generates BYSETPOS rules and kept red
without the guard.
2026-11-03 23:30:20 +01:00
Marc-André Bombeck 42cae738f1 build: pin the runner's Node and PostgreSQL client packages
nodejs-22 and postgresql-18-client are installed at exact package
versions, so a rebuild of the same commit runs the same Node and the
same pg_dump; apk upgrade still moves the libraries under them. No
updater reads an apk pin. The image scan does the job instead: Wolfi
records the fixed version for each advisory, so a pinned package with a
published fix turns the HIGH/CRITICAL gate red and names the version.
A guard holds the pin format and keeps the client's minor on the bundled
database's.
2026-11-03 23:27:06 +01:00
Marc-André Bombeck a11ea8444d build: ship a wget for health checks written outside the image
The Wolfi runner has no wget, but health checks outside this repository
call it: compose files copied from here up to v1.46.6 run
'wget --no-verbose --tries=1 --spider .../api/health', and the TrueNAS
catalog app renders 'wget --quiet --spider <url>'. Either replaces the
image's HEALTHCHECK, so such a container never turned healthy and a proxy
that routes on health dropped its traffic.

/usr/local/bin/wget now runs scripts/wget-shim.mjs: the URL, --spider,
-q, -nv, --tries, --timeout/-T, -O - and -O /dev/null, -S; other flags are
ignored. Exit 0 on 2xx/3xx, 1 on any other status, a refused connection
or the timeout. It follows no redirect and refuses any other output file
before sending a request, so it is no download tool. The image's own
HEALTHCHECK and the bundled compose file keep the Node probe.
2026-11-03 23:27:06 +01:00
Marc-André Bombeck 5705184857 test(documents): drop a mock of a module that no longer exists 2026-11-03 23:24:49 +01:00
Marc-André Bombeck 253176d01a fix(documents): index every page of a PDF read by its text layer
"Read with AI" and the index backfill wrote a text-layer read as a complete
`local-pdf` index although it held only the first ten pages (the render
cap), while the local text index reads up to 200. `prepareVisionInput` takes
the number of pages a text-layer read may take, and both index writers ask
for the local index's own cap, so the index is complete.

The `partial` flag on a text read had no reader and is gone; the
extraction and the lab scan report their missing pages themselves.

Refs #1311
2026-11-03 23:24:49 +01:00
Marc-André Bombeck 763d6c600f fix(labs): bound each text-layer read of an unrenderable PDF
`planTextLayerReads` grouped whole pages into the reads the deadline
allows, with no bound on a read's size, while the text path refuses a part
past `OCR_TEXT_READ_MAX_CHARS`. A dense report in a request's three reads
went out as reads of 12 000 characters and more, which ends at the output
limit or loses rows.

`planTextLayerChunks` now cuts a page longer than one read at line
boundaries, keeps a page per read while the deadline allows that many, and
otherwise packs pages into reads of at most twice the surface's target
(8 000 characters for the lab scan, 5 000 for the document extraction).
What the reads cannot take is read from the start, and the pages left over
are reported with the pages without a text layer: `unreadPages` on the lab
scan, `extractionIncomplete` on a document.

Refs #1311
2026-11-03 23:24:38 +01:00
Marc-André Bombeck 119431dc15 fix(documents): fence document text in the summary and the filing suggestion
Both text modes spliced the text after a bare `OCR TEXT:` label, while the
extraction, the lab scan and the label read fence it. A PDF read by its
text layer on a server that cannot render now reaches these prompts, and
the automatic summary reads imported PDFs that way, so text the page does
not show (white on white) could write past its own end. The text is now
fenced with `fenceDocumentText`, the browser-OCR path included, and a marker
forged inside it is scrubbed.

Refs #1311
2026-11-03 23:24:26 +01:00
Marc-André Bombeck b975e86b39 test(clinician-share): start every case on an all-modules-on map
Two cases switch the doctor report off for the rest of their block, and
the blocks that did not set the map themselves inherited it under a
shuffled order, so a record share stopped aggregating.
2026-11-03 22:39:16 +01:00
Marc-André Bombeck 144840bab9 test(documents): type the fixtures the text-layer cases added instead of casting them
The stored bytes are already a Buffer and the MIME literal already fits
its union, so neither needs a cast; the external pick goes through one
helper. The as-never ratchet tightens accordingly.
2026-11-03 22:36:18 +01:00
Marc-André Bombeck 7cb67c2e75 Merge branch 'fix/v147-nightly' into release/v1.47.0 2026-11-03 22:30:50 +01:00
Marc-André Bombeck a598b00081 test(coach): start every snapshot case with no readings
The query-shape case read the measurement mock without setting it and
passed only after a case that left rows behind.
2026-11-03 22:25:43 +01:00
Marc-André Bombeck 1ac69420d5 test: reset to the factory answer instead of casting a new one
A reset mock goes back to the implementation its factory gave it, which
is the fresh session and the open capability these cases want; the cast
replacements grew the test-realism count.
2026-11-03 22:22:32 +01:00
Marc-André Bombeck 9a3140a4d3 test(backup): hold the record-shaped slices at their edges
The health score day, the intraday curve, a medication with its
composition and custom category, an intake row and the cycle's own
symptoms had fields and defaults no slice test met, added since the
floor was set; the backup-validation score had drifted to 40.2 % under
its 41 % floor. It is 54.1 % with these cases, and the floor follows
to 53 %.
2026-11-03 22:15:05 +01:00
Marc-André Bombeck fa439601c4 test(labs): give the unitless-marker refusal its own marker
The refusal case leaned on the marker the case before it mints, and
under a shuffled order it ran first and minted it itself.
2026-11-03 22:13:32 +01:00
Marc-André Bombeck b1cb387062 Merge branch 'fix/v147-pdf-unrenderable' into release/v1.47.0
# Conflicts:
#	.env.production.example
2026-11-03 22:10:14 +01:00
Marc-André Bombeck 3547c4bd2f test: pin the intake history's success badge at the one it now renders
The taken badge moved into one shared component, so the file carries the
token once.
2026-11-03 22:08:05 +01:00
Marc-André Bombeck 394828449a test(documents): read unrenderable PDFs through the routes against Postgres 2026-11-03 22:07:28 +01:00
Marc-André Bombeck df6d20b696 fix(documents): read a PDF the server cannot render by its text layer
On a host whose CPU cannot run the page renderer (x64 without AVX2, arm64
musl without LSE/RCpc), every AI read of a PDF for a provider that only
takes images failed with "Scanning PDFs needs a Claude vision provider",
which says nothing about the cause. Most medical PDFs are produced
digitally and carry every word in their text layer, which pdf.js reads
without rendering.

`prepareVisionInput` now answers a text read (`mode: "text"`) when the
renderer is unavailable on this host, the provider cannot take the PDF
whole and the PDF has a usable text layer. Summary, suggest and the
background summary send that text through their existing text mode;
"Read with AI" and the index backfill write it as the local text index it
is, with no provider call; the automatic index leaves it to its local path.
`planVisionReads` does the same for the extraction and the lab scan: text
reads by page group, with anchored provenance, and the pages without a text
layer reported as `extractionIncomplete` on a document and as `unreadPages`
on a lab scan. A supplement label PDF is read from its text layer too.

A scan on such a host now answers `documents.inbound.pdfRendererUnavailable`
and `labs.ocr.pdfRendererUnavailable` (422): this server's processor cannot
run the page renderer, so a scanned PDF can only be read by a provider that
takes PDF files directly, or as photos. The `pdfNeedsAnthropic` codes are no
longer emitted, since with a working renderer every provider reads a PDF;
the recorded index outcome keeps its `pdf-needs-anthropic` value with
corrected copy.

The extraction path no longer folds a render failure of one document into
the capability error: like `prepareVisionInput` since #776 it reports
"The PDF pages couldn't be rendered for scanning." (`extractFailed`), and
the lab scan answers `labs.ocr.extractFailed`.

Hosts that render, and providers that take PDFs whole, are unchanged.

Refs #1311
2026-11-03 22:07:22 +01:00
Marc-André Bombeck c47f61f02a feat(documents): read a PDF's text layer page by page without rendering
The reader child gains a `page-texts` request: the first pages' text
layers, each read the way a rendered page's is (cut and marked past the
per-page cap), plus the document's page count. Nothing is rendered, so it
works where the page renderer cannot run.

`readPdfTextLayer` judges what that read is worth: a page counts only with
at least 40 characters of its own besides white space, so a scanner's
stamped page number or fax header does not pass for a page of text; a PDF
with no such page is a scan and is reported as one.
2026-11-03 22:07:11 +01:00
Marc-André Bombeck d1ab195b43 Merge pull request #1330 from FloMaetschke/feature/injection-site-in-intake-history 2026-11-03 22:06:35 +01:00
Marc-André Bombeck a503c8855d Merge branch 'feat/v147-wolfi' into release/v1.47.0 2026-11-03 21:56:23 +01:00
Marc-André Bombeck 5d36afb487 build: run the image on a Wolfi runner
The dependency and build stages stay on node:22-trixie-slim, where pnpm
picks the glibc native packages. The runner moves to wolfi-base, pinned
by its index digest: glibc, about fifty packages, and every one of them
known to the image scan, Node and the PostgreSQL client included.

nodejs-22, tzdata and postgresql-18-client come from apk, so the pgclient
stage and its pin guard go. The builder writes its Node module ABI and
glibc version out and the runner refuses to build on another ABI or an
older glibc. Wolfi's npm is major 12 and runs no install scripts unless
package.json allows them, so the Prisma CLI install names the three
packages that have one, and the build checks that the schema engine file
is there before asking the CLI about it. npm is installed for that step
and removed in the same layer; the check that nothing is left asks a
fresh shell, whose command cache cannot remember the deleted binary.
2026-11-03 21:55:30 +01:00
Marc-André Bombeck 2c33c2a0a0 test(integration): stop cases reading what another case left behind
- the AI-state cases take their baseline in a world of their own
  instead of from the S0 case;
- the live-window case drops a rollup tier the rollup case built;
- the job readers start each case on an empty queue, and the
  no-schema case starts pg-boss again after it;
- the server move case reseeds the server it emptied;
- the extremes cases rebuild the tier one of them breaks on purpose;
- the first Google Health backfill seeds its own history;
- the batch budget warms the process caches as the reads do;
- the HOT-update count is read as a difference: since Postgres 15 the
  transaction view also shows an earlier transaction's unflushed counts;
- the MANAGE matrix checks its effect set after all cases rather than
  in a case of its own that a shuffle could run first.
2026-11-03 21:35:57 +01:00
Marc-André Bombeck d7046c9737 test(integration): keep the stateful walks in order, give the rest their own rows
The migration before-and-after files, the dump directory, the geo
backfill's three passes, the settings trip, the backup parity steps,
the lab conversion record and the pulse method's dry run before its
rebuild are each one sequence by design; they now say so and keep their
order with shuffle: false. The CSV import's zone and crash cases wrote
into the account the lab and medication walks count, and now use
accounts of their own.
2026-11-03 21:35:57 +01:00
Marc-André Bombeck 713806fe9b test(parity): keep each route walk in order under a shuffled run
A parity file is one account walked through a route's cases, each
request reading what the earlier ones stored, and the recorded files
pin that sequence. The nightly shuffle reordered the steps and every
snapshot after the first moved. Keep each walk in order with
shuffle: false, wrapping the files whose top-level blocks build on each
other, and note the rule for order-bound files in the testing guide.
2026-11-03 21:35:56 +01:00
Marc-André Bombeck 899a054e64 Merge branch 'feat/v147-mcp-reading-id' into release/v1.47.0 2026-11-03 21:33:50 +01:00
Marc-André Bombeck 66709222b2 docs(api): describe the lab reading id next to the analyte id 2026-11-03 21:33:18 +01:00
Marc-André Bombeck 52e6bcd480 test(mcp): prove a report's lab link lands on that report's reading
Two draws of one analyte a year apart and two reports on the same day, against
Postgres: each report lists its own reading id, and fetching it returns that
reading with that report in its links, not the latest one.
2026-11-03 21:33:17 +01:00
Marc-André Bombeck ba27e728cf feat(mcp): fetch one lab reading by its own id
A link to a lab result named its analyte, and fetch lab:<analyte> answers
with the latest reading of that marker. Following a link from a report that
holds an earlier draw therefore landed on a later reading, whose links named
a different report and visit.

fetch labresult:<id> now returns that one reading (value or text result,
unit, reference range, status, date) with its own links, behind the labs
module and narrowed to the caller's live rows. Every link to a lab result
uses this id, each reading in get_labs carries it, and fetch lab:<analyte>
names the reading it quotes in metadata.readingId. The analyte view and
search are unchanged.

Refs #1323
2026-11-03 21:33:16 +01:00
Marc-André Bombeck 691c2e7301 refactor(labs): shape one reading from one shared select
The labs block and a single-reading read now build a reading from the same
select and the same shaping, so one reading fetched by its id renders exactly
like the latest reading of a marker.
2026-11-03 21:33:14 +01:00
Marc-André Bombeck cd344125d5 fix(labs): let a text-only marker take the unit of its first number
A marker minted from a text result ("SEE NOTE") has an empty unit, and a
marker with a reading has its unit locked, so the first number to arrive,
a unitless ratio included, was refused against a unit nothing had chosen.
While every reading is text the unit has nothing to contradict, so the
first number sets it. A marker that already holds a number keeps its unit.

Refs #1313
2026-11-03 21:19:28 +01:00
Marc-André Bombeck 377e0d0d3a build: copy pg_dump and pg_restore from the PostgreSQL image
postgresql-client-18 from the PGDG repository brought Perl and Debian's
client wrapper scripts into the runner for two binaries the app calls by
name. A pgclient stage on postgres:18.6-trixie, pinned by digest, now
hands over pg_dump, pg_restore and the libpq they link; the Debian
libraries libpq needs (Kerberos, LDAP, SASL) still come from apt so the
upgrade and the image scan see them. The runner registers libpq with the
loader and fails the build when either binary has an unresolved library;
the major 18 version checks stay.

The client minor follows the bundled database's: a guard holds the two
pins together, and the Dockerfile's Dependabot watch now ignores a
PostgreSQL major the same way the compose watch does.
2026-11-03 21:18:44 +01:00
Marc-André Bombeck cf7905dc92 refactor(medications): hold recurrence.ts at its size baseline 2026-11-03 20:58:46 +01:00
Marc-André Bombeck 227c80878c test: give every case its own mock state under a shuffled order
Cases leaned on what an earlier case set up or left behind: a once-value
the code under test never consumed, an implementation swapped in and
kept by clearAllMocks, a payload only another block's setup provided,
the zone resolver's per-user cache, module state in a hoisted object,
and a scope set one case discovered for the next. Each now resets or
states what it reads.
2026-11-03 20:58:38 +01:00
Marc-André Bombeck 4f7802ba28 test(browser): leave no state behind for the next case
Await the form's explicit unmount: an unmount left running overlaps the
next render's act, and React then commits nothing for the rest of the
file. Return to the newest history entry after stepping back, so the
next case's push adds an entry instead of replacing the forward one.
2026-11-03 20:58:38 +01:00
Marc-André Bombeck 54d0647a6d fix(stats): keep r² within [0, 1] when y sits far from zero
The centred sums lose their last digits when every y is close to one
large value, and a two-point fit came out at r² 1.00000004. Clamp the
ratio to 1 and the y sum of squares at 0, and pin the two shrunk
counterexamples the nightly property run found.
2026-11-03 20:58:37 +01:00
Marc-André Bombeck 270c6ca619 refactor(medications): keep the rrule start alignment in its own module
recurrence.ts sits on the source-size ratchet; the helper and its reasoning
read on their own.
2026-11-03 20:57:46 +01:00
Marc-André Bombeck d8743e9111 chore: drop the trivyignore entries for npm's bundled packages
Both covered copies inside the npm CLI of the Node base image. The
runner removes npm, so neither CVE matches anything in the image; a scan
of the built image with an empty ignore file reports neither.
2026-11-03 20:53:29 +01:00
Marc-André Bombeck 3d0ae05192 docs: say the published image renders documents on a Raspberry Pi 4
The image runs the glibc canvas build now, which needs no ARMv8.2
features, so the Pi 4 gets thumbnails and scanned-PDF rendering back
without a code change: the gate only ever closed for musl on arm64. The
gate's comment, the env example and the getting-started prerequisites
say so, and keep NATIVE_CANVAS as the override. The x64 AVX2 gate stays:
the glibc x64 build rendered a PDF page under an emulated core without
AVX2, but it still carries AVX2 code and that one run does not cover
every path.
2026-11-03 20:53:28 +01:00
Marc-André Bombeck de7858100f build: run the image on Debian trixie instead of Alpine
Every stage now starts from node:22-trixie-slim, pinned by its multi-arch
index digest, so the native modules load their glibc builds. The arm64
musl Skia build needs ARMv8.2 (LSE atomics, RCpc loads) and dies with
SIGILL on an ARMv8.0 core such as the Raspberry Pi 4's Cortex-A72; the
glibc build runs there (#1311).

The runner keeps what it had: tzdata, TZ, the build-time OS upgrade, the
nextjs user (uid 1001, now in group nodejs gid 1001), the backup and
GeoLite2 directories and every build-time assertion. pg_dump and
pg_restore of major 18 come from the PGDG apt repository, whose signing
key is fetched by node and checked against a pinned SHA-256 before apt
trusts it; PATH names the major 18 directory so the binary does not
depend on Debian's version-picking wrapper. The canvas hoist takes the
-gnu binary package by the pinned version and still fails the build on a
mismatch. bookworm-slim was the other candidate; on the day of the
switch it carried a CRITICAL zlib finding that trixie-slim does not.
2026-11-03 20:53:20 +01:00
Marc-André Bombeck 65e35761ec build: probe container health with Node's fetch instead of wget
The probe now needs nothing but the node binary the image already runs,
which frees the base image from carrying wget. The image HEALTHCHECK and
the compose healthcheck use the same one-liner: a non-2xx answer, a
refused connection or the 4 s abort exit 1, inside Docker's 5 s timeout.
The boot-storm guard reads the abort from the fetch and refuses a probe
that names wget or curl.
2026-11-03 20:53:20 +01:00
Marc-André Bombeck 775933b7d5 Merge branch 'feat/v147-dbroles' into release/v1.47.0 2026-11-03 20:52:17 +01:00
Marc-André Bombeck 3d1a6224ba test: drop vi imports the removed schema-mirror mocks left unused 2026-11-03 20:49:31 +01:00
Marc-André Bombeck df1e19f1c2 docs: describe the app's database role, its limits and how to opt out 2026-11-03 20:38:46 +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 485161a657 feat(db): run the app as a role that cannot change the schema
After the migrations the entrypoint ensures healthlog_app, a login role
with row access to the app's schema, MAINTAIN on measurements and
ownership of the pgboss schema, and starts the app with a DATABASE_URL
for it in place of the owner's. The role cannot alter or drop tables,
create roles, schemas or functions in the app's schema, read server
files or run programs, or write the migration history.

The password is an HMAC of a fixed label under API_TOKEN_HMAC_KEY, so a
separate worker container derives the same one, and it is sent as a
SCRAM verifier. Every boot re-applies the grants in one transaction
under an advisory lock, and drops as the owner the indexes the
maintenance job may no longer drop. When the owner cannot create roles,
the server is older than Postgres 17, or the role cannot log in, the
boot says why and the app stays on the owner as before. DB_APP_ROLE=off
skips it.
2026-11-03 20:36:37 +01:00