Commit Graph
5922 Commits
Author SHA1 Message Date
Marc-André Bombeck f29995a18c docs(api): correct the score descriptions the adaptive rule outdated
Three descriptions in the OpenAPI registry still describe the floor that
v1.38 removed, and one of them contradicts its own error-response
description two dozen lines below it.

- The score-config PATCH still said the write is refused below three
  distinct areas and one physical measurement. The route refuses the
  empty selection and nothing else; its 422 description already said so.
- The dashboard snapshot's healthScore still said the block is null until
  three eligible domains include a physiological one. It is null only
  when no pillar is readable at all.
- The pillar domain field still pointed at a three-distinct-domains rule
  that no longer exists, where what a client needs to hear is that domain
  count comes resolved on scoreBasis and must not be counted out of
  composition.

Regenerated docs/api/openapi.yaml.

The user-facing description of the score lives in the docs site's own
repository, not here; nothing under docs/ describes the composite.
2026-08-30 17:08:39 +02:00
Marc-André Bombeck 81be61b6d1 fix(seed): give the demo every area of health the score can span
The demo seeds no waist measurement and no wellbeing screener, so two of
the seven pillars have never had data and the score rests on three areas
instead of five. Adiposity and wellbeing render as empty rows on the card
that is meant to show the feature.

Both are seeded now: a fortnightly waist that walks 96 cm down to 88 cm
over the window (0.527 to 0.484 at the demo profile's height, so the
ratio crosses NICE 2022's threshold inside the seeded span rather than
sitting flat green), and six WHO-5 / PHQ-9 / GAD-7 administrations with
their item answers encrypted the way the check-in route writes them, each
with the COMPUTED projection measurement so the history view and the
trend chart agree. Totals sit in the calm bands and item 9 is answered
zero throughout, which also keeps the pillar clear of its
crisis-signposting arm.

Verified against a throwaway Postgres: before, five ok pillars over three
areas; after, seven over five, tier full.

The dates were never the problem. Every timestamp the seed writes is
already relative to the run, and the header says so. What ages is the
seeded DATA — the pillar windows keep moving after a seed, so four weeks
on, activity and sleep fall out of theirs and the score narrows to
whatever cardiometabolic data is still fresh. Since v1.38 that is a
labelled partial score rather than a blank card, but it is still not the
demo at its best, and the header now says that too.
2026-08-30 17:08:29 +02:00
Marc-André Bombeck d98d714ee7 feat(score): pin the rule that keeps invented targets dead
The score once graded weight against 22 x height squared, widened by 2 kg
either side: a band nobody set, no surface named, and the only producer
of that pillar's verdict. v1.34 removed it and the composite rebuild left
a pillar set where every scored band cites somebody. Nothing but review
has kept it that way since, and review is what let the original in.

So the rule is written down. PillarReference.kind becomes a runtime list
carrying the reason there is no fourth kind, reference.source is
documented as a citation a reader could go and look up, and a guard
beside the pillars checks all seven modules three ways.

The arm worth arguing about is the second. An invented target is a
continuous function of a profile field; a guideline band is a step
function into a published table. So the guard sweeps every pillar across
a matrix of heights and ages and requires the scored bands it emits to
land inside a small enumerated set transcribed from the guideline that
pillar cites. No finite enumeration can hold a per-person curve, which is
what makes the arm able to fail rather than merely able to pass. The
other two arms cover the citation string and freeze which pillar may read
which profile field, with the reason written beside it and stale entries
failing as loudly as missing ones.

The proof is in the file: the pre-v1.34 weight target is restored as a
fixture in today's shape and each arm is shown refusing it.
2026-08-30 17:08:16 +02:00
Marc-André Bombeck a2a743c9e1 Merge pull request #858 from MBombeck/feat/health-score-composition-notice
feat: the score says when a pillar stopped counting
2026-08-30 16:42:42 +02:00
Marc-André Bombeck 58701706db Merge pull request #857 from MBombeck/fix/geometry-guard-must-measure
fix: the geometry guard has never measured in CI
2026-08-30 16:42:27 +02:00
Marc-André Bombeck e4880dcdc1 feat: the score says when a pillar stopped counting
A pillar can leave the set behind the number without anybody choosing it:
a window rolls past its floor, a module goes off, a source goes quiet. The
delta already refuses to narrate that as a health event, which stops a
false "you dropped six points" and puts nothing in its place. The level
moves, the panel shows a different number, and the cause is nowhere on the
surface. Under the adaptive minimum this matters more rather than less,
because compositions are smaller now and sit closer to their floors.

