Commit Graph
13 Commits
Author SHA1 Message Date
Antiheld86 8f61e0bc6a Plainer copy on the measurement token card
The card read like API documentation sitting in a settings page: it named
the Authorization header, spelled out both endpoint paths, and listed the
scope's limits as four clauses. None of that is what someone deciding
whether to mint a token needs, and the endpoint paths were already in the
API endpoints card immediately above it.

Rewrite to the shape the MCP connector card next door uses — a short
title, one line on what the token is for, one line saying it is shown
once, and one sentence on what it can and cannot do. Drop the endpoints
note entirely rather than rephrase it, since the card above owns that.

Say the useful consequence instead of the mechanism: readings arrive as
if entered by hand, so they can be corrected. That is the thing a person
will actually notice.

Translations were revised across all seven locales — the previous German
in particular read as translated English rather than as the rest of the
app, which uses short informal sentences.
2026-08-31 09:50:47 +00:00
Antiheld86 cba7a0fce5 Add a self-service measurements:write API token scope
Since v1.30.17 there has been no self-service path to a token that can
write a measurement. That release made the Bearer scope default
fail-closed, which closed a real privilege-escalation hole, and removed
the generic mint at POST /api/tokens along with it. The generic mint had
been the only way to get a token that could reach /api/measurements, so
a local sensor pushed through Home Assistant or Node-RED lost its door
while the OAuth integrations kept theirs.

Add a narrow scope accepted by exactly two routes, both writes, both on
the holder's own record.

The scope constant is a leaf module beside the surface that consumes it,
matching FHIR_READ_SCOPE and the MCP scopes. requireRecordAuth grows an
options object rather than a sibling function, because the sharing
surface guard keys every leg on that one identifier and a second name
would mean two copies of the delegable declaration. AuthContext carries
the admitting scope as a single nullable string rather than the token's
permission array, so nothing downstream can re-derive an authorisation
decision from it.

A scoped credential is refused a record selector before any grant is
read. Placing the refusal above the sharing check rather than beside it
means no future widening of what a grant permits can reach one of these
tokens.

Rows are attributed MANUAL. An entry that explicitly names APPLE_HEALTH
is refused 422 rather than relabelled: that source is half the
(userId, type, source, externalId) dedup key the iOS client writes into,
it participates in the cross-source merge, and it gates the HealthKit
sync fields. For the same reason a scoped write no longer advances
User.lastSyncedAt, which the phone reads to size its undelivered window.

Also closes a narrower gap found while building this. The idempotency
wrapper resolved a replayed delegated response against a live grant
without considering which credential was presenting it, so a cached
response about a shared record could be returned to a token the handler
itself would refuse. No write was reachable that way; the response body
was.

Guards: T3 gains the mint site, T4's vocabulary gains the scope and
learns to read the requireRecordAuth form (without which this change
would open a way to widen a route that the closed vocabulary could not
see), and a new T5 freezes the set of delegable routes naming a scope.
The sharing surface guard's AST matcher reads only the first two
arguments, so it needed prose but no code.
2026-08-31 08:46:16 +00: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 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 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 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
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 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
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
aucun6352 f7cc47850a fix(i18n): make the Korean locale actually work end to end
Registering `ko` in the `Locale` union left seven locale-keyed tables
without an entry, so the build could not type-check and the safety
screens, the citation-coverage grader and the reference-range parser
read nothing at all for a Korean reader.

Three failures were not missing keys but wrong assumptions:

- The shared fold decomposes with NFD and strips U+0300-U+036F. Latin
  accents become plain letters, but Hangul decomposes into jamo, which
  are not in that range and survived — so folded Korean text was a jamo
  sequence no precomposed table entry could ever match. It recomposes to
  NFC now; every Latin, Polish and German fold is byte-identical.
- `normaliseLabKey` kept `[a-z0-9]`, which reduced every Korean analyte
  name to the empty string, so the derived index held none of them and a
  Korean cholesterol result reached a FHIR export uncoded.
- The reference-range parser had the hyphen and the dashes but not the
  tilde a Korean lab actually prints (`3.5~5.0`).

The Korean safety banks carry no `\b` next to a Hangul token — `\b` is
an ASCII `\w` boundary and Hangul is not `\w`, so `/\b증량/` matches
nothing — and the clause order is the mirror of the Latin banks, since
Korean closes with the verb. The dose patterns require a target-marking
particle for the same reason the Latin banks require "to"/"by": without
it the permitted restatement "7.5mg을 복용 중이고, 체중은 내려가고
있어요" tripped the lowering pattern.

The Coach and Insights prompts route `ko` down the reviewed English body
plus a Korean reply directive; there is no `safety-contracts.ko.yaml`, so
the native builder has no Korean ground rules to compose from.
2026-08-26 20:00:44 +09:00
aucun6352 f1b0420479 feat(i18n): add Korean as a seventh end-to-end locale
Register `ko` in the locale list, language names, and labels, and ship
messages/ko.json so the whole UI — dashboard, settings, share views,
clinician PDF, notifications, and AI prompts — renders in Korean.

Extend the locale-parity, English-leak, plural-completeness, and copy
guard tests to cover the new locale, and update README/CONTRIBUTING to
list Korean among the supported languages.
2026-08-26 16:42:45 +09:00