The report carries `compositionNotice` when today's set differs from the
last local day the account has a stored score for. Optional and nullable,
the additive shape the score's wire has used six times, so a payload
cached before it existed stays valid. It names what left and what joined
as pillar ids; the surfaces already own the labels, and the reason a
departed pillar stopped counting comes from that pillar's own row in the
same report rather than from a second vocabulary.

Two changes deliberately do not raise it. A method move and a recipe
change each already have their own note, and telling someone twice about
one event teaches them to ignore both.

The key is `health-score:v<version>:composition:<hash of the sorted set>`
and rides the existing dismissed-item ledger, so a dismissal holds until
the set moves again rather than until tomorrow. The read excludes today on
purpose: the row for today is written by the same request that computed
the report, so comparing against the newest row would find today's own
composition on the second load of the same page and the note would vanish
between two views of it.

This gives `HealthScoreRecord` its first production reader. Since it
shipped it has been written on every scored read and consumed by nothing
but backup and restore, which is the write-only shape the contributor
notes call a liability and which nothing in the gate can notice, because
every check proves one end. The round trip is proved against real
Postgres: raise the note, wipe the table, restore it through the admin
route, get the same note back, and hold the dismissal across it.

Rendering is one muted line on the score card and a card of its own on the
settings page, beside the recipe note rather than inside it. Deliberately
not a push.

Four fake Prisma clients gain the new read. They are doubles of a client
the reader really does call now; leaving them short and guarding the call
site instead would have been a shim for a caller that does not exist.
2026-08-30 16:16:57 +02:00
Marc-André Bombeck 624432b3fe Merge pull request #855 from TimonBed/fix/withings-dev-redirect-uri
Shows warning for misconfigured callback urls
2026-08-30 15:59:17 +02:00
Marc-André Bombeck 88750e0048 fix: give the geometry check a browser and stop it skipping in CI
The hero-geometry check has never measured anything in CI. It launches
Chromium in a hook, catches the launch failure, and every case then
returns early — green. The quality job installs no browsers, so every
run of it since the file landed printed

  [hero geometry] SKIPPED — no Chromium build available (Error:
  browserType.launch: Executable doesn't exist at
  /home/runner/.cache/ms-playwright/chromium_headless_shell-1234/ch)

and passed. The file's own header warned about exactly this trap and
then fell into it.

Both ends move. The quality job installs the headless shell before the
unit suite — `--only-shell chromium`, because that is the build
`chromium.launch()` resolves to and the one the failing runs named, with
a cache on the lockfile hash so the download is paid once. And the check
now refuses to be optional: with CI set, a launch failure throws out of
the hook and fails the suite with the step to restore. Without CI it
still skips, through the test context so the skip and its reason land in
the report rather than in a console line nobody reads.

No panel number changed. The measurements the check reads back are the
same ones the reserve was set against: panels of 400 / 428 / 456 / 484
px for three to six rows, 420 px for three rows plus the basis line,
against a 448 px reserve.
2026-08-30 15:55:46 +02:00
Marc-André Bombeck 6c4fa2f484 i18n: translate the callback mismatch notice into the remaining five locales
The key shipped with the English sentence in es, fr, it, pl and ko, which
the English-leak guard catches by design: a value byte-identical to the
English one for a multi-word string is a forgotten translation, and that
guard exists because this project has shipped exactly that before.

Nobody sending a two-line fix should have to produce seven languages.
2026-08-30 15:41:45 +02:00
Marc-André Bombeck 319a75b02f Merge pull request #856 from MBombeck/feat/health-score-basis-surfaces
feat: the score says how much of the record it rests on
2026-08-30 15:40:09 +02:00
Marc-André Bombeck 59ccf5be2a feat: say how much of the record the score rests on
Two areas of health score now, and one does. The arithmetic is the same
at every breadth, so nothing on the web surfaces distinguished a number
resting on blood pressure alone from one resting on five areas. The
panel now states its scope.

The basis line. Below the recommended breadth the card renders "Based on
{n} of 3 areas of health" under the band sentence, in the same weight,
and stops there. It stands where the learning gate used to stand for
those accounts, which is what the gate was for before the refusal moved.
The number keeps its size and its band colour at every tier, there is no
warning glyph, no second colour, no apology: the coverage meter and the
confidence band underneath already do the quantitative caveat work, and
the composition list names the areas one tap away. At full breadth the
line says nothing, because a fraction that reads "3 of 3" (or "4 of 3" —
the block counts domains, the recommendation caps at three) is not a
label. The daily hero carries the same sentence beside its ring, in the
hero's quiet tier, because the ring shows a number and nothing else.

The gate survives for the one case that is still a refusal: not a single
pillar readable. Its copy stopped claiming the score needs three areas
and stopped counting them — above zero there is a score now, and the
meter below renders the fraction anyway.

The coverage axis. "Covers the three areas the score needs" was false
the moment three stopped being needed, and the branch that chose it was
worse: it asked whether a score exists, so a two-area account would have
been told it covers the three areas the score recommends — the old lie
one floor up. The branch is the breadth tier now. Full breadth states
the recommendation; everything narrower shows the fraction, which since
this release is a real moving number rather than a floor every scored
account cleared by definition. The key was renamed off "minimumMet"
because there is no minimum left to name.

Settings names the recommendation rather than a requirement: three areas
is what the score recommends, a selection with one pillar saves, and a
narrower score is worked out the same way.

Copy lands in all seven catalogues.

Tests: the axis guard gains a third claim, rendered rather than read out
of the source — the real panel at each tier, including the two-area arm
that was unreachable for a scored account until now. The card gains a
partial-score block comparing the headline number's markup byte for byte
against a full-breadth render, so "renders the same" is asserted where
it can actually break, plus the counter-case that a refusal still gets
the gate and nothing else. The hero strip proves the sentence reaches
the band, not only the component. The geometry reserve was re-measured
with a browser rather than widened: a three-row panel carrying the basis
line lands at 420px, inside the 400-484px range the reserve brackets, so
the declared minimum is unchanged and the narrow shape is now measured.
2026-08-30 15:21:39 +02:00
Timon_bed 8bec82b551 placeholders 2026-08-30 15:10:48 +02:00
Timon_bed 3453bfe101 fix: warn about mismatched OAuth callback URLs 2026-08-30 15:00:24 +02:00
Marc-André Bombeck 320788a984 Merge pull request #854 from MBombeck/feat/health-score-adaptive-eligibility
feat: the health score adapts to the data a person actually has
2026-08-30 14:59:29 +02:00
Marc-André Bombeck 9306fa3a21 test: the two-domain case grades instead of going dark
This integration test pinned the three-domain floor from the other side
of the wire: two graded pillars in two areas, composite insufficient.
That is the defect the milestone removes, so the assertion inverts and
now reads the partial tier and its basis out of the serialised response,
which is the only place a client would ever see it.

It survived the unit suite because integration tests run under their own
command; the model change was green everywhere the model is unit-tested
and red at the one place that exercises the route end to end.
2026-08-30 14:40:43 +02:00
Marc-André Bombeck 61110b635c test(score): turn the eligibility pins from a floor into a tier
Six suites pinned the old rule as the rule. Each inversion, and why it is
one:

`breadth.test.ts` — the case table asserted a refusal reason per set; it
asserts a tier and a `physiological` flag now. Every row in it except the
empty set used to be a refusal, and each is a number somebody gets to see.
Adds the tier boundaries (one pillar, two areas, physiological-absent), a
case proving the same pillars produce the same number at `partial` and at
`full`, and a block whose only job is the counter-case: the empty verdict
still carries a reason and no tier, and a composite whose selected pillars
all came back short still refuses with `no_usable_data`. A rule that has
stopped refusing anything is a deleted rule, not a graded one.

`composite.test.ts` — "requires three domains and one measured
physiological domain" asserted the refusal that no longer happens; it now
asserts the score and the basis block on the same fixture. The coverage
case keeps its fixture and its arithmetic and flips its outcome: two of
three used to be a refusal and is a scored account with a real coverage
fraction. Adds the one-area case and the zero-pillar counter-case, and the
`SCORE_VERSION` floor moves 2 -> 3 because widening who gets a score is
exactly the kind of change that must not ship without a version bump.

`record.test.ts` — "records nothing when the composite did not resolve"
used a two-pillar fixture to prove a below-the-floor day stored nothing.
That day has a number now and the table has to keep it, or an account that
starts narrow and widens later has a hole where its early days were. The
assertion is now that a one-pillar day builds a draft AND reaches the
database; the absence case survives on the fixture that genuinely produces
no score.

`composition-source.test.ts` — three recipes asserted as refused at read
time. Two of them score. What the block proves is unchanged — the reader
publishes the shared rule's verdict on the reader's own resolved set, so a
second copy of the rule would still surface here — but the agreement is
over a tier rather than over a refusal.

`route.test.ts` (score config) — the two too-narrow refusals become
acceptances, because a write that refuses what the read is willing to serve
is the two layers disagreeing. The empty selection keeps its 422 and gains
the assertions the old cases carried: errorCode, reason, and the sentence.
The errorCode driver loses the two selections that no longer 422.

`score-refusal-consumer-guard.test.ts` — its non-zero control anchored on
`three_domains_required`; it anchors on `no_pillars_selected`.

New: `health-score-basis-additive-contract.test.ts` pins that `scoreBasis`
is published, is optional on all three carriers, and points at the one
shared component — a required field on a cached surface would invalidate
every cached payload with nothing failing anywhere. It also pins that the
walk does not confuse the composite's basis with the older per-pillar
`scoreBasis` that blood pressure uses for its axis, since they share a key.

Fixtures: the notice-key assertions derive the method half from
`SCORE_VERSION` rather than restating 3. Three fixtures that named
`three_domains_required` now name `no_usable_data`, a reason the server can
still emit. The settings e2e drove its refusal through a two-pillar
selection that now succeeds; it deselects every pillar instead.
2026-08-30 14:16:33 +02:00
Marc-André Bombeck aadc3a8e89 feat(api): carry the score's basis on every surface that carries the score
`scoreBasis` rides the composite, the dashboard snapshot's flattened hero
block and the daily digest's score. It says how many distinct areas of
health the number counted, how many the method recommends, which tier that
lands in, and whether any counted pillar was a physiological measurement.

Optional everywhere, because two of the three surfaces are served out of a
cache and a payload written before this release cannot grow a field. The
snapshot and digest blocks have taken six additive fields this way already;
this is the seventh.

Resolved on the server for the same reason `configured` is. Counting
`composition` client-side would get it wrong more often than right — three
of the seven pillars share the cardiometabolic area, so a three-pillar
score can rest on one.

One shared component, `HealthScoreBasis`, declared once in the insights
schema module and referenced from the other two registry modules, rather
than three inline copies that drift the first time the block gains a field.
Declared through the assigning `.meta()` form: a bare `.meta()` statement
registers an instance nothing references, and the id then never becomes a
component.

The composite's `insufficient` reason narrows to the single value
`no_usable_data` and says so in the published description. That field is an
open string in the document, so the change is additive on the wire even
though the value space shrank.

The score-config 422 description drops the two retired reasons and states
plainly that the empty selection is now the only refusal, so a client does
not keep a three-area rule of its own in front of a write that no longer
has one.
2026-08-30 14:16:06 +02:00
Marc-André Bombeck 4ad00406d3 feat(score): compute a health score from the data a person actually has
Three distinct areas of health stops being the price of admission and
becomes what it always described: a recommendation. `evaluateScoreBreadth`
returns a tier instead of a pass/fail — `full` at three areas or more,
`partial` at two, `minimal` at one — and refuses only when not one pillar
came back usable, with the reason `no_usable_data`.

The arithmetic does not change with the tier. Same mean, same worst-pillar
band drag, same noise floor. A one-area score of 82 is an honest 82 that
rests on one area, and discounting it for the pillars somebody does not
track would have been a new invented target, which is the thing this score
spent two releases removing.

The physiological requirement becomes a labelling fact rather than a veto.
An account whose only readable pillar is activity or wellbeing gets its
number with `physiological: false` on the basis block; the old refusal told
that person nothing they could not already see.

`SCORE_MIN_ELIGIBLE_DOMAINS` is now `SCORE_RECOMMENDED_DOMAINS` and still
feeds `deriveCoverage`, so the coverage fraction and the confidence blend
keep telling the truth with no new machinery: a one-area score lands at low
confidence through the path that was already there. The fraction used to be
a floor every scored account had cleared; it is a real moving number now.

`SCORE_VERSION` 3 -> 4 and `SCORE_ALGORITHM_CHANGED_AT` moves to the
release date. Both are method-identity duties: deltas suppress as
`algorithm_changed` across the straddle window, the notice key rotates so
each account is told once, and stored days stamp 4. No v3 row is rewritten
and no v3 number changes meaning.

The settings write follows the same rule rather than a stricter one. It
refused two-area and non-physiological selections on the ground that they
could not produce a score; they can, so refusing the write would be the two
layers disagreeing about the one question they share. The empty selection
is still refused, under a single reason `no_pillars_selected`, and the seven
message catalogues trade the two old refusal sentences for one.

Also fixes the stale `./series.ts` reference in the config header — that
module was deleted as dead in v1.35; the columns the sentence describes are
still written by `./record.ts`.
2026-08-30 14:15:47 +02:00
Marc-André Bombeck f0ccd9db38 Merge pull request #853 from MBombeck/release/v1.37.33
chore(release): v1.37.33 — a community day
v1.37.33
2026-08-29 20:03:54 +02:00
Marc-André Bombeck 921e0bc81f chore(release): v1.37.33 — a community day
A contributor's first code fix, a self-hoster's Tailscale recipe turned
into documentation on both doc sites, the repeating admin geo notice
anchored to configuration state, the notification HTML stripper made
structural, and the code-scanning backlog triaged to zero untreated
findings.
2026-08-29 19:46:56 +02:00
Marc-André Bombeck b908d53b5f Merge pull request #852 from MBombeck/fix/scanning-triage-and-geo-notice
fix: scanning triage follow-ups and the repeating geo notice
2026-08-29 19:44:49 +02:00
Marc-André Bombeck ba3cf30035 Override flatted, js-yaml and minimatch past their advisories
- flatted 3.3.3 → 3.4.4: prototype pollution and unbounded recursion in
  parse(), fixed in 3.4.2 / 3.4.0.
- js-yaml 4.1.1 → 4.3.1: quadratic CPU on merge-key chains and !!omap
  resolution. The override is bounded to >=4.0.0 so a hypothetical 3.x
  consumer is never dragged onto a new major.
- minimatch 3.1.3 → 3.1.5: ReDoS via nested extglobs. The scanner
  suggested 10.x, but the fix is backported — only <3.1.4 is vulnerable —
  so the 3.x line stays where its consumers (eslint) expect it. The 5.x,
  9.x and 10.x copies in the tree were never affected.

The refreshed lockfile also carries brace-expansion 1.1.12 → 1.1.18
transitively, which clears that package's DoS advisories. pnpm audit
reports none of these packages afterwards.
2026-08-29 19:21:00 +02:00
Marc-André Bombeck bed7278e78 Pin the Prisma CLI install to exact versions
The /opt/prisma-cli npm install named prisma@7.8 and @prisma/engines@7.8
while its neighbours (tsx, dotenv) were exact — the one unpinned spot the
Scorecard pinned-dependencies probe flagged. Both ranges resolve to 7.8.0
today, so the exact pin changes nothing in the built image; it only stops
a future 7.8.x from arriving unreviewed.

Resolves Scorecard alert 63.
2026-08-29 19:20:59 +02:00
Marc-André Bombeck dd46bbbd60 Make the spacing-scale modifier-chain regexes linear
Both class-string regexes carried (?:[^\s"']*:)* for the optional
modifier prefix chain. The inner class includes the colon, so the star
could carve the same prefix into chunks many ways, and a long
non-matching string made the engine try them all — exponential
backtracking (CodeQL alert 44, js/redos). Excluding the colon from the
chunk class accepts exactly the same strings with one parse each. The
rule's 35 tests pass unchanged.

Resolves CodeQL alert 44.
2026-08-29 19:20:58 +02:00
Marc-André Bombeck 347e14d26b Centralise notification HTML stripping behind one hardened helper
Five senders (APNs, Web Push, webhook, ntfy, email) each carried their
own single-pass replace(/<[^>]*>/g, "") — the shape CodeQL flags as
incomplete multi-character sanitization, because a removal can re-form
the pattern it removed. Whether one particular regex happens to be
idempotent is an argument about that regex; five copies of the argument
is how the next edit quietly breaks one of them.

The shared stripHtml re-applies the strip until the string stops
changing, with an iteration cap and a bracket-dropping fallback so no
tag-shaped sequence can survive any input. Plain text passes through
unchanged, same as before. Unit tests pin the nested-tag bypass shapes
and the no-surviving-tag invariant.

Resolves CodeQL alerts 38-42.
2026-08-29 19:20:42 +02:00
Marc-André Bombeck 511ddf5de1 Send the offline-geo admin notice once per configuration state
The sent-latch was a module-level boolean, so every worker boot re-sent
the same notice while the host's configuration never changed (#851).

Anchor the notice in the notification_events ledger instead, through the
same claimNotificationEvent helper the reminder schedulers use. The
anchor is unwindowed: entering the unconfigured state sends once per
admin, staying in it is silent across any number of restarts, and the
anchor is released when the state exits — databases configured, or
IP_GEO_LOOKUP_DISABLED set — so a genuine re-entry notifies again.

Three consequences worth naming:

- A host with lookups disabled is no longer notified at all. With no
  egress there is nothing the notice warns about, and the disabled state
  counts as an exit, matching the notice's own remediation advice.
- The evaluation now keys on offlineGeoReady() rather than the ASN
  reader, so a host with only the ASN database mounted (city lookups
  still egress) hears about it, where before it stayed silent.
- The retention prune excludes the new ADMIN_CONFIG_NOTICE event type:
  a state-scoped anchor has a reader for as long as the state holds, and
  the 90-day horizon delete would have re-opened the repeat on a slower
  clock.

Refs #851
2026-08-29 19:20:33 +02:00
Marc-André Bombeck 8c7ae08fe4 Merge pull request #850 from MBombeck/docs/tailscale
docs: add Tailscale serve and funnel as a reverse-proxy option
2026-08-29 19:02:39 +02:00
Marc-André Bombeck 0263540ac1 Merge pull request #848 from TimonBed/fix/password-strength-error-color
fix: show short password labels as invalid
2026-08-29 18:49:22 +02:00
Marc-André Bombeck 1bb1ee0e55 docs: the compose whitelist now carries TRUST_PROXY_HOPS
The note predates the whitelist fix that landed alongside the restore
permission change; keep the manual-edit instruction as a version note
for older compose files.
2026-08-29 18:23:10 +02:00
Marc-André Bombeck d10bc099c8 docs: add Tailscale serve and funnel as a reverse-proxy option 2026-08-29 18:21:14 +02:00
Marc-André Bombeck 5196aff766 Merge pull request #846 from MBombeck/fix/restore-perms-trust-proxy
fix: write restored PHI 0o600 and let TRUST_PROXY_HOPS reach the container
2026-08-29 18:15:06 +02:00
Marc-André Bombeck 9b3c7f088f Merge pull request #845 from MBombeck/docs/truth-audit
docs: truth audit against the current code — correct and prune the operator docs
2026-08-29 18:15:03 +02:00
Marc-André Bombeck 05732c6632 Merge pull request #849 from MBombeck/feat/korean-locale
feat: Korean locale (#839, @aucun6352) — the seventh interface language
v1.37.32
2026-08-28 22:35:39 +02:00
Marc-André Bombeck 4f77abc157 chore(release): v1.37.32 — Korean joins as a seventh interface language
Integrates @aucun6352's Korean locale (PR #839): the full catalog, the
NFC recompose in the shared fold, the Hangul lab-name normaliser, the
tilde range separator, and the officially published Korean PHQ-9, GAD-7
and WHO-5 wording. Resolves the CHANGELOG and OpenAPI conflicts against
the current trunk and regenerates the spec. Raises the aggregate bundle
budget to 3700 KB gz for the seventh message catalog, with the reasoning
recorded next to the value.

The contributor's three commits are preserved with their authorship.
2026-08-28 22:09:26 +02:00
Marc-André Bombeck 6ff81b2717 Merge branch 'pr839tmp' into feat/korean-locale
# Conflicts:
#	CHANGELOG.md
2026-08-28 21:59:36 +02:00
Timon_bedandCursor d621699ca4 fix: show short password labels as invalid
Ensure minimum-length failures use the destructive token even when the password's entropy score is strong.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 17:27:50 +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 e680c8a884 Reflow the two touched tables through prettier 2026-08-27 12:28:24 +02:00
Marc-André Bombeck 6f514bfc7d Describe the API spec as generated and complete the MCP catalogue
- api/README: the YAML is emitted from the Zod registry by
  pnpm openapi:generate with a CI drift check; document the real
  servers, tags, and coverage (client-facing REST, most admin routes
  and the OAuth/webhook surfaces excluded) and replace the hand-edit
  workflow with the generator workflow
- mcp-capabilities: add the registered get_intraday_pulse,
  get_ecg_recordings and get_nutrients tools and the
  nutrient/{code} resource template to the tables
2026-08-27 12:28:05 +02:00
Marc-André Bombeck b2ee7f11d1 True up the integration references
- ai-providers: /api/ai/test returns provider type, model, token usage
  and a sample with a reasonCode on failure; drop drifted line numbers
- apple-health: source-priority overrides moved to /settings/sources;
  cite the constant instead of line numbers
- data-import: four import paths (medication dose history added); ECG
  and metric-typed clinical records are imported; measurement
  externalId documented; CSV re-uploads dedupe on the natural key;
  mood loggedAt defaults to the start of the given date; the type
  table is a curated subset with /api/meta/capabilities authoritative
- fitbit: the source-priority ladder ships, FITBIT is ranked in every
  default ladder
- google-health: Sources is its own settings section, not nested under
  Integrations
- withings: disconnect unsubscribes the webhook and deletes stored
  tokens without revoking the Withings-side grant; the connection test
  is self-serve, not admin-only; the safety-net cron is hourly;
  refresh stale references
2026-08-27 12:26:08 +02:00
Marc-André Bombeck 123fcc4ec3 Refresh the docs index and reference pages
- docs/README.md: drop the doctor-report.md and audit/ entries (neither
  exists), broaden the ops/ description, list the real subdirectories,
  and stop promising release-by-release migration notes
- migration/v1.3-to-v1.4: note which v1.4 routes have since moved or
  gone; rotation runs via pnpm dlx tsx
- ui-guidelines: six locales with guard tests, settings sections point
  at the registry instead of an eight-item list, /settings redirect is
  a 308
- apple-store-connect-checklist: drop the unsourced 40% rejection-rate
  figure
- codex-protocol-spec: the slug chain rotated on 2026-06-02; default is
  gpt-5.5, the shipped constant is authoritative
2026-08-27 12:23:37 +02:00
Marc-André Bombeck fa7e1ee739 Bring the self-hosting guides in line with the shipped code
- geolite2: quote the real commented compose mount (./geolite2 to
  /opt/geolite2, the GEOLITE2_DIR default, so no env var is needed for
  the default path)
- getting-started: healthcheck probes /api/health, not /api/version;
  the environment module reaches archive-api and geocoding-api
  .open-meteo.com, relevant for egress allowlists
- mcp: the module is opt-in and off by default, but no longer the only
  such module
- reverse-proxy: the Coach stream is SSE over plain HTTP, not
  WebSockets; note that TRUST_PROXY_HOPS is not on the bundled compose
  whitelist and must be added by hand
- scaling: /api/health healthcheck; worker liveness is in-memory and
  invisible across a web/worker split; pool sizing now goes through
  getConnectionBudget with DB_CONNECTION_LIMIT as the compose knob and
  a 2-connection pg-boss share
- sso: the native SSO handoff shipped, OIDC_ONLY works with app builds
  that carry it
- unraid: geo databases are baked in only when the build had a licence
  key, or mounted; replaceable either way
2026-08-27 12:21:27 +02:00
Marc-André Bombeck c986efab30 Correct operator runbooks against the current code
- attempt-number-semantics: name the real bucket column
  (consecutiveFailuresByKind) and drop a drifted line citation
- backup-restore: show the smoke-test response inside the standard
  { data, error } envelope
- deploy: the manual migration step now uses the bundled Prisma CLI
  via node (the image strips pnpm); WHOOP vars are already on the
  shipped compose whitelist
- encryption-key-rotation: point at the encrypted-column registry
  instead of a five-model list several releases behind
- env-check: the CI gate shipped (env-check.yml); the partial-APNs
  example now shows the synthetic all-or-none row and the non-zero exit
- password-reset: the admin API reset route exists; only the
  self-service route does not
- tls-cert-pin: stop asserting client-repo pin-set details that cannot
  be audited from this repository
- drop v141-followup-issues.md: a v1.4.1 punch list whose items are
  fixed, removed, or superseded; keeping it under docs/ops invites
  re-fixing shipped work
2026-08-27 12:18:34 +02:00
Marc-André Bombeck 7f407797f4 Merge pull request #844 from MBombeck/release/v1.37.31
release: v1.37.31
v1.37.31
2026-08-27 11:45:07 +02:00
Marc-André Bombeck a0bbf0bf46 chore(release): v1.37.31 — the retry ledger gets an operator's window 2026-08-27 11:27:07 +02:00
Marc-André Bombeck 6c4682acfe Give the operator a provider-health readout
The per-user retry ledger has recorded every AI delivery outcome for a
long time, but no surface ever read it for the operator: a central
provider could fail on every call for weeks and the admin console said
nothing. The AI settings section now carries a health card, backed by a
new admin-only endpoint that folds the ledger into one row per provider
type: users tracked, users currently failing, the worst uninterrupted
failure run, and the last success and failure instants. Operator-managed
types sort first because their failure takes the shared fallback down
for everyone whose chain relies on it. Counts and timestamps only; the
response never names a user.
2026-08-27 11:26:40 +02:00
aucun6352 31eb50e3e2 fix(i18n): give the Korean instruments their published source texts
The Korean questionnaire wording was translated freshly from English, so
a score built from it was comparable to nothing in the literature. Each
instrument now carries the text its own publisher put out, or no Korean
at all.

PHQ-9 and GAD-7 come verbatim from the instrument owner's distribution —
Pfizer's `PHQ9_Korean for Korea.pdf` (2010-06-15) and `GAD7_Korean for
Korea.pdf` (2010-05-18), the latter being the file Ahn/Kim/Choi 2019
names as what it validated. The sheets grant reproduction, translation,
display and distribution without permission. Verbatim means verbatim:
the spacing in "7 일 이상 방해 받았다" and the sheet's "안절부절 못하거나"
against the GAD-7 sheet's "안절부절못해서" are reproduced as printed.

WHO-5 has no official Korean arm. Neither the WHO's 2024 distribution
(26 languages) nor the Psychiatric Research Unit folder it inherited
from (31 languages) has ever carried one, and ePROVIDE lists the same
31. Korean therefore carries the published forward/back-translation
from Kim HJ et al., J Korean Geriatr Psychiatry 2010;14(2):90-96, which
is CC BY-NC and so redistributable; the attribution line names it. Its
validation sample was community-dwelling elderly rather than general
adults, which the definition says out loud.

The Sleep Condition Indicator reverts to English. It is validated in
English only, its cut-off was derived from those words, and no openly
redistributable Korean exists — so `ko` now matches de/es/fr/it/pl:
items, section stems and response anchors byte-identical to en.json,
under the localized "validated in English" note.

`ALL_APP_LOCALES` grows to seven, which is what stops the check-in
wizard claiming English wording over Korean items on every Korean
check-in.

One gap is deliberate and not yet resolved. The official Korean PHQ-9
and GAD-7 sheets disagree on anchor 2 — "7 일 이상 방해 받았다" against
"2 주 중 절반 이상 방해 받았다" — but both instruments read one shared
`mentalHealth.options` set, which holds for the other six locales
because their two sheets agree word for word. Korean takes the PHQ-9
wording for both here. GAD-7's anchors are therefore validated in
substance but not verbatim; splitting them into a `gad7Options`
namespace is the fix, and is left for the maintainer to weigh against
the 24 duplicated keys it would add across the other locales.

The functional-impairment item and its four anchors come from the same
PHQ-9 sheet. They are not scored and not covered by the
`validatedItemLocales` contract.
2026-08-27 17:37:26 +09:00
Marc-André Bombeck 1d7bd82ca8 Merge pull request #843 from MBombeck/release/v1.37.30
release: v1.37.30
v1.37.30
2026-08-27 09:15:44 +02:00
Marc-André Bombeck 7aecae863d Teach the pin inventory the conditional openai-client shape
The SSRF pin inventory asserted a literal unconditional pin in the
openai-client; the previous commit made that pin conditional for the
two person-typed base-URL tags. The guard now asserts the exact
ternary — allowlist helper for gateway/admin-key, literal true as the
floor — so neither the floor nor the condition can drift silently.
2026-08-27 08:56:53 +02:00
Marc-André Bombeck 96d6f64ee6 chore(release): v1.37.30 — the operator provider may cross into its own network 2026-08-27 08:49:49 +02:00