Compare commits

...
180 Commits
Author SHA1 Message Date
Marc-André Bombeck 46191337fc Merge pull request #829 from MBombeck/release/v1.37.26
v1.37.26 — the app reads what you wrote, in every language it offers
2026-08-23 15:54:16 +02:00
Marc-André Bombeck fed37829e2 chore(release): v1.37.26 — the app reads what you wrote, in every language it offers 2026-08-23 15:35:33 +02:00
Marc-André Bombeck 1ae21b5971 Merge pull request #828 from MBombeck/feat/retired-routes
Answer a retired route with 410 rather than a bare 404
2026-08-23 15:29:10 +02:00
Marc-André Bombeck b52262e27f Merge pull request #827 from MBombeck/fix/locale-matching
Match user-written text in every language the app ships
2026-08-23 15:28:41 +02:00
Marc-André Bombeck 4f2c90b66c Fold the side-effect tag through the shared helper
The three locale matchers were built in parallel and each grew its own
fold. Two of them are the same function: the side-effect tag fold and
foldForMatch agree character for character, so one now calls the other.

The third stays. foldLabel decomposes with NFKD, maps sharp s to ss and
joins with underscores; foldForMatch decomposes with NFD, leaves sharp s
alone and joins with spaces. That is a disagreement about who typed the
text, not an accident: one side of a lab comparison is a shipped label
retyped or prose a lab printed, where tolerating a different spelling
claims more than the fold can prove, while someone naming a metric to an
assistant is composing rather than reproducing. Both docblocks now say
so and name the other.
2026-08-23 14:58:51 +02:00
Marc-André Bombeck 5f8af25815 Merge branch 'fix/mcp-locale-aliases' into fix/locale-matching 2026-08-23 14:47:21 +02:00
Marc-André Bombeck 19d534432e Merge branch 'fix/lab-locale-parsing' into fix/locale-matching 2026-08-23 14:47:06 +02:00
Marc-André Bombeck bec4cb3dfb fix(mcp): let five of the six shipped languages name a metric
Three resolvers turned a user-authored word into an internal code by
comparing it against a hand-typed English list. The app ships six
languages, so five of them resolved nothing: `Fer` and `Żelazo` came back
as `unknown_nutrient` while the settings card rendered exactly those
words, and `poids` and `sommeil` reached no series at all.

Stop transcribing. `nutrients.names.*` and `measurements.type*` already
label these concepts in every bundle, so the match index is derived from
`locales` instead — a seventh language works the day its bundle lands,
with no edit at any call site. The nutrient tool's English labels now come
out of `messages/en.json` too, so the wire wording cannot drift from the
screen.

The MCP contract is unchanged in shape and stays protocol-level English.
What moves is which words are understood: `unknown_nutrient` /
`unknown_metric` still mean "could not place this word", distinct from a
placed name answering `no_data`. Nothing turns absence into data.

Two findings the sweep did not predict:

- `Puls` resolved to pulse-wave velocity, because the English
  display-name pass matches substrings and "pulse-wave velocity" contains
  it. An exact name now beats a loose English fragment.
- French and Italian use one phrase for body fat and fat mass, and three
  bundles use one for fat-free mass and lean body mass. A word two
  metrics claim resolves to neither; answering kilograms to someone who
  asked for a percentage is worse than saying the word was not
  recognised. The known set is pinned so a new collision surfaces.

The medication needles are the exception: they are drug names, with no
bundle to derive from. They fold to a normalised INN stem rather than to
a language, so `Metformin`, `Metformine`, `Metformina` and `Metforminum`
are one entry, and `Semaglutyd` and `Atorwastatyna` cost nothing to add.
A guard asserts no two molecules collapse onto one stem. Iron is the one
class with no stem to fold to — it is named for the element — so its word
list is hand-tabulated and says so.

Every English resolution that worked before returns the same answer.
2026-08-23 14:46:10 +02:00
Marc-André Bombeck 154b609768 refactor(documents): attach the derived analyte names to their signal class by identity
The derived names extended the analyte class by list index. Reordering the
signal list would have re-pointed them at the units or the report header with
nothing to notice, so the class is a named constant and the check compares
against it.
2026-08-23 14:44:23 +02:00
Marc-André Bombeck b06d7dbd92 fix(documents): recognise a lab report printed outside English and German
The gate that decides whether a transcribed document is worth an extraction
call scored four classes of evidence and needed two. Three of them — the
reference-range label, the analyte names, the report header — were written in
English and German, so a French, Spanish, Italian or Polish report scored at
most one: the units, which are the same everywhere. Auto-staging never fired
for it. The document was lost from the flow silently, because the manual
extract button stays available and nothing reads as broken.

The text is folded once — lower case, accents dropped — and every pattern is
matched against that, which on its own recovers a good deal: "Cholestérol",
"Créatinine" and "Emoglobina" reach the existing stems as soon as the accents
are out of the way. The reference-range labels and report headers of the four
locales are added by hand, because they are prose a lab printed and the app
owns no string for them.

The analyte names ARE derived, from `labs.catalog.<slug>` across every shipped
bundle: those are names the app itself displays, and a new locale should not
need an edit here to be recognised. Names under four folded characters are
dropped — "ALT" is German for "old", and a three-letter alternative in a gate
this cheap would make every German letter a lab report. The acronyms worth
having are in the hand-written half, where each was considered on its own.

Which way this errs, stated at the code: a false negative loses a whole
document from auto-staging, while a false positive costs one extraction call
and stages facts a person then declines — nothing commits either way. So the
vocabulary is widened, and the two-distinct-class rule holds the other side.
The derived names extend the analyte class rather than forming a fifth one, so
a document naming one analyte in two languages still scores one. Tests pin
each addition on a case that fails the gate without it, and pin the prose that
must keep failing it.
2026-08-23 14:28:53 +02:00
Marc-André Bombeck c5a01767aa fix(fhir): code a qualitative lab result recorded in any shipped locale
The lab form offers "negative" and "positive" as quick picks and writes the
label it rendered, so the column holds the bundle's own string in the language
the reading was recorded in. The SNOMED mapper matched that string against a
list transcribed in English and German, so "négatif", "negativo", "ujemny",
"positif" and "dodatni" all exported without a code — text-only on the one
document meant to be machine-readable.

The negative and positive arms read `labs.form.qualNegative` and
`labs.form.qualPositive` out of every bundle now. The form writes those exact
keys, so the write side and the read side cannot drift, and a new locale is
coded the day its bundle lands.

The detected / not-detected pair stays hand-written English and German, and
that is the honest limit of this change: the app ships no string for those
terms, they are prose a lab printed and a person copied in, and coding "non
détecté" or "niewykrywalny" would mean inventing clinical vocabulary in four
languages nobody here can check. This module's own rule is that an unverified
code is worse than an honest `.text`. When they need coding they need a quick
pick and a bundle key, which is the route the other two arms took. A test pins
that they stay uncoded rather than leaving it to be discovered.

Borderline stays uncoded as before; a test now pins that the derivation did
not sweep its bundle key up by accident. Comparison folds accents as well as
case, so a result typed without them still resolves.
2026-08-23 14:21:34 +02:00
Marc-André Bombeck ce73729916 fix(fhir): resolve a lab analyte's LOINC code from its name in any shipped locale
A biomarker minted from the labs catalog stores `t("labs.catalog.<slug>")` —
the name in the language of whoever minted it — and that stored name is what
reaches the FHIR exporter as free text. The exporter matched it against an
alias table written in English and German. So an account working in French,
Spanish, Italian or Polish exported "Cholestérol total", "Glucosa en ayunas",
"Glicemia a digiuno" and "Cholesterol całkowity" as text-only concepts with no
LOINC code, on the one document whose whole purpose is being read by another
system.

The names are DERIVED from `messages/<locale>.json` now, not transcribed into
this file. Both sides of the new slug map are app-owned identities with no
language in them, which is what makes it the one edit a new locale does not
need: `locales` grows, `messages/xx.json` lands, and the analyte names it
ships resolve the same day. Transcribing four more languages would have fixed
today's six and reopened the hole at the seventh.

The hand-written aliases keep precedence. They cover what the app owns no
string for — clinical abbreviations, the ways a person shortens a name — and
they were verified one at a time over several releases. A derived name landing
on the same key is a harmless duplicate; one landing on a different key would
be a translation typo re-pointing a verified mapping at another analyte, so
the build refuses it and the guard suite asserts the refusal held.

The lookup fold now drops accents before stripping punctuation. Without it a
name carrying diacritics lost them entirely rather than reducing to its base
letters — "Cholestérol total" folded to `cholestroltotal`, and "Hämoglobin" to
`hmoglobin`. That last one means the German umlaut spellings never resolved
either: the hand-written `hamoglobin` and `nuchternglucose` aliases only ever
caught the un-umlauted typing. Every pre-existing alias is ASCII, so the fold
is a widening and what resolved before resolves to the same key.
2026-08-23 14:19:18 +02:00
Marc-André Bombeck 2dc553057d fix(labs): read a reference range printed in French, Spanish, Italian or Polish
The free-text reference-range parser decided what a printed window meant from
an alternation of German and English words. The app ships six locales, and a
lab prints in the language of the country it stands in, so `jusqu'à 5,0`,
`hasta 5,0`, `fino a 5,0` and `do 5,0` all fell through to `low: null,
high: null`. The string was stored, the reading was then judged against the
biomarker's own band or against nothing at all, and no surface anywhere said
that the lab had printed a window the app could not read.

The vocabulary is TABULATED here, not derived from `messages/<locale>.json`
the way an app-owned word list would be. These words are prose a lab printed
on paper; the app owns no string they could be read out of, and the language
of a report has nothing to do with the language of the UI. So the table is
hand-written, keyed by the shipped `Locale` union so that adding a locale
fails to compile until someone has answered how that language prints a range,
and every locale's words are tried against every input at once. The cost is
written down at the table: a report in a language absent from it stays
text-only.

Three things had to change beside the word lists. Introducing words are now
stripped only on a second pass, because Italian "da", Polish "od" and French
"de" also state a floor on their own and stripping first read "da 3,5" as a
bare number. Input and table are diacritic-folded and their apostrophes
normalised, so a table row written "jusqu'a" meets a report's "jusqu'à" and
the curled apostrophe a word processor emits. And one-letter separators ("a",
"e", "y") are only accepted with whitespace on both sides, so "3.5e5" is not
read as a window. Decimal commas already worked and are pinned by the new
cases.

An unreadable window now has a name. `isUnreadableRange` separates "the report
stated nothing" from "the report stated something nobody could read" — the two
were indistinguishable at every call site, which is what let the French case
pass unnoticed. The manual form already showed the reader that the text was
kept but not read; the two ingest paths now count it into their wide event, so
an unsupported notation surfaces as a number rather than as silence.

The German and English lists are the previous alternation split word for word,
and the existing suite covering them passes unchanged.
2026-08-23 14:16:15 +02:00
Marc-André Bombeck f4c0eb0ab9 feat(api): publish retirement in the contract, the capability read, and the coverage guard
Three surfaces, one source. The registry the proxy answers 410 from now
also emits the tombstones into `docs/api/openapi.yaml` and rides on
`GET /api/meta/capabilities`, so a retirement cannot be honoured at
runtime and invisible everywhere a client could have read it first.

The contract publishes each retired path with 410 as its only response,
the removing version, and the replacement. `deprecated: true` is
imprecise — the word means "still works, stop using it", and these do not
work — but it is what makes a generator warn at the call site, and the
description carries the exact truth the flag approximates. `security: []`
because the answer is produced before any handler and reads no
credential; publishing it as authenticated would tell a client to sign in
to be told a path is gone.

The capability read carries the same list. That surface is the one a
client already polls on launch, so a retirement can reach it before the
failing call rather than through it.

The coverage guard gains a third state. Published and exempted both
describe a route that exists, so removing one used to leave exactly one
legal move — erase it from the contract as well — and that move is
invisible to every client already calling it. A registered path is now
required to be absent from `src/app` AND present in the contract, which
is the one combination the phantom-path case rejected outright. Both ways
out are named in the failure message.

Its docblock says what the state cannot do, because the first draft
overclaimed: a check over one commit's state cannot see a deletion, so it
cannot insist that a removal should have been registered. Proving that
needs the previous release tag, and the job running this suite checks out
at depth one with no tags, which would make the check one that silently
cannot fail. What is held instead is the registration's integrity, in
three directions and with the mutation for each written down.

The sharing-denial guard learns the same state: a retired path resolves
no record fence because it reaches no handler.
2026-08-23 14:06:52 +02:00
Marc-André Bombeck 87929eb3b8 feat(api): answer a retired route with 410 rather than a bare 404
A route removed on purpose still leaves a client calling it. As reported
in iOS #94: between 2026-08-08 and 2026-08-20 the native client asked
`/api/auth/me/research-mode` on every GLP-1 detail screen, got a bare
404, and painted a permanent error card with a retry button aimed at the
same removed path. Twelve days, on every reachable instance including the
public demo, which was built after the removal. Deleting the route was
right and the removing commit reasons it out in full; the answer said
nothing. A 404 from a deployment is indistinguishable from a broken
build, a reverse proxy pointed at the wrong upstream, or a typo in the
caller's own path, so reading it as transient and retrying was the
correct reading of the only evidence there was.

One registry, `src/lib/http/retired-routes.ts`, carrying the path, the
release that removed it, the replacement where there is one, and one
sentence on why. Every other surface reads it.

410 over a 404 with an error code because it is the status whose entire
meaning is "this existed here and is gone": a client that understands
nothing else about this API still learns not to retry. The code is not
dropped, it rides in the same envelope with `removedIn` and `replacedBy`
for a client that reads further. The status is the floor, the code is the
detail.

Answered in the proxy, before routing, rather than by a catch-all route
module: a catch-all under `/api` would intercept every unmatched path and
make the ordinary 404 ours to manufacture, which is a far wider blast
radius than the problem. The proxy already owns the exact-path decisions
of this shape. It reads nothing off the request but the path, so an
anonymous caller and an authenticated one get identical bytes and the
tombstone cannot become an authentication oracle.

Seeded from the removed-route set: every route path under `src/app` at
every release tag, unioned and differenced against the current tree.
Twenty-seven paths have been removed over the project's history; the five
registered here are the ones that were published or client-facing, and
each removal is pinned to its release by the route's presence at one tag
and absence at the next. Whether any client still calls one of them is a
separate question that evidence in this repository cannot answer, and the
registry deliberately makes no claim about it.
2026-08-23 14:06:37 +02:00
Marc-André Bombeck 97d0c26ceb fix(glp1): match side-effect tags by key, not by an English/German word list
The GLP-1 side-effect chips in the mood form write the localised label into
the flat `MoodEntry.tags` column, and three readers matched that label against
a hand-written list of English and German words: the therapy timeline, the
Coach snapshot, and the doctor report's GLP-1 block. The app ships six locales.
Tap the nausea chip with the UI in French, Spanish, Italian or Polish and the
day never reached the timeline, the Coach grounded its answers on an empty
side-effect list, and a clinician read an empty table over a record that had
three months of symptoms. Nothing on any of those surfaces said the table was a
filter rather than the truth. The three lists had also drifted apart from each
other.

The chips are a closed set with stable keys, so matching the label was the
defect and the key is the fix. `glp1-side-effect-tags.ts` holds the catalogue —
seven keys, each with the i18n key its chip renders — and the mood form now
draws its strip from it, so the write side and the read side cannot diverge
again. `glp1-side-effect-tag-match.ts` builds the label-to-key index by reading
`messages/<locale>.json` for every entry in `locales`, rather than transcribing
the labels into a second list. Transcribing would have fixed today's four
locales and reopened the same hole on the day a seventh is added; deriving
means a new bundle is matchable as soon as it lands, with no edit in the
matcher. Comparison folds case, accents and separators, so the English and
German tags already in the database keep resolving and a straight apostrophe
matches the typographic one the French bundle uses.

All three readers now report the catalogue key instead of the stored string.
The same recorded symptom yields one key whatever language it was tagged in,
which also fixes the mixed-language output an account gets after switching UI
locale; the doctor-report PDF resolves the key through the label map so the
clinician reads it in the report's language.

A tag outside the catalogue is still never counted — a free-text mood tag must
not reach a doctor as an invented symptom, and the rest of somebody's day does
not belong on a therapy timeline. It is no longer dropped without trace: the
timeline route annotates the unresolved count on its wide event, so the
classification rate stays answerable while the tag text stays in the database.

The Coach's copy of the list also carried `vomiting`, `reflux` and `erbrechen`.
Those are removed. No chip writes them, so they only ever matched a hand-typed
tag in two of six languages; keeping them would have rebuilt the defect inside
its own fix. Restoring either symptom means giving it a chip and a label in all
six bundles.

The response schema for `GET /api/insights/glp1-timeline` now enumerates the
keys, and its description no longer documents the bilingual match as if it were
the contract.
2026-08-23 13:56:32 +02:00
Marc-André Bombeck b76468f455 Merge pull request #826 from MBombeck/release/v1.37.25
v1.37.25 — the Coach stops answering with numbers it cannot check
2026-08-22 17:06:14 +02:00
Marc-André Bombeck 590911ca72 chore(release): v1.37.25 — the Coach stops answering with numbers it cannot check 2026-08-22 16:49:04 +02:00
Marc-André Bombeck fefda545ae Merge pull request #825 from MBombeck/verify/coach-grounding
Grade the Coach turn that went looking and found nothing
2026-08-22 16:38:12 +02:00
Marc-André Bombeck 1efac85a68 Merge pull request #824 from MBombeck/fix/openapi-meta
Register the schemas that annotated themselves into nothing
2026-08-22 16:24:41 +02:00
Marc-André Bombeck 6f2f43bb49 Say what the AI surfaces actually compute
Three places where the copy promised more than the handler delivers.

The hero's personal-baseline line rendered unconditionally while both its
siblings in the same meta row gate on their own data. It claims the subtitle
above it was drawn from the reader's own last 90 days, but with no briefing the
subtitle is fixed fallback copy computed from nothing — so an account with no
briefing at all was told its generic sentence rested on a personal baseline.
Gate it on the briefing, like the rest of the row.

The pulse sub-page promised a personalised Karvonen target band. Karvonen is
not computed anywhere: the band comes from CDC/NCHS population percentiles by
age and sex, and falls back to a fixed 60-100 for a reader with no age on file.
The copy now says that in all six locales, and the fallback's own source label
says it is a general adult range rather than a personal one, because that arm
is the same band for everyone. Karvonen itself stays unimplemented; whether to
build it is a product decision, not a copy fix.

The Coach's `allTime` window returns 365 days, identical to `lastYear`, and it
is the default. "year so far" was wrong too — a rolling 365 days is not
year-to-date. Both labels now say what the reads do. The two options remain
functionally identical and one of them should probably go, but removing a
default is a product decision.
2026-08-22 16:14:54 +02:00
Marc-André Bombeck c1811bc85b Stop narrating rollup buckets the record cannot account for
A rollup band outlives the rows it was folded from. The read-swap falls back to
live SQL only when a band is EMPTY, so a bucket that is merely wrong is served
as current; `ensureUserRollupsFresh` repairs the DAY tier over the trailing 90
days only, and keys off `Measurement.updatedAt`, which a hard delete does not
bump. The WHOOP body sync hard-deletes a WEIGHT row with no invalidation, so a
MONTH bucket keeps reporting a value the record no longer holds.

The block was handing the model two irreconcilable accounts of the same record
and marking neither: the coarse tail said one thing, the all-time extremes read
live beside it said another, and `asOf` stamped the whole thing current because
it is derived from raw reading age and knows nothing about rollup age.

Reconcile them where they are assembled. A bucket mean is an average of real
rows, so it cannot fall outside those rows' own all-time extremes — when it
does, the band is describing readings the live read cannot see. That is an
invariant rather than a heuristic, so the disputed tail is dropped and
`asOf.coarseHistoryWithheld` records that it went. Narrating a ghost is worse
than narrating less.

`currentForTodayClaims` stays keyed to raw recency, which is what it means. The
surviving readings really are from today, and suppressing a true present-tense
statement to punish a stale history band would trade one dishonesty for another.

The bucket on disk is still wrong, and a wide event now says so. The deeper fix
is to make the tier notice — compare a bucket's `computedAt` against the type's
in-window MAX(`updatedAt`), or invalidate on the hard-delete path — which is a
larger change than this repair.
2026-08-22 16:14:54 +02:00
Marc-André Bombeck 251a42bb0a Grade the Coach turn that went looking and found nothing
The numeric grounding guard keyed its activation off `loop.toolResults` alone.
A tool that finds nothing resolves to `{ present: false, reason: "none" }` with
no `available` payload, `runCoachToolLoop` drops that shape, and the no-tools
snapshot fallback is populated only in the non-tool branch — so the guard sat
out every turn where the record was empty, which is precisely the turn where a
fabricated figure is likeliest and does the most harm. Ask about a metric you
have never logged and an invented number was persisted and streamed verbatim,
with no withheld-figure marker to say it had never been checked.

`toolTrace` is the discriminant, because it records the pure misses the results
array drops. Tools ran and every one missed: grade the reply — an empty ledger
there is not missing information, it is the positive statement that nothing can
be cited, so `findUnverifiedCoachNumbersInLedger` takes an explicit opt-in to
grade against it rather than no-opping. The model answered without calling a
tool: still dormant, unchanged, because the tool-mode base prompt carries no
pre-computed figures and flagging a legitimately recalled one was the v1.32.1
regression. The inventory counts ride along as a widener so a plain count
restatement stays grounded.

Eliding the digits is not enough on an empty record. "Your sleep averaged […]
minutes, up from […]" still asserts a series, an average and a direction for a
metric with no readings; only the precision goes, the false claim stays. So a
turn that missed every tool and lost a figure to the guard is replaced with the
honest answer, in all six locales. Scoped so a reply whose figure reconciles
against a prior turn or an inventory count is left alone.
2026-08-22 16:14:54 +02:00
Marc-André Bombeck d49479bc64 Pin where the Coach stops checking its own numbers
Three characterisation tests for grounding gaps found while verifying that
every figure the Coach states comes from the record.

The numeric grounding guard rewrites any figure the turn's Grounding Ledger
cannot account for, and it holds: removing the strip reddens the existing
cross-turn ledger test. What is not covered is its activation. On the tool
path the guard runs only when `loop.toolResults` is non-empty, and a tool that
found nothing resolves to `{ present: false, reason: "none" }` with no
`available` payload, which the loop drops. So the guard disarms itself exactly
when the record is empty: ask about a metric with no readings, and an invented
figure is persisted and streamed verbatim, with no withheld-figure marker to
tell the reader it was never checked. The no-tools snapshot fallback is
populated only in the non-tool-mode branch, so the tool path has nothing to
fall back to, and every cloud provider runs the tool path.

Separately, a rollup bucket outlives the rows it was folded from. The read-swap
falls back to live SQL only on an empty band, `ensureUserRollupsFresh` repairs
the DAY tier over the trailing 90 days only, and it keys off
`Measurement.updatedAt`, which a hard delete does not bump. The integration
test reproduces the hard `deleteMany` in the WHOOP body sync against a
MONTH-band bucket: the deleted value keeps reaching the Coach's coarse tail
while the all-time extremes beside it report only the surviving value, and the
block is stamped current because `asOf` is derived from raw reading age and
knows nothing about rollup age.

All three assert current behaviour and carry a docblock saying so. Closing
either gap will redden them, which is the point.
2026-08-22 16:14:54 +02:00
Marc-André Bombeck 475db3073c Merge the second half of the meta sweep
The generated document is the whole visible effect of both halves, so it
conflicted line for line. Regenerated from the merged registry rather
than resolved by hand.
2026-08-22 15:38:10 +02:00
Marc-André Bombeck 3ae5bfe1c0 Capture the schemas that annotated themselves into nothing
`.meta()` clones the schema in Zod 4 and registers the clone, so a bare
`schema.meta({ id, description })` statement registers nothing at all.
Forty-one of them sat across nine route modules. The ids they named —
`MeasurementType`, `MeasurementSource`, `LoginPasswordRequest`,
`CreateMeasurementRequest`, `CreateBatchWorkoutRequest` and thirty more —
reached `components.schemas` zero times, the descriptions written for them
were published nowhere, and every use site inlined an anonymous copy. A client
generated from the contract got duplicated nameless types where one named
model belongs, which is the part that matters, because the iOS client is why
the contract is published.

Forty of them now bind their clone, and every use site points at it. Where the
schema comes from `src/lib/validations/*` the import is aliased and the clone
takes the name the use sites already spell, so nothing mutates the runtime
validation schema. The seven that cross module boundaries are exported from
`./shared` and the eight consumers import them from there; importing the raw
schema from the validation module instead is exactly what inlined them.

Thirty-four named components appear where inline copies stood. Fully
dereferenced, the document differs by eighty-six added descriptions and
nothing else: no operation's request or response shape moves. `CoachPrefs`
was already force-registered in the index and finally carries its description.

Five ids legitimately stay out of `components.schemas` —
`ListMeasurementsQuery`, `ListAllergiesQuery`, `ListEncountersQuery`,
`SuggestEncounterQuery` and `ListPractitionersQuery`. The emitter expands a
`requestParams.query` object into inline `parameters`, so a query id has
nowhere to land; they stay in the assigning form for consistency.

Three findings the wiring turned up:

`CreateShareLinkRequest` and `HealthRecordExportRequest` were annotated in the
Coach module, which imported the two schemas for no other reason and never
referenced them. Both operations that send these bodies live in the
health-record module, so the annotations move there. Both descriptions
described a `sections` field: the export body has no such field and rejects it
with a 422, and the share-link body freezes scope through `selection` plus
`documentIds` / `documentOnly`. Publishing either sentence verbatim would have
published a lie, so both are corrected to the shape the routes actually take.

`CoachPlansListQuery` — the forty-first — named a component that nothing
referenced and that a query object could never reach. The operation publishes
its two parameters by hand, because the schema carries a cross-field
refinement the expansion cannot express, and every clause of the annotation is
already published on the operation and on both parameters. It is deleted
rather than left registering nothing.
2026-08-22 15:34:32 +02:00
Marc-André Bombeck 60e17a2617 Publish the mood-tag layout schema its own description relies on
The annotation was already in the assigning form and still never reached
the document, because both consumers extend the base rather than
reference it and .extend() builds a new object. Same shape as Medication
and CoachPrefs, same remedy: name it in the forced-registration block.
2026-08-22 15:34:27 +02:00
Marc-André Bombeck 50287d2743 Merge branch 'fix/openapi-meta-b' into fix/openapi-meta 2026-08-22 15:29:51 +02:00
Marc-André Bombeck 4d53241574 Merge pull request #823 from MBombeck/docs/openapi-contract
Publish the contract the application actually answers: 262 paths to 410
2026-08-22 15:29:27 +02:00
Marc-André Bombeck 032ea5b1a0 Register the schema names the contract was discarding
Zod 4's `.meta()` clones the schema and registers the clone, so a bare
`someSchema.meta({ id })` statement registers nothing at all. Thirty-nine of
those across eleven route modules published neither the name nor the
description written for it: the schema inlined anonymously at every use site,
and a client generated from the contract got a duplicated nameless type where
one named model belongs. That last part is the cost that matters, because the
native client is the reason the contract is published.

Bind each annotated clone to a const and point the route table at that const.
Twenty-nine ids now emit under `components.schemas`, referenced from their
thirty use sites. Seven stay absent by design: they annotate
`requestParams.query` objects, which the emitter expands into inline
`parameters` rather than a component. `MoodTagLayout` stays absent too, for
the reason `Medication` and `CoachPrefs` already carry a forced registration
in the components block: its only consumer is an `.extend()`, and extending
builds a new object rather than referencing the base.

Two annotations in the illness module named ids that no route ever referenced,
`GetIllnessDayLogQuery` and `ListIllnessDayLogsQuery` — the day-log path
publishes the merged `GetIllnessDayLogsQuery` instead. They were dead twice
over, so they go rather than get wired to nothing.

`docs/api/openapi.yaml` grows the twenty-nine components and their refs. The
only other movement in it is thirty descriptions appearing where the text had
never reached the document. No request or response shape changes.
2026-08-22 15:24:05 +02:00
Marc-André Bombeck 965d41f1ed Say how every operation is authenticated
The document defined bearerAuth and cookieAuth and referenced neither,
so the schemes were decoration: a client generated from the contract
wires no credential and reads the whole surface as open.

The default now offers the two as alternatives, and the ten operations
reachable before a credential exists opt out with an empty array. The
opt-out list is derived from what each handler accepts, not from the
proxy's public-path list — /api/ingest/medication is on that list and
requires a Bearer token, so reading it as the answer would publish an
authenticated route as open. Writing the list from the proxy is exactly
the mistake the guard caught: /api/auth/oidc/callback went on it and is
not published at all.
2026-08-22 15:03:28 +02:00
Marc-André Bombeck 50b483d8d1 Merge the health-data route group
Two groups created environment.ts independently; one carried all six
paths, the other only the overview read, so the fuller side stands and
the barrel keeps one import.
2026-08-22 13:29:59 +02:00
Marc-André Bombeck c985f634aa Merge the environment and ingest route group 2026-08-22 13:28:18 +02:00
Marc-André Bombeck 30930b46d0 Merge the auth and integration route groups
Two component-id collisions, both from the same cause: two paths that
are two doors onto one action, documented independently.

DELETE /api/devices/{id} and DELETE /api/auth/me/devices/{id} both call
revokeDeviceCascade and answer with the same shape. Each grew its own
DeviceRevokeResponse, which the emitter refuses outright. The response
moves to shared.ts so neither module owns it and the next door onto the
same call finds it; the two envelopes keep separate names because an
envelope names an operation.

The settings module took both halves by hand rather than by keep-both:
one side added nine paths, the other three, with no overlap but a shared
import block and a shared reminder-thresholds entry that a mechanical
union duplicated.
2026-08-22 13:26:45 +02:00
Marc-André Bombeck bb9bf0ba68 Refuse what these four routes used to absorb in silence
Four surfaces answered a bad request by quietly substituting a good one. Each
fix comes with the test that catches it, and each test was confirmed red
against the old behaviour before the fix went back in.

`PATCH /api/user/ai-provider` inspected its body key by key with `typeof`
guards, so a numeric `model` or a boolean `baseUrl` failed every guard,
produced no entry in the update, and the write went ahead without it — a 200
that says "saved" for a field that was discarded. A body of nothing but such
keys came back as "No valid fields" naming none of them. The real schema now
lives in `src/lib/validations/ai-provider.ts`, the route runs one `safeParse`,
and a mismatch is the multi-issue 422 every sibling answers with. The accepted
field set is unchanged, and the object is deliberately not strict: an unknown
key has always been ignored here and still is. The one thing the schema has to
carry that no other does is that this body holds plaintext credentials, so no
rule may interpolate the value it rejected — noted where the next `.refine()`
would go.

`?slice=` on `/api/analytics` was compared against one literal and fell
through to the DEFAULT body on anything else, so `?slice=summary` answered 200
after running the heaviest chain on the surface. Invisible and expensive at
once. Closed enum, 422 on anything outside it.

`?metricType=` and `?limit=` on `/api/personal-records` were parsed
defensively, which read as robustness and behaved as silence — and in one
direction it was worse than silence: dropping an unrecognised `metricType`
WIDENS the read to every metric, so a typo returned more of the record than
was asked for. Both refuse now, and the 500 ceiling refuses rather than
clamps, because serving a different number of rows than were requested is the
same quiet substitution. No caller in the tree sends a value the new rules
reject.

`POST /api/ingest/medication` bucketed on the client address, so a household
running a chat bridge, a button and a home-automation rule — the normal
deployment for this route — put all three in one 60/min cell and let the
chattiest starve the rest. It already resolves a per-caller identity two lines
further down and threw it away; the bucket is that token now. A request that
resolves no token keeps the per-IP bucket it always had, through the shared
anonymous helper, so a flood is still capped and a broken proxy chain
collapses into the tight global cell rather than a shared "unknown" one. Its
429 goes through `apiError` and carries `meta` like every other refusal.

Two older tests pinned the defects by name — "drops an unknown metricType
silently" and "falls back to default on garbage ?limit" — and one pinned a
throw where the rest of the surface returns an envelope. All three are
rewritten with the reason beside them.

The generated YAML is deliberately not in this commit.
2026-08-22 13:23:48 +02:00
Marc-André Bombeck edccc17a4d Register the rest of the export, insights and medication surface
Twenty-five path entries across nine domains, all of them served and none
of them described. Same cause as the first batch: `openapi:check`
compares the registry against the YAML and never the route tree against
the registry, so a route registered nowhere drifts with every gate green.

Four groups where the contract had to say more than the shape does.

The full backup is the download that carries a whole record in plain
text, so what it contains is a safety question. It has two modes and this
route reaches only one: called with no options, it always emits the
PORTABLE form — decrypted columns, no tombstones, no row ids — while the
disaster-recovery form belongs to the worker and the admin path. Three
things it leaves out say so in the payload's own manifest: document
bytes, workout GPS and per-sample series, and the sensitive pair, where
the screener answers include the PHQ-9 self-harm item and a consent
receipt restored elsewhere would assert an agreement that operator never
obtained. The undeclared gap is named too, since roughly thirty models
the plan marks as backed up still have no reader or restore branch.

The four intake mutations differ in ways a caller has to know before
firing one. The by-id delete, the bulk delete and the purge all
tombstone, so the sync feed carries each removal; only the purge's
compliance rollups are dropped outright, and those recompute. The first
two refund the inventory stamp because not taking a dose puts the units
back; the purge deliberately does not, because refunding a whole history
would inflate the current container count instead. The bulk delete drops
unmatched ids silently, so a partial match succeeds with a lower count
than the ids sent.

The two provider-configuration surfaces are documented by what they do
NOT return. The chain answers provider types and boolean state, never a
credential. The per-medication ingest endpoint returns a raw token
exactly once, on the PUT that mints it, and a re-enable answers a null
token with `created: false` rather than re-issuing — the stored value is
an HMAC and there is no path back.

Insight generation costs money, so both gates are published with the
refusals they produce: the hourly per-user limit, and the day's token
ceiling, which is refused at reservation time before any provider is
contacted and carries its own code so a client can tell "too often" from
"the day's spend is gone". A 200 there does not mean a provider ran.

Two things the code said that were no longer true. The seeded-question
route's docblock declared it deliberately unregistered; it is registered
now, and a comment contradicting the registry is worse than none. And
the idempotency guard went red on `/api/insights/feedback`, whose
exemption said the path could not be published — which is exactly what
that leg exists to catch, so the entry is gone rather than the assertion.

`docs/api/openapi.yaml` is regenerated separately.
2026-08-22 13:23:25 +02:00
Marc-André Bombeck 75f4a39066 Merge branch 'docs/openapi-auth' into docs/openapi-contract 2026-08-22 13:18:42 +02:00
Marc-André Bombeck 79a1395104 Check the routes against the contract, and the contract against itself
The drift check compares the registry to the emitted YAML. It has never
compared the routes on disk to the registry, so a route that was never
registered produced no drift and no failure: the document stayed
internally consistent while going quietly incomplete. Two hundred and
twenty-two paths reached that state.

The coverage guard walks src/app, reads the verbs each module exports,
and requires every verb to be either published or named with a reason.
The reasons are kinds rather than free text, because these fall into a
small number of genuinely different shapes and writing one sentence
seventy-three times would hide the entry whose sentence is different.

The security guard is the same idea one level down. The document defined
bearerAuth and cookieAuth and referenced neither, so a generated client
wired no credential and read the whole surface as open. The schemes
being present is what hid it. The document now declares them as
alternatives, and the guard checks the reference rather than the
declaration.

Both are red until the routes are documented, which is the point.
2026-08-22 13:18:33 +02:00
Marc-André Bombeck 6f62f66105 Describe the WHOOP disconnect in the contract
The eighth teardown endpoint, missed when the seven siblings were split across
the earlier batches. It has no per-user unsubscribe to make — WHOOP webhook
subscriptions are registered once per developer app rather than per category
the way Withings does it — and it leaves the BYO-key credentials in place so a
reconnect does not force a re-paste.

Like every other `POST /api/<provider>/disconnect`, a repeat call answers 404
rather than an idempotent 200, and the description says so. That completes the
tally: all seven disconnect endpoints refuse a repeat, all six credential
DELETEs accept one.
2026-08-22 13:18:25 +02:00
Marc-André Bombeck aa80cb45c1 Say which rule a rejected Web Push subscription broke
`POST` and `DELETE /api/notifications/web-push` both answered a flat
`apiError("Invalid data", 422)` and discarded the Zod issues. A non-HTTPS
endpoint, an endpoint aimed at an internal host, an over-long one and a missing
subscription key produced byte-identical bodies, so a browser whose
subscription was refused could not tell a fixable client bug from a policy this
instance will never satisfy. Both now answer the multi-issue envelope every
other body-parsing route in the tree uses.

Echoing the issues is safe here and the route says why, because it is the kind
of thing that gets re-derived wrongly later: the endpoint is a subscription's
routing secret and `keys` is its crypto material, but `sanitiseZodIssues` emits
`path`, `code` and `message` only — the rejected value lives in `issue.params`
and stays server-side — and every message this schema can produce is a fixed
string or a length/format default rather than an interpolation. A test pins
that none of the four secrets reaches the wire, so a future schema change that
adds an interpolating message fails here instead of leaking quietly.

The tests assert on content rather than status: a status-only check passes
against the flat error it replaces, which is how this survived. Two of them
compare whole bodies between two different refusals and require them to differ.

The two `/test` routes were checked in the same pass and need nothing. Neither
accepts a body — they take no parameters at all — so neither runs a Zod parse
to discard. Their 422s already carry `not_configured`, `vapid_not_configured`
and `rate_limited_self` as machine-readable codes.
2026-08-22 13:17:45 +02:00
Marc-André Bombeck b6e34d1264 Close five honesty gaps on the integration surface
Each of these made the server report something that was not so, and each now
has a test that fails without the fix.

The Withings credential DELETE wrote no audit row and left the ledger at
whatever state it last held. Fitbit, Google Health and WHOOP all park theirs,
and three of them say in their own comments that they do it for parity with a
list that never included Withings. A client reading `state` after the teardown
was told `connected` about a connection that no longer existed.

Oura's and Strava's credential DELETEs audited only when an access token
happened to be present, so removing a stored pair that had never completed
OAuth left no trail at all. Polar's twin has always done both unconditionally;
they match it now. The old Oura test asserted the skip, which is why the gap
survived a rewrite — that expectation is inverted here, with the reason next to
it.

The Withings and WHOOP manual syncs had no rate limiter, while `fullSync` walks
full history against a per-user provider budget. The Fitbit route's own comment
records that it was the outlier until it got one; these two were never brought
along. Both now carry the same 5/60s baseline and 1/hour full-sync buckets as
their siblings.

Every sync route parsed its body inside a catch commented "no body provided",
which was true for one of the three ways to reach it. An absent body still
means an incremental run — that is the documented shape and the web cards send
it. A body that is present and unparseable is now 400, and one that fails the
schema is 422: `{"fullSync": "true"}` used to be read as false and answered 200,
so a client that asked for full history and mistyped the value was told the run
it never requested had succeeded. Unknown keys are still ignored. The four
copies collapse into one helper.

The consolidated integration status swallowed a failed freshness query into an
empty array, which is exactly what a record with nothing in it also produces —
so the signal built to say "this metric has gone quiet" answered "nothing has
gone quiet" when it had failed to look. `metricFreshnessDegraded` separates the
two, as one flag rather than one per entry, because it is a single query for all
eight providers.

The contract follows the behaviour in the same commit.
2026-08-22 13:13:18 +02:00
Marc-André Bombeck e39e2b60c2 Let the GLP-1 read reach the record its own write already reaches
`GET /api/medications/{id}/glp1` resolved the caller as themselves while
the POST beside it has resolved the record since v1.37.0. A manager
inside a shared record could therefore add a titration step to a
medication whose history the same path refused them: may write, may not
read. No reading of the sharing model produces that shape, and the
sibling reads on the same medication — `/inventory`, `/side-effects`,
`/cadence` — have all resolved the record at READ level for releases.

The read joins them at that level rather than at the POST's MANAGE,
because it is strictly narrower than the write already admitted. The
module was already on both reviewed allowlists, so nothing is newly
delegable; its reason line said the module was reached through the
MANAGE arm alone, which is no longer true and now says so.

The gap survived this long because a route that refuses every switched
caller is indistinguishable from a route nobody classified. Three legs
close that: a delegate scoped to medications reads the owner's titration
history and still cannot reach their own medication, a delegate scoped
elsewhere is refused, and a caller who never switched is unchanged. Both
delegate legs assert the refusal CODE rather than the status, because the
pre-fix answer was also a 403 — verified by reverting the handler, at
which point the first leg reads 403 for 200 and the second reads
`sharing.not_permitted` for `sharing.access.denied`.
2026-08-22 13:04:33 +02:00
Marc-André Bombeck a283211586 Describe the remaining auth, settings and notification-transport surfaces
Twenty-eight more verbs that exist, are called, and said nothing in the
contract: registration, password rotation, the profile patch, the SSO entry
point, passkey enrolment, the Codex device-auth flow, the device list and its
revocation cascade, four per-user preference scalars, the email channel and the
four channel self-tests, the record wipe, the privacy facts, the update check,
and the Web Push transport.

Four of them needed more care than the rest.

`GET /api/auth/oidc/login` is documented as the two-armed route it is. The
browser arm redirects to the provider; the native arm takes `client=native`
plus a mandatory S256 challenge and returns to a compile-time scheme with a
one-time code — or an MFA ticket when the account has a second factor. The two
PKCE exchanges in play are named apart, because confusing them is the way to
get this wrong.

`POST /api/auth/register` is the only genuinely anonymous surface in the set,
and its refusals are stated exactly: the SSO-only block, the 5-per-15-minutes
bucket that collapses rather than falls open, the invite that is consumed
whenever it is sent, the zero-user bootstrap that mints the first admin under a
lock, and the one 409 that covers a taken email and a taken username so it
cannot be used to enumerate accounts. `POST /api/auth/password` is a change
behind authentication, not a reset, so it is described as one.

`DELETE /api/settings/data` names what it takes and what it leaves: ninety-nine
models hard-deleted, the account's own personal columns reset, credentials and
interface preferences kept, one transaction, and a confirmation string that is
deliberately not the one account deletion wants.

Two collisions surfaced while wiring this up and are resolved rather than
worked around. `PUT /api/auth/profile` and `PATCH /api/user/profile` are two
doors onto one handler, so they now share one published request component
instead of two descriptions of one validator. Their RESPONSES differ, though —
one carries id and role, the other the display preferences, and neither is a
superset — so they are named apart and the older description no longer claims
to cover both.

The four Web Push and APNs surfaces land in their own route module rather than
beside the notification preferences: that module answers what gets sent and
whether it arrived, this one the plumbing a client sets up and then verifies.
Both self-tests report failure inside a 200, which the contract says plainly,
because a client reading only the status will tell someone their notifications
work when they do not.

The generated YAML is left out; it is regenerated once the remaining groups
land.
2026-08-22 13:03:11 +02:00
Marc-André Bombeck 49da15feaa Publish the nineteen routes the drift check could not see
`openapi:check` compares the registry against the committed YAML, so a route
that was never registered produced no drift and no failure. These nineteen
had shipped without a contract: the health probe, the dashboard analytics
aggregate, the profile summary, personal records, the badge grid, the whole
environmental-context module, both onboarding writes, the Apple Health import
pair, the external medication ingest, the self-context adopt, the AI-provider
update and the AI connection probe.

Each entry is read off the route rather than inferred from a neighbour, so it
carries what a client cannot guess: that `/api/health` answers a bare object
and not the envelope, that `/api/analytics` records a score row and mints
pattern ids on a GET, that `/api/ai/test` reports a provider failure as 200
with `ok: false` because a 5xx comes back as somebody's HTML error page, that
the ingest route replays with 200 and creates with 201, and that a home
location re-stamps its effective-from instant on every write.

Three route-local request schemas move to `src/lib/validations/` so the
published shape and the runtime parse are one object rather than two copies —
a route module may only export handlers, which is why they could not be
imported where they were. The AI-provider PATCH has no schema to move: it
hand-rolls its parse field by field, so its entry says so and spells out that
a wrongly-typed key is skipped in silence.

The generated YAML is deliberately not in this commit.
2026-08-22 12:54:43 +02:00
Marc-André Bombeck 7f67c24ecc Describe the rest of the integration surface in the contract
Thirty-seven more paths the clients already call and the published spec did not
describe: the six OAuth providers' credential, status, disconnect, sync and
probe endpoints, the cross-provider surfaces under `/api/integrations/*`, the
managed record's configuration tree, and the native device-revoke path.

Where providers share a verb name they do not always share a contract, and the
descriptions say so instead of averaging it out. Three divergences are stated
per path. A disconnect refuses a repeat with 404 on six providers and answers an
idempotent 200 on two. `/api/<provider>/test` answers a bare probe result while
the same-named `/api/integrations/<provider>/test` adds the connection's last
sync, and the Google Health one accepts a body that replaces the response shape
outright. `GET /api/withings/status` refreshes and re-persists the OAuth token
while it reads; the other six status endpoints write nothing, and each now says
which it is.

Two further facts a client cannot infer. The Withings credential DELETE writes
no audit row and leaves the ledger at its last state, unlike every sibling, so
its `state` is not proof of a live connection afterwards. The WHOOP connect
ticket is a credential in a response body, returned once and stored only as a
hash.

The Google Health credential schema moves to the validations module; the managed
record's six strict patch schemas are exported from where they already lived, so
the contract publishes the objects the handler parses rather than a restatement.
2026-08-22 12:44:36 +02:00
Marc-André Bombeck 63f791120a Stop the API switch hiding live tokens, and cap the unit-preference body
Four defects the registry work surfaced, each with a check that fails without
the fix.

`GET /api/tokens` no longer consults the instance-wide API switch. That switch
gates the surfaces a token is FOR — external ingest, the MCP bridge — and not a
token's ability to authenticate: nothing on the `requireAuth` path reads it, so
a token minted before an operator flipped it stays live while it is off, and
refusing the list hid exactly the credentials their owner most needed to see,
their own signed-in phone among them. The revoke keeps the gate; the asymmetry
is stated in both route comments and pinned by the test, because widening it is
a decision about what the switch means rather than a fix to the read.

`PATCH /api/auth/me/unit-preference` reads its body through `safeJson` with a
1 KB cap, like the scalar beside it. It used a bare `request.json()` and was the
one endpoint in the family with no body-size limit at all — the new test proves
it by sending 2 KB and watching the old handler answer 200. Malformed JSON now
answers 400 like every sibling instead of 422; the only caller checks `res.ok`
and never the status, so nothing depends on the old code. The existing
malformed-JSON assertion was changed deliberately: it pinned the inconsistency,
not a contract.

`PATCH /api/auth/passkeys/{id}` returns the multi-issue envelope. It answered a
flat "Invalid request", so an empty name and a 200-character name produced
byte-identical bodies and the form had nothing to show beside the field.

`GET /api/user/thresholds` has a header that describes the handler. It promised
`{ defaults, overrides, effective }` and has always returned
`{ effective, overrides }`. The guard compares the two, so the comment cannot
drift again in either direction.

The two published operations whose contracts moved are updated with them.
2026-08-22 12:39:43 +02:00
Marc-André Bombeck d66c4f6cdc Register eleven served routes in the OpenAPI contract
Eleven routes the native client calls have never been in the registry, so
`docs/api/openapi.yaml` does not describe them. Nothing caught it:
`openapi:check` compares the registry against the YAML and never compares
the route tree against the registry, so a route registered nowhere drifts
with every gate green.

  GET       /api/analytics
  GET,POST  /api/cycle/symptoms/custom
  GET       /api/dashboard/summary
  GET       /api/environment
  GET       /api/export
  GET,PUT   /api/insights/settings
  GET       /api/insights/targets
  GET,POST  /api/medications/intake
  GET,POST  /api/medications/{id}/glp1
  GET       /api/mood/insights
  GET       /api/personal-records

Each contract is derived from the handler rather than from its comments:
the methods it exports, the parameters it reads with their real defaults
and bounds, the statuses it can actually produce, whether it resolves the
record fence, and which module gate it sits behind. Where the answer is
awkward it is written down rather than smoothed over — `/api/export`
answers a bare `data` key and not the standard envelope; `/api/analytics`
serves two unrelated shapes behind one query parameter and records the
health score it computes; `GET /api/medications/intake?scope=today`
projects rows before it reads them; the GLP-1 GET is not delegable while
the POST on the same path is; and `highlightInsight` on the dashboard
summary is pinned null by the handler and has never carried anything.

The intake aggregator's query and body schemas move from inside the
handler to `@/lib/validations/medication`, so the contract generates from
the same object the route parses with instead of a second copy of it. The
environmental overview gets its own route module because nothing owns that
surface. `docs/api/openapi.yaml` is regenerated separately.
2026-08-22 12:30:05 +02:00
Marc-André Bombeck b6efc6cb5a Register the sixteen auth, settings and discovery routes the spec never had
`pnpm openapi:check` compares the registry against the committed YAML and
never the routes against the registry, so a route that was simply never
registered stayed invisible to the gate. Sixteen of them are called by the
native client and describe nothing: sign-out, the three per-user preference
scalars, the two pre-session discovery reads, passkey login options, the
passkey rename and delete, registration status, the consent latest-receipt
reader and its revoke, account deletion, the instance service switches, the
API-token list and revoke, the threshold overrides, the version read, and the
assistant feature matrix.

Each contract is derived from the handler rather than from its docblock, so
the published statuses are the ones the code can actually produce — the 413 on
a 1 KB body cap, the 400 the consent family answers where the rest of the API
answers 422, the 403 an instance-wide API switch produces on a token READ, the
last-admin and last-Guardian refusals on account deletion, and the fresh-factor
arm that a Bearer transport cannot clear at any scope.

Four request bodies moved to the validation modules the handlers now parse
with, so the published wire cannot drift from the runtime one: the three
`/api/auth/me` preference scalars into a new `user-prefs` module, and the
passkey rename beside the login schemas. Behaviour is unchanged.

The generated YAML is left out; it is regenerated once the remaining groups
land.
2026-08-22 12:24:52 +02:00
Marc-André Bombeck 1c2e6c5c80 Merge pull request #822 from MBombeck/work/backup-clinical
Carry the clinical history: ECG strips, efficacy targets, schedule eras
2026-08-22 12:23:21 +02:00
Marc-André Bombeck c8035fdd78 Describe the integration, notification and MCP surfaces in the contract
Sixteen routes the native client already calls were absent from the OpenAPI
registry, so the published spec said nothing about them. `openapi:check`
compares the registry against the YAML and never the routes against the
registry, which is why the gap survived.

Nightscout status / test / disconnect, the WHOOP credential and status reads,
and the Withings status / disconnect pair land in the integrations module. The
three per-channel configs under `/api/settings/*` join the settings module; the
notification preference and delivery-health surfaces get their own module, as do
the MCP connector connections and tokens.

These are credential-bearing surfaces, so the contract states what each response
actually carries. Every channel GET reduces its secret to a boolean, and the
descriptions say so, along with the consequence: a client cannot read the value
back, so an empty value on the way in preserves the stored one rather than
clearing it. `POST /api/mcp/tokens` is the one operation that returns a usable
Bearer, and its description says outright that this is the only copy.

Two inline request schemas move to the validations modules they belong in, so
the registry documents the same object the handler parses rather than a
restatement of it.
2026-08-22 12:21:16 +02:00
Marc-André Bombeck a74dc289a8 Carry the archived schedule eras, chain and all
The register said a restored medication keeps today's schedule and loses
the record of when the dose or the timing moved. Measured, it costs more
than a record: the band minter reads each past day against the schedule
that was live THEN, so an account that came back without its eras had its
own compliance history rewritten against a plan it was never on.

The hard part was supersededByRevisionId, the pointer from a corrected
ARCHIVED era to the MANUAL row that replaced it. Three things about it
had to be settled.

It is a bare id column with no relation, so nothing in the database
refuses a value that points at nothing. That makes losing it quieter and
worse than a broken foreign key would be: every era consumer SKIPS a row
that carries the pointer, so a corrected era whose link came back null
goes live again beside its own correction, and the timeline shows one
window twice with past days minted against whichever the query read
first.

A portable restore mints fresh ids, so the pointer cannot travel as one.
It travels as a POSITION in the drug's own ordered era list. That list is
the only address space both ends share: the builder sorts it totally
(validFrom, then createdAt, then id) so two eras sharing an instant
cannot swap places between two exports of the same account, and the
pointer never crosses a drug, because every route that writes it scopes
the update to one medication. The same mechanism serves both purposes
rather than one carrying ids and the other positions.

Writing it needs two passes, because the row a link addresses is as often
written after the row addressing it as before. Pass one creates every era
with the link null; pass two patches the links once every position has an
id.

A hand-edited file that names a position out of range, or an era's own
position, gets the era and not the pointer, and the position is reported
as scheduleRevisionLink with the JSON path into the file. Refusing the
file would cost an operator every era of every drug over one bad integer,
and writing the link would point a correction at whatever row happened to
be there. A row that supersedes itself is treated the same way: every
consumer would skip it, so the era would vanish from the timeline while
still sitting in the table.

The round trip seeds three eras with one supersede among them and asserts
the shape of the chain, then asserts that exactly two are live. Three
rows with every link null satisfies any count and has thrown away the
only thing the pointer says.
2026-08-22 11:51:52 +02:00
Marc-André Bombeck 768320f3d3 Restore what each medication was supposed to move
The register said the drug comes back with no statement of what it was
for, which is exactly what a pinned efficacy target is: a row exists ONLY
where the person overrode the derived ATC / name resolution, so it is a
decision and never a computation. Nothing recomputes it after a restore
because there is nothing to recompute it from.

The lab arm travels as a biomarker NAME rather than an id, the way a lab
result's cross-reference already does. A portable restore mints a fresh
catalogue id, and `Biomarker` is unique on `(userId, name)`, so the name
is the only end of the reference that survives the trip.

The obstacle was ordering, not machinery: the remap map already existed,
but the drugs are rebuilt several hundred lines before the analytes are.
The targets are collected against the id each drug actually got and
written in a second pass once the biomarkers exist.

The alternative was hoisting the biomarker restore above the medications,
and it loses on two counts. `biomarkerByName` and `restoredBiomarkerIds`
are read by the lab-result loop immediately below the block, so moving it
would separate a map from its only other consumer and leave a later edit
free to re-order them back with nothing noticing. And the dependency
would read backwards: a pinned target is a leaf of the medication tree,
and letting a leaf dictate where the clinical record gets rebuilt inverts
what the file is about. The second pass states the constraint where it
applies instead of hiding it in a line order.

A named analyte the restore did not put back is dropped and reported as
medicationTarget rather than written. `biomarkerId` is a real foreign
key, so a dangling value would fail the constraint and cost the whole
account over one override. Dropping beats nulling here because the
override IS the reference: a row with neither arm set is what the
resolver already reads as "no override", so keeping it would restore
something that means nothing while still holding the primary slot.

The round trip asserts the restored target points at the biomarker the
restore itself wrote, resolved from the database rather than remembered
from the fixture. Counting would have passed on a row whose biomarkerId
came back null, and nothing else would have complained: the resolver
falls back to deriving a target and the page still renders.
2026-08-22 11:49:52 +02:00
Marc-André Bombeck 14638a6660 Bring the ECG strips back from a restore
The register said the originating device may still hold them. That holds
for about as long as the phone beside the watch does, and not at all once
a Withings connection is revoked: the signal id stops resolving with it.
So a self-hoster who exported and wiped had a file with no traces in it
and a restore that reported success.

Both ends live in src/lib/export/ecg-backup.ts, the arrangement the
reminders, visits and Coach sections already use.

Three things needed deciding.

The waveform follows the note contract: ciphertext verbatim in a
disaster-recovery payload, the micro-volt samples decrypted in a portable
one. There is no placeholder for a strip that will not decrypt the way
the Coach has one for a turn, because waveformEncrypted is a required
column. Such a recording is left out of a portable file with a warning on
the wide event, so the number of strips in the file is the number of
strips that can be shown.

measurementId is a REAL foreign key against the event row a strip was
filed with, unlike the Coach references next door. A value that will not
resolve does not dangle quietly here, it violates the constraint and
rolls the whole restore back over one cross-reference. A portable export
omits soft-deleted measurements, so that case is reachable rather than
theoretical. The reference is resolved against the measurements the
restore wrote, nulled when it cannot be, and reported as ecgReference.

The strips are wiped and rewritten by the section itself. The wipe at the
top of the restore never reached them: a recording hangs off the account,
not off a measurement, so deleting the measurements only nulled the
pointer and left the row behind.

The round trip asserts the row column by column rather than counting it.
A recording is three things at once and each is the whole value of the
row on its own: a strip with no rhythm verdict is a squiggle nobody has
read, a strip with no recordedAt belongs to no day, and a strip with no
samples is a claim that an ECG exists which nothing can draw.
2026-08-22 11:44:01 +02:00
Marc-André Bombeck a6c239d952 Merge pull request #820 from MBombeck/work/backup-awards
Carry the bests, the badges and the environment days through backup and restore
2026-08-22 11:37:24 +02:00
Marc-André Bombeck 775efad923 Merge pull request #821 from MBombeck/fix/proxy-relative-redirects
Stop redirecting to the address the process bound to
2026-08-22 11:14:31 +02:00
Marc-André Bombeck f95fb95295 Carry the bests, the badges and the environmental history through a restore
Four models leave the coverage-pending register with both ends and a
round-trip proof: PersonalRecord, UserAchievement, EnvironmentContext and
EnvironmentTravelLocation. Two new section modules hold reader and restore
beside each other, the arrangement the visits, vaccinations, reminders and
Coach sections already use.

The register understated two of them. The bests are "recomputable in
principle", which is true and does not help: nothing recomputes them, and a
recomputation over a restored history would find different rows, because
sample series and GPS routes never travel, a portable file omits deleted
readings, and a record set in a reading that has since been corrected cannot
be found twice. The badge's unlock date is worse. The evaluator prefers a
persisted date over a derived one, and several of the dates it can still
derive come from counters no backup carries, so a dropped row does not relock
the badge, it re-earns it today.

PersonalRecord.sourceMeasurementId is the reference that needed care, and it
is the dangerous kind. The schema declares no relation for it; migration 0054
created the column as a real foreign key against measurements. Writing it
blind is not a dangling pointer, it is a constraint violation that rolls the
whole restore back and hands the operator an empty account. Measured, with the
resolution removed: "Foreign key constraint violated on the constraint:
personal_records_source_measurement_id_fkey", HTTP 500, nothing restored. So
the restore runs after the measurements, resolves the pointer against the rows
it actually wrote, nulls what it cannot resolve and reports it under a new
personalRecordReference skip kind.

An unlocked badge names a definition in the code catalogue, which drifts
between the release that writes a file and the one that reads it. The id is
carried and written back verbatim rather than judged, the same answer the
vaccination restore gives an antigen slug it cannot resolve.

The readings and the location periods travel as a pair because carrying the
readings alone is worse than losing them. The environment resolver reads the
periods live, so with them gone the nightly seven-day lookback re-resolves
every trip day to the home location and upserts over the row: an account would
come back with a fortnight abroad and then have it quietly rewritten as a
fortnight at home, days after a restore that reported success. Day keys stay
strings end to end, and fetchedAt is restored verbatim so a two-year-old
provisional reading does not claim it was confirmed today.

The round trip seeds bests that share a metric type and an instant and differ
only in the sport slot, two badges earned years apart (one of them under an id
this build no longer defines), and a trip day beside a home day, then reads
each back column by column. Counting alone passes every one of the failures
worth fearing here: slots dropped so a 5 km best is presented as a 10 km best,
unlock dates stamped at restore time, a trip day re-attributed to the home
city, a location period whose bounds no longer cover the reading it explains.
Each of those was applied on purpose and each failed on its own assertion.

Two schema comments claimed no read path selects a column that this backup now
selects; both say what reads it instead of staying wrong.
2026-08-22 11:14:15 +02:00
Marc-André Bombeck 974a90f28f Merge pull request #819 from MBombeck/work/backup-docs
Carry the vault's filing and its staged facts through backup and restore
2026-08-22 10:58:28 +02:00
Marc-André Bombeck cc8c69aa07 Stop redirecting to the address the process bound to
Reported from the iOS side, iOS #96. `GET /api/auth/native/login` answers

    location: https://0.0.0.0:3000/auth/login?flow=native

behind a proxy that does not pass `Host` through. `NextResponse.redirect`
needs an absolute URL, the route built one with `new URL(path, req.url)`,
and `req.url` there is the address the Node process bound to.

That is the primary onboarding path. ASWebAuthenticationSession follows
the redirect, iOS refuses the restricted port, and the person lands on a
browser error page with no way forward. Distributed builds carry no
associated domains on purpose, so the browser handoff is also how
passkeys are meant to work; without it a new account is down to a
password and a confirmation code.

Two things hid it. Every error branch of that route builds the fixed
healthlog:// scheme with no host at all, so the route behaved correctly
on every failure and broke only when it was supposed to succeed. And its
own test built a request whose host matched, so the absolute URL it
produced looked right. The assertion there used toContain, which is
satisfied by the broken value just as happily as the correct one.

The same line was in six more places: the error redirects of the
Withings, WHOOP and Fitbit connect routes, which send a browser to
/settings/integrations. Behind the same proxy those land on the same
unreachable address, so all seven move to a relative Location.

A relative Location is valid per RFC 7231 and the client resolves it
against the address it actually called, which is the public one by
definition. The alternative is reading X-Forwarded-Host, and that would
put one more proxy setting between a self-hoster and a working sign-in.

Three checks, as the report asked for. The route test now drives a
request whose URL names the bind address, which is what a proxy hop
looks like from inside the handler. A structural test freezes the
pattern out of the four files and, separately, requires each of them to
still reach for the helper, because a file that simply deleted its
redirect would pass the first check and fail its users. The helper
refuses a protocol-relative value, which is a host in disguise.

Verified by breaking it: putting the old call back fails four checks,
the last of them reporting the reported value verbatim.

Refs iOS #96.
2026-08-22 10:55:40 +02:00
Marc-André Bombeck 352ff617da Stop the switched-payload spec failing on its own reload
The scoped-sharing spec routes `/api/auth/me`, and the account switch it
drives ends in a full reload. The reload cancels whatever call is in
flight, Playwright marks the route handled, and the handler's `fulfill`
then lands on it with "Route is already handled!". With
`failOnFlakyTests` on CI that turns the whole shard red over a teardown
race rather than over anything the test is checking.

Same guard the record-session-fence spec and this file's own
accessibility sibling already use. The `fetch` is guarded too, because a
request cancelled mid-flight rejects there rather than at the fulfill,
and the earlier of the two is the one that decides which error the run
reports.
2026-08-22 10:42:32 +02:00
Marc-André Bombeck d5dde3eb1c Carry the vault's filing and its staged facts through backup and restore
`DocumentConditionLink` and `ExtractedFact` were classified as backed up and
came back from no restore. The register said a restored vault is unsorted and
that the facts are re-derivable by re-running the extraction. The first is
right; the second understates it. What a re-run cannot produce is the REVIEW
DECISION on each fact, and every column holding one defaults to "nobody has
looked at this yet". `POST /api/documents/inbound/[id]/confirm` acts only on a
PENDING fact and commits it into the structured store, so a restore that let
those columns default would offer an already-committed reading for approval a
second time and write a second lab result behind it.

Both ends live in `src/lib/export/document-filing-backup.ts`, beside each other
like the Coach, reminder and vaccination sections. `status`, `needsReview`,
`confidence` and the commitment pair are carried and restored verbatim, and the
wire schema requires the first three rather than defaulting them.

Ordering: the section runs after the documents, the condition episodes, the lab
results and the medications. The first two are real foreign keys, unlike the
Coach's bare id columns, so an unresolvable value there does not cost one edge,
it aborts the transaction and the operator gets none of the account back. The
last two are what `committedRecordId` resolves against. The builder therefore
carries a filing or a fact only when both of its ends are carried, and the
restore still checks and drops what a hand-edited file leaves unresolvable.
`committedRecordId` is nulled together with its type, because a type with no id
claims a commitment the row cannot name.

The round trip seeds a second condition and files the page under it, so a
restore that kept the count and lost the pairing fails rather than handing back
a lab report filed under a head cold. A third case breaks both references in a
saved file to prove the drop path runs and still answers 200.

Three new skip catalogues arrive with it, and with them labels for the three
that had none: a dropped Coach attachment had been reaching the operator's
screen labelled "mood factor" since it was added. The label chain is exhaustive
now, so the next catalogue without one stops the build.
2026-08-22 10:23:58 +02:00
Marc-André Bombeck 4d2fc06fe7 Merge pull request #818 from MBombeck/work/backup-sensitive
Carry the screener history and the consent record, disaster recovery only
2026-08-22 10:16:48 +02:00
Marc-André Bombeck f4fe08c73e Carry the screener history and the consent record, disaster recovery only
Two models come off the coverage register together, and neither is an
ordering or an id-remap problem. Both hang off the account and nothing
else. What kept them off was a question the code never wrote down, so
this change writes it down.

The screeners hold completed WHO-5, PHQ and GAD administrations, and for
PHQ-9 the encrypted blob contains item 9, the question about thoughts of
being better off dead. The application already draws a line around that
blob: it keeps a coarse item9Flagged boolean so the history view can
re-surface crisis resources without decrypting anything. The intent here
was to follow that line and carry the administration while leaving the
answers encrypted. The schema settles it more firmly. responsesEncrypted
is Bytes and NOT NULL, so there is no half-row to write and the choice
is the whole thing or none of it. A plaintext export in a downloads
folder is the wrong home for that answer, so the row rides in a
disaster-recovery file only.

The consent receipts are not sensitive in that way. Their problem is
that a consent is given to an operator. A receipt restored onto a
different instance asserts an agreement that operator never obtained,
with a signature timestamp to make it look settled. A disaster-recovery
file goes back to the same instance, so it travels there and not in a
portable one.

In both cases the portable file SAYS so in its own manifest rather than
reporting an account that has neither. The manifest is merged rather
than added beside the records one, because two keys of the same name
would shadow each other depending on spread order.

item9Flagged is restored from the file rather than recomputed.
Recomputing would mean decrypting every administration during a restore,
which is the handling this data is kept away from, and the stored flag
is what the history view has been reading all along.

Verified by breaking it: dropping revokedAt on the way back fails the
round trip with a withdrawn consent coming back as an active one.
2026-08-22 09:48:10 +02:00
Marc-André Bombeck 79725a85e9 Merge pull request #817 from MBombeck/feat/backup-notification-prefs
Carry per-drug reminder phase timings, and stop claiming the notification preferences could travel
2026-08-22 09:34:59 +02:00
Marc-André Bombeck 16b10c7855 Carry per-drug reminder phase timings, and stop claiming the notification preferences could travel
Two entries sat side by side on the coverage register, described as "the
same shape of loss". They are not the same shape at all, and the
difference is worth writing down.

ReminderPhaseConfig hangs off a medication, one row per drug, nested
inside something that already travels. It needed nothing new and it
travels now. The round trip tunes all four thresholds away from their
defaults and puts one of them in PERCENT rather than MINUTES, because a
restore that quietly fell back to the schema defaults would hand back
four plausible numbers and change when the drug turns orange.

NotificationPreference cannot travel, and the register was wrong to
imply it was merely unbuilt. A preference addresses ONE notification
channel by a hard foreign key. The channel model is deliberately
excluded from every backup because it carries channel secrets, and the
restore deletes every channel it finds. A carried preference would point
at a row that cannot exist: the same constraint violation the custom
mood categories were causing, waiting for somebody to implement it in
good faith.

So it moves to the deliberate exclusions with a reason that names the
dependency instead of the symptom. Re-applying somebody's tuning to a
channel they add after a restore is a feature about channel types, not a
backup contract about rows, and it should be argued for on its own
terms rather than smuggled in as debt repayment.
2026-08-22 09:16:38 +02:00
Marc-André Bombeck e56b54cf24 Merge pull request #816 from MBombeck/feat/backup-mood-taxonomy
Restore an account that grouped its own mood factors
2026-08-22 09:16:34 +02:00
Marc-André Bombeck d596f8c66d Restore an account that grouped its own mood factors
An account that ever created its own mood category could not be restored
at all. Not partially, not with a gap: the restore violated
mood_tags_category_id_fkey and rolled the whole file back, so the
operator got a 500 naming a constraint and an empty account.

The coverage register described this as custom tags landing in a
grouping that no longer exists, which reads like a cosmetic loss.
Measured, it is the same shape the retired-catalogue-key work already
removed once: one row nobody thought about taking down everything else
with it.

MoodTag.categoryId is a real foreign key. Seeded categories belong to
the instance and are deliberately still not carried, but a category the
person made is theirs, cascades away with them, and has to travel. It
now does, and it is written before the tags that point at it.

The hidden set travels by key rather than id, because what people hide
is almost always a seeded tag whose id differs on every instance. A key
this instance no longer knows costs one hidden row and is reported: a
silent drop would un-hide a factor somebody had deliberately taken out
of their mood editor, and they would have no way to know why it came
back.

Found by experiment rather than by reading: seeding a user-owned
category into the round trip turned it red with the constraint name, and
the same test is green now.
2026-08-22 01:15:59 +02:00
Marc-André Bombeck 460ed37a2a Merge pull request #815 from MBombeck/feat/backup-medication-stock
Carry medication stock and its ledger through backup and restore
2026-08-22 01:15:55 +02:00
Marc-André Bombeck 86231ed23e Carry medication stock and its ledger through backup and restore
A restored account reported no supply at all. Every pack was gone, and
with it every low-stock reminder, on a feature that became first-class
when medication moved to one stock per drug.

The two travel together because the register itself paired them: without
the events the count "cannot be rebuilt even in principle", and without
the packs the events are a list of deltas against nothing.

The count comes back exactly as recorded, not recalculated from the
ledger. It is the value the server had resolved and the person was
shown, and every reminder already sent was computed from it. The fixture
makes the difference visible on purpose: thirty in, eight consumed, and
a remaining count of twenty-one, because somebody dropped one without
recording it. A restore that "corrected" that to twenty-two would
disagree with the account's own history and look like a bug fix while
doing it.

Both decimal columns cross the wire as strings. Half tablets are a real
prescription and a JSON number would round them.

The note follows the contract the side effects and dose changes beside
it already use, and the packs nest inside their drug, so a portable
restore minting fresh ids needs no remap.

Verified by breaking it: recomputing the remaining count from the ledger
fails the round trip with twenty-one against twenty-two.
2026-08-22 00:59:15 +02:00
Marc-André Bombeck f4e65c827a Merge pull request #814 from MBombeck/feat/backup-coach-memory
Carry the Coach's memory through backup and restore
2026-08-22 00:59:10 +02:00
Marc-André Bombeck 1fbd3b628b Carry the Coach's memory through backup and restore
The transcript came back in the previous change; what the Coach keeps
BETWEEN threads did not. A restored account had every word of every
conversation and a Coach that has to be told its own history again — the
register said as much for all three: "a restore makes the account
re-tell its history", "keeps the talk and loses the commitment", "a
restored account silently stops sending them".

Tombstones ride only in a disaster-recovery file. All three soft-delete,
and a deleted fact is hidden from injection while kept for audit — a
distinction only the owning instance can act on. A portable file
carrying them would hand a person rows their own app refuses to show
them, and a restore that resurrected one would put a fact back into the
Coach's mouth that the person had deliberately taken out of it.

Neither reference here is a foreign key. sourceConversationId, on all
three, and relatedPlanId on the reminder are bare id columns, so nothing
in the database refuses a value pointing at nothing. That makes them
more dangerous than a real key rather than less: a dangling reference
costs no error, it just quietly stops meaning anything while the row
goes on looking complete. The restore resolves both against what it
actually wrote, nulls what it cannot resolve, and reports the count.

Nulling rather than dropping is the point. A live reminder must not be
lost because the plan it mentions was tombstoned out of a portable file.
The reminder is still the person's and it still fires; what it loses is
a pointer that was already going nowhere.

Order follows from that: threads, then plans, then reminders. Running
the memory before the transcript would cut every fact loose from the
conversation it came out of and still report success.

Verified by breaking it: making the conversation lookup always return
null fails the round trip on the fact's own source field.
2026-08-22 00:41:26 +02:00
Marc-André Bombeck ad8325f18d Merge pull request #813 from MBombeck/feat/backup-coach-transcript
Carry the Coach transcript through backup and restore
2026-08-22 00:41:21 +02:00
Marc-André Bombeck bcb7716ffa Carry the Coach transcript through backup and restore
A restored account got a Coach that had never spoken to it. The
conversations, every turn in them, and the record of which vault
documents a thread was grounded in were all classified as backed up and
travelled with nothing. The register's own entry for the messages called
them the largest volume of free text an account owns after its
documents.

The three travel together because two of them mean nothing alone: a turn
without its thread has nowhere to hang, and an attachment names a
pairing rather than a thing.

documentScoped is the field that makes this more than lost history. It
is a permanent fence — set when a conversation is created through the
fenced endpoint or gets its first document, and lowered by no code path
anywhere, not by detaching every document and not by deleting one. A
restore that let it default to false would hand the tool loop back to
precisely the conversations the fence exists to keep away from it, and
nothing in the interface would look wrong afterwards. So it is carried
verbatim, restored verbatim, and asserted per thread rather than in
aggregate: a restore that wrote false everywhere would still return both
threads and every turn and satisfy every count.

Prose follows the contract the rest of the payload already uses. A
disaster-recovery file carries the stored ciphertext; a portable file
carries the text, because a portable export exists to be readable by the
person who owns it, exactly as it already decrypts medication notes,
mood notes and document summaries. A single row under a key the instance
no longer holds is named rather than thrown, so one unreadable turn
cannot cost someone the rest of the transcript.

Ids ride in both modes, as the reminders do, so an attachment resolves
and the facts, plans and reminders still owed have something real to
point at. The restore runs after the documents for the same reason, and
an attachment naming a document the file does not carry is dropped and
reported instead of failing the whole file over one link.

Verified by breaking it: forcing documentScoped to false on the way back
fails the round trip on that field by name.
2026-08-22 00:25:07 +02:00
Marc-André Bombeck b400e8bb4a Merge pull request #812 from MBombeck/test/portable-restore-refusal-ordering
Pin that a refused restore has not already wiped the account
2026-08-22 00:24:26 +02:00
Marc-André Bombeck a8898bbee7 Pin that a refused restore has not already wiped the account
The restore refuses a portable backup, because a portable export carries
document metadata without the stored bytes and `contentEncrypted` is NOT
NULL. That refusal is deliberate and documented at the guard.

What nothing pinned is the half that makes it survivable: the check runs
above the transaction that wipes the account. The guard reads like input
validation, and input validation drifts toward wherever the value is
first used. Move it below the wipe and the operator loses everything to
a file the server was always going to reject — and the non-null
assertions further down are safe only because it stands where it does.

The test drives a real portable payload through the real route and reads
rows, not return values: the refusal happens, the account still has
every row it had, the cache invalidation never fired, and the audit row
names the document. Asserting the status alone would stay green through
exactly the edit this is here to catch, so the surviving rows are
asserted separately rather than inferred from it.

Verified by breaking it: disabling the guard turns the 422 into a 500,
which is the raw TypeError from decoding bytes that are not there.
2026-08-22 00:01:52 +02:00
Marc-André Bombeck 3f570b6f53 Merge pull request #811 from MBombeck/feat/backup-medication-history
Carry medication dose changes through backup and restore
2026-08-21 23:39:46 +02:00
Marc-André Bombeck 5e0994aab9 Carry medication dose changes through backup and restore
The titration history was classified as backed up and travelled with
nothing. An account restored from a backup showed the current dose and
no record of how it got there: the ramp, the reductions, the note the
prescriber gave for each step. For a long-running drug that history is
often the more clinically interesting half.

Export writes the rows nested inside their drug, ordered by
effectiveFrom, so a portable restore that mints fresh ids needs no
remap. The note follows the contract the side effects already use: a
portable export decrypts it for a human reader, a disaster-recovery
export carries the ciphertext verbatim, and a restore never writes it
into the legacy plaintext column.

Order is the content here, not decoration. Two rows on the same drug in
the same unit, 5 mg then 10 mg, describe a ramp; the same two rows
returned in the other order describe a taper. Counting them back cannot
tell those apart, so the round-trip test asserts the sequence and the
values, not the count.

MedicationDoseChange moves from the coverage-pending register into the
two-ended list, which makes the round-trip registry incomplete until a
row is seeded and counted. That is a compile error, not a review note.
2026-08-21 23:23:23 +02:00
Marc-André Bombeck aca5b2d30b Merge pull request #810 from MBombeck/feat/backup-mental-health-consent
Carry medication pause eras through backup and restore
2026-08-21 22:19:29 +02:00
Marc-André Bombeck 48f4cba3aa Carry medication pause eras through backup and restore
`MedicationPauseEra` was classified as backed up and travelled nowhere. The
spans where a drug was deliberately paused are the difference between "stopped
taking it" and "was told to stop taking it", and nothing else in the payload can
reconstruct them: an era is a decision, not an observation, and no intake row
records its own absence.

Without them the compliance recomputation reads a deliberate pause as missed
doses, so a restored account looks non-adherent for a stretch during which it
did exactly what it was told.

Both ends land together. The eras ride nested inside their medication, like the
schedules and side effects beside them, so a portable file that mints fresh
medication ids needs no remap — Prisma binds each child to whatever id the
parent actually got.

## The open era is the point

`resumedAt` is nullable end to end and the writer always emits it. An open era
means the drug is paused right now, and readers run it to the present. The
restore passes the null through rather than closing it at restore time: a pause
that was still running when the backup was taken is still running afterwards,
and closing it would silently turn an ongoing decision into a historical one.

Counting alone cannot catch that — a restore that closed the era would still
hand back two rows. So the round-trip test seeds one closed era and one open
one and asserts both field by field.

## What is proven and what is not

Moving the model out of `COVERAGE_PENDING` and onto `TWO_ENDED_MODELS` makes
the round-trip test's registry incomplete, which is a compile error. Verified
by watching `tsc` fail with TS2741 before the count-back was written: the
coupling the plan's comment describes does hold.

The round-trip test itself needs Postgres via testcontainers and has not been
run here. Typecheck, lint, format and the 21458-test unit suite are green; the
integration proof comes from CI.
2026-08-21 22:01:51 +02:00
Marc-André Bombeck af386066e5 Merge pull request #809 from MBombeck/docs/idempotency-contract
Publish the idempotency contract the server has honoured all along
2026-08-21 18:51:04 +02:00
Marc-André Bombeck eddb67975d Publish the idempotency contract the server has honoured all along
`withIdempotency` has made writes safe to retry since v1.5.x. It was described
in route comments and in no schema, so an app generated against
`docs/api/openapi.yaml` could not learn that a retry was safe. The iOS side
asked for it; the mechanism does not change, only what it says about itself.

34 operations now declare `Idempotency-Key` and the `X-Idempotent-Replay`
header, plus the 409 that refuses a duplicate still in flight. The description
leads with the one silent failure in the mechanism: a key that misses
`^[A-Za-z0-9_\-:.]{8,128}$` is IGNORED rather than refused, so the write
proceeds unprotected and looks exactly like a request that carried no key.

## What the work turned up

Three things the contract had wrong or missing, none of them the thing being
built:

- `/api/insights/chat` describes `withIdempotency()` in its header comment and
  applies it nowhere. The Coach chat is not retry-safe. Its prose says it is.
- `/api/measurements` exports GET, POST, PUT and DELETE and wraps only POST.
  Publishing the header per PATH would have promised a safe retry on two
  methods that do not honour one — the dangerous direction, so the guard works
  per OPERATION.
- `/api/insights/feedback` and `/api/admin/backups/{id}/restore` are not in the
  document at all. Both are named in the guard's exemption list with a reason,
  not filtered out silently.

## The guard

`idempotency-contract-publication.test.ts` reads the expected set out of the
route tree on every run — nothing is hand-listed, so it cannot fall behind the
code. It fails in both directions: a new wrapped route that forgets to publish,
and a published claim no handler honours.

A fourth assertion checks the exemption list itself is still true. It earned
its place immediately: the first draft exempted the whole `/api/medications`
family on the strength of a grep for path literals that came back empty. The
paths are not written as literals there; the emitted document carries 19 of
them. The test went red, the claim was wrong, the list is two entries now.
Measure the artefact, not the source that builds it.
2026-08-21 18:32:29 +02:00
Marc-André Bombeck b8a0aead7f Merge pull request #808 from MBombeck/fix/prisma-cli-deepmerge-override
Pin deepmerge-ts in the npm install the workspace override cannot reach
2026-08-21 14:08:37 +02:00
Marc-André Bombeck 2e5ec7f49f Pin deepmerge-ts in the npm install the workspace override cannot reach
The image builds two dependency trees. `pnpm install` builds the app tree and
honours `pnpm-workspace.yaml`. A second, npm-owned install builds
`/opt/prisma-cli` for the migration CLI, and it reads none of that file.

Only the first was pinned. `prisma@7.8.0` resolves `@prisma/config@7.8.0`,
which resolves `deepmerge-ts@7.1.5`, so the Trivy image scan reported
CVE-2026-40345 (HIGH, stack exhaustion on recursive merges) against
`/opt/prisma-cli` on a commit whose dependency audit over the app tree was
green. The pin read as complete because the check that could see the gap runs
against the artefact, and the one that ran against the lockfile could not.

`overrides` is npm's own mechanism, so `npm pkg set` writes it into the
package.json `npm init -y` just created, before anything resolves. Rebuilding
this exact install both ways: without the field `deepmerge-ts@7.1.5` lands,
with it `8.0.1` lands and `prisma --version` still reports 7.8.0.

The guard beside it fails when the two files name one package at two ranges,
and asserts a non-zero match count first, so a matcher that stops matching
fails loudly instead of passing vacuously. It compares declared ranges only —
a new vulnerable transitive that neither file mentions remains something only
the image scan can see, and that job runs on main-push, not on pull requests.
2026-08-21 13:31:49 +02:00
Marc-André Bombeck 5d3d512e82 Merge pull request #806 from MBombeck/fix/demo-published-credentials
Write the published demo password on every migration
2026-08-21 13:28:34 +02:00
Marc-André Bombeck 92af3c213c Merge branch 'main' into fix/demo-published-credentials 2026-08-21 13:10:00 +02:00
Marc-André Bombeck dc24e2250b Merge pull request #807 from MBombeck/fix/deepmerge-ts-advisory
Force the patched deepmerge-ts through Prisma's config loader
2026-08-21 13:09:33 +02:00
Marc-André Bombeck b8859ae424 Force the patched deepmerge-ts through Prisma's config loader
A new advisory turned the dependency audit red on the trunk without a line of
our code changing: DeepmergeTS recurses without bound on deeply nested input
until the stack gives out (GHSA-ggr8-5vv4-36mx, fixed in 8.0.0). Re-running the
same job against the same main commit that passed on 16 August confirms it is
the advisory database that moved, not us.

Waiting for Prisma does not help. `@prisma/config` still asks for 7.1.5 on the
current 7.9.1, so the copy never updates on its own, and an override is the only
route to the patched version. The workflow reserves `auditConfig.ignoreCves` for
advisories with no upstream fix, which is not this one.

The reach is the config-loading and CLI path rather than anything under a
request, so the practical exposure was small. The blocked release train was not.

Verified rather than assumed: `pnpm audit --prod --audit-level=high`, the exact
command CI runs, drops from twelve findings with one high to eleven with none
and exits clean. Because this is a major bump under Prisma's own config loader,
`prisma generate` and `prisma validate` were run explicitly alongside typecheck,
lint, format, 21446 unit tests and the production build. All pass.
2026-08-20 08:58:16 +02:00
Marc-André Bombeck 350294670d Write the published demo password on every migration instead of carrying one
The edge01 demo refresh copied `password_hash` straight from the source
account, and the README beside it asserted "demo login works". That only held
while the source account happened to use the password the project publishes.
The source is the operator's own instance, so the moment that password was
rotated the demo started rejecting the credentials the README hands to every
visitor, and nothing anywhere noticed. Someone eventually filed it as a bug
(#805) after being locked out of the shop window.

The load now hands the row an explicit password: the same Argon2id literal the
seed script writes, applied right after the copy, then read back and compared
byte for byte. A mismatch aborts the run rather than waiting for a stranger to
discover it at a login screen.

The statement goes in on psql's stdin. Interpolating it into the ssh command
string would let the remote shell expand the hash's `$argon2id`, `$v`, `$m`,
`$t` and `$p` segments to empty strings and store something no password can
ever match, which is the same outage wearing a different hat. A guard test
pins that too, alongside pinning the literal to the seed script's copy so the
two cannot drift apart again.
2026-08-20 08:20:16 +02:00
Marc-André Bombeck 0a20925d35 Merge pull request #763 from MBombeck/dependabot/github_actions/pnpm/action-setup-6.0.10
Bump pnpm/action-setup from 6.0.9 to 6.0.10
2026-08-16 20:11:20 +02:00
Marc-André Bombeck 08fd7c208f Merge pull request #762 from MBombeck/dependabot/npm_and_yarn/playwright/test-1.62.1
Bump @playwright/test from 1.60.0 to 1.62.1
2026-08-16 20:11:17 +02:00
Marc-André Bombeck 2435c9cefe Merge pull request #760 from MBombeck/dependabot/npm_and_yarn/pg-boss-12.27.0
Bump pg-boss from 12.26.0 to 12.27.0
2026-08-16 20:10:37 +02:00
Marc-André Bombeck e6c2aa86b1 Merge pull request #764 from MBombeck/dependabot/npm_and_yarn/testcontainers-12.1.0
Bump testcontainers from 12.0.3 to 12.1.0
2026-08-16 19:54:07 +02:00
Marc-André Bombeck c486096845 Re-verify the failed-row retention assumptions against pg-boss 12.27.0
The guard pins the installed version alongside the three source facts the
72-hour reader depends on, so a dependency bump has to stop and be looked at
rather than sliding past. It did its job here: 12.27.0 tripped the version
line.

Checked the release against 12.26.0. The retention plan is untouched, all
three assertions still match the shipped dist/plans.js verbatim. What moved
sits in the fetch path (group concurrency reworked to a keyed JSONB lookup,
schema names now routed through normalizeSchemaName / resolveSchemaName) and
in start_after, which becomes inclusive so a job written with the default
start time is fetchable on the next statement. None of that touches the
failed-row ledger.

So the assumption holds and the pin moves with it.
2026-08-16 19:28:11 +02:00
dependabot[bot] 552f11c21f Bump @playwright/test from 1.60.0 to 1.62.1
Bumps [@playwright/test](https://github.com/microsoft/playwright) from 1.60.0 to 1.62.1.
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.60.0...v1.62.1)

---
updated-dependencies:
- dependency-name: "@playwright/test"
  dependency-version: 1.62.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-16 17:27:52 +00:00
dependabot[bot] c9a3132893 Bump testcontainers from 12.0.3 to 12.1.0
Bumps [testcontainers](https://github.com/testcontainers/testcontainers-node) from 12.0.3 to 12.1.0.
- [Release notes](https://github.com/testcontainers/testcontainers-node/releases)
- [Commits](https://github.com/testcontainers/testcontainers-node/compare/v12.0.3...v12.1.0)

---
updated-dependencies:
- dependency-name: testcontainers
  dependency-version: 12.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-16 17:27:38 +00:00
Marc-André Bombeck 50e4138ed5 Merge pull request #761 from MBombeck/dependabot/npm_and_yarn/testcontainers/postgresql-12.1.0
Bump @testcontainers/postgresql from 12.0.3 to 12.1.0
2026-08-16 19:25:17 +02:00
Marc-André Bombeck 2e119f19c4 Merge pull request #759 from MBombeck/dependabot/npm_and_yarn/prettier-3.9.6
Bump prettier from 3.9.1 to 3.9.6
2026-08-16 19:24:39 +02:00
dependabot[bot] 73c3eff294 Bump @testcontainers/postgresql from 12.0.3 to 12.1.0
Bumps [@testcontainers/postgresql](https://github.com/testcontainers/testcontainers-node) from 12.0.3 to 12.1.0.
- [Release notes](https://github.com/testcontainers/testcontainers-node/releases)
- [Commits](https://github.com/testcontainers/testcontainers-node/compare/v12.0.3...v12.1.0)

---
updated-dependencies:
- dependency-name: "@testcontainers/postgresql"
  dependency-version: 12.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-16 16:41:09 +00:00
dependabot[bot] 238d63f3b9 Bump pg-boss from 12.26.0 to 12.27.0
Bumps [pg-boss](https://github.com/timgit/pg-boss) from 12.26.0 to 12.27.0.
- [Release notes](https://github.com/timgit/pg-boss/releases)
- [Commits](https://github.com/timgit/pg-boss/compare/12.26.0...12.27.0)

---
updated-dependencies:
- dependency-name: pg-boss
  dependency-version: 12.27.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-16 16:41:07 +00:00
dependabot[bot] cc670633ff Bump prettier from 3.9.1 to 3.9.6
Bumps [prettier](https://github.com/prettier/prettier) from 3.9.1 to 3.9.6.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.1...3.9.6)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.9.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-16 16:41:05 +00:00
dependabot[bot] 68448b2c5f Bump pnpm/action-setup from 6.0.9 to 6.0.10
Bumps [pnpm/action-setup](https://github.com/pnpm/action-setup) from 6.0.9 to 6.0.10.
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/0ebf47130e4866e96fce0953f49152a61190b271...0977fd99725f1db4007ccb2928dbb4e90d06cc86)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-16 16:40:16 +00:00
Marc-André Bombeck c93a947408 Merge pull request #804 from MBombeck/fix/codeql-action-4-37-7
Bump every codeql-action reference together
2026-08-16 18:19:34 +02:00
Marc-André Bombeck 290ce3441a Bump every codeql-action reference together
Dependabot treats `init`, `analyze` and `upload-sarif` as separate
dependencies, so it raised `init` to 4.37.7 in one PR and left `analyze` on
4.37.6 in the same workflow. The action refuses that pairing outright:
"Loaded a configuration file for version '4.37.7', but running version
'4.37.6'", and the CodeQL job failed on the very PR doing the bump.

All five pinned references move together here: init and analyze in
codeql.yml, and the three upload-sarif steps in scorecard.yml and trivy.yml.
The commit SHA is the one the v4.37.7 tag dereferences to, which is also the
SHA both Dependabot PRs pin.
2026-08-16 17:58:49 +02:00
Marc-André Bombeck e00d013459 Merge pull request #801 from MBombeck/release/v1.37.24
v1.37.24 — stop waiting for sleep that never comes
2026-08-16 10:12:30 +02:00
Marc-André Bombeck d252940381 Give the just-in spec a record that expects sleep
The scenario purges every sleep row so last night can be the arrival that
flips the day. That left an account with no sleep source at all, which now
reads `final` on purpose, so the provisional assertion could not be reached.

Seeding a night from three days ago restores what the scenario means: a
record that does track sleep, waiting for last night's. Written through the
same ingest route as the fresh night and swept by the same purge.
2026-08-16 09:56:12 +02:00
Marc-André Bombeck 23278318ef chore(release): v1.37.24 — the card stops waiting for sleep that never comes 2026-08-16 09:33:42 +02:00
Marc-André Bombeck 6c4f27794b Stop waiting for sleep that is never coming
The hero card on the start page told anyone without a sleep source, every
morning, that last night's sleep was not in yet. The freshness rule only
asked whether last night's sleep had landed, so a record that has never had
a night in it, or whose owner has stopped wearing the tracker, read as
permanently waiting. Not everyone owns one, and people put them away.

Waiting now requires that sleep is actually reaching the record: a night
within the last seven days. A flat battery or a few nights without the
tracker keeps the note, since tonight's sleep really is still expected. A
week of silence retires it.

The same rule settles the day itself. It used to park on `provisional`
forever for those records, which the daily push line reads too, so the
notification carried the same untrue "last night's sleep is still missing"
aside.

Two existing tests pinned the old behaviour by name ("is provisional when
sleep has never been recorded", "stays provisional when sleep never
arrives"). They described the defect rather than a contract worth keeping,
so they now assert the opposite, and a third covers the boundary between an
ordinary gap and a source that has gone quiet.
2026-08-16 09:32:01 +02:00
Marc-André Bombeck b7fdfc3a2e Merge pull request #800 from MBombeck/release/v1.37.23
v1.37.23 — postpone reads as an action, not a menu row
2026-08-15 19:47:21 +02:00
Marc-André Bombeck 370a7b3df8 chore(release): v1.37.23 — postpone reads as an action, not a menu row 2026-08-15 19:29:40 +02:00
Marc-André Bombeck 12f7441f82 Put postpone beside the checkup's primary action
The preventive-care card kept postponing in the overflow kebab, three taps
from the thing a person actually wants when a due date does not fit: open
the menu, find the row, open the sheet. The medication card has never done
that — taken and skipped sit side by side on the card, one tap each — and
the checkup card should read the same way.

Postpone is a second button in the action row now, outline against the
filled primary, at its natural width beside it. The dense list branch gets
the same action as an icon button, so neither view offers what the other
withholds. The kebab keeps edit, measurements and delete.

The sheet behind the button is unchanged in what it offers (quick offsets,
a free date, the honest-skip zone), but the offsets are computed on the
calendar now rather than by adding 86 400 000 ms per day. Across a DST
shift the old arithmetic moved the wall clock over midnight and wrote the
neighbouring date, so a chip labelled "+7 days" could fill in the sixth or
the eighth.
2026-08-15 19:26:23 +02:00
Marc-André Bombeck 9a3c1d181c Merge pull request #799 from MBombeck/fix/dead-code-sweep
Remove the unreachable insights cluster and the retired comparison toggle
2026-08-15 03:53:45 +02:00
Marc-André Bombeck eb087f9fc2 Drop the two recommendation type aliases the deleted card exported for
The schemas stay: the insight result still parses recommendations
through them. Only the exported aliases went unused once the card that
consumed them was removed, and the dead-code gate says so.
2026-08-15 03:35:41 +02:00
Marc-André Bombeck 52f8c5a57a Remove six insights surfaces and a toggle nobody mounts
The production dead-code check has been documented rather than enforced
since PR #691, because its list mixed real debt with false positives.
Triaged against the shipped tree, six of them are genuinely unreachable
and go now.

Five died when their host was retired and the leaves were left behind:
the recommendation card with its confidence meter and feedback control,
the grid that mounted them, and the suggested-prompts chip strip that
left the hero band in v1.18.7. The sixth, the comparison toggle, has had
two regression tests pinning its absence since v1.4.22, because the
preference it once set is a Settings concern now.

Deleting them freed sixteen translation keys in every locale, a pinned
entry in the success-affordance register, and two entries in the Coach
gate fixtures. Each of those was found by a guard rather than by
reading, which is the point of having them.

The adapter under signals/adapters stays: knip reports it as unreachable
because only a test imports it, but that test is the registry invariant
it exists to prove, and deleting it would remove a check rather than
dead weight.
2026-08-15 03:15:48 +02:00
Marc-André Bombeck 8ec682f007 Merge pull request #798 from MBombeck/release/v1.37.22
release/v1.37.22
2026-08-15 00:55:24 +02:00
Marc-André Bombeck 1a4cff11d2 chore(release): v1.37.22 — two deferred audit findings, closed 2026-08-15 00:36:30 +02:00
Marc-André Bombeck 09446846b1 Refuse an export nobody scoped, and stop naming UCUM without a code
Two findings from the deferred-audit list, both live since July.

A FHIR lab quantity stamped the UCUM system unconditionally while the
code was only added when the analyte map resolved a symbol for exactly
that unit. A receiver was told the unit came from UCUM and then handed
no UCUM code, which is a coding claim the bundle cannot back. System
and code now travel together, on the value and on both reference-range
bounds.

The doctor-report export accepted a selection that admits nothing. The
panel disables its own submit in that state, so only a hand-built
request reached it, and it rendered an empty artefact while spending a
rate-limit slot. This route has no documents-only mode that would make
an empty leaf list a legitimate scope, so it refuses with 422. The
share-link route, which does have that mode, is untouched.
2026-08-15 00:23:44 +02:00
Marc-André Bombeck d2491d5589 Merge pull request #797 from MBombeck/release/v1.37.21
release/v1.37.21
2026-08-14 19:17:28 +02:00
Marc-André Bombeck 5ddf00420e Carry the version bump into the API description 2026-08-14 19:00:24 +02:00
Marc-André Bombeck 548e94cc75 chore(release): v1.37.21 — every intake path resolves a dose the same way 2026-08-14 18:42:50 +02:00
Marc-André Bombeck aafc1a3c1a Quiet the snooze and clear the web reminder on every intake path
The two halves of a resolved dose were split across routes: the single
intake route reset the medication's snooze but never closed the pending
dose-due Web Push reminder, while the bulk route closed the reminder but
left the snooze standing. Each path was missing exactly what the other
had. A dose drained from a device's offline queue lands on these routes,
so a synced take could keep ringing as snoozed or leave a stale web
badge behind.

The bulk route now nulls snoozedUntil once per affected medication when
a non-skipped entry actually lands, and the by-id route dispatches the
same fire-and-forget web clear the other intake paths have carried since
v1.18.4.
2026-08-14 18:34:11 +02:00
Marc-André Bombeck 3a2c0e757e Merge pull request #796 from MBombeck/release/v1.37.20
release/v1.37.20
2026-08-14 14:49:54 +02:00
Marc-André Bombeck 54afd3e42c chore(release): v1.37.20 — checkups you can postpone, skip, and look back on 2026-08-14 14:28:41 +02:00
Marc-André Bombeck a104963daf Register the skip and snooze admissions in the MANAGE handler matrix
The two new measurement-reminder routes were admitted in the frozen
fixture but never got their integration drivers, so the matrix count
stayed at 75 and the executable proof was missing. Both routes now
carry a full manageContract driver: the skip driver asserts the stamped
column, exactly one SKIPPED ledger row, and that lastSatisfiedAt stays
untouched; the snooze driver asserts the snoozed-until write under the
same invariant. All three inventory pins move to 77.
2026-08-14 14:27:58 +02:00
Marc-André Bombeck a5016c6eed Accept Apple's single-column ECG waveform layout
Apple Health exports write ECG CSVs in two shapes. The importer only
understood the paired Lead,Voltage form; the single-column form (Lead
and Unit as metadata rows, a blank separator, then one bare reading per
line) failed every row as malformed and the whole recording was skipped.

The parser now treats a blank line after Lead and Unit metadata as the
start of the waveform, reads one bounded numeric sample per row, and
converts the declared unit to integer microvolts before the existing
amplitude and safe-integer bounds. The paired layout parses exactly as
before, and an unknown unit fails with a fixed message.

Refs #794
2026-08-14 12:49:15 +02:00
Marc-André Bombeck a48efb6976 Keep the status description one sentence and admit the retokenise fact
The rewritten notification-status description ran to two sentences and
130 characters — the card-description guard is right, the slot holds one
meta sentence. Folded to one em-dash sentence in every locale. The new
documents_retokenised job fact joins the outcome allowlist with its
reason.
2026-08-14 10:14:21 +02:00
Marc-André Bombeck d33be187e6 Copy polish: one pause dash, du-imperative errors, bare-question confirms, tokened micro-text
The German bundle's 109 spaced en-dash pauses join the em-dash norm (the
two date-range placeholders stay ranges); the three straight-quote
stragglers and three mixed-quote strings take German quotes; the eleven
Bitte-Infinitiv validation errors speak du-imperative like the rest of
the app; the two wirklich confirms become bare questions; the
notification-status description trades its semicolon telegram style for
two sentences in every locale; the Withings setup action shortens to the
sibling single word. Twelve arbitrary text-[10px]/[11px] sites move onto
the text-2xs/text-xs tokens; chart tooltips and the vendor calendar stay
as they are.
2026-08-14 10:08:49 +02:00
Marc-André Bombeck 09fbac3a33 Hygiene: drop dead export modifiers, delete the dead series module, wire the tokenizer version
The knip blind spot (ignoreExportsUsedInFile) left eight symbols carrying
an export nothing outside their file consumes — the modifier goes, the
symbols stay. The score series module goes whole: its only importer was
its own guard test, whose composite- and config-guarding suites survive
in place.

DocumentContentIndex.tokenizerVersion gains the consumer that was the
point of storing it: the content-index job now re-tokenises rows whose
version trails CONTENT_TOKENIZER_VERSION, from the decrypted index text
along the same path the rotation script uses, keyset-paginated and
fault-isolated per row — a future tokenizer bump migrates the index
instead of silently splitting it. ImportJob.uploadSha256 turned out to
already have its dedupe consumer (both kick-off routes short-circuit on
same-bytes non-failed jobs); its comment now names that instead of
pretending to be write-only. The remaining write-only columns carry
their honest annotations in the shape the audit sweep parses.
2026-08-14 10:08:43 +02:00
Marc-André Bombeck 4941cd075b Carry the custom-metric entry tombstone through backup and restore
The entries gained a soft-delete this release; both backup ends follow in
the same one. A portable export omits tombstoned readings so a restore
cannot resurrect them; a disaster-recovery payload carries the tombstone
and the restore writes it back, so the account returns exactly as it
stood — undo affordance included.
2026-08-14 09:58:11 +02:00
Marc-André Bombeck bcabd43ca7 Hygiene: name the FHIR immunization gap, cache the digest offline, freeze the no-FK pair, document the pin bump
Four small debts from the audit tail. The FHIR bundle's missing
Immunization resource gets the same written-decision paragraph the visits
exclusion carries — deliberate follow-up, not oversight. The service
worker's offline allowlist gains /api/daily/digest (both copies, policy
module and sw.js, plus the test) so the Today rail renders on an offline
reopen. The two Telegram plumbing tables that link to an account by a
bare userId string are frozen as a closed NO_FK_BY_DESIGN set in the wipe
guard — a third such table now fails the test instead of passing rule 1
silently. And deploy.md documents the manual digest-pin bump end to end:
imagetools inspect, the env update, and the three-probe /api/version
verification with the too-fast-deploy warning.
2026-08-14 09:56:45 +02:00
Marc-André Bombeck 9d64eb6aa2 Show what a backup contains before the operator types RESTORE
The restore dialog asked for its typed confirmation on nothing but a
filename and a date; the counts only surfaced in the audit row after the
rows were already replaced. A read-only summary endpoint now decrypts and
schema-validates the stored file through the same helpers the restore
itself runs and answers the summarizeBackup counts, and the dialog shows
them — per section, with an other-records line so a future summary key
can never silently vanish from the preview. A failed preview states its
own absence and never blocks the restore.
2026-08-14 09:51:40 +02:00
Marc-André Bombeck 90bad17cc8 Show the answers behind a screening score
Refs A3-8. The per-item PHQ-9 / GAD-7 / WHO-5 / SCI answers have been
stored encrypted since the assessments shipped, and nothing ever read
them back — the clinical detail (item 9 included) was invisible. A new
by-id read decrypts them for whoever holds the mind read grant (the same
grant that already serves the mood diary's prose and the item-9 flag),
degrades to an honest items-unavailable state on any decrypt or shape
problem, and the history rows gain an expandable breakdown rendering the
official instrument texts the check-in flow already carries — nothing
retranslated, no alarm styling on item 9 beyond what the house already
does.

Rides along: the guard inventories learn the branch's new routes (frozen
counts moved with reasons), the reminder-engine test harnesses carry the
ledger delegate, and the custom-metric restore toast keys sit in their
right namespace in all six locales.
2026-08-14 09:46:48 +02:00
Marc-André Bombeck 651d37b494 Let the dashboard hero show what matters most to its owner
The hero card composition was fixed: score panel first, the worth-a-look
rail beneath. A reminder-driven person asked for the reverse. The stored
dashboard layout gains an optional hero field ('score' default,
'reminders' promotes the rail into the hero slot), validated as a closed
enum, preserve-when-absent on write, omitted from the blob at the default
so untouched accounts serialize byte-identically. The choice sits in
Settings beside the other hero controls; the promoted rail degrades to
the calm all-clear line when nothing is due.
2026-08-14 09:46:35 +02:00
Marc-André Bombeck a3561b4600 Tell a first-run what Apple Health is actually doing
Refs #778. Two honesty gaps from the first-run report: the web onboarding
presented Apple Health like a web connector although the connection only
completes in the iPhone app, and the initial backfill gave no sign of
progress, so a working first sync looked broken.

The onboarding source step now says plainly that Apple Health is set up in
the iPhone app after onboarding, without dead-ending the flow. The
integration card gains a server-authoritative progress summary — rows
accepted, oldest reading reached, and an honest flowing/waiting line
derived from the last accepted batch. Nothing the server does not know is
shown: no percentage, no ETA, no invented throttle state; a quiet stretch
reads as waiting for the phone, not as an error.
2026-08-14 09:46:24 +02:00
Marc-André Bombeck b1be67bfa7 Carry the reminder schedule and its ledger through backup and restore
The reminder cadences were the oldest entry on the coverage-pending
register: carried in name, restored never — an account came back silent
until every reminder was rebuilt by hand. Both models are two-ended now,
in the house shape (both ends in one file, reminders-backup.ts): every
column travels, nextDueAt verbatim rather than recomputed, the completion
ledger beside it, tombstones in disaster recovery only, and a portable
export omits a tombstoned reminder together with its ledger rows.

Encounter.reminderId and VaccinationRecord.reminderId stop being dropped
on restore: both remap against the restored reminders and fall to NULL
with a skip report only when the file genuinely lacks the row. The wipe
plan names the ledger child-before-parent; the classification guard,
completeness guards and the integration round trip all demand the new
coverage (proven failing with the section removed).

Refs #223
2026-08-14 09:36:03 +02:00
Marc-André Bombeck a1ca0dc911 Let a German search find an English letter, and the other way round
Document search does whole-token equality over a blind index, so
'Blutdruck' never found the discharge letter that says 'blood pressure'
and 'hypertension' missed every German Arztbrief. A curated German/English
medical synonym list (vitals, common labs, imaging, specialties, document
words — static, no vendor names) now expands the QUERY tokens before they
are hashed: the stored index, its tokenizer version and the schema are
untouched, the expansion happens ahead of the one-way hash, and recall
simply widens. Typed tokens always survive the expansion cap.
2026-08-14 09:34:54 +02:00
Marc-André Bombeck 7bf8dbd8af Give custom-metric values the same second chance as every other entry
Refs A3-11. Deleting a logged custom-metric value was the one hard delete
left on an entry surface — everywhere else a deletion tombstones and the
toast offers Undo. The DELETE verb now stamps deletedAt, every read
(list, latest-value, counts, revive check) filters the tombstone, and the
new entries/restore endpoint clears it scoped to owner and parent metric —
a foreign, live or mismatched id is a silent no-op, never an existence
leak. The history table's delete toast carries the Undo action.
2026-08-14 09:34:43 +02:00
Marc-André Bombeck 689cacc039 Keep the measurements and mood filters in the URL
The documents view already round-trips its filters through the query
string; the measurements and mood lists kept theirs in memory, so any
navigation dropped them. The documents pattern is extracted into a shared
hook (use-url-filter-sync) and both lists adopt it: type/source/day-bounds
and the measurement value range survive reload, back and sharing, defaults
stay out of the URL, invalid facets drop leniently. The insights readings
subpage keeps its locked type in memory — that URL belongs to the insights
route. /measurements no longer strips ?type=, so the deep links the
Vorsorge cards mint now persist as filter state.
2026-08-14 09:24:08 +02:00
Marc-André Bombeck 5619783648 Let a checkup be skipped or pushed back, and remember every outcome
Refs #223. A due Vorsorge item could only be done, deleted or ignored;
Telegram's postpone moved the date with no trace. Both decisions are
first-class now, and both are honest:

- POST /{id}/skip (no body): the interval restarts from the skip instant
  through the recurrence engine, lastSkippedAt is stamped, skipCount
  increments, any snooze clears. lastSatisfiedAt is NEVER touched — done
  and skipped stay distinguishable forever, enforced in the primitive and
  pinned by tests.
- POST /{id}/snooze ({until: YYYY-MM-DD}): the server resolves the day to
  the notifyHour in the profile timezone and moves snoozedUntil and
  nextDueAt to the same instant, so every due-state consumer follows
  without knowing snooze exists. At least tomorrow, at most five years.
- GET /{id}/history: the new append-only completion ledger
  (MeasurementReminderEvent), one row per satisfy from every engine path
  and per skip, onTime derived at write time against the due instant that
  was current when the event landed.

Two anchor fixes close the backdated-event gaps: the auto-resolve floor is
now the later of last satisfy and last skip (an old reading synced late no
longer resolves the cycle the user just skipped), and a backdated real
result re-anchors the next due at the later of its own instant and the
skip decision.

Screenings stay skippable and snoozable — the satisfy-side 409 guards
claimed fulfilment; a skip claims the opposite. Appointment reminders 404
on all three routes, in the lookup, like every by-id sibling. /checkups
gains the postpone sheet (quick chips, date field, the skip zone with its
own confirm) in both view branches, with muted meta lines and no card
colour change. The MCP preventive-care tool carries the resolved fields;
the OpenAPI contract publishes all three paths and the widened DTO.
2026-08-14 09:23:58 +02:00
Marc-André Bombeck cd12b3886e Say what the stage tooltip's total actually is
Refs #611. The nap separation itself shipped in v1.34.4; what remained of
the report was the tooltip footer labelling a single night's total with the
orphaned 'Average per night' key (the headline card that owned it was
dropped in v1.22.0, so hovering one column claimed an average that was that
night's sum). The footer now names the main session's sleep, and on a day
with an inferred nap adds the whole-day total beside it — the Main sleep /
Naps / Total triple the report asked for. Percentages keep dividing by the
night alone.
2026-08-14 09:23:39 +02:00
Marc-André Bombeck 57ec22073c Publish the mood-entries family, the linked-context read and the import endpoint
Ten operations the spec owed: the seven mood-entries routes (list, create,
read, edit, delete, restore, bulk-delete) whose /bulk twin was already
published, GET /api/mood/linked-context (a cross-section read through the
mood URL that grant-scope reviewers need visible), and POST /api/import
(the spec documented polling an import job with no way to create one).
Every schema mirrors the handler's own Zod source; the envelope, refusal
and error semantics are read off the routes, not invented. Request-body
ids are bound instances now — a bare .meta() call clones in Zod 4 and
never reaches components.schemas.
2026-08-14 09:23:34 +02:00
Marc-André Bombeck 832aad9dd8 Add the Vorsorge skip/snooze columns, the completion ledger and the custom-metric tombstone
Migration 0334, all additive. MeasurementReminder gains snoozedUntil,
lastSkippedAt and skipCount; the new MeasurementReminderEvent table is the
append-only completion ledger (kind SATISFIED|SKIPPED, occurredAt, write-time
onTime, source). CustomMetricEntry gains the deletedAt tombstone for undo
parity with every other entry surface. Consumers land in the sibling commits
of this branch.

Refs #223
2026-08-14 08:54:43 +02:00
Marc-André Bombeck d8cbe2596f Merge pull request #793 from MBombeck/release/v1.37.19
Release v1.37.19
2026-08-14 08:01:42 +02:00
Marc-André Bombeck 1b183f64fe Wrap the widened assertion the way prettier wants it 2026-08-14 07:44:08 +02:00
Marc-André Bombeck 7cfe13db8c Follow the toast copy into the vault delete assertion 2026-08-14 07:43:31 +02:00
Marc-André Bombeck 83fa7856c8 chore(release): v1.37.19 — the supply math agrees with itself 2026-08-14 07:19:43 +02:00
Marc-André Bombeck d2ff7cb769 Settle the suite-wide fallout of the wave
- The record-settings contract, slug-list, and section-frame tests
  follow the slugs that left the registry (layout children resolve
  their headings from the layout namespace, like production).
- The orphaned settings.sections.<module>.title/description keys leave
  all six locales; the reverse-coverage guard flagged them the moment
  their nav entries stopped existing.
- The operator-cap ledger read fails open on a read error — the
  reservation layer still meters against the same ledger, and a
  transient blip must not take the fallback chain down.
- The reaper queue joins the subsystem surface; its healed count and
  the sweep's nag_dismissed join the job-fact vocabulary.
- The intake-event contract fixture carries the full row the resource
  now declares; the workouts empty-state test asserts the
  source-neutral cue; the persister poll gates on rendered content
  first.
2026-08-14 06:37:30 +02:00
Marc-André Bombeck 4eeaec74aa Give the tiered context its promised live fallback and un-truncate all-time stats
- The tiered AI context's band reader now falls back to a live grouped
  aggregate when a rollup band is empty (the module doc promised
  replace-with-fallback from day one; only the replace half existed), so
  a coverage-miss or never-folded YEAR partition no longer hands the
  model empty history. Far-from-UTC users get the day-grain band keyed
  on their own calendar (the same ±3h guard the correlation readers
  carry).
- The slim analytics slice splices measurements older than the 5-year
  fold window into its all-time count/min/max/mean: the rollup tier
  only covers the window, so "all time" silently truncated for long
  histories while the live fallback told the truth. A type whose only
  rows are pre-fold no longer reads as never logged.
2026-08-14 06:24:37 +02:00
Marc-André Bombeck 7611c82ae7 Poll on real conditions in the e2e suite and un-age the expiry fixtures
- account-sharing polls the offline persister's own IndexedDB store for
  the flushed snapshot instead of sleeping past the debounce.
- The record-session-fence cases poll for the observable halves of their
  setups (the switch round-trip landing, the exit switch being issued);
  the one remaining fixed window is a documented absence-assertion grace
  with nothing to poll on.
- The documents suite's two remaining sleeps are named for what they
  are: a focus-dance grace the outer document cannot observe, and the
  long-press gesture duration itself.
- The inventory-date fixtures derive their printed expiry from now
  instead of a fixed 2027 date that would silently flip the fixture to
  expired when the calendar catches up.
2026-08-14 06:17:34 +02:00
Marc-André Bombeck 97cb63a77f Report every restored section, disclose export decrypt losses, re-fold on zone change
- The restore summary counts practitioners, encounters, the three link
  tables, and vaccinations — restored in full since 08-01 but absent
  from the report, so a file carrying visits or an Impfpass read as if
  those sections were empty.
- A portable export that cannot open a field's ciphertext still nulls it
  fail-soft, but now names the loss in the file's own decryptFailures
  manifest — an export with a lost emergency note is no longer
  byte-identical to one where the note was never written.
- A timezone change enqueues the medication-compliance re-fold: the
  rollup keys its rows by the local day minted under the OLD zone, and
  the coverage probe compares counts, not keys, so nothing else could
  notice the stale bucketing.
2026-08-14 06:14:13 +02:00
Marc-André Bombeck ac44b35867 Claim reminder slots before egress, classify the bulk race, fold the vault probes
- The measurement-reminder tick claims its slot in the record-event
  ledger BEFORE any provider egress (the documented claim-first pattern
  from reminder-dedup.ts); a crash between dispatch and the nextDueAt
  advance can no longer double-send.
- mood-entries/bulk classifies a lost P2002 race as the duplicate it is
  (with the winning row id) instead of a skip carrying a raw Prisma
  message — the row IS stored, the racing twin wrote it. The workouts
  batch already re-derives per-entry status from RETURNING.
- The vault list's content-index and thumbnail probes ride the same
  Promise.all as the link loads: four independent grouped queries, no
  serialized round-trips on a 4s-polled list.
2026-08-14 06:06:43 +02:00
Marc-André Bombeck 14e9edfa7a Adopt the shared post-mutation tail everywhere a Measurement is written
The six measurement routes carried eight copy-pasted rollup-recompute +
status-invalidate blocks — the documented two-ended failure class
waiting for the next surface to remember one leg and forget the other.
All of them now call afterMeasurementMutation (the helper the deletion
fix introduced), and the two write surfaces that bypassed the hook
entirely join it: the nightly Recovery/Stress/Strain score rows and the
PHQ-9/GAD-7 assessment projection now recompute their (type, day)
bucket on the tick that writes them.
2026-08-14 05:59:12 +02:00
Marc-André Bombeck 6ebd5e8711 Guard the operator's cap on fallback hops and reap dead summary promises
- The provider-chain walker now checks the day's recorded spend against
  OPERATOR_COST_CAP before invoking an operator-funded admin-* entry. A
  request whose primary provider runs on the user's own key reserves
  under the generous user-plan ceiling; a fallback hop (or health-ledger
  reorder) onto the operator's key could then spend past the operator's
  cap unchecked. An exhausted cap skips the candidate like a failed hop.
- summaryState PENDING gets a giver-upper: the detail serialiser
  degrades a PENDING older than an hour to UNAVAILABLE (which offers the
  manual generate action), and an hourly reaper persists the same heal
  following the orphan-ImportJob reconcile precedent — a job dead past
  its retry budget no longer leaves the sheet saying "generating"
  forever.
2026-08-14 05:53:17 +02:00
Marc-André Bombeck f67fcec1c9 Complete the intake-event spec and align workout nullability with the DTO
- MedicationIntakeEvent declares the full row both intake responses
  actually return: autoMissed, attributionSource, injectionSite,
  doseTaken, inventoryConsumption, externalId, updatedAt, syncVersion,
  deletedAt were served but undeclared.
- GET /api/medications/{id}/intake is published (query schema, paged
  envelope); the spec only knew the POST while the client-facing
  history list has consumed the GET since v1.4.37.
- WorkoutListEntry's sportType/startedAt/endedAt/durationSec become
  nullable, matching the DTO — declaring them non-null was a
  strict-decoder bomb for the Swift client.
- OpenAPI regenerated.
2026-08-14 05:44:36 +02:00
Marc-André Bombeck 4b009c3a45 Publish the resolved per-slot dose units and a slot-aware runway on the medication wire
A medication whose slots carry different doses (a whole tablet in the
morning, a half at noon — #219) was consumed correctly but reported
wrongly: the wire shipped only the raw nullable per-slot column (clients
had to derive the inheritance), stockDosesRemaining divided by the
medication-level units alone, and no runway figure existed outside the
low-stock push.

- Every schedule now carries resolvedUnitsPerDose, resolved server-side
  exactly like the intake consumption resolver.
- stockDosesRemaining divides the units pool by the schedule-weighted
  average units per dose.
- runwayDays (whole days of supply under the slot-aware burn rate) rides
  the list AND detail payloads; the detail GET also gains the stock
  aggregates the list already had.
- The low-stock engine runs the same slot-aware math, so the push and
  the wire cannot disagree; the web cards, table, and detail Übersicht
  prefer the server figure over their client estimate.
- OpenAPI regenerated with the new fields.
2026-08-14 05:41:15 +02:00
Marc-André Bombeck 542ca91be5 Finish the wiring polish: verbosity gate, upload flash, /about links, dead settings slugs
- The Coach verbosity picker is disabled while the concise tone forces
  brief, with a hint saying so, instead of a live control that silently
  does nothing.
- A fresh document upload flashes its new row like the duplicate path
  always did; the settle decision is a pure exported helper with a test.
- /about gains its two inbound links: the /privacy footer and a
  Settings→About credits row (the GeoLite2 EULA wants the attribution
  reachable from the running app).
- The eight settings slugs shadowed by permanent redirects (the seven
  layout children + sharing) leave the slug registry: no more
  prerendering unreachable pages, no dead component wirings, and the
  SectionPlaceholder fallback that could never render is gone. The
  redirects, section components, and layout-hub subpages are unchanged.
2026-08-14 05:31:33 +02:00
Marc-André Bombeck 3b1314d1b0 Enforce the reminder nag cap, compare the disclaimer version, drop the unread analytics columns
- The daily Coach sweep now charges each ignored day against
  surfaceCount and auto-dismisses at three — the cap the schema comment
  promised since v1.22 but nothing enforced.
- The welcome gate compares the stored disclaimer acknowledgment
  version against the current one, so revised disclaimer text
  re-prompts; /api/auth/me carries the version for the client.
- slope90 and anomalyCount leave the analytics wire: slope90 had no
  reader anywhere (computed on every request, in SQL twice), and the
  slim path could never compute anomalyCount (it always reported 0).
  anomalyCount stays as an in-process field on summarize() for the
  insights feature builder, which is its only real consumer.
2026-08-14 05:19:05 +02:00
Marc-André Bombeck 6b3a5a4182 Settle the four remaining layout deviations from the audit
- Vorsorge dashboard tile title joins the dashboard heading ladder
  (titleAs=h2 like its sibling tiles).
- A user's own note on the mobile measurement card renders in the
  foreground colour; user data is never muted.
- The compact Vorsorge list row expresses its density on the Card
  instead of overriding the content slot's padding with an off-scale
  step.
- The AI-consent card holds its space with a skeleton while the receipt
  loads instead of popping in and shifting the section below.
2026-08-14 05:07:14 +02:00
Marc-André Bombeck 487eb6970c Polish the copy the audit flagged across all six locales
- Singular variants for the bulk-delete toast and confirm dialog
  (measurements + mood); the call sites branch on a count of one.
- Consent confirm buttons shortened so they no longer overflow the
  small button on phones.
- Workouts empty-state CTA is source-neutral instead of assuming an
  iPhone with Apple Health.
- "Pills" is no longer a navigation term in a medication app: DE says
  Navigations-Chips, EN chips, PL chipy; ES/FR/IT subtitles align with
  their own established terms.
- Health-score insufficient hint drops the translationese and fixes the
  pronoun.
- Side-effect tag helper uses the established Stimmungs-Tags term.
- Success toasts that are single fragments lose the terminal period
  (37 keys, all locales).
- Integration pill parked state short enough to survive truncation.
- The iPhone-managed reminder badge carries a short label; the full
  sentence moved to a helper line below instead of clipping mid-word.
2026-08-14 05:02:49 +02:00
Marc-André Bombeck 1cde353965 Merge pull request #792 from MBombeck/release/v1.37.18
Release v1.37.18
2026-08-14 04:19:03 +02:00
Marc-André Bombeck dd885043c4 chore(release): v1.37.18 — the app stops promising what it does not do 2026-08-14 04:01:21 +02:00
Marc-André Bombeck 9e555b7fd2 Expect the fenced label on the real-DB preventive-care read
The integration twin of the tool test still asserted the raw reminder
label; the tool now fences it like the appointment reason beside it.
2026-08-14 03:56:38 +02:00
Marc-André Bombeck 8ee123d06b Settle the suite-wide fallout of the wave
The evaluator stubs hoist properly for vitest's factory rewrite, the
WHOOP webhook route test's prisma double learns the pre-image read the
delete branch now performs, and the connector-token card description
shrinks to the one-sentence, 120-character shape the card guard
enforces — still saying what the mint truthfully does.
2026-08-14 02:40:53 +02:00
Marc-André Bombeck 2b3a67d43c Keep the leaf-guard regexes inside the compiler's target 2026-08-14 02:34:40 +02:00
Marc-André Bombeck 95baf58be7 Harden the guards that could go green for the wrong reason
The bearer MINT matcher demanded a literal apiToken.create( in the very
file that documents how a whitespace-wrapped call walked past its T1
sibling; it is whitespace-tolerant now, asserts a non-zero match count,
and the always-true filter beside it is gone. The T1 resolver matcher no
longer demands tokenHash first in the where object. The English-leak
guard pins a non-zero key floor per bundle so a broken walker cannot
pass as no-leaks-found. The two audit-row integration suites poll for
the fire-and-forget rows instead of sleeping a fixed span, with one
deliberately widened grace kept for the absence assertion. And the
fixture fuses with 2027 horizons (suggested-actions next-due, the
printed-expiry trio) are now-anchored so they can never age into
inverted assertions.
2026-08-14 02:33:07 +02:00
Marc-André Bombeck b49013e6c6 Bound the sleep-timeline delete to the window the re-sync can restore
The Withings arm of the backfill deleted the source's entire
SLEEP_DURATION history while its re-sync fetches only the trailing 30
days regardless of fullSync — every older night was raw data loss with
orphaned rollup buckets on top. The delete is now bounded to the
re-sync window and the touched rollup span is recomputed afterwards;
WHOOP keeps the whole-history clear because its full sync genuinely
re-imports every night.
2026-08-14 02:29:01 +02:00
Marc-André Bombeck 2c0b4d60dd Re-anchor the service-worker fallback cache version
The fallback stayed at v1.37.15 through two releases while package.json
moved on, and the anchor test that exists to catch exactly this reads
red on the tip. Point it at the current release.
2026-08-14 02:26:05 +02:00
Marc-André Bombeck b7dfd236ee Move the API error classes into a leaf module
api-handler imports the record-session fence and the fence threw
RecordSessionChangedError / SharingAccessDeniedError imported back from
api-handler — the one real import cycle in the tree, working only by
evaluation-order luck. HttpError, the SharingAuthError family and
StepUpRequiredError now live in api-errors.ts (importing nothing but
the fence contract), api-handler re-exports the identical class objects
so no importer moves, and the fence imports the leaf. A guard pins both
the identity and the leaf structure.
2026-08-14 02:26:05 +02:00
Marc-André Bombeck d75034a9f4 Mint a default Idempotency-Key on every client POST and PATCH
Every apiHandler route accepts the header and the wrapped write routes
dedupe on it, yet almost no client call site sent one — a replayed
create could land a duplicate dose or vaccination and shift the series
index and booster-due date behind it. apiPost and apiPatch now mint a
per-call crypto.randomUUID() key unless the caller set its own; reads
stay bare.
2026-08-14 02:19:59 +02:00
Marc-André Bombeck 3973af69d4 Fence the reminder free text the preventive-care tool shipped raw
get_preventive_care passed a reminder's label and location — the user's
own free text — unfenced beside a fenced sibling, and get_lab_history
shipped the document-derived canonical analyte raw while sanitizing the
valueText next to it. Both are the marker-forging class the fence
doctrine exists for: fence label and location like the appointment
reason, scrub and sanitize the analyte like the default labs path.
2026-08-14 02:16:46 +02:00
Marc-André Bombeck 30aad51fac Sweep the measurement caches after Google Health and Withings syncs
Both hourly sync tails left the per-user analytics, correlations,
targets, derived and achievements caches serving pre-sync bodies until
their TTL lapsed — the correlations route documents the exact invariant
these tails were breaking. Mirror the Fitbit tail: mark the cells stale
whenever rows landed, on partial failures too.
2026-08-14 02:12:49 +02:00
Marc-André Bombeck 4e12aef5fd Type the evaluator stubs as variadic mocks
tsc rejects spreading unknown[] into a zero-parameter vi.fn; give the
context-evaluator stubs a variadic signature and cast the recorded call
tuples where the tests read them back.
2026-08-14 02:07:42 +02:00
Marc-André Bombeck db6465c17d Account for every dropped Apple Health sample the result used to hide
Three silent gaps in the import accounting, one commit:

The reproductive samples of an export land in recordsRead but were
discarded without a counter when cycle tracking is off — the result now
names them (samplesSkippedModuleDisabled). A cycle flush that failed
partway zeroed its stats AFTER day-by-day writes had landed; the flush
is now fault-isolated per day and reports daysFailed plus the first
failure reason instead of claiming nothing happened. And the import
card hardcoded skipped: 0 while the worker has computed, persisted and
served per-class skip counters since v1.15 — it now renders them,
splitting deliberate exclusions (unsupported and deferred types, a
disabled cycle module) from genuine refusals (unparseable, out of
range, failed writes), with a per-type breakdown. Only refusals tip
the outcome tone.
2026-08-14 02:06:19 +02:00
Marc-André Bombeck a45dde2039 Retire the guardian toggle over a pref nothing reads
The evidence disclosure stopped honouring showEvidenceByDefault in
v1.4.27 and the owner surface retired the control with it — but the
managed-record coach form still offered the switch, silently governing
nothing. Remove the control, the managed-schema pick, the response
field, and the label in all six locales; the strict schema now refuses
the key outright.
2026-08-14 01:56:25 +02:00
Marc-André Bombeck 15fb5dbbdb Point the import-guide links at the docs site that actually has them
Both import cards linked /docs/integrations/data-import — a path the
app never serves, so every click 404'd. Link the external docs site
through the shared INTEGRATION_DOCS_BASE constant, opening in a new tab
like every other integrations runbook link.
2026-08-14 01:54:10 +02:00
Marc-André Bombeck c23e677b6e Gate the bottom-nav Meds slot on the medications module
The mobile bar pinned /medications unconditionally while the page
renders nothing when the medications module is off — a visible tab over
a blank screen. Run both fixed flanks through the one slot filter the
Insights slot already uses (sharing coverage + module map, fail-closed
until mounted), so a disabled module is hidden everywhere instead of
relocated.
2026-08-14 01:52:37 +02:00
Marc-André Bombeck 11aa3dc060 Stop describing a Research-Mode gate that no longer exists
The titration-curve docblock contrasted itself against an estimated-PK
chart 'behind the Research-Mode gate' — that switch was retired as inert
and the columns dropped with migration 0321. Describe the real contract:
both charts render ungated, the estimated one carrying the persistent
modelled-not-measured disclaimer.
2026-08-14 01:48:31 +02:00
Marc-André Bombeck cd127010fe Evaluate the context cues the reminder grammar has promised since v1.22
A Coach reminder captured with NEXT_BP_LOGGED, NEXT_WEIGHT_LOGGED,
NEXT_SLEEP_LOGGED or NEXT_APP_OPEN confirmed to the user and then never
fired: the daily sweep skipped context triggers and no other evaluator
existed.

Build the evaluator. The three logging cues resolve on evidence — a
live matching-type measurement logged after capture — and ride the same
eventful reminder-satisfy hook every ingest path already fires, with the
daily sweep re-running them as the idempotent backstop. NEXT_APP_OPEN
resolves on the nudge-status read (the poll that mounts with the app
chrome), owner-only so a delegate's visit never satisfies it, with a
capture grace so the session that created the reminder does not count as
the next open. Every trigger delivers through the surfacing transaction
extracted from the sweep: conversation + assistant message + status
flip, all-or-nothing.
2026-08-14 01:45:47 +02:00
Marc-André Bombeck 0cec8b07bd Publish the vaccination booster mint and upload suggestion
The iOS tracker was told both endpoints were in the spec; neither was
registered. Publish POST /api/vaccinations/{id}/booster (201 mint /
200 re-anchor with the resolved MeasurementReminder DTO) and
GET /api/vaccinations/suggest (the server-decided one/many/none
verdict), and regenerate the YAML. A structural guard now derives the
expected path set from the route files so the next vaccination route
cannot ship unpublished.
2026-08-14 01:30:28 +02:00
Marc-André Bombeck cb86b24c0d Tell the truth about what a connector token can do
The mint card called every connector token read-only while the
health:write toggle sat directly beneath it — a token minted with the
toggle on writes measurements and mood entries. Rewrite the card
description and scope note in all six locales so the copy matches the
mint: read is the floor, write is a per-token choice made at mint time.
The copy guard now pins the pair so the read-only claim cannot return.
2026-08-14 01:28:03 +02:00
Marc-André Bombeck c7400abb48 Scrub document-derived biomarker names before they reach the prompt
The biomarker catalog mints name and unit from labResult.analyte —
model-transcribed free text out of an uploaded document. Both rode into
the status generator raw, the name in system-prompt position, so a
hostile PDF could smuggle instructions past the fence the coach
labs-snapshot already applies to the same fields. Sanitize both at the
snapshot and the system-prompt site.
2026-08-14 01:25:15 +02:00
Marc-André Bombeck 9fb5d7d7d0 Route measurement deletions into the rollup recompute path
A soft-deleted measurement never reached the rollup tier: the WHOOP
delete webhook tombstoned rows without recomputing the dirtied buckets,
and the freshness watermark probe filtered tombstones out, so the
deletion's updatedAt bump was invisible and the ghost aggregate read as
fresh forever.

Extract the post-write tail every measurement surface repeats into
afterMeasurementMutation (rollup recompute + status-insight
invalidation, both best-effort), call it from the WHOOP delete webhook
with the (type, measuredAt) pre-image collected before the updateMany,
and drop the deletedAt filter from the watermark probe so a tombstone
counts as staleness.
2026-08-14 01:22:41 +02:00
529 changed files with 73809 additions and 11392 deletions

No files matched your search

+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
+2 -2
View File
@@ -53,7 +53,7 @@ jobs:
# minutes and can produce nothing actionable, because nobody edits
# it and the fix for anything found there is `pnpm prisma generate`.
- name: Initialise CodeQL
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
languages: javascript-typescript
build-mode: none
@@ -68,6 +68,6 @@ jobs:
- public
- name: Analyse
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
category: "/language:javascript-typescript"
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
+1 -1
View File
@@ -57,7 +57,7 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
+1 -1
View File
@@ -54,7 +54,7 @@ jobs:
publish_results: true
- name: Upload results
uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
sarif_file: scorecard-results.sarif
category: scorecard
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
@@ -68,7 +68,7 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
+2 -2
View File
@@ -88,7 +88,7 @@ jobs:
skip-dirs: src/generated
- name: Upload results
uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
sarif_file: trivy-filesystem.sarif
category: trivy-filesystem
@@ -145,7 +145,7 @@ jobs:
exit-code: "0"
- name: Upload results
uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
sarif_file: trivy-published-image.sarif
category: trivy-published-image
+157
View File
@@ -1,5 +1,162 @@
# Changelog
## [1.37.26] — 2026-08-23
The app is offered in six languages, and in seven places it was reading what you wrote as though there were two.
### Fixed
- Side effects you recorded on a GLP-1 medication contributed nothing to the timeline unless you use English or German. Tapping a chip stores the translated word, so the same symptom is `nausea` on one account and `Nudności` on another, and the timeline compared that against a hand-typed list of the English and German labels. The same list sat behind the Coach and behind the doctor report, where a clinician read an empty side-effect table over a record that had entries.
- A lab reference range printed as `jusqu'à 5,0` was read as no range at all, so the value was never flagged as normal or out of range. Ranges in all six languages parse now, and one the parser genuinely cannot read is recorded as unreadable instead of quietly looking like a result nobody set a range for.
- Lab analytes named in French, Spanish, Italian or Polish got no LOINC code on the FHIR export, and a qualitative result of `négatif` or `ujemny` got no SNOMED code. A lab report in one of those languages also failed the check that decides whether a document is a lab report, so it was never offered for automatic staging.
- Analyte names written with German umlauts never matched either, which is the older half of the same defect and affected the language most people here use. The key normaliser deleted any character outside a to z, so `Hämoglobin` became `hmoglobin` and never met the alias written as `hamoglobin`. It only ever worked for people who left the umlaut off. `Nüchternglukose` and `Harnsäure` were the same.
- Over the assistant connection, `Fer`, `Żelazo`, `poids` and `Sommeil` resolved to nothing, and asking about `Puls` answered with pulse-wave velocity, because a fallback pass matched English display names by substring. Medication names written as `Metformina` or `Lewotyroksyna` missed the class that connects a dose to a symptom.
### Changed
- A route that has been removed now answers `410 Gone` with a machine-readable code, the version that removed it, and its replacement where there is one, rather than the bare `404` it used to. A `404` is indistinguishable from a broken deployment, which is why a client that met one kept retrying a route that was never coming back. The same list is published in the API document and on the capability endpoint, so a client can learn about a retirement without comparing versions.
- Removing a route now has two legal answers rather than one, and a test enforces the choice: drop it from the published contract, or register it as retired. Doing neither, which is how a client finds out by rendering an error, no longer passes.
## [1.37.25] — 2026-08-22
Two things this release is about. The Coach could state a figure your record does not support, in exactly the situation where it was most likely to: when you asked about something you have never recorded. And a backup you cannot fully restore is not a backup, so the register of what a restore left behind went from twenty-seven tables to two.
### Fixed
- The Coach checks its own numbers against a record of what it looked up, and rewrites any figure that record cannot account for. That check did not run when every lookup came back empty. Ask about a metric you have never tracked, and an invented number was passed straight through, with no note that anything went unchecked, so the answer read exactly like a verified one. The check now runs on a turn where the lookups found nothing, and because removing only the digits would leave the sentence still claiming a trend that does not exist, such an answer is replaced with a plain statement that nothing is recorded.
- The Coach could also narrate a weight history that no longer exists. Syncing a body-composition provider replaces your rows, but the monthly summaries folded from the old ones stayed behind, so the same block could show a year at 95 kg beside an all-time range that peaked at 80. A summary is an average of real readings and cannot fall outside their own extremes; when it does, the history band is dropped and the Coach is told it was withheld rather than shown a number nobody can account for.
- The Insights page promised "based on your last 90 days" whether or not there was a briefing behind it, and the pulse page promised a personalised Karvonen target band while computing population percentiles by age and sex, or a flat 60 to 100 when your age is unknown. Both now say what they do, in every language. The Coach's "all time" window was 365 days, the same as the option beside it called "year so far"; both labels tell the truth now.
- An account that had grouped its own mood factors could not be restored at all. Not partially: the restore hit a foreign key on a custom category the file never carried, rolled the whole transaction back, and answered with a constraint name and an empty account. The categories a person creates travel now, and they are written before the tags that point at them.
- Signing in from the iOS app failed behind a reverse proxy that does not pass the host through. The redirect was built from the address the server process bound to, so it came back as `https://0.0.0.0:3000/...`, which iOS refuses outright. Redirects to this same deployment no longer name a host at all. Seven places had the same bug; all seven are fixed, and a test fails if the pattern comes back.
- The Prisma CLI in the published image still resolved the vulnerable deepmerge-ts. The application tree had been pinned already, but that install is a separate one with its own resolution, so the image carried the advisory while every dependency check read clean. A test holds the two pins in step now.
- The demo instance resets its published credentials on every migration run, so the account named on the site keeps working.
- Turning the API off used to lock people out of their own tokens. The operator switch was checked on the read as well as the revoke, so a user could neither see nor clean up tokens that were still live. It stops tokens from working; it no longer stops you tidying them.
- A preference update read its request body with no size limit at all, where its two neighbours cap at one kilobyte. Four more routes threw away the detail of why they refused a value, so a name that was empty and a name that was too long came back as the same sentence.
- Someone managing a shared record could write a titration step onto a medication and then be refused that medication's history. The read is fenced at the same level as its siblings now.
- Disconnecting a Withings account left the connection state reading "connected" until something else refreshed it, because the teardown neither wrote its audit row nor parked the ledger the way every sibling does. Two more teardowns wrote no audit trail at all when there was nothing connected.
- Two sync routes had no rate limit where their siblings carry two each, and every sync route read its request body inside a swallowing catch, so a typo in the body reported a successful run. A body that is absent still means an incremental sync; a body that is present and wrong is refused now.
- The integrations page could not tell "nothing delivered yet" from "the freshness query failed": both arrived as an empty list. The failure says so.
- Two list endpoints widened their result on a typo instead of refusing it: an unknown metric type was dropped and the read came back unfiltered, and a nonsense page size reverted to the default.
- The medication ingest endpoint counted its rate limit per client address, so several bridges behind one router starved each other. It counts per token now.
### Added
The restore now returns your conversation history with the Coach: every thread, every turn in it, the record of which documents a thread was grounded in, and the facts, plans and reminders it carries between threads. Until now a restored account had a Coach that had never spoken to it and had to be told its own history again.
It also returns the medication history: pause periods, the titration steps behind today's dose, the packs on the shelf, and the ledger of refills and consumption behind their counts. The remaining count comes back exactly as recorded rather than recalculated from the ledger, because that number is what the low-stock reminders were computed from. Per-drug reminder phase timings travel too, so a restored drug turns orange when it did before instead of falling back to the defaults. Archived schedule eras come back with their succession chain intact, and what a medication was meant to move comes back with the analyte it pointed at.
The rest, in short: ECG strips with their waveform, their classification and the reading they were filed against, where a restore into a populated account used to leave the old strips behind. Which conditions a document was filed under, and the facts extracted from it, including which of those were reviewed and committed to the record. The mood taxonomy an account shaped for itself: the groupings it created and the seeded factors it chose to hide. Personal bests, badges, and the per-day environment readings with the location periods that explain them.
Screener history and the consent record come back in the disaster-recovery export only. Completed WHO-5, PHQ and GAD administrations include the PHQ-9 self-harm item, which is not written into a portable file that can be opened anywhere. The portable export says in its own manifest that they are absent rather than leaving you to notice.
### Changed
The published API document listed 262 paths. The application answers on 410.
The drift check compares the registry against the committed document and fails when they disagree. It has never compared the routes on disk against the registry, so a route that was never registered produced no drift and no failure: the document stayed internally consistent while going quietly incomplete. Forty-three of the missing paths are ones the iOS app calls every day.
Every route is now either published or named in a list with a reason for its absence. Seventy-three are deliberately outside, and the reasons say which kind: the admin console cannot be reached by an API client at all, OAuth callbacks are driven by the provider, webhooks authenticate by a shared secret, and three discovery documents have their format fixed by somebody else's specification.
The document also says how to authenticate, which it never did. Both schemes were defined and neither was referenced, so anyone generating a client from the contract got one that sends no credentials and treats the whole surface as open.
Two tests keep it that way. One walks the route tree and fails on a verb that is neither published nor excused. The other fails on an operation that names no scheme.
- The idempotency contract is in the published API document. Thirty-four write operations declare the `Idempotency-Key` header they honour and the `X-Idempotent-Replay` header they answer with, including the detail that a malformed key is ignored rather than refused.
- Per-event notification preferences are recorded as deliberately outside the backup rather than as an unpaid debt. Each one addresses a single notification channel by a foreign key, the channel itself is excluded for carrying secrets, and the restore deletes every channel it finds, so a carried preference would point at a row that cannot exist.
- Dependency updates: pg-boss, Playwright, Testcontainers, Prettier, and two workflow actions.
## [1.37.24] — 2026-08-16
### Fixed
- The hero card on the start page no longer says last night's sleep is still missing when no sleep reaches the record at all. It used to check only whether last night had arrived, so anyone without a tracker read the note every morning about a delay that was never going to end. The note now needs a night within the last week: a flat battery or a few nights without the device keeps it, a week of silence retires it, and someone who has never recorded sleep never sees it.
- The same rule settles the day itself. Records with no sleep source used to sit on "provisional" indefinitely, which the daily push line reads too, so the notification carried the same untrue aside.
## [1.37.23] — 2026-08-15
### Changed
- Postponing a checkup is a button on the card now, beside "erledigt" or "jetzt messen", instead of a row inside the overflow menu. That is the shape the medication card has always had, where "genommen" and "übersprungen" sit next to each other, and it puts the action one tap away rather than three. The compact list view carries the same action as an icon button. Edit, measurements and delete stay in the menu.
### Fixed
- The quick options in the postpone sheet ("+7 Tage" and its siblings) fill in the day they name even when a daylight-saving change falls inside the span. They used to add a fixed number of hours, which shifts the clock past midnight in the week a country moves its offset, so the option could write the day before or after the one on its label.
## [1.37.22] — 2026-08-15
### Fixed
- The doctor-report export refuses a selection that includes nothing instead of rendering an empty document. The selection panel already disabled its own button in that state, so this only ever reached the server through a hand-built request, and it spent an export slot on a file with no content in it.
- A lab value in the FHIR export no longer names UCUM as its unit system when it carries no UCUM code. That combination told a receiving system the unit was coded when it was not, which is worse than saying nothing: the unit still travels as a display string, and the coded form appears only when the analyte and unit actually resolve to one.
## [1.37.21] — 2026-08-14
### Fixed
- A dose that reaches the server through the batch route, the path a phone's offline queue drains through, now quiets an active snooze the same way a directly logged dose always has. Before, a take synced in later kept counting as snoozed and the reminder kept ringing.
- Marking a dose through the single-medication intake route now also closes the still-pending dose-due reminder in the web app and refreshes its badge. The other intake paths have done this since v1.18.4; this one, which is exactly the route a phone's offline replay uses, left the web reminder standing.
## [1.37.20] — 2026-08-14
### Added
- A checkup reminder can now be postponed or skipped. Postponing moves the next due date to a day you pick; skipping closes the current cycle and restarts the interval from today, without pretending the checkup happened. Every completion and every skip lands in a new history, kept per reminder, including whether it happened on time, so the checkups page and the API can show how a reminder has actually been handled over the years.
- The dashboard hero can show the reminder rail instead of the health score. The choice lives in the dashboard settings and does nothing until you change it.
- The restore dialog shows what a backup contains before you confirm: per-type counts read from the stored file, with an honest note when the preview cannot be computed.
- A mental-health screening shows its per-item answers on the detail view, so you can see which questions carried the score, not only the total.
- Document search understands German and English medical synonyms: searching for "Blutdruck" finds a report that says "blood pressure", and the other way round.
- A deleted custom-metric entry can be brought back with one tap for a short while instead of being gone the moment you tap.
### Changed
- Onboarding says plainly that Apple Health data arrives through the iPhone app, and the Apple Health card reports the backfill progress the server actually sees: accepted counts, the oldest reading, and how recently data last arrived.
- The measurements and mood pages keep their filters in the address bar, so a filtered view survives a reload and can be shared as a link, the way documents already worked.
- The sleep-stage tooltip labels one night's figures as main sleep, naps and total sleep instead of an averaged label that fit none of them.
- The daily digest is available offline like the rest of the app shell.
- Backups carry the checkup reminders and their new completion history end to end, and an appointment or vaccination that references a reminder keeps that link across a restore.
### Fixed
- Apple Health ECG exports in the single-column layout import correctly. Apple writes two CSV shapes; the importer understood only the paired one and skipped every recording in the other, reporting each row as malformed. Both layouts parse now, and the declared unit is converted before validation.
## [1.37.19] — 2026-08-14
### Changed
- Medication stock understands per-slot doses everywhere now. The remaining-doses figure weighs each scheduled slot by its own dose, the API carries the resolved dose per slot plus a runway in days, and the low-stock notification runs on the same arithmetic, so the app, the API and the push can no longer disagree about how long a supply lasts.
- A document whose summary job died no longer says "being generated" forever. After an hour the detail view says honestly that no summary is available and offers the manual action, and an hourly sweep repairs stuck documents in the background.
- The daily coach reminder sweep counts how often it has surfaced an ignored reminder and quietly dismisses it after three mornings instead of nagging indefinitely.
- Accepting an updated disclaimer is now tracked by version, so a future revision can actually ask again.
- The restore report lists practitioners, appointments, vaccinations and their links, and states what was written, not only what was cleared. The portable export names any field that could not be decrypted instead of exporting it as if it were never set.
- Changing your timezone re-folds the medication compliance statistics so the day boundaries match your new zone.
- Long health histories are honest again in two places: the all-time summary includes readings older than five years instead of silently truncating, and the AI context falls back to live data when a pre-computed band is missing.
- A cleaner copy voice across all six languages: shorter consent buttons, a source-neutral workouts empty state, consistent toast punctuation, "Navigations-Chips" instead of "Pills" in German, and a medication reminder chip that no longer clips mid-sentence.
- Settings pages that had been permanently redirected are no longer built, the retired placeholder is gone, and the about page is reachable from the privacy page and the settings.
### Fixed
- A measurement reminder claims its notification slot before sending, so a crash mid-send can no longer deliver the same reminder twice.
- A simultaneous double-submit of the same mood entry reports "duplicate" with the winning entry instead of a confusing error.
- The intake-event API description now documents every field the server actually returns, and the workout list description matches the real nullability, so generated clients decode without surprises.
## [1.37.18] — 2026-08-14
### Fixed
- Coach reminders tied to a moment ("remind me when I next log blood pressure", "next time I open the app") now actually fire. The app has offered these since v1.22 and confirmed them, but nothing ever evaluated them; a matching measurement or the next app open now surfaces the reminder, and the daily sweep backs it up.
- The Apple Health import tells you what it skipped and why. Every dropped sample class is now listed on the import card: unknown types, out-of-range values, deferred records, and cycle samples that were read while cycle tracking is switched off, which previously vanished without a trace. A cycle write that fails halfway also reports the failure instead of claiming zero samples.
- Deleting a measurement now reaches the pre-computed aggregates. Before, a deleted reading stayed inside day, week, month and year statistics forever; the freshness probe can now see tombstones, and the WHOOP deletion webhook recomputes the affected days.
- The sleep history repair job only deletes what it can restore. It used to clear a provider's entire sleep history and re-import just 30 days; the delete is now bounded to the same window.
- The connector token settings said tokens are read-only while offering a write toggle on the same card. The copy now tells the truth in all six languages.
- A lab report cannot smuggle instructions into the AI layer through a biomarker name anymore; document-derived names are cleaned before they reach any prompt, and the assistant tools fence checkup labels and lab analyte names the same way.
- Google Health and Withings syncs now refresh the server-side caches like every other integration, so analytics pick up synced data on the next read instead of waiting out the cache lifetime.
- The vaccination booster and suggestion endpoints are published in the API description, as the contract notes already claimed.
- On phones, the Medications tab no longer appears in the bottom bar when the medications module is switched off; tapping it used to open an empty page.
- The import guide links point at the documentation site instead of a page that does not exist.
- A caregiver settings toggle that has had no effect since its feature was retired is gone.
- Every create and update request from the app now carries an idempotency key, so a network retry cannot write a vaccination or an appointment twice.
- The service worker's offline fallback version is anchored to the release version again; it had silently stayed behind for two releases.
## [1.37.17] — 2026-08-13
### Changed
+18
View File
@@ -157,9 +157,27 @@ COPY --from=builder /app/src/generated ./src/generated
# the maintenance scripts already shipped in the standalone tree. The Prisma
# config loads dotenv from /app, so expose the isolated copy there before
# stripping npm/Corepack and their caches from the runtime surface.
#
# This install belongs to npm, not pnpm, so the `overrides` block in
# `pnpm-workspace.yaml` does not reach it: a package pinned there for the app
# tree still arrives here at whatever version Prisma asks for. That is not
# hypothetical. `prisma@7.8.0` resolves `@prisma/config@7.8.0`, which resolves
# `deepmerge-ts@7.1.5`, and the image scan reported CVE-2026-40345 (HIGH,
# stack exhaustion on recursive merges) against `/opt/prisma-cli` on a commit
# whose dependency audit over the app tree was green. Two installs, two
# override mechanisms, one of them set. `overrides` is npm's own mechanism and
# `npm pkg set` writes it into the package.json that `npm init -y` just
# created, before anything resolves.
#
# Measured by rebuilding this exact install both ways: without the field
# `deepmerge-ts@7.1.5` lands, with it `8.0.1` lands and `prisma --version`
# still reports 7.8.0. Keep the range in step with the entry in
# `pnpm-workspace.yaml` — they are one decision applied to two package
# managers, and a fix that lands in only one of them is the defect above.
RUN mkdir -p /opt/prisma-cli && \
cd /opt/prisma-cli && \
npm init -y && \
npm pkg set 'overrides.deepmerge-ts=^8.0.0' && \
npm install --omit=dev prisma@7.8 @prisma/engines@7.8 tsx@4.23.1 dotenv@17.4.2 && \
ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx && \
ln -sfn /opt/prisma-cli/node_modules/dotenv /app/node_modules/dotenv && \
+29
View File
@@ -99,6 +99,35 @@ Every read tool is annotated read-only / non-destructive / idempotent /
closed-world — the read-only guarantee is structural (only read tools are
registered for a read session), and the annotation merely advertises it.
### Naming a metric or a nutrient
The tools that take a free-text name — `compare_metric`,
`get_metric_baseline`, `detect_changepoints`, `get_nutrients` — accept the
metric's or nutrient's own display name in **any language HealthLog ships**
(de / en / es / fr / it / pl), alongside the internal code and the English
aliases. `poids`, `Blutzucker`, `Fer` and `Żelazo` reach the same series as
`weight`, `blood_glucose` and `iron`. Case, accents and punctuation are
folded, so `magnésium` and `MAGNESIUM` are one word.
The accepted names are read out of the app's own message bundles rather
than listed in the resolver, so a language works as soon as its bundle
ships. Two consequences worth relying on:
- **The vocabulary is still closed.** A word in no bundle is a miss, not a
guess: `{ present: false, reason: "unknown_metric" }` or
`"unknown_nutrient"`. That reason is distinct from `"no_data"`, which
means the name WAS understood and the record honestly holds nothing.
Widening the accepted spellings moves words from the first answer to the
second; it never turns absence into data.
- **An ambiguous word is refused, not resolved.** Where a language uses one
phrase for two different measurements — French and Italian call both body
fat and fat mass `masse grasse` / `massa grassa`, and French, Italian and
Polish use one phrase for fat-free mass and lean body mass — the word
resolves to neither. Name those in English, or by their metric key.
Responses are unchanged: labels and units come back in protocol-level
English whatever language the request used.
### Clinical signals
The v1.25 clinical-signals set — **grip strength**, **pain (0–10 NRS)**,
+31339 -5067
View File
File diff suppressed because it is too large. Load diff
+28
View File
@@ -131,6 +131,34 @@ without touching the file:
- `HEALTHLOG_IMAGE_REF=:v1.16.5` — pin to a release tag (still mutable).
- `HEALTHLOG_IMAGE_REF=@sha256:<digest>` — pin to the immutable digest.
#### Bumping a digest pin by hand
A digest-pinned instance never follows a release on its own — an image
promote does not touch the pin, so moving it is part of shipping. The
mechanic, end to end:
1. Resolve the new release's digest from the registry (the manifest-list
digest, which covers both architectures):
```sh
docker buildx imagetools inspect ghcr.io/mbombeck/healthlog:v1.37.20 \
| grep -m1 Digest
```
2. Set `HEALTHLOG_IMAGE_REF=@sha256:<that digest>` where the compose
environment lives — the `.env` next to the compose file, or the
deployment platform's env vars for the app service.
3. Deploy as usual.
4. Verify `/api/version` on the target — and verify it **three times a
few seconds apart**, not once: during the container swap a single
probe can be answered by the outgoing container (or a cache) and read
as success while the old release keeps running. Three stable answers
reporting the shipped `version` + `buildSha` are the signal; a deploy
that finishes suspiciously fast (under ~30 s) is a warning that
nothing was actually pulled.
> **A pin is sticky, and a stale pin is silent.** Once set, every later deploy
> ships that reference until someone changes it. The deploy still reports
> success, the tag still builds, the image still publishes, and the running
+42 -2
View File
@@ -363,9 +363,49 @@ test.describe("account sharing", () => {
// Let the offline persister flush the delegate's own dashboard to
// IndexedDB (1 s debounce). That snapshot is the stale-paint source this
// whole assertion is aimed at — without it the check would pass against a
// disk that was empty anyway.
// disk that was empty anyway. Poll the persister's own store for the
// written snapshot instead of sleeping past the debounce (C3): the
// condition IS the flush, so a slow CI box waits exactly as long as it
// needs to and a fast one moves on immediately.
await page.goto("/");
await page.waitForTimeout(2000);
// Gate on rendered content first (the dashboard must actually mount and
// populate the query cache before there is anything to persist).
await expect(page.locator('[data-slot="coach-fab"]')).toBeVisible({
timeout: 10_000,
});
await expect
.poll(
() =>
page.evaluate(
() =>
new Promise<boolean>((resolve) => {
const req = indexedDB.open("healthlog-query-cache", 1);
req.onerror = () => resolve(false);
req.onsuccess = () => {
const db = req.result;
try {
const get = db
.transaction("kv", "readonly")
.objectStore("kv")
.get("react-query");
get.onsuccess = () => {
db.close();
resolve(get.result !== undefined);
};
get.onerror = () => {
db.close();
resolve(false);
};
} catch {
db.close();
resolve(false);
}
};
}),
),
{ timeout: 10_000 },
)
.toBe(true);
await openSwitcher(page);
await page.locator('[data-slot="account-switcher-entry"]').click();
+10 -1
View File
@@ -392,7 +392,9 @@ test.describe("document vault", () => {
await sheet.getByRole("button", { name: "Delete" }).click();
// Undo toast → restore → the card returns.
await expect(page.getByText("Document deleted.")).toBeVisible();
await expect(
page.getByText("Document deleted", { exact: true }),
).toBeVisible();
await expect(openButton(page, `${title}.pdf`)).not.toBeVisible();
await page.getByRole("button", { name: "Undo" }).click();
await expect(openButton(page, `${title}.pdf`)).toBeVisible();
@@ -559,6 +561,11 @@ test.describe("document vault", () => {
// to the dialog after Chromium's PDF viewer grabs it, so Escape works
// regardless of when the user presses it.
await expect(dialog.locator("iframe")).toBeVisible();
// True grace (C3): Chromium's PDF viewer steals focus from inside the
// iframe and the sheet hands it back — both moves are invisible to the
// outer document (activeElement is "the iframe" before AND after the
// grab), so there is no condition to poll on. The window only needs to
// outlive the focus dance.
await page.waitForTimeout(250);
await page.keyboard.press("Escape");
await expect(dialog).not.toBeVisible();
@@ -573,6 +580,8 @@ test.describe("document vault", () => {
await expect(button).toBeVisible();
await button.dispatchEvent("touchstart");
// Not a wait-for-condition sleep (C3): this IS the gesture — the finger
// stays down past the long-press threshold before lifting.
await page.waitForTimeout(700);
await button.dispatchEvent("touchend");
+3
View File
@@ -243,6 +243,9 @@ test.describe("Apple Health guidance", () => {
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
metricFreshness: [],
// #778 — nothing accepted yet, so the card must show no
// progress section for this pending-first-sync account.
syncProgress: { recordsAccepted: 0, oldestMeasuredAt: null },
},
error: null,
}),
+29
View File
@@ -153,6 +153,35 @@ test("a fresh arrival flips the day in place", async ({ page }) => {
).toBe(true);
}
// A night from earlier in the week, so this is a record that expects
// sleep at all. The digest waits for last night only when sleep actually
// reaches the account (a night inside the last seven days); without one
// the purge above leaves a record with no sleep source, which reads
// `final` by design and puts the provisional assertion below out of
// reach. Written through the same ingest route as the fresh night, and
// swept by the same `e2e-just-in-` purge on the next run.
const priorNight = new Date(Date.now() - 3 * 24 * 60 * 60_000);
const priorWoke = new Date(priorNight.getTime() + 7 * 60 * 60_000);
const priorBatch = await page.request.post("/api/measurements/batch", {
data: {
entries: [
{
hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis",
value: 420,
unit: "min",
startDate: priorNight.toISOString(),
endDate: priorWoke.toISOString(),
externalId: `e2e-just-in-prior-${priorNight.getTime()}`,
sleepStage: 3,
},
],
},
});
expect(
priorBatch.ok(),
`prior-night sleep batch must be accepted (got ${priorBatch.status()})`,
).toBe(true);
// The account needs a reason to paint a hero that is not the arrival
// itself. A connection needing a reconnect is that reason: the digest
// reads `integration_statuses` fresh on every request rather than
-1
View File
@@ -116,7 +116,6 @@ function summary(partial: Partial<DataSummary>): DataSummary {
avg30: null,
slope7: null,
slope30: null,
slope90: null,
anomalyCount: 0,
avg30LastMonth: null,
avg30LastYear: null,
+24 -2
View File
@@ -326,11 +326,19 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
const accountId = await entry.getAttribute("data-account-id");
expect(accountId).not.toBeNull();
let switchCommitted = false;
await initiator.route("**/api/account/switch", async (route) => {
await route.fetch();
switchCommitted = true;
await route.abort();
});
await entry.click({ noWaitAfter: true });
// Poll for the server round-trip having landed (the observable half of
// the setup), THEN hold a short fixed window — this one is a true
// absence-assertion grace (C3): "the peer never painted guessed state"
// has no completion signal to poll on, only time in which the wrong
// paint could have happened.
await expect.poll(() => switchCommitted, { timeout: 20_000 }).toBe(true);
await initiator.waitForTimeout(500);
// The peer must never paint owner-ready state it guessed at …
@@ -386,13 +394,19 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
// Hold the response, close the tab while it is in flight. The server has
// committed; nothing will ever broadcast the commit.
const held = deferred();
// The commit is observable: flag once the server round-trip inside the
// route handler finishes, and poll for it instead of a fixed sleep
// (C3) — the tab must close AFTER the write landed, and this waits
// exactly that long.
let switchCommitted = false;
await initiator.route("**/api/account/switch", async (route) => {
await route.fetch();
switchCommitted = true;
await held.promise;
await route.abort();
});
await entry.click({ noWaitAfter: true });
await initiator.waitForTimeout(300);
await expect.poll(() => switchCommitted, { timeout: 20_000 }).toBe(true);
held.release();
await initiator.close();
@@ -591,10 +605,18 @@ test.describe.serial("FENCE record-session fence in the browser", () => {
expect(held.scope).toBe(accountId);
// Now move the context out from under it, and only then let it land.
// "Out from under it" is observable: the exit posts
// `/api/account/switch`, so poll for that request having been issued
// instead of sleeping and hoping the click got that far (C3).
let exitSwitchIssued = false;
await page.route("**/api/account/switch", async (route) => {
exitSwitchIssued = true;
await route.continue();
});
const leaving = page
.locator('[data-slot="shared-record-banner-exit"]')
.click({ noWaitAfter: true });
await page.waitForTimeout(500);
await expect.poll(() => exitSwitchIssued, { timeout: 20_000 }).toBe(true);
held.release?.();
await leaving.catch(() => {});
+24 -7
View File
@@ -291,15 +291,32 @@ test.describe.serial("scoped sharing browser journeys", () => {
let corruptAuthPayload = true;
await page.route("**/api/auth/me", async (route) => {
const response = await route.fetch();
if (!corruptAuthPayload) {
await route.fulfill({ response });
// The switch this test drives ends in a full reload, and the reload
// cancels whatever `/api/auth/me` call is in flight. Playwright then
// considers the route handled, and the `fulfill` below lands on it with
// "Route is already handled!" — a failure of the teardown, not of the
// thing under test, and one that turns the whole shard red under
// `failOnFlakyTests`.
//
// Same guard as `v137-record-session-fence.spec.ts` and the a11y sibling
// of this file. The fetch is guarded too: a request cancelled mid-flight
// rejects there rather than at the fulfill.
let response;
try {
response = await route.fetch();
} catch {
return;
}
await route.fulfill({
response,
json: withDivergentActiveAccountAccess(await response.json()),
});
if (!corruptAuthPayload) {
await route.fulfill({ response }).catch(() => {});
return;
}
await route
.fulfill({
response,
json: withDivergentActiveAccountAccess(await response.json()),
})
.catch(() => {});
});
const ownerOnlyReads: string[] = [];
+259 -218
View File
File diff suppressed because it is too large. Load diff
+140 -99
View File
@@ -516,6 +516,9 @@
"heroItemsTitle": "Today highlights",
"heroItemsDescription": "Choose which item types can appear in Today highlights.",
"heroItemsNotificationNote": "Notification settings are managed separately.",
"heroContentTitle": "Top card shows",
"heroContentScore": "Health score and daily summary",
"heroContentReminders": "Today highlights",
"heroItems": {
"coach_checkin": "Coach check-ins",
"dose_window": "Medication doses",
@@ -560,7 +563,7 @@
"viewAll": "View all",
"empty": {
"title": "No workouts yet",
"cta": "Open Apple Health on your iPhone to sync workouts here."
"cta": "Connect a data source in Settings to see workouts here."
}
},
"metric": {
@@ -729,9 +732,12 @@
"sourceWhoop": "WHOOP",
"sourceFitbit": "Fitbit",
"bulkDeleteSuccess": "{count} measurements deleted",
"bulkDeleteSuccessOne": "Measurement deleted",
"bulkDeleteError": "Error deleting the selected measurements",
"bulkDeleteConfirmTitle": "Delete {count} measurements?",
"bulkDeleteConfirmTitleOne": "Delete this measurement?",
"bulkDeleteConfirmBody": "The {count} selected measurements will be deleted. You can undo this for a short moment afterwards.",
"bulkDeleteConfirmBodyOne": "The selected measurement will be deleted. You can undo this for a short moment afterwards.",
"typePhq9Score": "PHQ-9 score",
"typeGad7Score": "GAD-7 score",
"typeGripStrength": "Grip strength",
@@ -879,9 +885,12 @@
"sourceTelegram": "Telegram",
"sourceDaylio": "Daylio",
"bulkDeleteSuccess": "{count} entries deleted",
"bulkDeleteSuccessOne": "Entry deleted",
"bulkDeleteError": "Error deleting the selected entries",
"bulkDeleteConfirmTitle": "Delete {count} entries?",
"bulkDeleteConfirmTitleOne": "Delete this entry?",
"bulkDeleteConfirmBody": "The {count} selected mood entries will be deleted. You can undo this for a short moment afterwards.",
"bulkDeleteConfirmBodyOne": "The selected mood entry will be deleted. You can undo this for a short moment afterwards.",
"customize": "Customize mood tags",
"addTagInline": "Add tag",
"manage": {
@@ -1779,7 +1788,8 @@
"enabledToast": "Reminders on",
"disabledToast": "Reminders off",
"toggleFailed": "Could not change the reminder setting.",
"clientManagedChip": "Your iPhone manages reminders for this medication."
"clientManagedChip": "Managed by iPhone",
"clientManagedHint": "Your iPhone manages reminders for this medication."
},
"settings": {
"title": "Settings",
@@ -1932,7 +1942,7 @@
"addQuantityHelper": "1–1000 units per pack or container.",
"addExpiryLabel": "Expiry date (optional)",
"addSubmit": "Register",
"addSuccess": "Supply registered.",
"addSuccess": "Supply registered",
"addFailed": "The supply could not be registered.",
"adjustButton": "Adjust",
"adjustTitle": "Correct container",
@@ -1940,11 +1950,11 @@
"adjustQuantityLabel": "Remaining units",
"adjustHelper": "Capacity: {total} units.",
"adjustSubmit": "Save",
"adjustSuccess": "Stock updated.",
"adjustSuccess": "Stock updated",
"adjustFailed": "The stock could not be updated.",
"deleteTitle": "Delete container?",
"deleteDescription": "Permanently removes this pack or container from the stock. Logged intakes stay unchanged.",
"deleteSuccess": "Stock entry deleted.",
"deleteSuccess": "Stock entry deleted",
"deleteFailed": "The stock entry could not be deleted.",
"containerTypeLabel": "Container type",
"containerType": {
@@ -2139,7 +2149,7 @@
"scaleToggleLabel": "Chart scale"
},
"import": {
"resultSuccess": "{imported} intakes imported.",
"resultSuccess": "{imported} intakes imported",
"resultPartial": "{imported} imported, {skipped} skipped.",
"resultNothing": "Nothing was imported: all {skipped} entries were skipped.",
"resultAlreadyRecorded": "{recorded} intakes are already recorded. Nothing new was added.",
@@ -2302,7 +2312,6 @@
}
},
"comparison": {
"toggleLabel": "Compare to",
"baseline": {
"none": "None",
"lastMonth": "Last month",
@@ -2402,7 +2411,7 @@
"dragHandle": "Drag to reorder",
"show": "Show",
"hide": "Hide",
"manageInSettings": "Manage pills & detail pages in Settings",
"manageInSettings": "Manage chips & detail pages in Settings",
"groupSleep": "Sleep",
"groupMood": "Mood",
"groupEvents": "Events",
@@ -2423,23 +2432,6 @@
"badge": "PR",
"tooltip": "Personal record: {value} {unit} on {date}"
},
"recommendation": {
"source": "Source",
"viewSource": "Open guideline in a new tab",
"rationaleWindow": "Window",
"rationaleComparedTo": "Compared to",
"rationaleDeviation": "Deviation",
"rationaleExpand": "Show why",
"rationaleCollapse": "Hide why",
"feedbackHelpful": "Helpful",
"feedbackNotHelpful": "Not helpful",
"feedbackThanks": "Thanks for your feedback",
"feedbackAlreadyRated": "Already rated",
"feedbackError": "Could not save your feedback. Please try again.",
"confidenceAria": "Confidence: {value} of 100",
"confidenceLow": "Low confidence — based on limited data",
"confidenceDraft": "Draft"
},
"loadError": "Failed to load insights",
"heroPersonalBaseline": "Based on your last 90 days",
"heroRegenerating": "Regenerating",
@@ -2545,11 +2537,6 @@
"areas": "{present} of {required} areas of health"
}
},
"suggestedPrompts": {
"label": "Try asking",
"tellMyDoctor": "What should I tell my doctor?",
"medicationWorking": "Is my medication working?"
},
"dailyBriefing": {
"title": "Daily Briefing",
"keyFindingsTitle": "Key findings",
@@ -2761,8 +2748,8 @@
"last7days": "last 7 days",
"last30days": "last 30 days",
"last90days": "last 90 days",
"lastYear": "year so far",
"allTime": "all time"
"lastYear": "last 12 months",
"allTime": "all time (12 months of detail)"
},
"windowLabel": "Window",
"settingsAriaLabel": "Coach settings",
@@ -2776,6 +2763,7 @@
"settingsVerbosityBrief": "Brief",
"settingsVerbosityDefault": "Default",
"settingsVerbosityDetailed": "Detailed",
"settingsVerbosityConciseHint": "The concise tone already keeps replies short — verbosity is pinned to Brief while it is active.",
"settingsSourcesPointer": "Which data clusters the coach may draw on and the default analysis window are managed directly in the coach chat (\"What I draw on\" rail).",
"settingsSourcesPointerLink": "Open the coach",
"settingsExcludeLabel": "Exclude metrics",
@@ -2787,7 +2775,7 @@
"settingsDefaultWindowLabel": "Default analysis window",
"settingsDefaultWindowHint": "The Coach reads this much history per chat unless you override it from the drawer header.",
"settingsSave": "Save",
"settingsSaved": "Saved.",
"settingsSaved": "Saved",
"settingsCancel": "Cancel",
"feedbackHelpful": "Helpful",
"feedbackUnhelpful": "Not quite",
@@ -3059,7 +3047,7 @@
"loadError": "Could not load this metric right now.",
"blutdruckDescription": "Your systolic / diastolic trend with age-personalised target zones and a written assessment.",
"gewichtDescription": "Weight trend mapped against your height-derived healthy band, plus a written assessment.",
"pulsDescription": "Pulse trend against the personalised Karvonen target band, plus a written assessment.",
"pulsDescription": "Resting pulse against an age- and sex-based reference band from CDC/NCHS population percentiles, plus a written assessment.",
"stimmungDescription": "Daily mood scores over time with a written assessment.",
"medikamenteDescription": "Per-medication adherence calendars and a written assessment.",
"bmiDescription": "BMI derived from your weight and height, with a written assessment.",
@@ -3160,7 +3148,8 @@
"sleep": {
"title": "Sleep",
"description": "Stage breakdown and nightly duration, drawn from Apple Health.",
"headlineTitle": "Average per night",
"mainSleep": "Main sleep",
"totalSleep": "Total sleep",
"compositionTitle": "Stage composition",
"compositionSubtitle": "Last {nights} nights",
"compositionAriaLabel": "Sleep stage composition over {nights} nights",
@@ -4159,13 +4148,13 @@
"overviewDescription": "Show or hide sections and set their order on the Insights overview."
},
"pillOrder": {
"title": "Sort the navigation pills",
"description": "Drag the pills into the order you want and show or hide them via the eye icon.",
"title": "Sort the navigation chips",
"description": "Drag the chips into the order you want and show or hide them via the eye icon.",
"dragHandle": "Reorder",
"saveSuccess": "Pill order saved.",
"saveError": "Saving the pill order failed.",
"saveSuccess": "Chip order saved",
"saveError": "Saving the chip order failed.",
"save": "Save",
"empty": "No navigation pills available yet.",
"empty": "No navigation chips available yet.",
"detail": "Hidden detail pages are also removed from the top navigation."
},
"customize": "Customize Insights",
@@ -4509,7 +4498,7 @@
"title": "Withings",
"body": "Scales, blood-pressure cuffs and trackers from Withings sync automatically once connected.",
"cta": "Connect Withings",
"setupCta": "Set up in Settings",
"setupCta": "Set up",
"setupHint": "Needs your own free Withings developer app first — about 5 minutes, one time."
},
"more": {
@@ -4520,7 +4509,8 @@
"oura": "Oura",
"polar": "Polar",
"nightscout": "Nightscout",
"fitbit": "Fitbit"
"fitbit": "Fitbit",
"appleHealthHint": "Apple Health has no web connection — finish onboarding here, then open the HealthLog app on your iPhone to complete Apple Health setup."
},
"import": "Import existing history"
},
@@ -4719,7 +4709,6 @@
"last30days": "Last 30 days",
"last90days": "Last 90 days",
"allTime": "All time",
"showEvidence": "Show evidence by default",
"excludedMetrics": "Excluded metrics",
"dataClusters": "Data clusters",
"customClusters": "Use a custom cluster selection",
@@ -4834,8 +4823,6 @@
}
},
"dashboard": {
"title": "Dashboard",
"description": "Tile layout and order.",
"subtitle": "Arrange the tiles on your dashboard."
},
"thresholds": {
@@ -4856,9 +4843,9 @@
"resetDefaultsConfirm": "Reset order",
"moveUp": "Move up",
"moveDown": "Move down",
"saveSuccess": "Source priority saved.",
"saveSuccess": "Source priority saved",
"saveError": "Saving the source priority failed.",
"resetSuccess": "Source priority reset to defaults.",
"resetSuccess": "Source priority reset to defaults",
"perMetricToggle": "Per-metric overrides ({count})",
"perMetricHelp": "Metrics you have customised away from the default ladder. Use this list to roll any single metric back to its constant default without touching the rest.",
"perMetricEmpty": "No per-metric overrides set — every metric uses the global ladder above.",
@@ -5002,7 +4989,14 @@
"unpacking": "Unpacking the archive…",
"parsing": "Reading the export…",
"upserting": "Importing records…"
}
},
"skipUnsupported": "{count} samples skipped — data types HealthLog doesn't import.",
"skipRefused": "{count} samples couldn't be written (unreadable, out of range, or the save failed).",
"skipBreakdown": "Show breakdown ({count} types)",
"skipBreakdownMore": "+ {count} more types",
"cycleSummary": "{samples} cycle samples merged into {days} day logs.",
"cycleModuleOff": "{count} cycle samples skipped — cycle tracking is off for this account.",
"cycleDaysFailed": "{count} cycle days couldn't be written: {reason}"
},
"json": {
"title": "JSON file",
@@ -5020,7 +5014,7 @@
"failed": "The import failed. Please try again.",
"readFailed": "Couldn't read that file.",
"resultSummary": "Imported {measurements} measurements and {moods} mood entries, {skipped} skipped.",
"resultSuccess": "Imported {measurements} measurements and {moods} mood entries.",
"resultSuccess": "Imported {measurements} measurements and {moods} mood entries",
"resultNothing": "Nothing was imported. All {skipped} entries were skipped.",
"resultEmpty": "That file carried no measurements and no mood entries."
},
@@ -5044,10 +5038,10 @@
"previewSummary": "Preview: {inserted} would import, {skipped} skipped.",
"resultSummary": "Imported {inserted} new and updated {updated}, {skipped} skipped.",
"rowError": "Line {line}: {reason}",
"resultSuccess": "Imported {inserted} new and updated {updated}.",
"resultSuccess": "Imported {inserted} new and updated {updated}",
"resultNothing": "Nothing was imported. All {skipped} rows were skipped.",
"resultEmpty": "That file has a header but no data rows.",
"previewSuccess": "Preview: {inserted} would import.",
"previewSuccess": "Preview: {inserted} would import",
"previewNothing": "Preview: nothing would import. All {skipped} rows would be skipped.",
"skipGroupOne": "{count} row skipped: {reason}",
"skipGroupFew": "{count} rows skipped: {reason}",
@@ -5138,38 +5132,26 @@
"subtitle": "Share read-only access to your data."
},
"insights": {
"title": "Insights",
"description": "Overview and navigation-pill order.",
"subtitle": "Arrange your overview sections and pill order."
"subtitle": "Arrange your overview sections and chip order."
},
"medications": {
"title": "Medications",
"description": "List view, order, and injection sites for your medications.",
"subtitle": "Manage your medication list view and order."
},
"mood": {
"title": "Mood tags",
"description": "Groups, tags, and archived tags for the mood picker.",
"subtitle": "Organize your mood tags, groups, and picker order."
},
"labs": {
"title": "Labs",
"description": "View, sort order, and biomarkers for your labs.",
"subtitle": "Manage the view, sort order, and biomarkers."
},
"illness": {
"title": "Illness journal",
"description": "View and order of your illness episodes.",
"subtitle": "Manage the view and order of episodes."
},
"vorsorge": {
"title": "Checkups",
"description": "View, order, and individual checkup reminders.",
"subtitle": "Manage the view, order, and individual reminders."
},
"modules": {
"title": "Modules",
"saved": "Saved.",
"saved": "Saved",
"error": "Couldn't save. Please try again.",
"toggleable": {
"title": "What you track",
@@ -5217,7 +5199,7 @@
"use": "Use",
"select": "Select",
"saveError": "Could not save your change.",
"homeSaved": "Home location saved.",
"homeSaved": "Home location saved",
"travelAdded": "Location period added.",
"travelRemoved": "Location period removed.",
"backfillQueued": "Backfill queued.",
@@ -5274,7 +5256,7 @@
"current": "Your saved selection"
},
"save": "Save",
"saved": "Saved.",
"saved": "Saved",
"error": "Couldn't save. Please try again.",
"loadFailed": "Couldn't load your selection.",
"eligibilityFailed": "Couldn't check which pillars currently have data. The switches below still work.",
@@ -5312,7 +5294,7 @@
"auto": "Automatic (language)",
"h24": "24-hour",
"h12": "12-hour (AM/PM)",
"saved": "Hour format saved.",
"saved": "Hour format saved",
"saveError": "Saving the hour format failed."
},
"dateFormat": {
@@ -5322,7 +5304,7 @@
"dmy": "DD.MM.YYYY",
"mdy": "MM/DD/YYYY",
"ymd": "YYYY-MM-DD",
"saved": "Date format saved.",
"saved": "Date format saved",
"saveError": "Saving the date format failed."
},
"username": "Username",
@@ -5616,7 +5598,7 @@
"clearBody": "This removes the free-text note only. Your allergies and intolerances stay exactly as they are.",
"clearConfirm": "Clear note",
"save": "Save",
"savedToast": "About-me note saved.",
"savedToast": "About-me note saved",
"clearedToast": "About-me note cleared.",
"saveError": "Could not save. Please try again.",
"loadError": "The note could not be loaded.",
@@ -5638,14 +5620,14 @@
"title": "Read documents automatically with AI",
"subLabel": "Documents are read and indexed by your configured AI provider without a per-document tap.",
"honesty": "When on, each document you upload is sent to your configured AI provider to be read, described, and indexed for search — with no per-document confirmation. Turning it on also reaches backwards: documents you have already uploaded that carry no summary yet are sent too, up to 200 each time you switch it on. This happens on the server and the document's contents leave this machine to that provider. If your configured provider is a subscription connection (a signed-in AI account rather than an API key), that provider's own data settings apply — subscription providers may use the content to improve their models, and no data-processing agreement covers it. Documents read by a self-hosted local model never leave your server. Off by default; turn it on only if you accept this trade for your documents.",
"confirm": "I understand — turn on automatic AI reading",
"confirm": "I understand — turn on",
"cancel": "Cancel"
},
"centralCodex": {
"title": "Use the server's shared AI access",
"subLabel": "Route AI requests through the connection the operator set up for this server, alongside your own providers as a fallback.",
"honesty": "The shared access is a single signed-in AI account the operator connected for everyone on this server — not your own key. Requests you make on it are trained on by the provider by default and no data-processing agreement covers them, the account's hourly and weekly limits are shared with every other user who opted in, and your own configured providers are still tried first. Turn it on only if you accept this trade.",
"confirm": "I understand — use the shared connection",
"confirm": "I understand — use connection",
"cancel": "Cancel"
},
"consent": {
@@ -5667,7 +5649,7 @@
"description": "Choose how distances and speeds are shown across charts and tiles.",
"metric": "Metric",
"imperial": "Imperial",
"saved": "Unit preference saved.",
"saved": "Unit preference saved",
"saveError": "Could not save your unit preference."
}
},
@@ -5855,7 +5837,7 @@
"errorReconnect": "Error — reconnect",
"warningServerError": "Sync failing",
"failureCount": "{count}/{threshold}",
"parkedReconnect": "Paused — reconnect manually",
"parkedReconnect": "Paused — reconnect",
"notConnected": "Not connected",
"justNow": "just now",
"minutesAgo": "{count} min ago",
@@ -5939,6 +5921,7 @@
"repository": "Source code",
"changelog": "Changelog",
"docs": "Documentation",
"credits": "Credits & data attributions",
"linksHeading": "Sources & docs",
"newerAvailable": "Newer version: {tag}"
},
@@ -5966,7 +5949,7 @@
},
"notificationStatus": {
"title": "Delivery status",
"description": "Per-channel state; \"Auto-disabled\" stops retries after permanent errors.",
"description": "State of each channel — “Auto-disabled” means retries stopped after permanent errors.",
"emptyDescription": "Set up a channel under Settings › Integrations › Delivery channels.",
"stateActive": "Active",
"stateAutoDisabled": "Auto-disabled",
@@ -6032,7 +6015,7 @@
"expiryInvalid": "Expiry must be between 1 and {max} days.",
"create": "Create link",
"tokenCreated": "Share link created",
"copied": "Copied to clipboard.",
"copied": "Copied to clipboard",
"activeTitle": "Active links",
"noActive": "No active share links.",
"created": "Created",
@@ -6184,7 +6167,7 @@
"revokeSession": "Sign out",
"revokeError": "Could not complete the request. Please try again.",
"signOutEverywhere": "Sign out everywhere else",
"signOutEverywhereDone": "Signed out {count} other session(s).",
"signOutEverywhereDone": "Signed out {count} other session(s)",
"activityTitle": "Security activity",
"activityDescription": "Recent sign-ins, security changes, and data exports on your account.",
"activityEmpty": "No security activity yet.",
@@ -6265,7 +6248,7 @@
"revokeError": "Could not revoke the device. Please try again.",
"revoke": "Revoke",
"revokeAll": "Forget all devices",
"revokeAllDone": "Forgot {count} trusted device(s).",
"revokeAllDone": "Forgot {count} trusted device(s)",
"unnamed": "Unknown device",
"current": "This device",
"expires": "Trusted until {date}",
@@ -6329,8 +6312,8 @@
"endpointLabel": "Connector URL",
"connectDescription": "Paste this URL into your assistant's custom-connector setup, then authorize with the token below or sign in when prompted.",
"tokensTitle": "Connector tokens",
"tokensDescription": "Mint a read-only token to connect an assistant.",
"scopeNote": "Tokens grant read-only access to your own records by default. Write access is optional and enabled per token with the toggle below. The tokens can never reach admin functions.",
"tokensDescription": "Mint a token to connect an assistant — read-only unless you allow logging below.",
"scopeNote": "New tokens can only read your own records. Write access (health:write) is granted per token with the toggle below. A token can never reach admin functions.",
"writeScopeToggleLabel": "Allow logging (health:write)",
"writeScopeNote": "Adds the health:write scope so the assistant can log new measurements and mood entries to your account. Every write is confirmed first. The token still can't delete or edit existing entries, change medications, or reach REST or admin functions. Leave off for read-only access.",
"connectionsTitle": "Connected assistants",
@@ -6386,6 +6369,13 @@
"importNote": "Need older history or cannot use the iOS app? The Apple Health XML/ZIP upload is a one-shot fallback and does not enable live sync.",
"importAction": "Open one-shot import",
"staleNote": "No Apple Health data has arrived for over a week. Open the HealthLog app on your iPhone and check the Apple Health permissions.",
"progress": {
"title": "Sync progress",
"receivedLabel": "Records received",
"oldestLabel": "Oldest reading reached",
"flowing": "Data arrived within the last hour — the iPhone app is still delivering.",
"waiting": "Waiting for the iPhone app to send more data. Delivery continues when the app is open or when iOS grants it background time — a quiet stretch during the first big backfill is normal, not an error."
},
"delivery": {
"title": "Delivery",
"lastTriggerLabel": "Latest sync arrived",
@@ -6590,13 +6580,13 @@
"maxUsesLabel": "Maximum uses",
"maxUsesHint": "How many signups this link admits (1–50).",
"createConfirm": "Create invite",
"createdToast": "Invite created.",
"createdToast": "Invite created",
"createError": "Could not create the invite.",
"tokenTitle": "Invite link",
"tokenOnceHint": "Copyable only now — copy the link or have the QR code scanned.",
"hashExplainer": "Only a hash of the link is stored, so it cannot be shown again later. If it gets lost, revoke the invite and create a new one.",
"copyLink": "Copy link",
"copiedToast": "Link copied.",
"copiedToast": "Link copied",
"copyError": "Copy failed.",
"qrAlt": "QR code for the invite link",
"loadError": "Could not load the invites.",
@@ -6890,6 +6880,23 @@
"restoreTitle": "Restore from backup",
"restoreDescription": "This will replace ALL current data for {username} with the snapshot from {when}. Existing measurements, medications, intake events, mood entries, notification channels, push subscriptions and queued Telegram deletions will be deleted. If included in the snapshot, instance-wide settings overwrite the configuration for the entire installation.",
"restorePromptLabel": "Type RESTORE to continue",
"previewTitle": "What this backup contains",
"previewLoading": "Reading contents…",
"previewUnavailable": "Contents could not be read — restoring is still possible.",
"previewEmpty": "The file contains no records.",
"previewOther": "Other records",
"previewMeasurements": "Measurements",
"previewMedications": "Medications and intakes",
"previewMood": "Mood entries",
"previewLabs": "Lab results",
"previewDocuments": "Documents",
"previewWorkouts": "Workouts",
"previewCycles": "Cycle data",
"previewVisits": "Doctor visits",
"previewVaccinations": "Vaccinations",
"previewReminders": "Preventive-care reminders",
"previewCustomMetrics": "Custom metrics",
"previewHealthScores": "Score history",
"restoreConfirm": "Restore now",
"restoreInProgress": "Restoring…",
"restoreSuccess": "Restore complete",
@@ -6905,7 +6912,17 @@
"restoreSkippedVisitReference": "Visit reference",
"restoreSkippedVaccinationReference": "Vaccination reference",
"restoreSkippedCheckupClosure": "Checkup closure",
"docsLink": "Documentation"
"restoreSkippedReminderReference": "Reminder reference",
"restoreSkippedCoachAttachment": "Coach document link",
"restoreSkippedCoachReference": "Coach reference",
"restoreSkippedDocumentConditionLink": "Document filing",
"restoreSkippedExtractedFact": "Document fact",
"restoreSkippedFactCommitment": "Fact commitment",
"docsLink": "Documentation",
"restoreSkippedPersonalRecordReference": "Personal best provenance",
"restoreSkippedEcgReference": "ECG strip provenance",
"restoreSkippedMedicationTarget": "Medication target analyte",
"restoreSkippedScheduleRevisionLink": "Schedule era succession"
},
"danger-zone": {
"title": "Danger Zone",
@@ -7339,7 +7356,7 @@
"saveError": "Save failed. Please try again.",
"hourLabel": "Reminder time",
"hourAria": "Mood reminder hour",
"hourSavedToast": "Reminder time updated.",
"hourSavedToast": "Reminder time updated",
"detail": "Routes only through channels you've already configured (Telegram, ntfy, Web Push, Apple Push) and stays silent once you've logged today."
},
"coachNudge": {
@@ -7380,8 +7397,8 @@
"leadLabel": "Reorder lead time (days)",
"leadAria": "Reorder lead time in days",
"leadHelp": "The alert fires this many days plus one dose-interval earlier so a refill arrives before your last dose. A single medication can override this on its supply tab.",
"leadSavedToast": "Reorder lead time saved.",
"savedToast": "Low-stock threshold saved.",
"leadSavedToast": "Reorder lead time saved",
"savedToast": "Low-stock threshold saved",
"enabledToast": "Low-stock alerts enabled.",
"disabledToast": "Low-stock alerts disabled.",
"saveError": "Could not save. Please try again.",
@@ -8041,10 +8058,10 @@
"protected": "Whether contraception was used during intercourse.",
"protectedLabel": "What does protected mean?"
},
"saveSuccess": "Entry saved.",
"deleteSuccess": "Entry deleted.",
"periodStartSaved": "Period start saved.",
"periodEndSaved": "End of period saved.",
"saveSuccess": "Entry saved",
"deleteSuccess": "Entry deleted",
"periodStartSaved": "Period start saved",
"periodEndSaved": "End of period saved",
"startedPeriodOnDate": "My period started this day",
"endedPeriodOnDate": "My period ended this day"
},
@@ -8084,6 +8101,23 @@
"delete": "Delete",
"deleteConfirmTitle": "Delete reminder?",
"deleteConfirmDescription": "This checkup reminder will be permanently deleted.",
"postpone": {
"menuItem": "Postpone…",
"title": "Postpone due date",
"description": "Only moves the current due date — the regular rhythm stays unchanged.",
"chip": "+{days} days",
"dateLabel": "New date",
"confirm": "Postpone"
},
"skip": {
"heading": "Skip this due date",
"explainer": "Recorded honestly as skipped — not as done. The interval restarts from today.",
"action": "Skip",
"confirmTitle": "Skip this due date?"
},
"snoozedUntil": "Postponed until {date}",
"skippedOn": "Skipped on {date}",
"skipCount": "skipped {count}×",
"disabledBadge": "Off",
"nextDue": {
"none": "Not scheduled",
@@ -8933,7 +8967,8 @@
"outOfScope": "I can only help with the health metrics in your log — measurements, medications, mood, trends. I cannot answer questions outside that scope.",
"promptInjection": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data — please rephrase your question in those terms.",
"conversationPoisoned": "An earlier message in this conversation contains wording that overrides my instructions. Please start a new conversation."
}
},
"noRecordedData": "I don't have any recorded readings for what you asked about, so there is nothing I can give you a figure from. Once you log some, ask me again and I'll work from your own numbers."
},
"safety": {
"floor": {
@@ -9034,7 +9069,9 @@
"flaggedBadge": "Support shown",
"chartTitle": "Score trend",
"totalLabel": "Score",
"bandLabel": "Category"
"bandLabel": "Category",
"answersToggle": "Answers",
"answersUnavailable": "The stored answers for this check-in cannot be decrypted."
},
"crisis": {
"title": "You don't have to face this alone",
@@ -9305,7 +9342,7 @@
"freeText": {
"label": "Free-text note (allergies & intolerances)",
"hint": "A free-text supplement to the structured list above, for context a single entry cannot carry.",
"savedToast": "Allergy note saved.",
"savedToast": "Allergy note saved",
"saveError": "Could not save. Please try again."
}
},
@@ -9364,7 +9401,7 @@
"conditionsLabel": "Chronic conditions",
"focusLabel": "What the Coach watches",
"loadError": "Couldn't load your conditions.",
"savedToast": "Conditions saved.",
"savedToast": "Conditions saved",
"saveError": "Could not save. Please try again."
},
"emergency": {
@@ -9468,7 +9505,7 @@
},
"toast": {
"duplicate": "Already stored — highlighting the existing document.",
"deleted": "Document deleted.",
"deleted": "Document deleted",
"restoreFailed": "Couldn't restore the document."
},
"timeline": {
@@ -9641,9 +9678,9 @@
"unverifiedNoteOther": "{count} values still need checking and stay pending.",
"finish": "Finish review",
"confirmFailed": "The review couldn't be saved. Try again.",
"savedToastOne": "1 value saved.",
"savedToastFew": "{count} values saved.",
"savedToastOther": "{count} values saved.",
"savedToastOne": "1 value saved",
"savedToastFew": "{count} values saved",
"savedToastOther": "{count} values saved",
"nothingSavedToast": "Review saved. No values were added.",
"extract": "Extract lab values",
"extracting": "Extracting…",
@@ -9738,6 +9775,8 @@
"editedToast": "Value updated",
"editError": "Could not update the value",
"deletedToast": "Value deleted",
"restoredToast": "Value restored",
"restoreError": "Restore failed",
"deleteError": "Could not delete the value"
},
"chart": {
@@ -10184,7 +10223,7 @@
"errorRateLimit": "Too many profiles created in the last hour. Wait a while and try again."
},
"toast": {
"savedTo": "Saved to {name}'s record."
"savedTo": "Saved to {name}'s record"
},
"activityVerb": {
"measurementCreate": "{name} added a reading",
@@ -10207,6 +10246,8 @@
"measurementReminderDelete": "{name} removed a measurement reminder",
"measurementReminderComplete": "{name} completed a measurement reminder",
"measurementReminderSatisfy": "{name} cleared a measurement reminder",
"measurementReminderSkip": "{name} skipped a measurement reminder",
"measurementReminderSnooze": "{name} postponed a measurement reminder",
"labResultUpdate": "{name} changed a lab result",
"labResultDelete": "{name} removed a lab result",
"labResultRestore": "{name} restored a lab result",
+135 -94
View File
@@ -516,6 +516,9 @@
"heroItemsTitle": "Destacados de hoy",
"heroItemsDescription": "Elige qué tipos de elementos pueden aparecer en los destacados de hoy.",
"heroItemsNotificationNote": "Los ajustes de notificaciones se gestionan por separado.",
"heroContentTitle": "La tarjeta superior muestra",
"heroContentScore": "Puntuación de salud y resumen del día",
"heroContentReminders": "Destacados de hoy",
"heroItems": {
"coach_checkin": "Revisión con Coach",
"dose_window": "Dosis de medicamentos",
@@ -560,7 +563,7 @@
"viewAll": "Ver todos",
"empty": {
"title": "Aún no hay entrenamientos",
"cta": "Abre la app Apple Salud en el iPhone para sincronizar los entrenamientos."
"cta": "Conecta una fuente de datos en los ajustes para ver aquí los entrenamientos."
}
},
"metric": {
@@ -729,9 +732,12 @@
"sourceWhoop": "WHOOP",
"sourceFitbit": "Fitbit",
"bulkDeleteSuccess": "{count} mediciones eliminadas",
"bulkDeleteSuccessOne": "Medición eliminada",
"bulkDeleteError": "Error al eliminar las mediciones seleccionadas",
"bulkDeleteConfirmTitle": "¿Eliminar {count} mediciones?",
"bulkDeleteConfirmTitleOne": "¿Eliminar esta medición?",
"bulkDeleteConfirmBody": "Las {count} mediciones seleccionadas se eliminarán. Justo después podrás deshacerlo durante un breve momento.",
"bulkDeleteConfirmBodyOne": "La medición seleccionada se eliminará. Justo después podrás deshacerlo durante un breve momento.",
"typePhq9Score": "Puntuación PHQ-9",
"typeGad7Score": "Puntuación GAD-7",
"typeGripStrength": "Fuerza de agarre",
@@ -879,9 +885,12 @@
"sourceTelegram": "Telegram",
"sourceDaylio": "Daylio",
"bulkDeleteSuccess": "{count} entradas eliminadas",
"bulkDeleteSuccessOne": "Entrada eliminada",
"bulkDeleteError": "Error al eliminar las entradas seleccionadas",
"bulkDeleteConfirmTitle": "¿Eliminar {count} entradas?",
"bulkDeleteConfirmTitleOne": "¿Eliminar esta entrada?",
"bulkDeleteConfirmBody": "Las {count} entradas de estado de ánimo seleccionadas se eliminarán. Justo después podrás deshacerlo durante un breve momento.",
"bulkDeleteConfirmBodyOne": "La entrada de estado de ánimo seleccionada se eliminará. Justo después podrás deshacerlo durante un breve momento.",
"customize": "Personalizar etiquetas de ánimo",
"addTagInline": "Añadir etiqueta",
"manage": {
@@ -1779,7 +1788,8 @@
"enabledToast": "Recordatorios activados",
"disabledToast": "Recordatorios desactivados",
"toggleFailed": "No se pudo cambiar el ajuste de recordatorios.",
"clientManagedChip": "Tu iPhone gestiona los recordatorios de este medicamento."
"clientManagedChip": "Gestionado por el iPhone",
"clientManagedHint": "Tu iPhone gestiona los recordatorios de este medicamento."
},
"settings": {
"title": "Ajustes",
@@ -1932,7 +1942,7 @@
"addQuantityHelper": "1–1000 unidades por envase o recipiente.",
"addExpiryLabel": "Fecha de caducidad (opcional)",
"addSubmit": "Registrar",
"addSuccess": "Existencias registradas.",
"addSuccess": "Existencias registradas",
"addFailed": "No se pudieron registrar las existencias.",
"adjustButton": "Ajustar",
"adjustTitle": "Corregir envase",
@@ -1940,11 +1950,11 @@
"adjustQuantityLabel": "Unidades restantes",
"adjustHelper": "Capacidad: {total} unidades.",
"adjustSubmit": "Guardar",
"adjustSuccess": "Existencias actualizadas.",
"adjustSuccess": "Existencias actualizadas",
"adjustFailed": "No se pudieron actualizar las existencias.",
"deleteTitle": "¿Eliminar envase?",
"deleteDescription": "Elimina permanentemente este envase o recipiente de las existencias. Las tomas registradas no cambian.",
"deleteSuccess": "Entrada de existencias eliminada.",
"deleteSuccess": "Entrada de existencias eliminada",
"deleteFailed": "No se pudo eliminar la entrada de existencias.",
"containerTypeLabel": "Tipo de envase",
"containerType": {
@@ -2139,7 +2149,7 @@
"scaleToggleLabel": "Escala del gráfico"
},
"import": {
"resultSuccess": "{imported} tomas importadas.",
"resultSuccess": "{imported} tomas importadas",
"resultPartial": "{imported} importadas, {skipped} omitidas.",
"resultNothing": "No se importó nada: se omitieron las {skipped} entradas.",
"resultAlreadyRecorded": "{recorded} tomas ya están registradas. No se añadió nada nuevo.",
@@ -2302,7 +2312,6 @@
}
},
"comparison": {
"toggleLabel": "Comparar con",
"baseline": {
"none": "Desactivado",
"lastMonth": "Mes anterior",
@@ -2423,23 +2432,6 @@
"badge": "PR",
"tooltip": "Récord personal: {value} {unit} el {date}"
},
"recommendation": {
"source": "Fuente",
"viewSource": "Abrir guía en una pestaña nueva",
"rationaleWindow": "Ventana",
"rationaleComparedTo": "Comparado con",
"rationaleDeviation": "Desviación",
"rationaleExpand": "Mostrar justificación",
"rationaleCollapse": "Ocultar justificación",
"feedbackHelpful": "Útil",
"feedbackNotHelpful": "No útil",
"feedbackThanks": "Gracias por tu opinión",
"feedbackAlreadyRated": "Ya valorado",
"feedbackError": "No se pudo guardar tu opinión. Inténtalo de nuevo.",
"confidenceAria": "Confianza: {value} de 100",
"confidenceLow": "Confianza baja — pocos datos",
"confidenceDraft": "Borrador"
},
"loadError": "No se han podido cargar los análisis",
"heroPersonalBaseline": "Basado en tus últimos 90 días",
"heroRegenerating": "Generando",
@@ -2545,11 +2537,6 @@
"areas": "{present} de {required} ámbitos de salud"
}
},
"suggestedPrompts": {
"label": "Pregúntame",
"tellMyDoctor": "¿Qué debería contarle a mi médico?",
"medicationWorking": "¿Está funcionando mi medicamento?"
},
"dailyBriefing": {
"title": "Resumen del día",
"keyFindingsTitle": "Hallazgos clave",
@@ -2761,8 +2748,8 @@
"last7days": "Últimos 7 días",
"last30days": "Últimos 30 días",
"last90days": "Últimos 90 días",
"lastYear": "lo que va de año",
"allTime": "Todo el tiempo"
"lastYear": "últimos 12 meses",
"allTime": "todo el tiempo (12 meses en detalle)"
},
"windowLabel": "Periodo",
"settingsAriaLabel": "Ajustes del coach",
@@ -2776,6 +2763,7 @@
"settingsVerbosityBrief": "Breve",
"settingsVerbosityDefault": "Estándar",
"settingsVerbosityDetailed": "Detallado",
"settingsVerbosityConciseHint": "El tono conciso ya mantiene las respuestas cortas: la extensión queda fijada en «Breve» mientras esté activo.",
"settingsSourcesPointer": "Los grupos de datos que el coach puede usar y la ventana de análisis predeterminada se gestionan directamente en el chat del coach (panel «En qué me baso»).",
"settingsSourcesPointerLink": "Abrir el coach",
"settingsExcludeLabel": "Excluir métricas",
@@ -2787,7 +2775,7 @@
"settingsDefaultWindowLabel": "Periodo de análisis por defecto",
"settingsDefaultWindowHint": "El coach lee este periodo por chat, salvo que elijas otro en la cabecera del cajón.",
"settingsSave": "Guardar",
"settingsSaved": "Guardado.",
"settingsSaved": "Guardado",
"settingsCancel": "Cancelar",
"feedbackHelpful": "Útil",
"feedbackUnhelpful": "Poco útil",
@@ -3059,7 +3047,7 @@
"loadError": "No se pudo cargar esta métrica ahora mismo.",
"blutdruckDescription": "Tu evolución sistólica/diastólica con rangos objetivo ajustados a la edad y una valoración en texto.",
"gewichtDescription": "Evolución del peso frente a la banda saludable derivada de tu altura, con valoración.",
"pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.",
"pulsDescription": "Pulso en reposo frente a una banda de referencia por edad y sexo basada en percentiles poblacionales de CDC/NCHS, con una valoración escrita.",
"stimmungDescription": "Valores diarios de estado de ánimo en el tiempo, con una valoración en texto.",
"medikamenteDescription": "Calendario de tomas por medicamento, más una valoración en texto.",
"bmiDescription": "IMC derivado de tu peso y altura, con valoración.",
@@ -3160,7 +3148,8 @@
"sleep": {
"title": "Sueño",
"description": "Desglose por fases y duración nocturna, tomados de Apple Health.",
"headlineTitle": "Media por noche",
"mainSleep": "Sueño principal",
"totalSleep": "Sueño total",
"compositionTitle": "Distribución de fases",
"compositionSubtitle": "Últimas {nights} noches",
"compositionAriaLabel": "Distribución de fases del sueño en {nights} noches",
@@ -4162,7 +4151,7 @@
"title": "Ordenar las pestañas de navegación",
"description": "Arrastra las pestañas al orden que quieras y muéstralas u ocúltalas con el icono del ojo.",
"dragHandle": "Reordenar",
"saveSuccess": "Orden de las pestañas guardado.",
"saveSuccess": "Orden de las pestañas guardado",
"saveError": "No se pudo guardar el orden de las pestañas.",
"save": "Guardar",
"empty": "Aún no hay pestañas de navegación disponibles.",
@@ -4509,7 +4498,7 @@
"title": "Withings",
"body": "Las básculas, los tensiómetros y los trackers de Withings se sincronizan automáticamente una vez conectados.",
"cta": "Conectar Withings",
"setupCta": "Configurar en Ajustes",
"setupCta": "Configurar",
"setupHint": "Necesita primero tu propia app de desarrollador de Withings, gratuita — unos 5 minutos, una sola vez."
},
"more": {
@@ -4520,7 +4509,8 @@
"oura": "Oura",
"polar": "Polar",
"nightscout": "Nightscout",
"fitbit": "Fitbit"
"fitbit": "Fitbit",
"appleHealthHint": "Apple Health no tiene conexión web — termina aquí la puesta en marcha y luego abre la app HealthLog en tu iPhone para completar la configuración de Apple Health."
},
"import": "Importar historial existente"
},
@@ -4719,7 +4709,6 @@
"last30days": "Últimos 30 días",
"last90days": "Últimos 90 días",
"allTime": "Todo el tiempo",
"showEvidence": "Mostrar evidencia de forma predeterminada",
"excludedMetrics": "Métricas excluidas",
"dataClusters": "Grupos de datos",
"customClusters": "Usar una selección personalizada de grupos",
@@ -4834,8 +4823,6 @@
}
},
"dashboard": {
"title": "Panel",
"description": "Diseño y orden de los mosaicos.",
"subtitle": "Organiza los mosaicos de tu panel."
},
"thresholds": {
@@ -4856,9 +4843,9 @@
"resetDefaultsConfirm": "Restablecer orden",
"moveUp": "Subir",
"moveDown": "Bajar",
"saveSuccess": "Prioridad de fuentes guardada.",
"saveSuccess": "Prioridad de fuentes guardada",
"saveError": "No se pudo guardar la prioridad de fuentes.",
"resetSuccess": "Prioridad de fuentes restablecida a los valores predeterminados.",
"resetSuccess": "Prioridad de fuentes restablecida a los valores predeterminados",
"perMetricToggle": "Ajustes por métrica ({count})",
"perMetricHelp": "Métricas que has personalizado respecto al orden predeterminado. Usa esta lista para devolver cualquier métrica concreta a su valor predeterminado sin tocar las demás.",
"perMetricEmpty": "No hay ajustes por métrica — todas las métricas usan el orden global de arriba.",
@@ -5002,7 +4989,14 @@
"unpacking": "Descomprimiendo el archivo…",
"parsing": "Leyendo la exportación…",
"upserting": "Importando registros…"
}
},
"skipUnsupported": "{count} muestras omitidas: tipos de datos que HealthLog no importa.",
"skipRefused": "{count} muestras no se pudieron escribir (ilegibles, fuera de rango o falló el guardado).",
"skipBreakdown": "Mostrar desglose ({count} tipos)",
"skipBreakdownMore": "+ {count} tipos más",
"cycleSummary": "{samples} muestras de ciclo combinadas en {days} registros diarios.",
"cycleModuleOff": "{count} muestras de ciclo omitidas: el seguimiento del ciclo está desactivado en esta cuenta.",
"cycleDaysFailed": "{count} días de ciclo no se pudieron escribir: {reason}"
},
"json": {
"title": "Archivo JSON",
@@ -5020,7 +5014,7 @@
"failed": "La importación falló. Inténtalo de nuevo.",
"readFailed": "No se pudo leer ese archivo.",
"resultSummary": "{measurements} mediciones y {moods} entradas de ánimo importadas, {skipped} omitidas.",
"resultSuccess": "{measurements} mediciones y {moods} entradas de ánimo importadas.",
"resultSuccess": "{measurements} mediciones y {moods} entradas de ánimo importadas",
"resultNothing": "No se importó nada. Se omitieron las {skipped} entradas.",
"resultEmpty": "Ese archivo no contenía mediciones ni entradas de ánimo."
},
@@ -5044,10 +5038,10 @@
"previewSummary": "Vista previa: {inserted} se importarían, {skipped} omitidas.",
"resultSummary": "{inserted} nuevas importadas y {updated} actualizadas, {skipped} omitidas.",
"rowError": "Línea {line}: {reason}",
"resultSuccess": "{inserted} nuevas importadas y {updated} actualizadas.",
"resultSuccess": "{inserted} nuevas importadas y {updated} actualizadas",
"resultNothing": "No se importó nada. Se omitieron las {skipped} filas.",
"resultEmpty": "Ese archivo tiene encabezado pero ninguna fila de datos.",
"previewSuccess": "Vista previa: {inserted} se importarían.",
"previewSuccess": "Vista previa: {inserted} se importarían",
"previewNothing": "Vista previa: no se importaría nada. Se omitirían las {skipped} filas.",
"skipGroupOne": "{count} fila omitida: {reason}",
"skipGroupFew": "{count} filas omitidas: {reason}",
@@ -5138,38 +5132,26 @@
"subtitle": "Comparte acceso de solo lectura a tus datos."
},
"insights": {
"title": "Análisis",
"description": "Orden de la vista general y de las pestañas de navegación.",
"subtitle": "Organiza las secciones de tu resumen y el orden de las píldoras."
"subtitle": "Organiza las secciones de tu resumen y el orden de las pestañas."
},
"medications": {
"title": "Medicamentos",
"description": "Vista de lista, orden y zonas de inyección de tus medicamentos.",
"subtitle": "Gestiona la vista y el orden de tu lista de medicamentos."
},
"mood": {
"title": "Etiquetas de ánimo",
"description": "Grupos, etiquetas y etiquetas archivadas del selector de ánimo.",
"subtitle": "Organiza tus etiquetas de ánimo, grupos y orden del selector."
},
"labs": {
"title": "Laboratorio",
"description": "Vista, orden y biomarcadores de tu laboratorio.",
"subtitle": "Gestiona la vista, el orden y los biomarcadores."
},
"illness": {
"title": "Diario de enfermedad",
"description": "Vista y orden de tus episodios de enfermedad.",
"subtitle": "Gestiona la vista y el orden de los episodios."
},
"vorsorge": {
"title": "Revisiones",
"description": "Vista, orden y recordatorios de revisión individuales.",
"subtitle": "Gestiona la vista, el orden y los recordatorios individuales."
},
"modules": {
"title": "Módulos",
"saved": "Guardado.",
"saved": "Guardado",
"error": "No se pudo guardar. Inténtalo de nuevo.",
"toggleable": {
"title": "Qué registras",
@@ -5217,7 +5199,7 @@
"use": "Usar",
"select": "Seleccionar",
"saveError": "No se pudo guardar el cambio.",
"homeSaved": "Ubicación principal guardada.",
"homeSaved": "Ubicación principal guardada",
"travelAdded": "Periodo de ubicación añadido.",
"travelRemoved": "Periodo de ubicación eliminado.",
"backfillQueued": "Relleno programado.",
@@ -5274,7 +5256,7 @@
"current": "Tu selección guardada"
},
"save": "Guardar",
"saved": "Guardado.",
"saved": "Guardado",
"error": "No se pudo guardar. Inténtalo de nuevo.",
"loadFailed": "No se pudo cargar tu selección.",
"eligibilityFailed": "No se pudo comprobar qué pilares tienen datos ahora mismo. Los interruptores de abajo siguen funcionando.",
@@ -5312,7 +5294,7 @@
"auto": "Automático (idioma)",
"h24": "24 horas",
"h12": "12 horas (AM/PM)",
"saved": "Formato horario guardado.",
"saved": "Formato horario guardado",
"saveError": "No se pudo guardar el formato horario."
},
"dateFormat": {
@@ -5322,7 +5304,7 @@
"dmy": "DD.MM.AAAA",
"mdy": "MM/DD/AAAA",
"ymd": "AAAA-MM-DD",
"saved": "Formato de fecha guardado.",
"saved": "Formato de fecha guardado",
"saveError": "No se pudo guardar el formato de fecha."
},
"username": "Nombre de usuario",
@@ -5616,7 +5598,7 @@
"clearBody": "Solo se elimina el texto libre. Tus alergias e intolerancias permanecen tal cual.",
"clearConfirm": "Vaciar nota",
"save": "Guardar",
"savedToast": "Nota «Sobre mí» guardada.",
"savedToast": "Nota «Sobre mí» guardada",
"clearedToast": "Nota «Sobre mí» eliminada.",
"saveError": "No se pudo guardar. Inténtalo de nuevo.",
"loadError": "No se pudo cargar la nota.",
@@ -5638,14 +5620,14 @@
"title": "Leer documentos automáticamente con IA",
"subLabel": "Los documentos son leídos e indexados por tu proveedor de IA configurado, sin confirmar cada documento.",
"honesty": "Cuando está activado, cada documento que subes se envía a tu proveedor de IA configurado para leerlo, describirlo e indexarlo para la búsqueda, sin confirmación por documento. Activarlo también actúa de forma retroactiva: los documentos que ya has subido y que aún no tienen resumen también se envían, hasta 200 cada vez que lo activas. Esto ocurre en el servidor y el contenido del documento sale de esta máquina hacia ese proveedor. Si tu proveedor configurado es una conexión de suscripción (una cuenta de IA con sesión iniciada en lugar de una clave de API), se aplican sus propios ajustes de datos: los proveedores de suscripción pueden usar el contenido para mejorar sus modelos, y ningún acuerdo de tratamiento de datos lo cubre. Los documentos leídos por un modelo local autoalojado nunca salen de tu servidor. Desactivado de forma predeterminada; actívalo solo si aceptas este compromiso para tus documentos.",
"confirm": "Lo entiendo: activar la lectura automática con IA",
"confirm": "Entendido: activar",
"cancel": "Cancelar"
},
"centralCodex": {
"title": "Usar el acceso de IA compartido del servidor",
"subLabel": "Enruta las solicitudes de IA a través de la conexión que el operador configuró para este servidor, junto a tus propios proveedores como alternativa.",
"honesty": "El acceso compartido es una única cuenta de IA con sesión iniciada que el operador conectó para todos en este servidor, no tu propia clave. Las solicitudes que hagas se usan de forma predeterminada para entrenar los modelos del proveedor y ningún acuerdo de tratamiento de datos las cubre, los límites por hora y semanales de la cuenta se comparten con todos los demás que se han unido, y tus propios proveedores se prueban primero. Actívalo solo si aceptas este compromiso.",
"confirm": "Entendido: usar la conexión compartida",
"confirm": "Entendido: usar la conexión",
"cancel": "Cancelar"
},
"consent": {
@@ -5667,7 +5649,7 @@
"description": "Elige cómo se muestran las distancias y velocidades en los gráficos y mosaicos.",
"metric": "Métrico",
"imperial": "Imperial",
"saved": "Preferencia de unidades guardada.",
"saved": "Preferencia de unidades guardada",
"saveError": "No se pudo guardar tu preferencia de unidades."
}
},
@@ -5855,7 +5837,7 @@
"errorReconnect": "Error — reconectar",
"warningServerError": "Sincronización con errores",
"failureCount": "{count}/{threshold}",
"parkedReconnect": "Pausado — reconectar manualmente",
"parkedReconnect": "Pausado — reconectar",
"notConnected": "No conectado",
"justNow": "ahora mismo",
"minutesAgo": "hace {count} min",
@@ -5939,6 +5921,7 @@
"repository": "Código fuente",
"changelog": "Changelog",
"docs": "Documentación",
"credits": "Créditos y atribuciones de datos",
"linksHeading": "Fuentes y documentación",
"newerAvailable": "Nueva versión: {tag}"
},
@@ -5966,7 +5949,7 @@
},
"notificationStatus": {
"title": "Fiabilidad de los canales",
"description": "Estado por canal; «Desactivado automáticamente» detiene los reintentos tras errores permanentes.",
"description": "Estado de cada canal — «Desactivado automáticamente» significa detenido tras errores permanentes.",
"emptyDescription": "Configura un canal en Ajustes › Integraciones › Canales de entrega.",
"stateActive": "Activo",
"stateAutoDisabled": "Desactivado automáticamente",
@@ -6032,7 +6015,7 @@
"expiryInvalid": "La caducidad debe estar entre 1 y {max} días.",
"create": "Crear enlace",
"tokenCreated": "Enlace para compartir creado",
"copied": "Copiado al portapapeles.",
"copied": "Copiado al portapapeles",
"activeTitle": "Enlaces activos",
"noActive": "No hay enlaces para compartir activos.",
"created": "Creado",
@@ -6184,7 +6167,7 @@
"revokeSession": "Cerrar sesión",
"revokeError": "No se pudo completar la solicitud. Inténtalo de nuevo.",
"signOutEverywhere": "Cerrar sesión en los demás dispositivos",
"signOutEverywhereDone": "Se cerraron {count} sesión(es).",
"signOutEverywhereDone": "Se cerraron {count} sesión(es)",
"activityTitle": "Actividad de seguridad",
"activityDescription": "Inicios de sesión, cambios de seguridad y exportaciones recientes de tu cuenta.",
"activityEmpty": "Aún no hay actividad de seguridad.",
@@ -6265,7 +6248,7 @@
"revokeError": "No se pudo revocar el dispositivo. Inténtalo de nuevo.",
"revoke": "Revocar",
"revokeAll": "Olvidar todos los dispositivos",
"revokeAllDone": "Se olvidaron {count} dispositivo(s) de confianza.",
"revokeAllDone": "Se olvidaron {count} dispositivo(s) de confianza",
"unnamed": "Dispositivo desconocido",
"current": "Este dispositivo",
"expires": "De confianza hasta el {date}",
@@ -6329,8 +6312,8 @@
"endpointLabel": "URL del conector",
"connectDescription": "Pega esta URL en la configuración del conector personalizado de tu asistente y autoriza con el token de abajo o inicia sesión cuando se te solicite.",
"tokensTitle": "Tokens del conector",
"tokensDescription": "Genera un token de solo lectura para conectar un asistente.",
"scopeNote": "De forma predeterminada, los tokens solo tienen acceso de lectura a tus propios registros. El acceso de escritura es opcional y se activa por token con el interruptor de abajo. Los tokens nunca pueden acceder a funciones de administración.",
"tokensDescription": "Genera un token para conectar un asistente: solo lectura, salvo que permitas el registro abajo.",
"scopeNote": "Los tokens nuevos solo pueden leer tus propios registros. El acceso de escritura (health:write) se concede por token con el interruptor de abajo. Un token nunca puede acceder a funciones de administración.",
"writeScopeToggleLabel": "Permitir registro (health:write)",
"writeScopeNote": "Añade el ámbito health:write para que el asistente pueda registrar nuevas mediciones y entradas de estado de ánimo en tu cuenta. Cada escritura se confirma antes. El token sigue sin poder eliminar ni editar entradas existentes, cambiar medicamentos ni acceder a funciones REST o de administración. Déjalo desactivado para acceso de solo lectura.",
"connectionsTitle": "Asistentes conectados",
@@ -6386,6 +6369,13 @@
"importNote": "¿Necesitas un historial anterior o no puedes usar la app de iOS? La carga XML/ZIP de Apple Health es una alternativa puntual y no activa la sincronización en vivo.",
"importAction": "Abrir importación puntual",
"staleNote": "Hace más de una semana que no llegan datos de Apple Health. Abre la app HealthLog en tu iPhone y revisa los permisos de Apple Health.",
"progress": {
"title": "Progreso de sincronización",
"receivedLabel": "Registros recibidos",
"oldestLabel": "Lectura más antigua alcanzada",
"flowing": "Han llegado datos en la última hora — la app del iPhone sigue enviando.",
"waiting": "Esperando a que la app del iPhone envíe más datos. La transferencia continúa cuando la app está abierta o cuando iOS le concede tiempo en segundo plano — una pausa durante la primera sincronización grande es normal, no un error."
},
"delivery": {
"title": "Entrega",
"lastTriggerLabel": "La última sincronización llegó",
@@ -6590,13 +6580,13 @@
"maxUsesLabel": "Usos máximos",
"maxUsesHint": "Cuántos registros admite este enlace (1–50).",
"createConfirm": "Crear invitación",
"createdToast": "Invitación creada.",
"createdToast": "Invitación creada",
"createError": "No se pudo crear la invitación.",
"tokenTitle": "Enlace de invitación",
"tokenOnceHint": "Solo se puede copiar ahora: copia el enlace o deja que escaneen el código QR.",
"hashExplainer": "Solo se guarda un hash del enlace, por lo que no se puede volver a mostrar. Si se pierde, revoca la invitación y crea una nueva.",
"copyLink": "Copiar enlace",
"copiedToast": "Enlace copiado.",
"copiedToast": "Enlace copiado",
"copyError": "No se pudo copiar.",
"qrAlt": "Código QR del enlace de invitación",
"loadError": "No se pudieron cargar las invitaciones.",
@@ -6890,6 +6880,23 @@
"restoreTitle": "Restaurar desde copia de seguridad",
"restoreDescription": "Esto reemplazará TODOS los datos actuales de {username} con la instantánea del {when}. Se eliminarán las mediciones, los medicamentos, los eventos de toma, las entradas de ánimo, los canales de notificación, las suscripciones push y las eliminaciones de Telegram en cola existentes.",
"restorePromptLabel": "Escribe RESTORE para continuar",
"previewTitle": "Contenido de la copia",
"previewLoading": "Leyendo el contenido…",
"previewUnavailable": "No se pudo leer el contenido — restaurar sigue siendo posible.",
"previewEmpty": "El archivo no contiene registros.",
"previewOther": "Otros registros",
"previewMeasurements": "Mediciones",
"previewMedications": "Medicamentos y tomas",
"previewMood": "Registros de ánimo",
"previewLabs": "Resultados de laboratorio",
"previewDocuments": "Documentos",
"previewWorkouts": "Entrenamientos",
"previewCycles": "Datos del ciclo",
"previewVisits": "Visitas médicas",
"previewVaccinations": "Vacunas",
"previewReminders": "Recordatorios preventivos",
"previewCustomMetrics": "Métricas propias",
"previewHealthScores": "Historial de puntuación",
"restoreConfirm": "Restaurar ahora",
"restoreInProgress": "Restaurando...",
"restoreSuccess": "Restauración completada",
@@ -6905,7 +6912,17 @@
"restoreSkippedVisitReference": "Referencia de visita",
"restoreSkippedVaccinationReference": "Referencia de vacunación",
"restoreSkippedCheckupClosure": "Cierre de revisión",
"docsLink": "Documentación"
"restoreSkippedReminderReference": "Referencia de recordatorio",
"restoreSkippedCoachAttachment": "Enlace de documento del Coach",
"restoreSkippedCoachReference": "Referencia del Coach",
"restoreSkippedDocumentConditionLink": "Clasificación de documento",
"restoreSkippedExtractedFact": "Dato del documento",
"restoreSkippedFactCommitment": "Registro del dato",
"docsLink": "Documentación",
"restoreSkippedPersonalRecordReference": "Procedencia de un récord personal",
"restoreSkippedEcgReference": "Procedencia de un trazado de ECG",
"restoreSkippedMedicationTarget": "Analito objetivo de un medicamento",
"restoreSkippedScheduleRevisionLink": "Sucesión de periodos de pauta"
},
"danger-zone": {
"title": "Zona de peligro",
@@ -7339,7 +7356,7 @@
"saveError": "No se pudo guardar. Inténtalo de nuevo.",
"hourLabel": "Hora del recordatorio",
"hourAria": "Hora del recordatorio de ánimo",
"hourSavedToast": "Hora del recordatorio actualizada.",
"hourSavedToast": "Hora del recordatorio actualizada",
"detail": "Se envía solo por canales ya configurados (Telegram, ntfy, Web Push, Apple Push) y permanece en silencio una vez que has registrado el ánimo hoy."
},
"coachNudge": {
@@ -7380,8 +7397,8 @@
"leadLabel": "Plazo de reposición (días)",
"leadAria": "Plazo de reposición en días",
"leadHelp": "El aviso se envía estos días más un intervalo de dosis antes, para que la reposición llegue antes de tu última dosis. Un medicamento concreto puede anular este valor en su pestaña de existencias.",
"leadSavedToast": "Plazo de reposición guardado.",
"savedToast": "Umbral de existencias guardado.",
"leadSavedToast": "Plazo de reposición guardado",
"savedToast": "Umbral de existencias guardado",
"enabledToast": "Avisos de existencias bajas activados.",
"disabledToast": "Avisos de existencias bajas desactivados.",
"saveError": "No se pudo guardar. Inténtalo de nuevo.",
@@ -8041,10 +8058,10 @@
"protected": "Si se usó anticoncepción durante las relaciones.",
"protectedLabel": "¿Qué significa protegido?"
},
"saveSuccess": "Entrada guardada.",
"deleteSuccess": "Entrada eliminada.",
"periodStartSaved": "Inicio del periodo guardado.",
"periodEndSaved": "Fin del periodo guardado.",
"saveSuccess": "Entrada guardada",
"deleteSuccess": "Entrada eliminada",
"periodStartSaved": "Inicio del periodo guardado",
"periodEndSaved": "Fin del periodo guardado",
"startedPeriodOnDate": "Mi periodo empezó este día",
"endedPeriodOnDate": "Mi periodo terminó este día"
},
@@ -8084,6 +8101,23 @@
"delete": "Eliminar",
"deleteConfirmTitle": "¿Eliminar recordatorio?",
"deleteConfirmDescription": "Este recordatorio de revisión se eliminará de forma permanente.",
"postpone": {
"menuItem": "Aplazar…",
"title": "Aplazar vencimiento",
"description": "Solo mueve el vencimiento actual — el ritmo regular no cambia.",
"chip": "+{days} días",
"dateLabel": "Nueva fecha",
"confirm": "Aplazar"
},
"skip": {
"heading": "Omitir este vencimiento",
"explainer": "Se registra honestamente como omitido — no como hecho. El intervalo se reinicia desde hoy.",
"action": "Omitir",
"confirmTitle": "¿Omitir este vencimiento?"
},
"snoozedUntil": "Aplazado hasta {date}",
"skippedOn": "Omitido el {date}",
"skipCount": "omitido {count} veces",
"disabledBadge": "Apagado",
"nextDue": {
"none": "Sin programar",
@@ -8933,7 +8967,8 @@
"outOfScope": "Solo puedo ayudarte con los datos de salud de tu registro: mediciones, medicamentos, estado de ánimo, tendencias. No puedo responder preguntas fuera de ese ámbito.",
"promptInjection": "He detectado instrucciones que intentan anular mis directrices. Soy el Coach de HealthLog y solo resumo tus propios datos de salud; reformula tu pregunta en esos términos, por favor.",
"conversationPoisoned": "Un mensaje anterior de esta conversación contiene instrucciones que intentan anular mis directrices. Empieza una conversación nueva, por favor."
}
},
"noRecordedData": "No tienes ninguna medición registrada sobre lo que preguntas, así que no puedo darte ninguna cifra. Cuando registres algo, vuelve a preguntarme y trabajaré con tus propios datos."
},
"safety": {
"floor": {
@@ -9034,7 +9069,9 @@
"flaggedBadge": "Apoyo mostrado",
"chartTitle": "Evolución de la puntuación",
"totalLabel": "Puntuación",
"bandLabel": "Categoría"
"bandLabel": "Categoría",
"answersToggle": "Respuestas",
"answersUnavailable": "Las respuestas guardadas de esta revisión no se pueden descifrar."
},
"crisis": {
"title": "No tienes que afrontar esto a solas",
@@ -9305,7 +9342,7 @@
"freeText": {
"label": "Nota de texto libre (alergias e intolerancias)",
"hint": "Un complemento de texto libre a la lista estructurada de arriba, para contexto que una entrada individual no puede reflejar.",
"savedToast": "Nota de alergias guardada.",
"savedToast": "Nota de alergias guardada",
"saveError": "No se pudo guardar. Inténtalo de nuevo."
}
},
@@ -9364,7 +9401,7 @@
"conditionsLabel": "Enfermedades crónicas",
"focusLabel": "Qué vigila el Coach",
"loadError": "No se pudieron cargar tus enfermedades.",
"savedToast": "Enfermedades guardadas.",
"savedToast": "Enfermedades guardadas",
"saveError": "No se pudo guardar. Inténtalo de nuevo."
},
"emergency": {
@@ -9468,7 +9505,7 @@
},
"toast": {
"duplicate": "Ya estaba guardado — se resalta el documento existente.",
"deleted": "Documento eliminado.",
"deleted": "Documento eliminado",
"restoreFailed": "No se pudo restaurar el documento."
},
"timeline": {
@@ -9641,9 +9678,9 @@
"unverifiedNoteOther": "{count} valores aún necesitan comprobación y quedan pendientes.",
"finish": "Terminar revisión",
"confirmFailed": "No se pudo guardar la revisión. Inténtalo de nuevo.",
"savedToastOne": "1 valor guardado.",
"savedToastFew": "{count} valores guardados.",
"savedToastOther": "{count} valores guardados.",
"savedToastOne": "1 valor guardado",
"savedToastFew": "{count} valores guardados",
"savedToastOther": "{count} valores guardados",
"nothingSavedToast": "Revisión guardada. No se añadió ningún valor.",
"extract": "Extraer valores de laboratorio",
"extracting": "Extrayendo…",
@@ -9738,6 +9775,8 @@
"editedToast": "Valor actualizado",
"editError": "No se pudo actualizar el valor",
"deletedToast": "Valor eliminado",
"restoredToast": "Valor restaurado",
"restoreError": "No se pudo restaurar",
"deleteError": "No se pudo eliminar el valor"
},
"chart": {
@@ -10184,7 +10223,7 @@
"errorRateLimit": "Demasiados perfiles creados en la última hora. Espera un poco y vuelve a intentarlo."
},
"toast": {
"savedTo": "Guardado en el historial de {name}."
"savedTo": "Guardado en el historial de {name}"
},
"activityVerb": {
"measurementCreate": "{name} añadió una medición",
@@ -10207,6 +10246,8 @@
"measurementReminderDelete": "{name} eliminó un recordatorio de medición",
"measurementReminderComplete": "{name} completó un recordatorio de medición",
"measurementReminderSatisfy": "{name} cerró un recordatorio de medición",
"measurementReminderSkip": "{name} omitió un recordatorio de medición",
"measurementReminderSnooze": "{name} aplazó un recordatorio de medición",
"labResultUpdate": "{name} cambió un resultado de laboratorio",
"labResultDelete": "{name} eliminó un resultado de laboratorio",
"labResultRestore": "{name} restauró un resultado de laboratorio",
+135 -94
View File
@@ -516,6 +516,9 @@
"heroItemsTitle": "Temps forts du jour",
"heroItemsDescription": "Choisis les types d’éléments qui peuvent apparaître dans les temps forts du jour.",
"heroItemsNotificationNote": "Les réglages des notifications sont gérés séparément.",
"heroContentTitle": "La carte du haut affiche",
"heroContentScore": "Score de santé et résumé du jour",
"heroContentReminders": "Temps forts du jour",
"heroItems": {
"coach_checkin": "Bilan avec Coach",
"dose_window": "Doses de médicaments",
@@ -560,7 +563,7 @@
"viewAll": "Tout voir",
"empty": {
"title": "Aucun entraînement",
"cta": "Ouvre l'app Apple Santé sur ton iPhone pour synchroniser les entraînements."
"cta": "Connecte une source de données dans les réglages pour afficher les entraînements ici."
}
},
"metric": {
@@ -729,9 +732,12 @@
"sourceWhoop": "WHOOP",
"sourceFitbit": "Fitbit",
"bulkDeleteSuccess": "{count} mesures supprimées",
"bulkDeleteSuccessOne": "Mesure supprimée",
"bulkDeleteError": "Erreur lors de la suppression des mesures sélectionnées",
"bulkDeleteConfirmTitle": "Supprimer {count} mesures ?",
"bulkDeleteConfirmTitleOne": "Supprimer cette mesure ?",
"bulkDeleteConfirmBody": "Les {count} mesures sélectionnées seront supprimées. Vous pourrez annuler cette action pendant un court instant juste après.",
"bulkDeleteConfirmBodyOne": "La mesure sélectionnée sera supprimée. Vous pourrez annuler cette action pendant un court instant juste après.",
"typePhq9Score": "Score PHQ-9",
"typeGad7Score": "Score GAD-7",
"typeGripStrength": "Force de préhension",
@@ -879,9 +885,12 @@
"sourceTelegram": "Telegram",
"sourceDaylio": "Daylio",
"bulkDeleteSuccess": "{count} entrées supprimées",
"bulkDeleteSuccessOne": "Entrée supprimée",
"bulkDeleteError": "Erreur lors de la suppression des entrées sélectionnées",
"bulkDeleteConfirmTitle": "Supprimer {count} entrées ?",
"bulkDeleteConfirmTitleOne": "Supprimer cette entrée ?",
"bulkDeleteConfirmBody": "Les {count} entrées d'humeur sélectionnées seront supprimées. Vous pourrez annuler cette action pendant un court instant juste après.",
"bulkDeleteConfirmBodyOne": "L'entrée d'humeur sélectionnée sera supprimée. Vous pourrez annuler cette action pendant un court instant juste après.",
"customize": "Personnaliser les étiquettes d’humeur",
"addTagInline": "Ajouter une étiquette",
"manage": {
@@ -1779,7 +1788,8 @@
"enabledToast": "Rappels activés",
"disabledToast": "Rappels désactivés",
"toggleFailed": "Impossible de modifier le réglage des rappels.",
"clientManagedChip": "Votre iPhone gère les rappels de ce médicament."
"clientManagedChip": "Géré par l’iPhone",
"clientManagedHint": "Votre iPhone gère les rappels de ce médicament."
},
"settings": {
"title": "Réglages",
@@ -1932,7 +1942,7 @@
"addQuantityHelper": "1–1000 unités par boîte ou contenant.",
"addExpiryLabel": "Date de péremption (facultatif)",
"addSubmit": "Enregistrer",
"addSuccess": "Stock enregistré.",
"addSuccess": "Stock enregistré",
"addFailed": "Le stock n'a pas pu être enregistré.",
"adjustButton": "Ajuster",
"adjustTitle": "Corriger le contenant",
@@ -1940,11 +1950,11 @@
"adjustQuantityLabel": "Unités restantes",
"adjustHelper": "Capacité : {total} unités.",
"adjustSubmit": "Enregistrer",
"adjustSuccess": "Stock mis à jour.",
"adjustSuccess": "Stock mis à jour",
"adjustFailed": "Le stock n'a pas pu être mis à jour.",
"deleteTitle": "Supprimer le contenant ?",
"deleteDescription": "Supprime définitivement cette boîte ou ce contenant du stock. Les prises enregistrées restent inchangées.",
"deleteSuccess": "Entrée de stock supprimée.",
"deleteSuccess": "Entrée de stock supprimée",
"deleteFailed": "L'entrée de stock n'a pas pu être supprimée.",
"containerTypeLabel": "Type de contenant",
"containerType": {
@@ -2139,7 +2149,7 @@
"scaleToggleLabel": "Échelle du graphique"
},
"import": {
"resultSuccess": "{imported} prises importées.",
"resultSuccess": "{imported} prises importées",
"resultPartial": "{imported} importées, {skipped} ignorées.",
"resultNothing": "Rien n’a été importé : les {skipped} entrées ont été ignorées.",
"resultAlreadyRecorded": "{recorded} prises sont déjà enregistrées. Rien de nouveau n’a été ajouté.",
@@ -2302,7 +2312,6 @@
}
},
"comparison": {
"toggleLabel": "Comparer avec",
"baseline": {
"none": "Désactivé",
"lastMonth": "Mois précédent",
@@ -2423,23 +2432,6 @@
"badge": "PR",
"tooltip": "Record personnel : {value} {unit} le {date}"
},
"recommendation": {
"source": "Source",
"viewSource": "Ouvrir la recommandation dans un nouvel onglet",
"rationaleWindow": "Fenêtre",
"rationaleComparedTo": "Comparé à",
"rationaleDeviation": "Écart",
"rationaleExpand": "Afficher la justification",
"rationaleCollapse": "Masquer la justification",
"feedbackHelpful": "Utile",
"feedbackNotHelpful": "Peu utile",
"feedbackThanks": "Merci pour ton retour",
"feedbackAlreadyRated": "Déjà noté",
"feedbackError": "Impossible d’enregistrer votre retour. Réessayez.",
"confidenceAria": "Confiance : {value} sur 100",
"confidenceLow": "Confiance faible — peu de points de données",
"confidenceDraft": "Brouillon"
},
"loadError": "Impossible de charger les analyses",
"heroPersonalBaseline": "Basé sur tes 90 derniers jours",
"heroRegenerating": "Génération en cours",
@@ -2545,11 +2537,6 @@
"areas": "{present} domaines de santé sur {required}"
}
},
"suggestedPrompts": {
"label": "Demande-moi",
"tellMyDoctor": "Que devrais-je dire à mon médecin ?",
"medicationWorking": "Mon médicament fonctionne-t-il ?"
},
"dailyBriefing": {
"title": "Briefing du jour",
"keyFindingsTitle": "Constats clés",
@@ -2761,8 +2748,8 @@
"last7days": "7 derniers jours",
"last30days": "30 derniers jours",
"last90days": "90 derniers jours",
"lastYear": "depuis le début de l'année",
"allTime": "Toute la période"
"lastYear": "12 derniers mois",
"allTime": "toute la période (12 mois en détail)"
},
"windowLabel": "Période",
"settingsAriaLabel": "Paramètres du coach",
@@ -2776,6 +2763,7 @@
"settingsVerbosityBrief": "Bref",
"settingsVerbosityDefault": "Standard",
"settingsVerbosityDetailed": "Détaillé",
"settingsVerbosityConciseHint": "Le ton concis garde déjà les réponses courtes — la longueur reste fixée sur « Bref » tant qu'il est actif.",
"settingsSourcesPointer": "Les groupes de données que le coach peut utiliser et la fenêtre d'analyse par défaut se gèrent directement dans le chat du coach (volet « Ce sur quoi je m'appuie »).",
"settingsSourcesPointerLink": "Ouvrir le coach",
"settingsExcludeLabel": "Exclure des métriques",
@@ -2787,7 +2775,7 @@
"settingsDefaultWindowLabel": "Période d’analyse par défaut",
"settingsDefaultWindowHint": "Le coach lit cette période par chat, sauf si tu en choisis une autre dans l’en-tête du panneau.",
"settingsSave": "Enregistrer",
"settingsSaved": "Enregistré.",
"settingsSaved": "Enregistré",
"settingsCancel": "Annuler",
"feedbackHelpful": "Utile",
"feedbackUnhelpful": "Peu utile",
@@ -3059,7 +3047,7 @@
"loadError": "Impossible de charger cette mesure pour le moment.",
"blutdruckDescription": "Ton évolution systolique/diastolique avec des plages cibles adaptées à l’âge et une évaluation textuelle.",
"gewichtDescription": "Évolution du poids face à la bande saine dérivée de ta taille, avec évaluation.",
"pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.",
"pulsDescription": "Pouls au repos comparé à une plage de référence selon l'âge et le sexe, issue des percentiles de population CDC/NCHS, avec une évaluation rédigée.",
"stimmungDescription": "Valeurs d’humeur quotidiennes dans le temps, avec une évaluation textuelle.",
"medikamenteDescription": "Calendrier des prises par médicament, ainsi qu’une évaluation textuelle.",
"bmiDescription": "IMC dérivé de ton poids et de ta taille, avec évaluation.",
@@ -3160,7 +3148,8 @@
"sleep": {
"title": "Sommeil",
"description": "Répartition des phases et durée nocturne, issues d’Apple Health.",
"headlineTitle": "Moyenne par nuit",
"mainSleep": "Sommeil principal",
"totalSleep": "Sommeil total",
"compositionTitle": "Répartition des phases",
"compositionSubtitle": "{nights} dernières nuits",
"compositionAriaLabel": "Répartition des phases de sommeil sur {nights} nuits",
@@ -4162,7 +4151,7 @@
"title": "Trier les onglets de navigation",
"description": "Faites glisser les onglets dans l'ordre souhaité et affichez-les ou masquez-les via l'icône en forme d'œil.",
"dragHandle": "Réorganiser",
"saveSuccess": "Ordre des onglets enregistré.",
"saveSuccess": "Ordre des onglets enregistré",
"saveError": "L'enregistrement de l'ordre des onglets a échoué.",
"save": "Enregistrer",
"empty": "Aucun onglet de navigation disponible pour le moment.",
@@ -4509,7 +4498,7 @@
"title": "Withings",
"body": "Balances, tensiomètres et trackers Withings se synchronisent automatiquement une fois connectés.",
"cta": "Connecter Withings",
"setupCta": "Configurer dans les Réglages",
"setupCta": "Configurer",
"setupHint": "Nécessite d'abord votre propre application développeur Withings, gratuite — environ 5 minutes, une seule fois."
},
"more": {
@@ -4520,7 +4509,8 @@
"oura": "Oura",
"polar": "Polar",
"nightscout": "Nightscout",
"fitbit": "Fitbit"
"fitbit": "Fitbit",
"appleHealthHint": "Apple Health n'a pas de connexion web — terminez d'abord la configuration ici, puis ouvrez l'app HealthLog sur votre iPhone pour finaliser la configuration d'Apple Health."
},
"import": "Importer un historique existant"
},
@@ -4719,7 +4709,6 @@
"last30days": "30 derniers jours",
"last90days": "90 derniers jours",
"allTime": "Depuis toujours",
"showEvidence": "Afficher les sources par défaut",
"excludedMetrics": "Mesures exclues",
"dataClusters": "Groupes de données",
"customClusters": "Utiliser une sélection personnalisée de groupes",
@@ -4834,8 +4823,6 @@
}
},
"dashboard": {
"title": "Tableau de bord",
"description": "Disposition et ordre des tuiles.",
"subtitle": "Organisez les tuiles de votre tableau de bord."
},
"thresholds": {
@@ -4856,9 +4843,9 @@
"resetDefaultsConfirm": "Réinitialiser l’ordre",
"moveUp": "Monter",
"moveDown": "Descendre",
"saveSuccess": "Priorité des sources enregistrée.",
"saveSuccess": "Priorité des sources enregistrée",
"saveError": "Échec de l’enregistrement de la priorité des sources.",
"resetSuccess": "Priorité des sources rétablie aux valeurs par défaut.",
"resetSuccess": "Priorité des sources rétablie aux valeurs par défaut",
"perMetricToggle": "Réglages par métrique ({count})",
"perMetricHelp": "Métriques que vous avez personnalisées par rapport à l’ordre par défaut. Utilisez cette liste pour rétablir une métrique donnée à sa valeur par défaut sans toucher aux autres.",
"perMetricEmpty": "Aucun réglage par métrique — chaque métrique utilise l’ordre global ci-dessus.",
@@ -5002,7 +4989,14 @@
"unpacking": "Décompression de l'archive…",
"parsing": "Lecture de l'export…",
"upserting": "Import des enregistrements…"
}
},
"skipUnsupported": "{count} échantillons ignorés — des types de données que HealthLog n'importe pas.",
"skipRefused": "{count} échantillons n'ont pas pu être écrits (illisibles, hors plage ou échec de l'enregistrement).",
"skipBreakdown": "Afficher le détail ({count} types)",
"skipBreakdownMore": "+ {count} autres types",
"cycleSummary": "{samples} échantillons de cycle fusionnés en {days} journaux quotidiens.",
"cycleModuleOff": "{count} échantillons de cycle ignorés — le suivi du cycle est désactivé pour ce compte.",
"cycleDaysFailed": "{count} jours de cycle n'ont pas pu être écrits : {reason}"
},
"json": {
"title": "Fichier JSON",
@@ -5020,7 +5014,7 @@
"failed": "L'import a échoué. Veuillez réessayer.",
"readFailed": "Impossible de lire ce fichier.",
"resultSummary": "{measurements} mesures et {moods} entrées d'humeur importées, {skipped} ignorées.",
"resultSuccess": "{measurements} mesures et {moods} entrées d'humeur importées.",
"resultSuccess": "{measurements} mesures et {moods} entrées d'humeur importées",
"resultNothing": "Rien n'a été importé. Les {skipped} entrées ont toutes été ignorées.",
"resultEmpty": "Ce fichier ne contenait ni mesures ni entrées d'humeur."
},
@@ -5044,10 +5038,10 @@
"previewSummary": "Aperçu : {inserted} seraient importées, {skipped} ignorées.",
"resultSummary": "{inserted} nouvelles importées et {updated} mises à jour, {skipped} ignorées.",
"rowError": "Ligne {line} : {reason}",
"resultSuccess": "{inserted} nouvelles importées et {updated} mises à jour.",
"resultSuccess": "{inserted} nouvelles importées et {updated} mises à jour",
"resultNothing": "Rien n'a été importé. Les {skipped} lignes ont toutes été ignorées.",
"resultEmpty": "Ce fichier a un en-tête mais aucune ligne de données.",
"previewSuccess": "Aperçu : {inserted} seraient importées.",
"previewSuccess": "Aperçu : {inserted} seraient importées",
"previewNothing": "Aperçu : rien ne serait importé. Les {skipped} lignes seraient toutes ignorées.",
"skipGroupOne": "{count} ligne ignorée : {reason}",
"skipGroupFew": "{count} lignes ignorées : {reason}",
@@ -5138,38 +5132,26 @@
"subtitle": "Partagez un accès en lecture seule à vos données."
},
"insights": {
"title": "Analyses",
"description": "Ordre de la vue d'ensemble et des onglets de navigation.",
"subtitle": "Organisez les sections d'aperçu et l'ordre des pastilles."
"subtitle": "Organisez les sections d'aperçu et l'ordre des onglets."
},
"medications": {
"title": "Médicaments",
"description": "Vue liste, ordre et sites d’injection de vos médicaments.",
"subtitle": "Gérez l'affichage et l'ordre de votre liste de médicaments."
},
"mood": {
"title": "Étiquettes d’humeur",
"description": "Groupes, étiquettes et étiquettes archivées du sélecteur d’humeur.",
"subtitle": "Organisez vos tags d'humeur, groupes et ordre du sélecteur."
},
"labs": {
"title": "Analyses",
"description": "Affichage, tri et biomarqueurs de vos analyses.",
"subtitle": "Gérez l’affichage, le tri et les biomarqueurs."
},
"illness": {
"title": "Journal de maladie",
"description": "Affichage et ordre de vos épisodes de maladie.",
"subtitle": "Gérez l’affichage et l’ordre des épisodes."
},
"vorsorge": {
"title": "Examens",
"description": "Affichage, ordre et rappels d’examen individuels.",
"subtitle": "Gérez l’affichage, l’ordre et les rappels individuels."
},
"modules": {
"title": "Modules",
"saved": "Enregistré.",
"saved": "Enregistré",
"error": "Échec de l'enregistrement. Veuillez réessayer.",
"toggleable": {
"title": "Ce que vous suivez",
@@ -5217,7 +5199,7 @@
"use": "Utiliser",
"select": "Sélectionner",
"saveError": "Impossible d'enregistrer la modification.",
"homeSaved": "Lieu de référence enregistré.",
"homeSaved": "Lieu de référence enregistré",
"travelAdded": "Période de localisation ajoutée.",
"travelRemoved": "Période de localisation supprimée.",
"backfillQueued": "Récupération planifiée.",
@@ -5274,7 +5256,7 @@
"current": "Votre sélection enregistrée"
},
"save": "Enregistrer",
"saved": "Enregistré.",
"saved": "Enregistré",
"error": "Enregistrement impossible. Veuillez réessayer.",
"loadFailed": "Impossible de charger votre sélection.",
"eligibilityFailed": "Impossible de vérifier quels piliers disposent de données. Les interrupteurs ci-dessous fonctionnent quand même.",
@@ -5312,7 +5294,7 @@
"auto": "Automatique (langue)",
"h24": "24 heures",
"h12": "12 heures (AM/PM)",
"saved": "Format horaire enregistré.",
"saved": "Format horaire enregistré",
"saveError": "Échec de l’enregistrement du format horaire."
},
"dateFormat": {
@@ -5322,7 +5304,7 @@
"dmy": "JJ.MM.AAAA",
"mdy": "MM/JJ/AAAA",
"ymd": "AAAA-MM-JJ",
"saved": "Format de date enregistré.",
"saved": "Format de date enregistré",
"saveError": "Échec de l’enregistrement du format de date."
},
"username": "Nom d'utilisateur",
@@ -5616,7 +5598,7 @@
"clearBody": "Seul le texte libre est supprimé. Vos allergies et intolérances restent inchangées.",
"clearConfirm": "Vider la note",
"save": "Enregistrer",
"savedToast": "Note « À propos de moi » enregistrée.",
"savedToast": "Note « À propos de moi » enregistrée",
"clearedToast": "Note « À propos de moi » supprimée.",
"saveError": "Impossible d'enregistrer. Veuillez réessayer.",
"loadError": "Impossible de charger la note.",
@@ -5638,14 +5620,14 @@
"title": "Lire les documents automatiquement avec l'IA",
"subLabel": "Les documents sont lus et indexés par votre fournisseur d'IA configuré, sans validation document par document.",
"honesty": "Lorsque cette option est activée, chaque document que vous téléversez est envoyé à votre fournisseur d'IA configuré pour être lu, décrit et indexé pour la recherche, sans confirmation par document. L'activation agit aussi rétroactivement : les documents que vous avez déjà téléversés et qui n'ont pas encore de résumé sont envoyés eux aussi, jusqu'à 200 à chaque activation. Cela se produit sur le serveur et le contenu du document quitte cette machine vers ce fournisseur. Si votre fournisseur configuré est une connexion par abonnement (un compte d'IA connecté plutôt qu'une clé API), ses propres paramètres de données s'appliquent : les fournisseurs par abonnement peuvent utiliser le contenu pour améliorer leurs modèles, et aucun accord de traitement des données ne le couvre. Les documents lus par un modèle local auto-hébergé ne quittent jamais votre serveur. Désactivé par défaut ; activez-le uniquement si vous acceptez ce compromis pour vos documents.",
"confirm": "Je comprends — activer la lecture automatique par l'IA",
"confirm": "Compris — activer",
"cancel": "Annuler"
},
"centralCodex": {
"title": "Utiliser l'accès IA partagé du serveur",
"subLabel": "Acheminez les requêtes IA via la connexion que l'opérateur a configurée pour ce serveur, en complément de vos propres fournisseurs comme solution de repli.",
"honesty": "L'accès partagé est un unique compte IA connecté que l'opérateur a relié pour tout le monde sur ce serveur, et non votre propre clé. Les requêtes que vous y faites servent par défaut à entraîner les modèles du fournisseur et aucun accord de traitement des données ne les couvre, les limites horaires et hebdomadaires du compte sont partagées avec tous les autres qui ont accepté, et vos propres fournisseurs sont essayés en premier. Ne l'activez que si vous acceptez ce compromis.",
"confirm": "J'ai compris — utiliser la connexion partagée",
"confirm": "Compris — utiliser la connexion",
"cancel": "Annuler"
},
"consent": {
@@ -5667,7 +5649,7 @@
"description": "Choisissez l'affichage des distances et des vitesses dans les graphiques et les tuiles.",
"metric": "Métrique",
"imperial": "Impérial",
"saved": "Préférence d'unités enregistrée.",
"saved": "Préférence d'unités enregistrée",
"saveError": "Impossible d'enregistrer votre préférence d'unités."
}
},
@@ -5855,7 +5837,7 @@
"errorReconnect": "Erreur — reconnecter",
"warningServerError": "Échec de synchronisation",
"failureCount": "{count}/{threshold}",
"parkedReconnect": "En pause — reconnecter manuellement",
"parkedReconnect": "En pause — reconnecter",
"notConnected": "Non connecté",
"justNow": "à l’instant",
"minutesAgo": "il y a {count} min",
@@ -5939,6 +5921,7 @@
"repository": "Code source",
"changelog": "Changelog",
"docs": "Documentation",
"credits": "Crédits et attributions de données",
"linksHeading": "Sources et documentation",
"newerAvailable": "Nouvelle version : {tag}"
},
@@ -5966,7 +5949,7 @@
},
"notificationStatus": {
"title": "Fiabilité des canaux",
"description": "État par canal ; « Désactivé automatiquement » arrête les nouvelles tentatives après des erreurs permanentes.",
"description": "État de chaque canal — « Désactivé automatiquement » signifie arrêté après des erreurs permanentes.",
"emptyDescription": "Ajoutez un canal dans Paramètres › Intégrations › Canaux de diffusion.",
"stateActive": "Actif",
"stateAutoDisabled": "Désactivé automatiquement",
@@ -6032,7 +6015,7 @@
"expiryInvalid": "L'expiration doit être comprise entre 1 et {max} jours.",
"create": "Créer le lien",
"tokenCreated": "Lien de partage créé",
"copied": "Copié dans le presse-papiers.",
"copied": "Copié dans le presse-papiers",
"activeTitle": "Liens actifs",
"noActive": "Aucun lien de partage actif.",
"created": "Créé",
@@ -6184,7 +6167,7 @@
"revokeSession": "Déconnecter",
"revokeError": "Impossible de traiter la demande. Veuillez réessayer.",
"signOutEverywhere": "Se déconnecter partout ailleurs",
"signOutEverywhereDone": "{count} autre(s) session(s) déconnectée(s).",
"signOutEverywhereDone": "{count} autre(s) session(s) déconnectée(s)",
"activityTitle": "Activité de sécurité",
"activityDescription": "Connexions récentes, modifications de sécurité et exports de données de votre compte.",
"activityEmpty": "Aucune activité de sécurité pour l'instant.",
@@ -6265,7 +6248,7 @@
"revokeError": "Impossible de révoquer l'appareil. Veuillez réessayer.",
"revoke": "Révoquer",
"revokeAll": "Oublier tous les appareils",
"revokeAllDone": "{count} appareil(s) de confiance oublié(s).",
"revokeAllDone": "{count} appareil(s) de confiance oublié(s)",
"unnamed": "Appareil inconnu",
"current": "Cet appareil",
"expires": "De confiance jusqu'au {date}",
@@ -6329,8 +6312,8 @@
"endpointLabel": "URL du connecteur",
"connectDescription": "Collez cette URL dans la configuration du connecteur personnalisé de votre assistant, puis autorisez avec le jeton ci-dessous ou connectez-vous lorsque vous y êtes invité.",
"tokensTitle": "Jetons du connecteur",
"tokensDescription": "Générez un jeton en lecture seule pour connecter un assistant.",
"scopeNote": "Par défaut, les jetons n'ont qu'un accès en lecture à vos propres données. L'accès en écriture est optionnel et s'active par jeton avec le commutateur ci-dessous. Les jetons ne peuvent jamais atteindre les fonctions d'administration.",
"tokensDescription": "Générez un jeton pour connecter un assistant — lecture seule, sauf si vous autorisez l'écriture ci-dessous.",
"scopeNote": "Les nouveaux jetons ne peuvent que lire vos propres données. L'accès en écriture (health:write) s'accorde par jeton avec le commutateur ci-dessous. Un jeton ne peut jamais atteindre les fonctions d'administration.",
"writeScopeToggleLabel": "Autoriser l'enregistrement (health:write)",
"writeScopeNote": "Ajoute la portée health:write pour que l'assistant puisse enregistrer de nouvelles mesures et entrées d'humeur dans votre compte. Chaque écriture est d'abord confirmée. Le jeton ne peut toujours pas supprimer ni modifier des entrées existantes, changer les médicaments, ni atteindre les fonctions REST ou d'administration. Laissez désactivé pour un accès en lecture seule.",
"connectionsTitle": "Assistants connectés",
@@ -6386,6 +6369,13 @@
"importNote": "Besoin d’un ancien historique ou impossible d’utiliser l’app iOS ? L’envoi XML/ZIP Apple Health est une solution ponctuelle et n’active pas la synchronisation en direct.",
"importAction": "Ouvrir l’import ponctuel",
"staleNote": "Aucune donnée Apple Health n'est arrivée depuis plus d'une semaine. Ouvrez l'app HealthLog sur votre iPhone et vérifiez les autorisations Apple Health.",
"progress": {
"title": "Progression de la synchronisation",
"receivedLabel": "Enregistrements reçus",
"oldestLabel": "Mesure la plus ancienne atteinte",
"flowing": "Des données sont arrivées au cours de la dernière heure — l'app iPhone continue d'envoyer.",
"waiting": "En attente de nouvelles données de l'app iPhone. Le transfert reprend quand l'app est ouverte ou quand iOS lui accorde du temps en arrière-plan — une accalmie pendant le premier grand rattrapage est normale, pas une erreur."
},
"delivery": {
"title": "Livraison",
"lastTriggerLabel": "La dernière synchronisation est arrivée",
@@ -6590,13 +6580,13 @@
"maxUsesLabel": "Utilisations maximales",
"maxUsesHint": "Nombre d'inscriptions que ce lien autorise (1–50).",
"createConfirm": "Créer l'invitation",
"createdToast": "Invitation créée.",
"createdToast": "Invitation créée",
"createError": "Impossible de créer l'invitation.",
"tokenTitle": "Lien d'invitation",
"tokenOnceHint": "Copiable uniquement maintenant — copiez le lien ou faites scanner le code QR.",
"hashExplainer": "Seul un hachage du lien est stocké : il ne pourra plus être affiché. En cas de perte, révoquez l'invitation et créez-en une nouvelle.",
"copyLink": "Copier le lien",
"copiedToast": "Lien copié.",
"copiedToast": "Lien copié",
"copyError": "Échec de la copie.",
"qrAlt": "Code QR du lien d'invitation",
"loadError": "Impossible de charger les invitations.",
@@ -6890,6 +6880,23 @@
"restoreTitle": "Restaurer à partir d'une sauvegarde",
"restoreDescription": "Cette action remplacera TOUTES les données actuelles de {username} par l'instantané du {when}. Les mesures, médicaments, événements de prise, entrées d'humeur, canaux de notification, abonnements push et suppressions Telegram en file d'attente existants seront supprimés.",
"restorePromptLabel": "Tapez RESTORE pour continuer",
"previewTitle": "Contenu de la sauvegarde",
"previewLoading": "Lecture du contenu…",
"previewUnavailable": "Le contenu n'a pas pu être lu — la restauration reste possible.",
"previewEmpty": "Le fichier ne contient aucun enregistrement.",
"previewOther": "Autres enregistrements",
"previewMeasurements": "Mesures",
"previewMedications": "Médicaments et prises",
"previewMood": "Entrées d'humeur",
"previewLabs": "Résultats de laboratoire",
"previewDocuments": "Documents",
"previewWorkouts": "Entraînements",
"previewCycles": "Données de cycle",
"previewVisits": "Visites médicales",
"previewVaccinations": "Vaccinations",
"previewReminders": "Rappels de prévention",
"previewCustomMetrics": "Mesures personnalisées",
"previewHealthScores": "Historique du score",
"restoreConfirm": "Restaurer maintenant",
"restoreInProgress": "Restauration en cours...",
"restoreSuccess": "Restauration terminée",
@@ -6905,7 +6912,17 @@
"restoreSkippedVisitReference": "Référence de visite",
"restoreSkippedVaccinationReference": "Référence de vaccination",
"restoreSkippedCheckupClosure": "Clôture d'un examen",
"docsLink": "Documentation"
"restoreSkippedReminderReference": "Référence de rappel",
"restoreSkippedCoachAttachment": "Lien de document du Coach",
"restoreSkippedCoachReference": "Référence du Coach",
"restoreSkippedDocumentConditionLink": "Classement du document",
"restoreSkippedExtractedFact": "Donnée du document",
"restoreSkippedFactCommitment": "Enregistrement de la donnée",
"docsLink": "Documentation",
"restoreSkippedPersonalRecordReference": "Provenance d'un record personnel",
"restoreSkippedEcgReference": "Provenance d'un tracé ECG",
"restoreSkippedMedicationTarget": "Analyte cible d'un médicament",
"restoreSkippedScheduleRevisionLink": "Succession des périodes de schéma"
},
"danger-zone": {
"title": "Zone de danger",
@@ -7339,7 +7356,7 @@
"saveError": "Échec de l’enregistrement. Réessayez.",
"hourLabel": "Heure du rappel",
"hourAria": "Heure du rappel d’humeur",
"hourSavedToast": "Heure du rappel mise à jour.",
"hourSavedToast": "Heure du rappel mise à jour",
"detail": "Utilise uniquement les canaux déjà configurés (Telegram, ntfy, Web Push, Apple Push) et reste silencieux dès que tu as noté ton humeur aujourd’hui."
},
"coachNudge": {
@@ -7380,8 +7397,8 @@
"leadLabel": "Délai de réapprovisionnement (jours)",
"leadAria": "Délai de réapprovisionnement en jours",
"leadHelp": "L’alerte est envoyée ce nombre de jours plus un intervalle de dose plus tôt, pour qu’un réapprovisionnement arrive avant ta dernière dose. Un médicament donné peut remplacer cette valeur dans son onglet stock.",
"leadSavedToast": "Délai de réapprovisionnement enregistré.",
"savedToast": "Seuil de stock enregistré.",
"leadSavedToast": "Délai de réapprovisionnement enregistré",
"savedToast": "Seuil de stock enregistré",
"enabledToast": "Alertes de stock faible activées.",
"disabledToast": "Alertes de stock faible désactivées.",
"saveError": "Échec de l’enregistrement. Réessayez.",
@@ -8041,10 +8058,10 @@
"protected": "Si une contraception a été utilisée pendant le rapport.",
"protectedLabel": "Que signifie protégé ?"
},
"saveSuccess": "Entrée enregistrée.",
"deleteSuccess": "Entrée supprimée.",
"periodStartSaved": "Début des règles enregistré.",
"periodEndSaved": "Fin des règles enregistrée.",
"saveSuccess": "Entrée enregistrée",
"deleteSuccess": "Entrée supprimée",
"periodStartSaved": "Début des règles enregistré",
"periodEndSaved": "Fin des règles enregistrée",
"startedPeriodOnDate": "Mes règles ont commencé ce jour-là",
"endedPeriodOnDate": "Mes règles se sont terminées ce jour-là"
},
@@ -8084,6 +8101,23 @@
"delete": "Supprimer",
"deleteConfirmTitle": "Supprimer le rappel ?",
"deleteConfirmDescription": "Ce rappel d’examen sera définitivement supprimé.",
"postpone": {
"menuItem": "Reporter…",
"title": "Reporter l'échéance",
"description": "Ne déplace que l'échéance actuelle — le rythme régulier reste inchangé.",
"chip": "+{days} jours",
"dateLabel": "Nouvelle date",
"confirm": "Reporter"
},
"skip": {
"heading": "Passer cette échéance",
"explainer": "Enregistré honnêtement comme passé — pas comme fait. L'intervalle redémarre à partir d'aujourd'hui.",
"action": "Passer",
"confirmTitle": "Passer cette échéance ?"
},
"snoozedUntil": "Reporté au {date}",
"skippedOn": "Passé le {date}",
"skipCount": "passé {count} fois",
"disabledBadge": "Désactivé",
"nextDue": {
"none": "Non planifié",
@@ -8933,7 +8967,8 @@
"outOfScope": "Je peux uniquement t'aider avec les données de santé de ton journal : mesures, médicaments, humeur, tendances. Je ne réponds pas aux questions en dehors de ce cadre.",
"promptInjection": "J'ai repéré des instructions qui cherchent à contourner mes consignes. Je suis le Coach HealthLog et je résume uniquement tes propres données de santé — reformule ta question dans ce cadre, s'il te plaît.",
"conversationPoisoned": "Un message précédent de cette conversation contient des instructions qui cherchent à contourner mes consignes. Démarre une nouvelle conversation, s'il te plaît."
}
},
"noRecordedData": "Aucune mesure n'est enregistrée pour ce que tu demandes, je ne peux donc te donner aucun chiffre. Dès que tu en auras saisi, repose-moi la question et je partirai de tes propres valeurs."
},
"safety": {
"floor": {
@@ -9034,7 +9069,9 @@
"flaggedBadge": "Aide affichée",
"chartTitle": "Évolution du score",
"totalLabel": "Score",
"bandLabel": "Catégorie"
"bandLabel": "Catégorie",
"answersToggle": "Réponses",
"answersUnavailable": "Les réponses enregistrées de ce bilan ne peuvent pas être déchiffrées."
},
"crisis": {
"title": "Tu n'as pas à affronter cela seul(e)",
@@ -9305,7 +9342,7 @@
"freeText": {
"label": "Note en texte libre (allergies et intolérances)",
"hint": "Un complément en texte libre à la liste structurée ci-dessus, pour un contexte qu'une entrée seule ne peut pas refléter.",
"savedToast": "Note d'allergies enregistrée.",
"savedToast": "Note d'allergies enregistrée",
"saveError": "Impossible d'enregistrer. Veuillez réessayer."
}
},
@@ -9364,7 +9401,7 @@
"conditionsLabel": "Affections chroniques",
"focusLabel": "Ce que le Coach surveille",
"loadError": "Impossible de charger vos antécédents.",
"savedToast": "Affections enregistrées.",
"savedToast": "Affections enregistrées",
"saveError": "Impossible d'enregistrer. Veuillez réessayer."
},
"emergency": {
@@ -9468,7 +9505,7 @@
},
"toast": {
"duplicate": "Déjà enregistré — le document existant est mis en évidence.",
"deleted": "Document supprimé.",
"deleted": "Document supprimé",
"restoreFailed": "Impossible de restaurer le document."
},
"timeline": {
@@ -9641,9 +9678,9 @@
"unverifiedNoteOther": "{count} valeurs restent à vérifier et demeurent en attente.",
"finish": "Terminer la vérification",
"confirmFailed": "La vérification n’a pas pu être enregistrée. Réessayez.",
"savedToastOne": "1 valeur enregistrée.",
"savedToastFew": "{count} valeurs enregistrées.",
"savedToastOther": "{count} valeurs enregistrées.",
"savedToastOne": "1 valeur enregistrée",
"savedToastFew": "{count} valeurs enregistrées",
"savedToastOther": "{count} valeurs enregistrées",
"nothingSavedToast": "Vérification enregistrée. Aucune valeur n’a été ajoutée.",
"extract": "Extraire les valeurs de laboratoire",
"extracting": "Extraction…",
@@ -9738,6 +9775,8 @@
"editedToast": "Valeur mise à jour",
"editError": "Impossible de mettre à jour la valeur",
"deletedToast": "Valeur supprimée",
"restoredToast": "Valeur restaurée",
"restoreError": "Échec de la restauration",
"deleteError": "Impossible de supprimer la valeur"
},
"chart": {
@@ -10184,7 +10223,7 @@
"errorRateLimit": "Trop de profils créés dans la dernière heure. Patientez un peu et réessayez."
},
"toast": {
"savedTo": "Enregistré dans le dossier de {name}."
"savedTo": "Enregistré dans le dossier de {name}"
},
"activityVerb": {
"measurementCreate": "{name} a ajouté une mesure",
@@ -10207,6 +10246,8 @@
"measurementReminderDelete": "{name} a supprimé un rappel de mesure",
"measurementReminderComplete": "{name} a terminé un rappel de mesure",
"measurementReminderSatisfy": "{name} a clôturé un rappel de mesure",
"measurementReminderSkip": "{name} a passé un rappel de mesure",
"measurementReminderSnooze": "{name} a reporté un rappel de mesure",
"labResultUpdate": "{name} a modifié un résultat de laboratoire",
"labResultDelete": "{name} a supprimé un résultat de laboratoire",
"labResultRestore": "{name} a restauré un résultat de laboratoire",
+135 -94
View File
@@ -516,6 +516,9 @@
"heroItemsTitle": "In evidenza oggi",
"heroItemsDescription": "Scegli quali tipi di elementi possono apparire nella sezione In evidenza oggi.",
"heroItemsNotificationNote": "Le impostazioni delle notifiche vengono gestite separatamente.",
"heroContentTitle": "La scheda in alto mostra",
"heroContentScore": "Punteggio di salute e riepilogo del giorno",
"heroContentReminders": "In evidenza oggi",
"heroItems": {
"coach_checkin": "Verifiche con Coach",
"dose_window": "Dosi dei farmaci",
@@ -560,7 +563,7 @@
"viewAll": "Vedi tutti",
"empty": {
"title": "Nessun allenamento",
"cta": "Apri l'app Salute di Apple sull'iPhone per sincronizzare gli allenamenti."
"cta": "Collega una fonte di dati nelle impostazioni per vedere qui gli allenamenti."
}
},
"metric": {
@@ -729,9 +732,12 @@
"sourceWhoop": "WHOOP",
"sourceFitbit": "Fitbit",
"bulkDeleteSuccess": "{count} misurazioni eliminate",
"bulkDeleteSuccessOne": "Misurazione eliminata",
"bulkDeleteError": "Errore durante l'eliminazione delle misurazioni selezionate",
"bulkDeleteConfirmTitle": "Eliminare {count} misurazioni?",
"bulkDeleteConfirmTitleOne": "Eliminare questa misurazione?",
"bulkDeleteConfirmBody": "Le {count} misurazioni selezionate verranno eliminate. Subito dopo potrai annullare l’operazione per un breve momento.",
"bulkDeleteConfirmBodyOne": "La misurazione selezionata verrà eliminata. Subito dopo potrai annullare l’operazione per un breve momento.",
"typePhq9Score": "Punteggio PHQ-9",
"typeGad7Score": "Punteggio GAD-7",
"typeGripStrength": "Forza di presa",
@@ -879,9 +885,12 @@
"sourceTelegram": "Telegram",
"sourceDaylio": "Daylio",
"bulkDeleteSuccess": "{count} voci eliminate",
"bulkDeleteSuccessOne": "Voce eliminata",
"bulkDeleteError": "Errore durante l'eliminazione delle voci selezionate",
"bulkDeleteConfirmTitle": "Eliminare {count} voci?",
"bulkDeleteConfirmTitleOne": "Eliminare questa voce?",
"bulkDeleteConfirmBody": "Le {count} voci dell'umore selezionate verranno eliminate. Subito dopo potrai annullare l’operazione per un breve momento.",
"bulkDeleteConfirmBodyOne": "La voce dell'umore selezionata verrà eliminata. Subito dopo potrai annullare l’operazione per un breve momento.",
"customize": "Personalizza i tag dell’umore",
"addTagInline": "Aggiungi tag",
"manage": {
@@ -1779,7 +1788,8 @@
"enabledToast": "Promemoria attivi",
"disabledToast": "Promemoria disattivati",
"toggleFailed": "Impossibile modificare l’impostazione dei promemoria.",
"clientManagedChip": "Il tuo iPhone gestisce i promemoria di questo farmaco."
"clientManagedChip": "Gestito dall’iPhone",
"clientManagedHint": "Il tuo iPhone gestisce i promemoria di questo farmaco."
},
"settings": {
"title": "Impostazioni",
@@ -1932,7 +1942,7 @@
"addQuantityHelper": "1–1000 unità per confezione o contenitore.",
"addExpiryLabel": "Data di scadenza (facoltativa)",
"addSubmit": "Registra",
"addSuccess": "Scorta registrata.",
"addSuccess": "Scorta registrata",
"addFailed": "Impossibile registrare la scorta.",
"adjustButton": "Correggi",
"adjustTitle": "Correggi il contenitore",
@@ -1940,11 +1950,11 @@
"adjustQuantityLabel": "Unità rimanenti",
"adjustHelper": "Capacità: {total} unità.",
"adjustSubmit": "Salva",
"adjustSuccess": "Scorta aggiornata.",
"adjustSuccess": "Scorta aggiornata",
"adjustFailed": "Impossibile aggiornare la scorta.",
"deleteTitle": "Eliminare la confezione?",
"deleteDescription": "Rimuove definitivamente questa confezione o contenitore dalla scorta. Le assunzioni registrate restano invariate.",
"deleteSuccess": "Scorta eliminata.",
"deleteSuccess": "Scorta eliminata",
"deleteFailed": "Impossibile eliminare la scorta.",
"containerTypeLabel": "Tipo di contenitore",
"containerType": {
@@ -2139,7 +2149,7 @@
"scaleToggleLabel": "Scala del grafico"
},
"import": {
"resultSuccess": "{imported} assunzioni importate.",
"resultSuccess": "{imported} assunzioni importate",
"resultPartial": "{imported} importate, {skipped} ignorate.",
"resultNothing": "Non è stato importato nulla: tutte le {skipped} voci sono state ignorate.",
"resultAlreadyRecorded": "{recorded} assunzioni sono già registrate. Non è stato aggiunto nulla di nuovo.",
@@ -2302,7 +2312,6 @@
}
},
"comparison": {
"toggleLabel": "Confronta con",
"baseline": {
"none": "Disattivato",
"lastMonth": "Mese precedente",
@@ -2423,23 +2432,6 @@
"badge": "PR",
"tooltip": "Record personale: {value} {unit} il {date}"
},
"recommendation": {
"source": "Fonte",
"viewSource": "Apri la linea guida in una nuova scheda",
"rationaleWindow": "Finestra",
"rationaleComparedTo": "Confrontato con",
"rationaleDeviation": "Scostamento",
"rationaleExpand": "Mostra motivazione",
"rationaleCollapse": "Nascondi motivazione",
"feedbackHelpful": "Utile",
"feedbackNotHelpful": "Poco utile",
"feedbackThanks": "Grazie per il riscontro",
"feedbackAlreadyRated": "Già valutato",
"feedbackError": "Impossibile salvare il tuo riscontro. Riprova.",
"confidenceAria": "Confidenza: {value} su 100",
"confidenceLow": "Confidenza bassa — pochi dati",
"confidenceDraft": "Bozza"
},
"loadError": "Impossibile caricare le analisi",
"heroPersonalBaseline": "Basato sugli ultimi 90 giorni",
"heroRegenerating": "Generazione in corso",
@@ -2545,11 +2537,6 @@
"areas": "{present} ambiti di salute su {required}"
}
},
"suggestedPrompts": {
"label": "Chiedimi",
"tellMyDoctor": "Cosa dovrei dire al mio medico?",
"medicationWorking": "Il mio farmaco sta funzionando?"
},
"dailyBriefing": {
"title": "Briefing del giorno",
"keyFindingsTitle": "Riscontri principali",
@@ -2761,8 +2748,8 @@
"last7days": "Ultimi 7 giorni",
"last30days": "Ultimi 30 giorni",
"last90days": "Ultimi 90 giorni",
"lastYear": "anno in corso",
"allTime": "Tutto il tempo"
"lastYear": "ultimi 12 mesi",
"allTime": "tutto il tempo (12 mesi in dettaglio)"
},
"windowLabel": "Periodo",
"settingsAriaLabel": "Impostazioni del coach",
@@ -2776,6 +2763,7 @@
"settingsVerbosityBrief": "Breve",
"settingsVerbosityDefault": "Standard",
"settingsVerbosityDetailed": "Dettagliata",
"settingsVerbosityConciseHint": "Il tono conciso mantiene già le risposte brevi: la verbosità resta fissata su «Breve» finché è attivo.",
"settingsSourcesPointer": "I gruppi di dati che il coach può usare e la finestra di analisi predefinita si gestiscono direttamente nella chat del coach (pannello «Su cosa mi baso»).",
"settingsSourcesPointerLink": "Apri il coach",
"settingsExcludeLabel": "Escludi metriche",
@@ -2787,7 +2775,7 @@
"settingsDefaultWindowLabel": "Periodo di analisi predefinito",
"settingsDefaultWindowHint": "Il coach legge questo periodo per chat, salvo che tu non scelga diversamente nell’intestazione del drawer.",
"settingsSave": "Salva",
"settingsSaved": "Salvato.",
"settingsSaved": "Salvato",
"settingsCancel": "Annulla",
"feedbackHelpful": "Utile",
"feedbackUnhelpful": "Poco utile",
@@ -3059,7 +3047,7 @@
"loadError": "Impossibile caricare questa metrica al momento.",
"blutdruckDescription": "Il tuo andamento sistolico/diastolico con range obiettivo adattati all’età e una valutazione testuale.",
"gewichtDescription": "Andamento del peso rispetto alla fascia sana derivata dalla tua altezza, con valutazione.",
"pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.",
"pulsDescription": "Battito a riposo confrontato con una fascia di riferimento per età e sesso, basata sui percentili di popolazione CDC/NCHS, con una valutazione scritta.",
"stimmungDescription": "Valori giornalieri di umore nel tempo, con una valutazione testuale.",
"medikamenteDescription": "Calendario delle assunzioni per farmaco e una valutazione testuale.",
"bmiDescription": "BMI derivato dal tuo peso e dalla tua altezza, con valutazione.",
@@ -3160,7 +3148,8 @@
"sleep": {
"title": "Sonno",
"description": "Suddivisione per fasi e durata notturna, da Apple Health.",
"headlineTitle": "Media per notte",
"mainSleep": "Sonno principale",
"totalSleep": "Sonno totale",
"compositionTitle": "Distribuzione delle fasi",
"compositionSubtitle": "Ultime {nights} notti",
"compositionAriaLabel": "Distribuzione delle fasi del sonno su {nights} notti",
@@ -4162,7 +4151,7 @@
"title": "Ordina le schede di navigazione",
"description": "Trascina le schede nell'ordine desiderato e mostrale o nascondile con l'icona dell'occhio.",
"dragHandle": "Riordina",
"saveSuccess": "Ordine delle schede salvato.",
"saveSuccess": "Ordine delle schede salvato",
"saveError": "Salvataggio dell'ordine delle schede non riuscito.",
"save": "Salva",
"empty": "Nessuna scheda di navigazione ancora disponibile.",
@@ -4509,7 +4498,7 @@
"title": "Withings",
"body": "Bilance, misuratori di pressione e tracker Withings si sincronizzano automaticamente una volta collegati.",
"cta": "Connetti Withings",
"setupCta": "Configura nelle Impostazioni",
"setupCta": "Configura",
"setupHint": "Richiede prima la tua app sviluppatore Withings gratuita — circa 5 minuti, una tantum."
},
"more": {
@@ -4520,7 +4509,8 @@
"oura": "Oura",
"polar": "Polar",
"nightscout": "Nightscout",
"fitbit": "Fitbit"
"fitbit": "Fitbit",
"appleHealthHint": "Apple Health non ha una connessione web — completa qui la configurazione iniziale, poi apri l'app HealthLog sul tuo iPhone per completare la configurazione di Apple Health."
},
"import": "Importa la cronologia esistente"
},
@@ -4719,7 +4709,6 @@
"last30days": "Ultimi 30 giorni",
"last90days": "Ultimi 90 giorni",
"allTime": "Tutto il periodo",
"showEvidence": "Mostra le fonti per impostazione predefinita",
"excludedMetrics": "Metriche escluse",
"dataClusters": "Gruppi di dati",
"customClusters": "Usa una selezione personalizzata di gruppi",
@@ -4834,8 +4823,6 @@
}
},
"dashboard": {
"title": "Cruscotto",
"description": "Layout e ordine dei riquadri.",
"subtitle": "Disponi i riquadri della tua dashboard."
},
"thresholds": {
@@ -4856,9 +4843,9 @@
"resetDefaultsConfirm": "Reimposta ordine",
"moveUp": "Sposta su",
"moveDown": "Sposta giù",
"saveSuccess": "Priorità delle fonti salvata.",
"saveSuccess": "Priorità delle fonti salvata",
"saveError": "Salvataggio della priorità delle fonti non riuscito.",
"resetSuccess": "Priorità delle fonti ripristinata ai valori predefiniti.",
"resetSuccess": "Priorità delle fonti ripristinata ai valori predefiniti",
"perMetricToggle": "Personalizzazioni per metrica ({count})",
"perMetricHelp": "Metriche che hai personalizzato rispetto all’ordine predefinito. Usa questo elenco per riportare una singola metrica al suo valore predefinito senza toccare le altre.",
"perMetricEmpty": "Nessuna personalizzazione per metrica — ogni metrica usa l’ordine globale qui sopra.",
@@ -5002,7 +4989,14 @@
"unpacking": "Estrazione dell'archivio…",
"parsing": "Lettura dell'esportazione…",
"upserting": "Importazione dei record…"
}
},
"skipUnsupported": "{count} campioni saltati — tipi di dati che HealthLog non importa.",
"skipRefused": "{count} campioni non sono stati scritti (illeggibili, fuori intervallo o salvataggio non riuscito).",
"skipBreakdown": "Mostra dettaglio ({count} tipi)",
"skipBreakdownMore": "+ {count} altri tipi",
"cycleSummary": "{samples} campioni del ciclo uniti in {days} registri giornalieri.",
"cycleModuleOff": "{count} campioni del ciclo saltati — il monitoraggio del ciclo è disattivato per questo account.",
"cycleDaysFailed": "{count} giorni del ciclo non sono stati scritti: {reason}"
},
"json": {
"title": "File JSON",
@@ -5020,7 +5014,7 @@
"failed": "L'importazione non è riuscita. Riprova.",
"readFailed": "Impossibile leggere il file.",
"resultSummary": "{measurements} misurazioni e {moods} voci dell'umore importate, {skipped} saltate.",
"resultSuccess": "{measurements} misurazioni e {moods} voci dell'umore importate.",
"resultSuccess": "{measurements} misurazioni e {moods} voci dell'umore importate",
"resultNothing": "Non è stato importato nulla. Tutte le {skipped} voci sono state saltate.",
"resultEmpty": "Questo file non conteneva né misurazioni né voci dell'umore."
},
@@ -5044,10 +5038,10 @@
"previewSummary": "Anteprima: {inserted} verrebbero importate, {skipped} saltate.",
"resultSummary": "{inserted} nuove importate e {updated} aggiornate, {skipped} saltate.",
"rowError": "Riga {line}: {reason}",
"resultSuccess": "{inserted} nuove importate e {updated} aggiornate.",
"resultSuccess": "{inserted} nuove importate e {updated} aggiornate",
"resultNothing": "Non è stato importato nulla. Tutte le {skipped} righe sono state saltate.",
"resultEmpty": "Questo file ha un'intestazione ma nessuna riga di dati.",
"previewSuccess": "Anteprima: {inserted} verrebbero importate.",
"previewSuccess": "Anteprima: {inserted} verrebbero importate",
"previewNothing": "Anteprima: non verrebbe importato nulla. Tutte le {skipped} righe verrebbero saltate.",
"skipGroupOne": "{count} riga saltata: {reason}",
"skipGroupFew": "{count} righe saltate: {reason}",
@@ -5138,38 +5132,26 @@
"subtitle": "Condividi l'accesso in sola lettura ai tuoi dati."
},
"insights": {
"title": "Approfondimenti",
"description": "Ordine della panoramica e delle schede di navigazione.",
"subtitle": "Disponi le sezioni della panoramica e l'ordine dei pill."
"subtitle": "Disponi le sezioni della panoramica e l'ordine delle schede."
},
"medications": {
"title": "Farmaci",
"description": "Vista elenco, ordine e siti di iniezione dei tuoi farmaci.",
"subtitle": "Gestisci vista e ordine del tuo elenco di farmaci."
},
"mood": {
"title": "Tag dell’umore",
"description": "Gruppi, tag e tag archiviati del selettore dell’umore.",
"subtitle": "Organizza i tag dell'umore, i gruppi e l'ordine del selettore."
},
"labs": {
"title": "Esami",
"description": "Vista, ordinamento e biomarcatori dei tuoi esami.",
"subtitle": "Gestisci la vista, l’ordinamento e i biomarcatori."
},
"illness": {
"title": "Diario delle malattie",
"description": "Vista e ordine dei tuoi episodi di malattia.",
"subtitle": "Gestisci la vista e l’ordine degli episodi."
},
"vorsorge": {
"title": "Controlli",
"description": "Vista, ordine e singoli promemoria di controllo.",
"subtitle": "Gestisci la vista, l’ordine e i singoli promemoria."
},
"modules": {
"title": "Moduli",
"saved": "Salvato.",
"saved": "Salvato",
"error": "Salvataggio non riuscito. Riprova.",
"toggleable": {
"title": "Cosa monitori",
@@ -5217,7 +5199,7 @@
"use": "Usa",
"select": "Seleziona",
"saveError": "Impossibile salvare la modifica.",
"homeSaved": "Posizione principale salvata.",
"homeSaved": "Posizione principale salvata",
"travelAdded": "Periodo di posizione aggiunto.",
"travelRemoved": "Periodo di posizione rimosso.",
"backfillQueued": "Recupero pianificato.",
@@ -5274,7 +5256,7 @@
"current": "La tua selezione salvata"
},
"save": "Salva",
"saved": "Salvato.",
"saved": "Salvato",
"error": "Non è stato possibile salvare. Riprova.",
"loadFailed": "Non è stato possibile caricare la tua selezione.",
"eligibilityFailed": "Non è stato possibile verificare quali pilastri hanno dati al momento. Gli interruttori qui sotto funzionano comunque.",
@@ -5312,7 +5294,7 @@
"auto": "Automatico (lingua)",
"h24": "24 ore",
"h12": "12 ore (AM/PM)",
"saved": "Formato orario salvato.",
"saved": "Formato orario salvato",
"saveError": "Salvataggio del formato orario non riuscito."
},
"dateFormat": {
@@ -5322,7 +5304,7 @@
"dmy": "GG.MM.AAAA",
"mdy": "MM/GG/AAAA",
"ymd": "AAAA-MM-GG",
"saved": "Formato data salvato.",
"saved": "Formato data salvato",
"saveError": "Salvataggio del formato data non riuscito."
},
"username": "Nome utente",
@@ -5616,7 +5598,7 @@
"clearBody": "Rimuove solo il testo libero. Allergie e intolleranze restano invariate.",
"clearConfirm": "Svuota nota",
"save": "Salva",
"savedToast": "Nota «Su di me» salvata.",
"savedToast": "Nota «Su di me» salvata",
"clearedToast": "Nota «Su di me» eliminata.",
"saveError": "Impossibile salvare. Riprova.",
"loadError": "Impossibile caricare la nota.",
@@ -5638,14 +5620,14 @@
"title": "Leggi i documenti automaticamente con l'IA",
"subLabel": "I documenti vengono letti e indicizzati dal provider di IA configurato, senza confermare ogni documento.",
"honesty": "Quando è attivo, ogni documento che carichi viene inviato al provider di IA configurato per essere letto, descritto e indicizzato per la ricerca, senza conferma per singolo documento. Attivarlo agisce anche retroattivamente: i documenti che hai già caricato e che non hanno ancora un riassunto vengono inviati anch'essi, fino a 200 per ogni attivazione. Questo avviene sul server e il contenuto del documento lascia questa macchina verso quel provider. Se il provider configurato è una connessione in abbonamento (un account IA con accesso effettuato anziché una chiave API), si applicano le sue impostazioni sui dati: i provider in abbonamento possono usare il contenuto per migliorare i propri modelli e nessun accordo sul trattamento dei dati lo copre. I documenti letti da un modello locale self-hosted non lasciano mai il tuo server. Disattivato per impostazione predefinita; attivalo solo se accetti questo compromesso per i tuoi documenti.",
"confirm": "Ho capito: attiva la lettura automatica con l'IA",
"confirm": "Ho capito — attiva",
"cancel": "Annulla"
},
"centralCodex": {
"title": "Usa l'accesso IA condiviso del server",
"subLabel": "Instrada le richieste IA attraverso la connessione che l'operatore ha configurato per questo server, insieme ai tuoi provider come ripiego.",
"honesty": "L'accesso condiviso è un unico account IA con accesso eseguito che l'operatore ha collegato per tutti su questo server, non la tua chiave. Le richieste che vi effettui vengono usate per impostazione predefinita per addestrare i modelli del provider e nessun accordo sul trattamento dei dati le copre, i limiti orari e settimanali dell'account sono condivisi con tutti gli altri che hanno aderito, e i tuoi provider vengono comunque provati per primi. Attivalo solo se accetti questo compromesso.",
"confirm": "Ho capito — usa la connessione condivisa",
"confirm": "Ho capito — usa la connessione",
"cancel": "Annulla"
},
"consent": {
@@ -5667,7 +5649,7 @@
"description": "Scegli come vengono mostrate distanze e velocità nei grafici e nei riquadri.",
"metric": "Metrico",
"imperial": "Imperiale",
"saved": "Preferenza unità salvata.",
"saved": "Preferenza unità salvata",
"saveError": "Impossibile salvare la preferenza delle unità."
}
},
@@ -5855,7 +5837,7 @@
"errorReconnect": "Errore — riconnetti",
"warningServerError": "Sincronizzazione in errore",
"failureCount": "{count}/{threshold}",
"parkedReconnect": "In pausa — riconnettere manualmente",
"parkedReconnect": "In pausa — riconnetti",
"notConnected": "Non connesso",
"justNow": "proprio ora",
"minutesAgo": "{count} min fa",
@@ -5939,6 +5921,7 @@
"repository": "Codice sorgente",
"changelog": "Changelog",
"docs": "Documentazione",
"credits": "Crediti e attribuzioni dei dati",
"linksHeading": "Fonti e documentazione",
"newerAvailable": "Nuova versione: {tag}"
},
@@ -5966,7 +5949,7 @@
},
"notificationStatus": {
"title": "Affidabilità dei canali",
"description": "Stato per canale; «Disattivato automaticamente» interrompe i tentativi dopo errori permanenti.",
"description": "Stato di ogni canale — «Disattivato automaticamente» significa fermato dopo errori permanenti.",
"emptyDescription": "Aggiungi un canale in Impostazioni › Integrazioni › Canali di recapito.",
"stateActive": "Attivo",
"stateAutoDisabled": "Disattivato automaticamente",
@@ -6032,7 +6015,7 @@
"expiryInvalid": "La scadenza deve essere tra 1 e {max} giorni.",
"create": "Crea link",
"tokenCreated": "Link di condivisione creato",
"copied": "Copiato negli appunti.",
"copied": "Copiato negli appunti",
"activeTitle": "Link attivi",
"noActive": "Nessun link di condivisione attivo.",
"created": "Creato",
@@ -6184,7 +6167,7 @@
"revokeSession": "Disconnetti",
"revokeError": "Impossibile completare la richiesta. Riprova.",
"signOutEverywhere": "Disconnetti ovunque",
"signOutEverywhereDone": "Disconnesse {count} altra/e sessione/i.",
"signOutEverywhereDone": "Disconnesse {count} altra/e sessione/i",
"activityTitle": "Attività di sicurezza",
"activityDescription": "Accessi recenti, modifiche di sicurezza ed esportazioni di dati del tuo account.",
"activityEmpty": "Nessuna attività di sicurezza.",
@@ -6265,7 +6248,7 @@
"revokeError": "Impossibile revocare il dispositivo. Riprova.",
"revoke": "Revoca",
"revokeAll": "Dimentica tutti i dispositivi",
"revokeAllDone": "Dimenticati {count} dispositivo/i attendibile/i.",
"revokeAllDone": "Dimenticati {count} dispositivo/i attendibile/i",
"unnamed": "Dispositivo sconosciuto",
"current": "Questo dispositivo",
"expires": "Attendibile fino al {date}",
@@ -6329,8 +6312,8 @@
"endpointLabel": "URL del connettore",
"connectDescription": "Incolla questo URL nella configurazione del connettore personalizzato del tuo assistente e autorizza con il token qui sotto o accedi quando richiesto.",
"tokensTitle": "Token del connettore",
"tokensDescription": "Genera un token in sola lettura per collegare un assistente.",
"scopeNote": "Per impostazione predefinita, i token hanno accesso in sola lettura ai tuoi dati. L'accesso in scrittura è opzionale e si attiva per singolo token con l'interruttore qui sotto. I token non possono mai raggiungere funzioni di amministrazione.",
"tokensDescription": "Genera un token per collegare un assistente: sola lettura, a meno che tu non consenta la scrittura qui sotto.",
"scopeNote": "I nuovi token possono solo leggere i tuoi dati. L'accesso in scrittura (health:write) si concede per singolo token con l'interruttore qui sotto. Un token non può mai raggiungere le funzioni di amministrazione.",
"writeScopeToggleLabel": "Consenti la registrazione (health:write)",
"writeScopeNote": "Aggiunge l'ambito health:write così l'assistente può registrare nuove misurazioni e voci dell'umore nel tuo account. Ogni scrittura viene prima confermata. Il token non può comunque eliminare o modificare voci esistenti, cambiare i farmaci o raggiungere funzioni REST o di amministrazione. Lascia disattivato per l'accesso in sola lettura.",
"connectionsTitle": "Assistenti collegati",
@@ -6386,6 +6369,13 @@
"importNote": "Ti serve lo storico precedente o non puoi usare l’app iOS? Il caricamento XML/ZIP di Apple Health è un’alternativa una tantum e non attiva la sincronizzazione in tempo reale.",
"importAction": "Apri importazione una tantum",
"staleNote": "Da più di una settimana non arrivano dati da Apple Health. Apri l'app HealthLog sull'iPhone e controlla i permessi di Apple Health.",
"progress": {
"title": "Avanzamento della sincronizzazione",
"receivedLabel": "Record ricevuti",
"oldestLabel": "Lettura più vecchia raggiunta",
"flowing": "Nell'ultima ora sono arrivati dati — l'app per iPhone sta ancora inviando.",
"waiting": "In attesa che l'app per iPhone invii altri dati. Il trasferimento continua quando l'app è aperta o quando iOS le concede tempo in background — una pausa durante il primo grande recupero è normale, non un errore."
},
"delivery": {
"title": "Consegna",
"lastTriggerLabel": "L'ultima sincronizzazione è arrivata",
@@ -6590,13 +6580,13 @@
"maxUsesLabel": "Utilizzi massimi",
"maxUsesHint": "Quante registrazioni consente questo link (1–50).",
"createConfirm": "Crea invito",
"createdToast": "Invito creato.",
"createdToast": "Invito creato",
"createError": "Impossibile creare l'invito.",
"tokenTitle": "Link di invito",
"tokenOnceHint": "Copiabile solo adesso: copia il link o fai scansionare il codice QR.",
"hashExplainer": "Viene salvato solo un hash del link: non potrà più essere mostrato. Se va perso, revoca l'invito e creane uno nuovo.",
"copyLink": "Copia link",
"copiedToast": "Link copiato.",
"copiedToast": "Link copiato",
"copyError": "Copia non riuscita.",
"qrAlt": "Codice QR del link di invito",
"loadError": "Impossibile caricare gli inviti.",
@@ -6890,6 +6880,23 @@
"restoreTitle": "Ripristina dal backup",
"restoreDescription": "Questa operazione sostituirà TUTTI i dati attuali di {username} con l'istantanea del {when}. Misurazioni, farmaci, eventi di assunzione, voci dell'umore, canali di notifica, sottoscrizioni push ed eliminazioni Telegram in coda esistenti verranno eliminati.",
"restorePromptLabel": "Digita RESTORE per continuare",
"previewTitle": "Contenuto del backup",
"previewLoading": "Lettura del contenuto…",
"previewUnavailable": "Impossibile leggere il contenuto — il ripristino resta possibile.",
"previewEmpty": "Il file non contiene record.",
"previewOther": "Altri record",
"previewMeasurements": "Misurazioni",
"previewMedications": "Farmaci e assunzioni",
"previewMood": "Voci dell'umore",
"previewLabs": "Risultati di laboratorio",
"previewDocuments": "Documenti",
"previewWorkouts": "Allenamenti",
"previewCycles": "Dati del ciclo",
"previewVisits": "Visite mediche",
"previewVaccinations": "Vaccinazioni",
"previewReminders": "Promemoria di prevenzione",
"previewCustomMetrics": "Metriche personalizzate",
"previewHealthScores": "Storico del punteggio",
"restoreConfirm": "Ripristina ora",
"restoreInProgress": "Ripristino in corso...",
"restoreSuccess": "Ripristino completato",
@@ -6905,7 +6912,17 @@
"restoreSkippedVisitReference": "Riferimento alla visita",
"restoreSkippedVaccinationReference": "Riferimento alla vaccinazione",
"restoreSkippedCheckupClosure": "Chiusura del controllo",
"docsLink": "Documentazione"
"restoreSkippedReminderReference": "Riferimento al promemoria",
"restoreSkippedCoachAttachment": "Collegamento documento del Coach",
"restoreSkippedCoachReference": "Riferimento del Coach",
"restoreSkippedDocumentConditionLink": "Classificazione del documento",
"restoreSkippedExtractedFact": "Dato del documento",
"restoreSkippedFactCommitment": "Registrazione del dato",
"docsLink": "Documentazione",
"restoreSkippedPersonalRecordReference": "Provenienza di un primato personale",
"restoreSkippedEcgReference": "Provenienza di un tracciato ECG",
"restoreSkippedMedicationTarget": "Analita obiettivo di un farmaco",
"restoreSkippedScheduleRevisionLink": "Successione dei periodi di schema"
},
"danger-zone": {
"title": "Zona pericolosa",
@@ -7339,7 +7356,7 @@
"saveError": "Salvataggio non riuscito. Riprova.",
"hourLabel": "Ora del promemoria",
"hourAria": "Ora del promemoria dell’umore",
"hourSavedToast": "Ora del promemoria aggiornata.",
"hourSavedToast": "Ora del promemoria aggiornata",
"detail": "Usa solo i canali già configurati (Telegram, ntfy, Web Push, Apple Push) e tace una volta registrato l’umore di oggi."
},
"coachNudge": {
@@ -7380,8 +7397,8 @@
"leadLabel": "Tempo di riordino (giorni)",
"leadAria": "Tempo di riordino in giorni",
"leadHelp": "L’avviso arriva questo numero di giorni più un intervallo di dose in anticipo, così la fornitura arriva prima dell’ultima dose. Un singolo farmaco può sovrascrivere questo valore nella sua scheda scorte.",
"leadSavedToast": "Tempo di riordino salvato.",
"savedToast": "Soglia delle scorte salvata.",
"leadSavedToast": "Tempo di riordino salvato",
"savedToast": "Soglia delle scorte salvata",
"enabledToast": "Avvisi di scorte basse attivati.",
"disabledToast": "Avvisi di scorte basse disattivati.",
"saveError": "Salvataggio non riuscito. Riprova.",
@@ -8041,10 +8058,10 @@
"protected": "Se durante il rapporto è stata usata la contraccezione.",
"protectedLabel": "Cosa significa protetto?"
},
"saveSuccess": "Voce salvata.",
"deleteSuccess": "Voce eliminata.",
"periodStartSaved": "Inizio delle mestruazioni salvato.",
"periodEndSaved": "Fine delle mestruazioni salvata.",
"saveSuccess": "Voce salvata",
"deleteSuccess": "Voce eliminata",
"periodStartSaved": "Inizio delle mestruazioni salvato",
"periodEndSaved": "Fine delle mestruazioni salvata",
"startedPeriodOnDate": "Le mestruazioni sono iniziate questo giorno",
"endedPeriodOnDate": "Le mestruazioni sono finite questo giorno"
},
@@ -8084,6 +8101,23 @@
"delete": "Elimina",
"deleteConfirmTitle": "Eliminare il promemoria?",
"deleteConfirmDescription": "Questo promemoria di controllo verrà eliminato definitivamente.",
"postpone": {
"menuItem": "Rimanda…",
"title": "Rimanda la scadenza",
"description": "Sposta solo la scadenza attuale — il ritmo regolare resta invariato.",
"chip": "+{days} giorni",
"dateLabel": "Nuova data",
"confirm": "Rimanda"
},
"skip": {
"heading": "Salta questa scadenza",
"explainer": "Registrato onestamente come saltato — non come fatto. L'intervallo riparte da oggi.",
"action": "Salta",
"confirmTitle": "Saltare questa scadenza?"
},
"snoozedUntil": "Rimandato al {date}",
"skippedOn": "Saltato il {date}",
"skipCount": "saltato {count} volte",
"disabledBadge": "Disattivato",
"nextDue": {
"none": "Non pianificato",
@@ -8933,7 +8967,8 @@
"outOfScope": "Posso aiutarti solo con i dati di salute del tuo registro: misurazioni, farmaci, umore, tendenze. Non rispondo a domande al di fuori di questo ambito.",
"promptInjection": "Ho notato istruzioni che cercano di aggirare le mie direttive. Sono il Coach di HealthLog e riassumo soltanto i tuoi dati di salute — riformula la domanda in questi termini, per favore.",
"conversationPoisoned": "Un messaggio precedente di questa conversazione contiene istruzioni che cercano di aggirare le mie direttive. Inizia una nuova conversazione, per favore."
}
},
"noRecordedData": "Non hai misurazioni registrate su quello che chiedi, quindi non posso darti alcun valore. Appena ne inserisci qualcuna, richiedimelo e userò i tuoi dati."
},
"safety": {
"floor": {
@@ -9034,7 +9069,9 @@
"flaggedBadge": "Supporto mostrato",
"chartTitle": "Andamento del punteggio",
"totalLabel": "Punteggio",
"bandLabel": "Categoria"
"bandLabel": "Categoria",
"answersToggle": "Risposte",
"answersUnavailable": "Le risposte salvate di questo check-in non possono essere decifrate."
},
"crisis": {
"title": "Non devi affrontare tutto questo da solo/a",
@@ -9305,7 +9342,7 @@
"freeText": {
"label": "Nota a testo libero (allergie e intolleranze)",
"hint": "Un'integrazione a testo libero all'elenco strutturato qui sopra, per il contesto che una singola voce non può contenere.",
"savedToast": "Nota sulle allergie salvata.",
"savedToast": "Nota sulle allergie salvata",
"saveError": "Impossibile salvare. Riprova."
}
},
@@ -9364,7 +9401,7 @@
"conditionsLabel": "Patologie croniche",
"focusLabel": "Cosa tiene d'occhio il Coach",
"loadError": "Impossibile caricare le tue patologie.",
"savedToast": "Patologie salvate.",
"savedToast": "Patologie salvate",
"saveError": "Impossibile salvare. Riprova."
},
"emergency": {
@@ -9468,7 +9505,7 @@
},
"toast": {
"duplicate": "Già archiviato — il documento esistente viene evidenziato.",
"deleted": "Documento eliminato.",
"deleted": "Documento eliminato",
"restoreFailed": "Impossibile ripristinare il documento."
},
"timeline": {
@@ -9641,9 +9678,9 @@
"unverifiedNoteOther": "{count} valori devono ancora essere controllati e restano in sospeso.",
"finish": "Concludi la revisione",
"confirmFailed": "La revisione non è stata salvata. Riprova.",
"savedToastOne": "1 valore salvato.",
"savedToastFew": "{count} valori salvati.",
"savedToastOther": "{count} valori salvati.",
"savedToastOne": "1 valore salvato",
"savedToastFew": "{count} valori salvati",
"savedToastOther": "{count} valori salvati",
"nothingSavedToast": "Revisione salvata. Nessun valore è stato aggiunto.",
"extract": "Estrai i valori di laboratorio",
"extracting": "Estrazione…",
@@ -9738,6 +9775,8 @@
"editedToast": "Valore aggiornato",
"editError": "Impossibile aggiornare il valore",
"deletedToast": "Valore eliminato",
"restoredToast": "Valore ripristinato",
"restoreError": "Ripristino non riuscito",
"deleteError": "Impossibile eliminare il valore"
},
"chart": {
@@ -10184,7 +10223,7 @@
"errorRateLimit": "Troppi profili creati nell'ultima ora. Aspetta un po' e riprova."
},
"toast": {
"savedTo": "Salvato nella cartella di {name}."
"savedTo": "Salvato nella cartella di {name}"
},
"activityVerb": {
"measurementCreate": "{name} ha aggiunto una misurazione",
@@ -10207,6 +10246,8 @@
"measurementReminderDelete": "{name} ha eliminato un promemoria di misurazione",
"measurementReminderComplete": "{name} ha completato un promemoria di misurazione",
"measurementReminderSatisfy": "{name} ha chiuso un promemoria di misurazione",
"measurementReminderSkip": "{name} ha saltato un promemoria di misurazione",
"measurementReminderSnooze": "{name} ha rimandato un promemoria di misurazione",
"labResultUpdate": "{name} ha modificato un risultato di laboratorio",
"labResultDelete": "{name} ha eliminato un risultato di laboratorio",
"labResultRestore": "{name} ha ripristinato un risultato di laboratorio",
+140 -99
View File
@@ -516,6 +516,9 @@
"heroItemsTitle": "Dzisiejsze wyróżnienia",
"heroItemsDescription": "Wybierz typy elementów, które mogą pojawiać się w dzisiejszych wyróżnieniach.",
"heroItemsNotificationNote": "Ustawieniami powiadomień zarządzasz osobno.",
"heroContentTitle": "Górna karta pokazuje",
"heroContentScore": "Wynik zdrowia i podsumowanie dnia",
"heroContentReminders": "Dzisiejsze wyróżnienia",
"heroItems": {
"coach_checkin": "Podsumowania z Coachem",
"dose_window": "Dawki leków",
@@ -560,7 +563,7 @@
"viewAll": "Zobacz wszystkie",
"empty": {
"title": "Brak treningów",
"cta": "Otwórz aplikację Apple Zdrowie na iPhonie, aby zsynchronizować treningi."
"cta": "Połącz źródło danych w ustawieniach, aby zobaczyć tu treningi."
}
},
"metric": {
@@ -729,9 +732,12 @@
"sourceWhoop": "WHOOP",
"sourceFitbit": "Fitbit",
"bulkDeleteSuccess": "Usunięto pomiary: {count}",
"bulkDeleteSuccessOne": "Usunięto pomiar",
"bulkDeleteError": "Błąd podczas usuwania wybranych pomiarów",
"bulkDeleteConfirmTitle": "Usunąć pomiary ({count})?",
"bulkDeleteConfirmTitleOne": "Usunąć ten pomiar?",
"bulkDeleteConfirmBody": "Wybrane pomiary ({count}) zostaną usunięte. Zaraz po usunięciu możesz to przez chwilę cofnąć.",
"bulkDeleteConfirmBodyOne": "Wybrany pomiar zostanie usunięty. Zaraz po usunięciu możesz to przez chwilę cofnąć.",
"typePhq9Score": "Wynik PHQ-9",
"typeGad7Score": "Wynik GAD-7",
"typeGripStrength": "Siła chwytu",
@@ -879,9 +885,12 @@
"sourceTelegram": "Telegram",
"sourceDaylio": "Daylio",
"bulkDeleteSuccess": "Usunięto wpisy: {count}",
"bulkDeleteSuccessOne": "Usunięto wpis",
"bulkDeleteError": "Błąd podczas usuwania wybranych wpisów",
"bulkDeleteConfirmTitle": "Usunąć wpisy ({count})?",
"bulkDeleteConfirmTitleOne": "Usunąć ten wpis?",
"bulkDeleteConfirmBody": "Wybrane wpisy nastroju ({count}) zostaną usunięte. Zaraz po usunięciu możesz to przez chwilę cofnąć.",
"bulkDeleteConfirmBodyOne": "Wybrany wpis nastroju zostanie usunięty. Zaraz po usunięciu możesz to przez chwilę cofnąć.",
"customize": "Dostosuj tagi nastroju",
"addTagInline": "Dodaj tag",
"manage": {
@@ -1779,7 +1788,8 @@
"enabledToast": "Przypomnienia włączone",
"disabledToast": "Przypomnienia wyłączone",
"toggleFailed": "Nie udało się zmienić ustawienia przypomnień.",
"clientManagedChip": "Twój iPhone zarządza przypomnieniami dla tego leku."
"clientManagedChip": "Zarządzane przez iPhone’a",
"clientManagedHint": "Twój iPhone zarządza przypomnieniami dla tego leku."
},
"settings": {
"title": "Ustawienia",
@@ -1932,7 +1942,7 @@
"addQuantityHelper": "1–1000 jednostek na opakowanie lub pojemnik.",
"addExpiryLabel": "Data ważności (opcjonalnie)",
"addSubmit": "Zarejestruj",
"addSuccess": "Zapas zarejestrowany.",
"addSuccess": "Zapas zarejestrowany",
"addFailed": "Nie udało się zarejestrować zapasu.",
"adjustButton": "Skoryguj",
"adjustTitle": "Popraw opakowanie",
@@ -1940,11 +1950,11 @@
"adjustQuantityLabel": "Pozostałe jednostki",
"adjustHelper": "Pojemność: {total} jednostek.",
"adjustSubmit": "Zapisz",
"adjustSuccess": "Zapas zaktualizowany.",
"adjustSuccess": "Zapas zaktualizowany",
"adjustFailed": "Nie udało się zaktualizować zapasu.",
"deleteTitle": "Usunąć opakowanie?",
"deleteDescription": "Trwale usuwa to opakowanie lub pojemnik z zapasu. Zarejestrowane dawki pozostają bez zmian.",
"deleteSuccess": "Zapas usunięty.",
"deleteSuccess": "Zapas usunięty",
"deleteFailed": "Nie udało się usunąć zapasu.",
"containerTypeLabel": "Rodzaj pojemnika",
"containerType": {
@@ -2139,7 +2149,7 @@
"scaleToggleLabel": "Skala wykresu"
},
"import": {
"resultSuccess": "Zaimportowano przyjęcia: {imported}.",
"resultSuccess": "Zaimportowano przyjęcia: {imported}",
"resultPartial": "Zaimportowano {imported}, pominięto {skipped}.",
"resultNothing": "Nic nie zaimportowano: pominięto wszystkie wpisy ({skipped}).",
"resultAlreadyRecorded": "Przyjęcia już zapisane: {recorded}. Nie dodano niczego nowego.",
@@ -2302,7 +2312,6 @@
}
},
"comparison": {
"toggleLabel": "Porównaj z",
"baseline": {
"none": "Wyłączone",
"lastMonth": "Poprzedni miesiąc",
@@ -2402,7 +2411,7 @@
"dragHandle": "Przeciągnij, aby zmienić kolejność",
"show": "Pokaż",
"hide": "Ukryj",
"manageInSettings": "Zarządzaj pigułkami i stronami szczegółów w ustawieniach",
"manageInSettings": "Zarządzaj chipami i stronami szczegółów w ustawieniach",
"groupSleep": "Sen",
"groupMood": "Nastrój",
"groupEvents": "Zdarzenia",
@@ -2423,23 +2432,6 @@
"badge": "PR",
"tooltip": "Rekord osobisty: {value} {unit} dnia {date}"
},
"recommendation": {
"source": "Źródło",
"viewSource": "Otwórz wytyczne w nowej karcie",
"rationaleWindow": "Okno",
"rationaleComparedTo": "W porównaniu z",
"rationaleDeviation": "Odchylenie",
"rationaleExpand": "Pokaż uzasadnienie",
"rationaleCollapse": "Ukryj uzasadnienie",
"feedbackHelpful": "Przydatne",
"feedbackNotHelpful": "Mało przydatne",
"feedbackThanks": "Dzięki za opinię",
"feedbackAlreadyRated": "Już ocenione",
"feedbackError": "Nie udało się zapisać opinii. Spróbuj ponownie.",
"confidenceAria": "Pewność: {value} na 100",
"confidenceLow": "Niska pewność — mało danych",
"confidenceDraft": "Wersja robocza"
},
"loadError": "Nie udało się załadować analiz",
"heroPersonalBaseline": "Na podstawie ostatnich 90 dni",
"heroRegenerating": "Generowanie",
@@ -2545,11 +2537,6 @@
"areas": "{present} z {required} obszarów zdrowia"
}
},
"suggestedPrompts": {
"label": "Zapytaj mnie",
"tellMyDoctor": "Co powinienem powiedzieć lekarzowi?",
"medicationWorking": "Czy mój lek działa?"
},
"dailyBriefing": {
"title": "Briefing dnia",
"keyFindingsTitle": "Kluczowe wnioski",
@@ -2761,8 +2748,8 @@
"last7days": "Ostatnie 7 dni",
"last30days": "Ostatnie 30 dni",
"last90days": "Ostatnie 90 dni",
"lastYear": "od początku roku",
"allTime": "Cały okres"
"lastYear": "ostatnie 12 miesięcy",
"allTime": "cały okres (12 miesięcy szczegółowo)"
},
"windowLabel": "Okres",
"settingsAriaLabel": "Ustawienia coacha",
@@ -2776,6 +2763,7 @@
"settingsVerbosityBrief": "Krótka",
"settingsVerbosityDefault": "Standardowa",
"settingsVerbosityDetailed": "Szczegółowa",
"settingsVerbosityConciseHint": "Zwięzły ton i tak skraca odpowiedzi — szczegółowość pozostaje ustawiona na „Krótko”, dopóki jest aktywny.",
"settingsSourcesPointer": "Tym, z jakich grup danych może korzystać coach, oraz domyślnym oknem analizy zarządzasz bezpośrednio w czacie coacha (panel „Z czego korzystam”).",
"settingsSourcesPointerLink": "Otwórz coacha",
"settingsExcludeLabel": "Wyklucz metryki",
@@ -2787,7 +2775,7 @@
"settingsDefaultWindowLabel": "Domyślny okres analizy",
"settingsDefaultWindowHint": "Coach czyta ten okres na rozmowę, chyba że wybierzesz inny w nagłówku panelu.",
"settingsSave": "Zapisz",
"settingsSaved": "Zapisano.",
"settingsSaved": "Zapisano",
"settingsCancel": "Anuluj",
"feedbackHelpful": "Przydatne",
"feedbackUnhelpful": "Mało przydatne",
@@ -3059,7 +3047,7 @@
"loadError": "Nie udało się teraz wczytać tej metryki.",
"blutdruckDescription": "Twój przebieg skurczowego/rozkurczowego z zakresami docelowymi dopasowanymi do wieku i oceną tekstową.",
"gewichtDescription": "Przebieg wagi na tle zdrowego pasma wyliczonego z twojego wzrostu, z oceną.",
"pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.",
"pulsDescription": "Tętno spoczynkowe na tle zakresu referencyjnego według wieku i płci, opartego na percentylach populacyjnych CDC/NCHS, wraz z oceną opisową.",
"stimmungDescription": "Dzienne wartości nastroju w czasie wraz z oceną tekstową.",
"medikamenteDescription": "Kalendarz przyjęć dla każdego leku plus ocena tekstowa.",
"bmiDescription": "BMI obliczone z twojej wagi i wzrostu, z oceną.",
@@ -3160,7 +3148,8 @@
"sleep": {
"title": "Sen",
"description": "Podział na fazy i nocna długość snu, dane z Apple Health.",
"headlineTitle": "Średnia na noc",
"mainSleep": "Sen główny",
"totalSleep": "Sen łączny",
"compositionTitle": "Rozkład faz",
"compositionSubtitle": "Ostatnie {nights} nocy",
"compositionAriaLabel": "Rozkład faz snu w {nights} nocach",
@@ -4159,13 +4148,13 @@
"overviewDescription": "Pokaż lub ukryj sekcje i ustaw ich kolejność w przeglądzie Analiz."
},
"pillOrder": {
"title": "Sortuj pigułki nawigacyjne",
"description": "Przeciągnij pigułki w wybranej kolejności i pokaż lub ukryj je ikoną oka.",
"title": "Sortuj chipy nawigacyjne",
"description": "Przeciągnij chipy w wybranej kolejności i pokaż lub ukryj je ikoną oka.",
"dragHandle": "Zmień kolejność",
"saveSuccess": "Zapisano kolejność pigułek.",
"saveError": "Zapisanie kolejności pigułek nie powiodło się.",
"saveSuccess": "Zapisano kolejność chipów",
"saveError": "Zapisanie kolejności chipów nie powiodło się.",
"save": "Zapisz",
"empty": "Brak dostępnych pigułek nawigacyjnych.",
"empty": "Brak dostępnych chipów nawigacyjnych.",
"detail": "Ukryte strony szczegółów znikają również z górnej nawigacji."
},
"customize": "Dostosuj Analizy",
@@ -4509,7 +4498,7 @@
"title": "Withings",
"body": "Wagi, ciśnieniomierze i trackery Withings synchronizują się automatycznie po połączeniu.",
"cta": "Połącz Withings",
"setupCta": "Skonfiguruj w Ustawieniach",
"setupCta": "Skonfiguruj",
"setupHint": "Najpierw wymaga własnej, darmowej aplikacji deweloperskiej Withings — około 5 minut, jednorazowo."
},
"more": {
@@ -4520,7 +4509,8 @@
"oura": "Oura",
"polar": "Polar",
"nightscout": "Nightscout",
"fitbit": "Fitbit"
"fitbit": "Fitbit",
"appleHealthHint": "Apple Health nie ma połączenia przez przeglądarkę — dokończ tu wstępną konfigurację, a następnie otwórz aplikację HealthLog na iPhonie, aby dokończyć konfigurację Apple Health."
},
"import": "Importuj istniejącą historię"
},
@@ -4719,7 +4709,6 @@
"last30days": "Ostatnie 30 dni",
"last90days": "Ostatnie 90 dni",
"allTime": "Cały okres",
"showEvidence": "Domyślnie pokazuj źródła",
"excludedMetrics": "Wykluczone metryki",
"dataClusters": "Grupy danych",
"customClusters": "Użyj własnego wyboru grup",
@@ -4834,8 +4823,6 @@
}
},
"dashboard": {
"title": "Pulpit",
"description": "Układ i kolejność kafelków.",
"subtitle": "Rozmieść kafelki na pulpicie."
},
"thresholds": {
@@ -4856,9 +4843,9 @@
"resetDefaultsConfirm": "Zresetuj kolejność",
"moveUp": "W górę",
"moveDown": "W dół",
"saveSuccess": "Zapisano priorytet źródeł.",
"saveSuccess": "Zapisano priorytet źródeł",
"saveError": "Zapisanie priorytetu źródeł nie powiodło się.",
"resetSuccess": "Przywrócono domyślny priorytet źródeł.",
"resetSuccess": "Przywrócono domyślny priorytet źródeł",
"perMetricToggle": "Ustawienia dla poszczególnych metryk ({count})",
"perMetricHelp": "Metryki dostosowane względem domyślnej kolejności. Użyj tej listy, aby przywrócić pojedynczą metrykę do wartości domyślnej, nie zmieniając pozostałych.",
"perMetricEmpty": "Brak indywidualnych ustawień — każda metryka korzysta z globalnej kolejności powyżej.",
@@ -5002,7 +4989,14 @@
"unpacking": "Rozpakowywanie archiwum…",
"parsing": "Odczytywanie eksportu…",
"upserting": "Importowanie rekordów…"
}
},
"skipUnsupported": "{count} próbek pominięto — typy danych, których HealthLog nie importuje.",
"skipRefused": "{count} próbek nie udało się zapisać (nieczytelne, poza zakresem lub zapis się nie powiódł).",
"skipBreakdown": "Pokaż zestawienie ({count} typów)",
"skipBreakdownMore": "+ {count} kolejnych typów",
"cycleSummary": "{samples} próbek cyklu połączono w {days} dzienników dziennych.",
"cycleModuleOff": "{count} próbek cyklu pominięto — śledzenie cyklu jest wyłączone dla tego konta.",
"cycleDaysFailed": "{count} dni cyklu nie udało się zapisać: {reason}"
},
"json": {
"title": "Plik JSON",
@@ -5020,7 +5014,7 @@
"failed": "Import nie powiódł się. Spróbuj ponownie.",
"readFailed": "Nie można odczytać tego pliku.",
"resultSummary": "Zaimportowano {measurements} pomiarów i {moods} wpisów nastroju, pominięto {skipped}.",
"resultSuccess": "Zaimportowano {measurements} pomiarów i {moods} wpisów nastroju.",
"resultSuccess": "Zaimportowano {measurements} pomiarów i {moods} wpisów nastroju",
"resultNothing": "Nic nie zaimportowano. Pominięto wszystkie {skipped} wpisów.",
"resultEmpty": "Ten plik nie zawierał pomiarów ani wpisów nastroju."
},
@@ -5044,10 +5038,10 @@
"previewSummary": "Podgląd: {inserted} zostanie zaimportowanych, pominięto {skipped}.",
"resultSummary": "Zaimportowano {inserted} nowych i zaktualizowano {updated}, pominięto {skipped}.",
"rowError": "Wiersz {line}: {reason}",
"resultSuccess": "Zaimportowano {inserted} nowych i zaktualizowano {updated}.",
"resultSuccess": "Zaimportowano {inserted} nowych i zaktualizowano {updated}",
"resultNothing": "Nic nie zaimportowano. Pominięto wszystkie {skipped} wierszy.",
"resultEmpty": "Ten plik ma nagłówek, ale nie zawiera wierszy danych.",
"previewSuccess": "Podgląd: {inserted} zostanie zaimportowanych.",
"previewSuccess": "Podgląd: {inserted} zostanie zaimportowanych",
"previewNothing": "Podgląd: nic nie zostanie zaimportowane. Pominięte zostaną wszystkie {skipped} wierszy.",
"skipGroupOne": "Pominięto {count} wiersz: {reason}",
"skipGroupFew": "Pominięto {count} wiersze: {reason}",
@@ -5138,38 +5132,26 @@
"subtitle": "Udostępnij dostęp tylko do odczytu do swoich danych."
},
"insights": {
"title": "Analizy",
"description": "Kolejność przeglądu i pigułek nawigacyjnych.",
"subtitle": "Rozmieść sekcje przeglądu i kolejność etykiet."
"subtitle": "Rozmieść sekcje przeglądu i kolejność chipów."
},
"medications": {
"title": "Leki",
"description": "Widok listy, kolejność i miejsca wstrzyknięć Twoich leków.",
"subtitle": "Zarządzaj widokiem i kolejnością listy leków."
},
"mood": {
"title": "Tagi nastroju",
"description": "Grupy, tagi i zarchiwizowane tagi w wyborze nastroju.",
"subtitle": "Uporządkuj tagi nastroju, grupy i kolejność wyboru."
},
"labs": {
"title": "Wyniki badań",
"description": "Widok, sortowanie i biomarkery Twoich wyników badań.",
"subtitle": "Zarządzaj widokiem, sortowaniem i biomarkerami."
},
"illness": {
"title": "Dziennik chorób",
"description": "Widok i kolejność Twoich epizodów chorobowych.",
"subtitle": "Zarządzaj widokiem i kolejnością epizodów."
},
"vorsorge": {
"title": "Badania",
"description": "Widok, kolejność i poszczególne przypomnienia o badaniach.",
"subtitle": "Zarządzaj widokiem, kolejnością i poszczególnymi przypomnieniami."
},
"modules": {
"title": "Moduły",
"saved": "Zapisano.",
"saved": "Zapisano",
"error": "Nie udało się zapisać. Spróbuj ponownie.",
"toggleable": {
"title": "Co śledzisz",
@@ -5217,7 +5199,7 @@
"use": "Użyj",
"select": "Wybierz",
"saveError": "Nie udało się zapisać zmiany.",
"homeSaved": "Zapisano lokalizację domową.",
"homeSaved": "Zapisano lokalizację domową",
"travelAdded": "Dodano okres lokalizacji.",
"travelRemoved": "Usunięto okres lokalizacji.",
"backfillQueued": "Zaplanowano uzupełnianie.",
@@ -5274,7 +5256,7 @@
"current": "Twój zapisany wybór"
},
"save": "Zapisz",
"saved": "Zapisano.",
"saved": "Zapisano",
"error": "Nie udało się zapisać. Spróbuj ponownie.",
"loadFailed": "Nie udało się wczytać Twojego wyboru.",
"eligibilityFailed": "Nie udało się sprawdzić, które filary mają teraz dane. Przełączniki poniżej i tak działają.",
@@ -5312,7 +5294,7 @@
"auto": "Automatycznie (język)",
"h24": "24-godzinny",
"h12": "12-godzinny (AM/PM)",
"saved": "Zapisano format godziny.",
"saved": "Zapisano format godziny",
"saveError": "Nie udało się zapisać formatu godziny."
},
"dateFormat": {
@@ -5322,7 +5304,7 @@
"dmy": "DD.MM.RRRR",
"mdy": "MM/DD/RRRR",
"ymd": "RRRR-MM-DD",
"saved": "Zapisano format daty.",
"saved": "Zapisano format daty",
"saveError": "Nie udało się zapisać formatu daty."
},
"username": "Nazwa użytkownika",
@@ -5616,7 +5598,7 @@
"clearBody": "Usuwa tylko tekst notatki. Alergie i nietolerancje pozostają bez zmian.",
"clearConfirm": "Wyczyść notatkę",
"save": "Zapisz",
"savedToast": "Notatka „O mnie” zapisana.",
"savedToast": "Notatka „O mnie” zapisana",
"clearedToast": "Notatka „O mnie” usunięta.",
"saveError": "Nie udało się zapisać. Spróbuj ponownie.",
"loadError": "Nie udało się wczytać notatki.",
@@ -5638,14 +5620,14 @@
"title": "Automatyczne czytanie dokumentów przez AI",
"subLabel": "Dokumenty są czytane i indeksowane przez skonfigurowanego dostawcę AI bez potwierdzania każdego dokumentu.",
"honesty": "Po włączeniu każdy przesłany dokument jest wysyłany do skonfigurowanego dostawcy AI, aby go odczytać, opisać i zaindeksować do wyszukiwania — bez potwierdzania każdego dokumentu. Włączenie działa też wstecz: dokumenty już przesłane, które nie mają jeszcze streszczenia, również zostaną wysłane — do 200 przy każdym włączeniu. Dzieje się to na serwerze, a treść dokumentu opuszcza to urządzenie i trafia do tego dostawcy. Jeśli skonfigurowany dostawca to połączenie subskrypcyjne (zalogowane konto AI zamiast klucza API), obowiązują jego własne ustawienia danych — dostawcy subskrypcyjni mogą wykorzystywać treść do ulepszania swoich modeli i nie obejmuje tego żadna umowa powierzenia przetwarzania danych. Dokumenty odczytywane przez samodzielnie hostowany model lokalny nigdy nie opuszczają Twojego serwera. Domyślnie wyłączone; włącz je tylko wtedy, gdy akceptujesz ten kompromis dla swoich dokumentów.",
"confirm": "Rozumiem — włącz automatyczne czytanie przez AI",
"confirm": "Rozumiem — włącz",
"cancel": "Anuluj"
},
"centralCodex": {
"title": "Użyj wspólnego dostępu AI serwera",
"subLabel": "Kieruj zapytania AI przez połączenie skonfigurowane przez operatora dla tego serwera, obok Twoich własnych dostawców jako rozwiązanie awaryjne.",
"honesty": "Wspólny dostęp to jedno zalogowane konto AI, które operator podłączył dla wszystkich na tym serwerze — nie Twój własny klucz. Zapytania, które przez nie wysyłasz, są domyślnie wykorzystywane do trenowania modeli dostawcy i nie obejmuje ich żadna umowa powierzenia przetwarzania danych, godzinowe i tygodniowe limity konta dzielisz z wszystkimi innymi, którzy się zgodzili, a Twoi właśni dostawcy są próbowani jako pierwsi. Włącz to tylko, jeśli akceptujesz ten kompromis.",
"confirm": "Rozumiem — użyj wspólnego połączenia",
"confirm": "Rozumiem — użyj połączenia",
"cancel": "Anuluj"
},
"consent": {
@@ -5667,7 +5649,7 @@
"description": "Wybierz sposób wyświetlania odległości i prędkości na wykresach i kafelkach.",
"metric": "Metryczne",
"imperial": "Imperialne",
"saved": "Zapisano preferencję jednostek.",
"saved": "Zapisano preferencję jednostek",
"saveError": "Nie udało się zapisać preferencji jednostek."
}
},
@@ -5855,7 +5837,7 @@
"errorReconnect": "Błąd — połącz ponownie",
"warningServerError": "Synchronizacja nie działa",
"failureCount": "{count}/{threshold}",
"parkedReconnect": "Wstrzymane — połącz ręcznie",
"parkedReconnect": "Wstrzymane — połącz ponownie",
"notConnected": "Nie połączono",
"justNow": "przed chwilą",
"minutesAgo": "{count} min temu",
@@ -5939,6 +5921,7 @@
"repository": "Kod źródłowy",
"changelog": "Changelog",
"docs": "Dokumentacja",
"credits": "Autorzy i źródła danych",
"linksHeading": "Źródła i dokumentacja",
"newerAvailable": "Nowa wersja: {tag}"
},
@@ -5966,7 +5949,7 @@
},
"notificationStatus": {
"title": "Niezawodność kanałów",
"description": "Stan każdego kanału; „Automatycznie wyłączone” przerywa ponawianie po trwałych błędach.",
"description": "Stan każdego kanału — „Automatycznie wyłączone” oznacza zatrzymanie po trwałych błędach.",
"emptyDescription": "Dodaj kanał w Ustawienia › Integracje › Kanały dostarczania.",
"stateActive": "Aktywne",
"stateAutoDisabled": "Automatycznie wyłączone",
@@ -6032,7 +6015,7 @@
"expiryInvalid": "Wygaśnięcie musi mieścić się między 1 a {max} dniami.",
"create": "Utwórz link",
"tokenCreated": "Link udostępniania utworzony",
"copied": "Skopiowano do schowka.",
"copied": "Skopiowano do schowka",
"activeTitle": "Aktywne linki",
"noActive": "Brak aktywnych linków udostępniania.",
"created": "Utworzono",
@@ -6184,7 +6167,7 @@
"revokeSession": "Wyloguj",
"revokeError": "Nie udało się zrealizować żądania. Spróbuj ponownie.",
"signOutEverywhere": "Wyloguj na pozostałych urządzeniach",
"signOutEverywhereDone": "Wylogowano {count} inną/e sesję/e.",
"signOutEverywhereDone": "Wylogowano {count} inną/e sesję/e",
"activityTitle": "Aktywność zabezpieczeń",
"activityDescription": "Ostatnie logowania, zmiany zabezpieczeń i eksporty danych Twojego konta.",
"activityEmpty": "Brak aktywności zabezpieczeń.",
@@ -6265,7 +6248,7 @@
"revokeError": "Nie udało się cofnąć urządzenia. Spróbuj ponownie.",
"revoke": "Cofnij",
"revokeAll": "Zapomnij wszystkie urządzenia",
"revokeAllDone": "Zapomniano {count} zaufane urządzenie/a.",
"revokeAllDone": "Zapomniano {count} zaufane urządzenie/a",
"unnamed": "Nieznane urządzenie",
"current": "To urządzenie",
"expires": "Zaufane do {date}",
@@ -6329,8 +6312,8 @@
"endpointLabel": "Adres URL łącznika",
"connectDescription": "Wklej ten adres URL w konfiguracji własnego łącznika asystenta, a następnie autoryzuj za pomocą poniższego tokenu lub zaloguj się po wyświetleniu monitu.",
"tokensTitle": "Tokeny łącznika",
"tokensDescription": "Wygeneruj token tylko do odczytu, aby połączyć asystenta.",
"scopeNote": "Domyślnie tokeny mają dostęp tylko do odczytu własnych danych. Dostęp do zapisu jest opcjonalny i włączany dla każdego tokena przełącznikiem poniżej. Tokeny nigdy nie mogą uzyskać dostępu do funkcji administracyjnych.",
"tokensDescription": "Wygeneruj token dla asystenta — tylko odczyt, chyba że pozwolisz poniżej na zapis.",
"scopeNote": "Nowe tokeny mogą tylko odczytywać Twoje dane. Dostęp do zapisu (health:write) przyznajesz dla każdego tokena przełącznikiem poniżej. Token nigdy nie może uzyskać dostępu do funkcji administracyjnych.",
"writeScopeToggleLabel": "Zezwól na zapis (health:write)",
"writeScopeNote": "Dodaje zakres health:write, aby asystent mógł zapisywać nowe pomiary i wpisy nastroju na Twoim koncie. Każdy zapis jest najpierw potwierdzany. Token nadal nie może usuwać ani edytować istniejących wpisów, zmieniać leków ani uzyskiwać dostępu do funkcji REST lub administracyjnych. Pozostaw wyłączone, aby uzyskać dostęp tylko do odczytu.",
"connectionsTitle": "Połączeni asystenci",
@@ -6386,6 +6369,13 @@
"importNote": "Potrzebujesz starszej historii lub nie możesz użyć aplikacji iOS? Przesłanie pliku XML/ZIP z Apple Health jest jednorazową alternatywą i nie włącza synchronizacji na żywo.",
"importAction": "Otwórz jednorazowy import",
"staleNote": "Od ponad tygodnia nie napłynęły żadne dane z Apple Health. Otwórz aplikację HealthLog na iPhonie i sprawdź uprawnienia Apple Health.",
"progress": {
"title": "Postęp synchronizacji",
"receivedLabel": "Odebrane rekordy",
"oldestLabel": "Najstarszy osiągnięty pomiar",
"flowing": "W ciągu ostatniej godziny dotarły dane — aplikacja na iPhonie nadal je przesyła.",
"waiting": "Oczekiwanie na kolejne dane z aplikacji na iPhonie. Przesyłanie trwa dalej, gdy aplikacja jest otwarta lub gdy iOS przydzieli jej czas w tle — cisza podczas pierwszego dużego uzupełniania historii jest normalna, to nie błąd."
},
"delivery": {
"title": "Dostarczanie",
"lastTriggerLabel": "Ostatnia synchronizacja dotarła",
@@ -6590,13 +6580,13 @@
"maxUsesLabel": "Maksymalna liczba użyć",
"maxUsesHint": "Ile rejestracji dopuszcza ten link (1–50).",
"createConfirm": "Utwórz zaproszenie",
"createdToast": "Zaproszenie utworzone.",
"createdToast": "Zaproszenie utworzone",
"createError": "Nie udało się utworzyć zaproszenia.",
"tokenTitle": "Link z zaproszeniem",
"tokenOnceHint": "Do skopiowania tylko teraz — skopiuj link lub udostępnij kod QR do zeskanowania.",
"hashExplainer": "Zapisywany jest tylko hash linku — nie można go później ponownie wyświetlić. W razie utraty unieważnij zaproszenie i utwórz nowe.",
"copyLink": "Kopiuj link",
"copiedToast": "Link skopiowany.",
"copiedToast": "Link skopiowany",
"copyError": "Kopiowanie nie powiodło się.",
"qrAlt": "Kod QR linku z zaproszeniem",
"loadError": "Nie udało się wczytać zaproszeń.",
@@ -6890,6 +6880,23 @@
"restoreTitle": "Przywróć z kopii zapasowej",
"restoreDescription": "Spowoduje to zastąpienie WSZYSTKICH bieżących danych {username} migawką z {when}. Istniejące pomiary, leki, zdarzenia przyjęcia, wpisy nastroju, kanały powiadomień, subskrypcje push i zakolejkowane usunięcia Telegram zostaną usunięte.",
"restorePromptLabel": "Wpisz RESTORE, aby kontynuować",
"previewTitle": "Zawartość kopii",
"previewLoading": "Odczytywanie zawartości…",
"previewUnavailable": "Nie udało się odczytać zawartości — przywracanie jest nadal możliwe.",
"previewEmpty": "Plik nie zawiera rekordów.",
"previewOther": "Inne rekordy",
"previewMeasurements": "Pomiary",
"previewMedications": "Leki i dawki",
"previewMood": "Wpisy nastroju",
"previewLabs": "Wyniki badań",
"previewDocuments": "Dokumenty",
"previewWorkouts": "Treningi",
"previewCycles": "Dane cyklu",
"previewVisits": "Wizyty lekarskie",
"previewVaccinations": "Szczepienia",
"previewReminders": "Przypomnienia profilaktyczne",
"previewCustomMetrics": "Własne metryki",
"previewHealthScores": "Historia wyniku",
"restoreConfirm": "Przywróć teraz",
"restoreInProgress": "Przywracanie...",
"restoreSuccess": "Przywracanie zakończone",
@@ -6905,7 +6912,17 @@
"restoreSkippedVisitReference": "Odwołanie wizyty",
"restoreSkippedVaccinationReference": "Odwołanie szczepienia",
"restoreSkippedCheckupClosure": "Zamknięcie badania",
"docsLink": "Dokumentacja"
"restoreSkippedReminderReference": "Odwołanie do przypomnienia",
"restoreSkippedCoachAttachment": "Powiązanie dokumentu z Coachem",
"restoreSkippedCoachReference": "Odwołanie Coacha",
"restoreSkippedDocumentConditionLink": "Przypisanie dokumentu",
"restoreSkippedExtractedFact": "Fakt z dokumentu",
"restoreSkippedFactCommitment": "Zapis faktu",
"docsLink": "Dokumentacja",
"restoreSkippedPersonalRecordReference": "Pochodzenie rekordu osobistego",
"restoreSkippedEcgReference": "Pochodzenie zapisu EKG",
"restoreSkippedMedicationTarget": "Analit docelowy leku",
"restoreSkippedScheduleRevisionLink": "Następstwo okresów schematu"
},
"danger-zone": {
"title": "Strefa zagrożenia",
@@ -7339,7 +7356,7 @@
"saveError": "Zapis nieudany. Spróbuj ponownie.",
"hourLabel": "Godzina przypomnienia",
"hourAria": "Godzina przypomnienia o nastroju",
"hourSavedToast": "Godzina przypomnienia zaktualizowana.",
"hourSavedToast": "Godzina przypomnienia zaktualizowana",
"detail": "Wysyłane tylko skonfigurowanymi już kanałami (Telegram, ntfy, Web Push, Apple Push) i milknie, gdy dziś zapisano nastrój."
},
"coachNudge": {
@@ -7380,8 +7397,8 @@
"leadLabel": "Czas na ponowne zamówienie (dni)",
"leadAria": "Czas na ponowne zamówienie w dniach",
"leadHelp": "Alert pojawia się o tyle dni plus jeden interwał dawki wcześniej, aby nowy zapas dotarł przed Twoją ostatnią dawką. Pojedynczy lek może nadpisać tę wartość na swojej karcie zapasu.",
"leadSavedToast": "Czas na ponowne zamówienie zapisany.",
"savedToast": "Próg zapasu zapisany.",
"leadSavedToast": "Czas na ponowne zamówienie zapisany",
"savedToast": "Próg zapasu zapisany",
"enabledToast": "Alerty niskiego zapasu włączone.",
"disabledToast": "Alerty niskiego zapasu wyłączone.",
"saveError": "Zapis nieudany. Spróbuj ponownie.",
@@ -8041,10 +8058,10 @@
"protected": "Czy podczas stosunku zastosowano antykoncepcję.",
"protectedLabel": "Co oznacza zabezpieczony?"
},
"saveSuccess": "Wpis zapisany.",
"deleteSuccess": "Wpis usunięty.",
"periodStartSaved": "Początek miesiączki zapisany.",
"periodEndSaved": "Koniec miesiączki zapisany.",
"saveSuccess": "Wpis zapisany",
"deleteSuccess": "Wpis usunięty",
"periodStartSaved": "Początek miesiączki zapisany",
"periodEndSaved": "Koniec miesiączki zapisany",
"startedPeriodOnDate": "Miesiączka zaczęła się tego dnia",
"endedPeriodOnDate": "Miesiączka skończyła się tego dnia"
},
@@ -8084,6 +8101,23 @@
"delete": "Usuń",
"deleteConfirmTitle": "Usunąć przypomnienie?",
"deleteConfirmDescription": "To przypomnienie o badaniu zostanie trwale usunięte.",
"postpone": {
"menuItem": "Przełóż…",
"title": "Przełóż termin",
"description": "Przesuwa tylko bieżący termin — regularny rytm pozostaje bez zmian.",
"chip": "+{days} dni",
"dateLabel": "Nowa data",
"confirm": "Przełóż"
},
"skip": {
"heading": "Pomiń ten termin",
"explainer": "Zapisane uczciwie jako pominięte — nie jako wykonane. Odstęp zaczyna się od nowa od dzisiaj.",
"action": "Pomiń",
"confirmTitle": "Pominąć ten termin?"
},
"snoozedUntil": "Przełożono do {date}",
"skippedOn": "Pominięto {date}",
"skipCount": "pominięto {count} razy",
"disabledBadge": "Wył.",
"nextDue": {
"none": "Niezaplanowane",
@@ -8933,7 +8967,8 @@
"outOfScope": "Mogę pomóc wyłącznie z danymi zdrowotnymi z Twojego dziennika: pomiarami, lekami, nastrojem, trendami. Na pytania spoza tego zakresu nie odpowiadam.",
"promptInjection": "Zauważyłem polecenia, które próbują obejść moje wytyczne. Jestem Coachem HealthLog i podsumowuję wyłącznie Twoje własne dane zdrowotne — sformułuj pytanie w tych ramach.",
"conversationPoisoned": "Wcześniejsza wiadomość w tej rozmowie zawiera polecenia, które próbują obejść moje wytyczne. Rozpocznij nową rozmowę."
}
},
"noRecordedData": "Nie masz zapisanych żadnych pomiarów dla tego, o co pytasz, więc nie mogę podać Ci żadnej liczby. Gdy coś zapiszesz, zapytaj ponownie — wtedy oprę się na Twoich własnych wartościach."
},
"safety": {
"floor": {
@@ -9034,7 +9069,9 @@
"flaggedBadge": "Pokazano wsparcie",
"chartTitle": "Przebieg wyniku",
"totalLabel": "Wynik",
"bandLabel": "Kategoria"
"bandLabel": "Kategoria",
"answersToggle": "Odpowiedzi",
"answersUnavailable": "Nie można odszyfrować zapisanych odpowiedzi tego sprawdzenia."
},
"crisis": {
"title": "Nie musisz mierzyć się z tym sam(a)",
@@ -9305,7 +9342,7 @@
"freeText": {
"label": "Notatka tekstowa (alergie i nietolerancje)",
"hint": "Tekstowe uzupełnienie ustrukturyzowanej listy powyżej, na kontekst, którego pojedynczy wpis nie odda.",
"savedToast": "Notatka o alergiach zapisana.",
"savedToast": "Notatka o alergiach zapisana",
"saveError": "Nie udało się zapisać. Spróbuj ponownie."
}
},
@@ -9364,7 +9401,7 @@
"conditionsLabel": "Choroby przewlekłe",
"focusLabel": "Na co zwraca uwagę Coach",
"loadError": "Nie udało się wczytać Twoich chorób.",
"savedToast": "Choroby zapisane.",
"savedToast": "Choroby zapisane",
"saveError": "Nie udało się zapisać. Spróbuj ponownie."
},
"emergency": {
@@ -9468,7 +9505,7 @@
},
"toast": {
"duplicate": "Już zapisano — istniejący dokument został wyróżniony.",
"deleted": "Dokument usunięty.",
"deleted": "Dokument usunięty",
"restoreFailed": "Nie udało się przywrócić dokumentu."
},
"timeline": {
@@ -9641,9 +9678,9 @@
"unverifiedNoteOther": "{count} wartości wymaga jeszcze sprawdzenia i pozostaje w oczekiwaniu.",
"finish": "Zakończ przegląd",
"confirmFailed": "Nie udało się zapisać przeglądu. Spróbuj ponownie.",
"savedToastOne": "Zapisano 1 wartość.",
"savedToastFew": "Zapisano {count} wartości.",
"savedToastOther": "Zapisano {count} wartości.",
"savedToastOne": "Zapisano 1 wartość",
"savedToastFew": "Zapisano {count} wartości",
"savedToastOther": "Zapisano {count} wartości",
"nothingSavedToast": "Przegląd zapisany. Nie dodano żadnych wartości.",
"extract": "Odczytaj wyniki laboratoryjne",
"extracting": "Odczytywanie…",
@@ -9738,6 +9775,8 @@
"editedToast": "Zaktualizowano wartość",
"editError": "Nie udało się zaktualizować wartości",
"deletedToast": "Usunięto wartość",
"restoredToast": "Przywrócono wartość",
"restoreError": "Nie udało się przywrócić",
"deleteError": "Nie udało się usunąć wartości"
},
"chart": {
@@ -10184,7 +10223,7 @@
"errorRateLimit": "Zbyt wiele profili utworzonych w ostatniej godzinie. Odczekaj chwilę i spróbuj ponownie."
},
"toast": {
"savedTo": "Zapisano w karcie użytkownika {name}."
"savedTo": "Zapisano w karcie użytkownika {name}"
},
"activityVerb": {
"measurementCreate": "{name} dodał pomiar",
@@ -10207,6 +10246,8 @@
"measurementReminderDelete": "{name} usunął przypomnienie o pomiarze",
"measurementReminderComplete": "{name} zrealizował przypomnienie o pomiarze",
"measurementReminderSatisfy": "{name} zamknął przypomnienie o pomiarze",
"measurementReminderSkip": "{name} pominął(-ęła) przypomnienie o pomiarze",
"measurementReminderSnooze": "{name} przełożył(-yła) przypomnienie o pomiarze",
"labResultUpdate": "{name} zmienił wynik badania",
"labResultDelete": "{name} usunął wynik badania",
"labResultRestore": "{name} przywrócił wynik badania",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.37.17",
"version": "1.37.26",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -84,7 +84,7 @@
"nodemailer": "9.0.3",
"otpauth": "^9.5.1",
"p-limit": "^7.3.1",
"pg-boss": "^12.26.0",
"pg-boss": "^12.27.0",
"prisma": "^7.8.0",
"qrcode": "^1.5.4",
"radix-ui": "^1.6.2",
@@ -109,9 +109,9 @@
"@axe-core/playwright": "^4.12.1",
"@next/bundle-analyzer": "^16.2.12",
"@openai/codex-security": "^0.1.1",
"@playwright/test": "^1.60.0",
"@playwright/test": "^1.62.1",
"@tailwindcss/postcss": "^4",
"@testcontainers/postgresql": "^12.0.3",
"@testcontainers/postgresql": "^12.1.0",
"@types/node": "26.1.1",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.20.0",
@@ -128,11 +128,11 @@
"pdf-parse": "^2.4.5",
"pdfjs-dist": "6.2.108",
"pg": "^8.22.0",
"prettier": "^3.9.1",
"prettier": "^3.9.6",
"prettier-plugin-tailwindcss": "^0.8.1",
"shadcn": "^4.16.1",
"tailwindcss": "^4",
"testcontainers": "^12.0.3",
"testcontainers": "^12.1.0",
"tw-animate-css": "^1.4.0",
"typescript": "^6",
"vitest": "^4.1.5"
+158 -170
View File
@@ -16,6 +16,7 @@ overrides:
ip-address@<10.3.1: ^10.3.1
sharp@<0.35.0: ^0.35.3
pdfjs-dist@>=5.6.83 <6.2.108: 6.2.108
deepmerge-ts@<8.0.0: ^8.0.0
importers:
@@ -101,7 +102,7 @@ importers:
version: 3.0.2
next:
specifier: 16.2.11
version: 16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.60.0)(@types/node@26.1.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(@types/node@26.1.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -115,8 +116,8 @@ importers:
specifier: ^7.3.1
version: 7.3.1
pg-boss:
specifier: ^12.26.0
version: 12.26.0
specifier: ^12.27.0
version: 12.27.0
prisma:
specifier: ^7.8.0
version: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)
@@ -177,7 +178,7 @@ importers:
devDependencies:
'@axe-core/playwright':
specifier: ^4.12.1
version: 4.12.1(playwright-core@1.60.0)
version: 4.12.1(playwright-core@1.62.1)
'@next/bundle-analyzer':
specifier: ^16.2.12
version: 16.2.12
@@ -185,14 +186,14 @@ importers:
specifier: ^0.1.1
version: 0.1.1(@types/node@26.1.1)(supports-color@7.2.0)
'@playwright/test':
specifier: ^1.60.0
version: 1.60.0
specifier: ^1.62.1
version: 1.62.1
'@tailwindcss/postcss':
specifier: ^4
version: 4.3.0
'@testcontainers/postgresql':
specifier: ^12.0.3
version: 12.0.3(supports-color@7.2.0)
specifier: ^12.1.0
version: 12.1.0(supports-color@7.2.0)
'@types/node':
specifier: 26.1.1
version: 26.1.1
@@ -242,11 +243,11 @@ importers:
specifier: ^8.22.0
version: 8.22.0
prettier:
specifier: ^3.9.1
version: 3.9.1
specifier: ^3.9.6
version: 3.9.6
prettier-plugin-tailwindcss:
specifier: ^0.8.1
version: 0.8.1(prettier@3.9.1)
version: 0.8.1(prettier@3.9.6)
shadcn:
specifier: ^4.16.1
version: 4.16.1(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(typescript@6.0.3)
@@ -254,8 +255,8 @@ importers:
specifier: ^4
version: 4.3.3
testcontainers:
specifier: ^12.0.3
version: 12.0.3(supports-color@7.2.0)
specifier: ^12.1.0
version: 12.1.0(supports-color@7.2.0)
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
@@ -1933,9 +1934,9 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
'@playwright/test@1.60.0':
resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==}
engines: {node: '>=18'}
'@playwright/test@1.62.1':
resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==}
engines: {node: '>=20'}
hasBin: true
'@polka/url@1.0.0-next.29':
@@ -2025,17 +2026,14 @@ packages:
'@protobufjs/float@1.0.2':
resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
'@protobufjs/inquire@1.1.2':
resolution: {integrity: sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==}
'@protobufjs/path@1.1.2':
resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
'@protobufjs/pool@1.1.0':
resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
'@protobufjs/utf8@1.1.1':
resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==}
'@protobufjs/utf8@1.1.2':
resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==}
'@radix-ui/number@1.1.2':
resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==}
@@ -3085,8 +3083,8 @@ packages:
'@tanstack/virtual-core@3.17.3':
resolution: {integrity: sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw==}
'@testcontainers/postgresql@12.0.3':
resolution: {integrity: sha512-y0l/ItWxNfdMU783EOZ66lMq90uuuXPqE+fo1jNSRfMbtfUq/9Ez/wluDxxaS+D/i3I0juep+jmz/WVuip8i9Q==}
'@testcontainers/postgresql@12.1.0':
resolution: {integrity: sha512-Pjf2VSVNirEPfz36nidyrVAnZvc2YhajOznY4VgyEsvfTd5qiMNOuPq96drREvxAUtXl5SFLX7vXj7sSq4aTcA==}
'@toon-format/toon@2.3.1':
resolution: {integrity: sha512-sWqEMeAJGMda9qRrfPKrX3C4c33CO9SJuvJzzrcBEn5sbDB+k6pSLkDsfN0rVnLQwR97MV3sDgVbcxQNsq8xVw==}
@@ -3635,32 +3633,28 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
bare-events@2.8.3:
resolution: {integrity: sha512-HdUm8EMQBLaJvGUdidNNbqpA1kYkwNcb+MYxkxCLAPJGQzlv9J0C24h8V65Z4c5GLd/JEALDvpFCQgpLJqc0zw==}
bare-events@2.9.1:
resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==}
peerDependencies:
bare-abort-controller: '*'
peerDependenciesMeta:
bare-abort-controller:
optional: true
bare-fs@4.7.1:
resolution: {integrity: sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==}
engines: {bare: '>=1.16.0'}
bare-fs@4.8.0:
resolution: {integrity: sha512-fM+MhCvdQhZ7NV6S95a07gPSqjIYKn6mFaXfx266wN3ajZGl/+1AzH+ubkXQ0fFZvOe2nk9VHkzdYkQE5zMV3Q==}
engines: {bare: '>=1.28.0'}
peerDependencies:
bare-buffer: '*'
peerDependenciesMeta:
bare-buffer:
optional: true
bare-os@3.9.1:
resolution: {integrity: sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==}
engines: {bare: '>=1.14.0'}
bare-path@3.1.1:
resolution: {integrity: sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==}
bare-path@3.0.0:
resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==}
bare-stream@2.13.1:
resolution: {integrity: sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==}
bare-stream@2.13.3:
resolution: {integrity: sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==}
peerDependencies:
bare-abort-controller: '*'
bare-buffer: '*'
@@ -3673,8 +3667,8 @@ packages:
bare-events:
optional: true
bare-url@2.4.3:
resolution: {integrity: sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==}
bare-url@2.5.2:
resolution: {integrity: sha512-L13PCJzKG8RGvx8V1/DdMi12ERhC3tprr7/8a94BxpmnRsFqxh5XZNdhtMxu5HPkRshYOOWRGY8lDP7ZhpG9Cg==}
base64-arraybuffer@1.0.2:
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
@@ -3724,11 +3718,8 @@ packages:
brace-expansion@1.1.18:
resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
brace-expansion@2.1.1:
resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==}
brace-expansion@2.1.2:
resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==}
brace-expansion@2.1.4:
resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==}
brace-expansion@5.0.9:
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
@@ -3966,10 +3957,9 @@ packages:
resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==}
engines: {node: '>= 14'}
cron-parser@5.6.2:
resolution: {integrity: sha512-yJ/G1LVir6CnlkLI40CsampPLKl1SprGGlUagWtGJxewytRVYezv5xVyzzbT+Pvzx+VIRvZVF7/a0eEMTxdnTA==}
cron-parser@5.10.0:
resolution: {integrity: sha512-izNAxJyRWUP8ljBoDSub5WyrVOUlT4SLGShswE7eoRBpp6QUsSycYxLBMJlbshgPBMcPT/nrfgjNY2918ayv2A==}
engines: {node: '>=18'}
deprecated: 'Published tarball was built from a stale dist/ and is missing the fixes from #414 and #415 — its contents are effectively v5.6.1. Upgrade to v5.7.0.'
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
@@ -4090,8 +4080,8 @@ packages:
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
deepmerge-ts@7.1.5:
resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==}
deepmerge-ts@8.0.1:
resolution: {integrity: sha512-szCXE7YLCvLKR9bFPJcvsezOShdalctSvrgN/LM/QGUEPZQajwjmsMObZ6/DuANT5lxzM/wtO8Feubwdkz8myA==}
engines: {node: '>=16.0.0'}
deepmerge@4.3.1:
@@ -4158,8 +4148,8 @@ packages:
resolution: {integrity: sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==}
engines: {node: '>= 8.0'}
dockerode@5.0.0:
resolution: {integrity: sha512-C52mvJ+7lcyhWNfrzVfFsbTrBfy/ezE9FGEYLpu17FUeBcCkxERk9nN7uDl/478ynDiQ4U+5DbQC2vENHkVEtQ==}
dockerode@5.0.1:
resolution: {integrity: sha512-avsq/xk4YPIrn0CgleX5bjT9Y8IT1p9PxrNQ++RBQ2WEyFfHCTDsT9kmyxz+H/axnjAwg8wJWEIuPGOUuNupiA==}
engines: {node: '>= 14.17'}
doctrine@2.1.0:
@@ -4673,9 +4663,9 @@ packages:
get-port-please@3.2.0:
resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==}
get-port@7.2.0:
resolution: {integrity: sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==}
engines: {node: '>=16'}
get-port@5.1.1:
resolution: {integrity: sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==}
engines: {node: '>=8'}
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
@@ -5554,8 +5544,8 @@ packages:
resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==}
engines: {node: '>=8.0.0'}
nan@2.27.0:
resolution: {integrity: sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==}
nan@2.28.0:
resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==}
nanoid@3.3.18:
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
@@ -5850,8 +5840,8 @@ packages:
performance-now@2.1.0:
resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
pg-boss@12.26.0:
resolution: {integrity: sha512-qMLyokiG2/Ey8U3sptl0We4nRGr1iveHL/lQ7g6n7cI/ubp9HVJZJnfPf6B9jzAbpd55uSdltKL/DMkyBC0ynw==}
pg-boss@12.27.0:
resolution: {integrity: sha512-DawcUffyDVJvxGYvaoc7B+G1yY86dJtvugVYMAt+CtO6Lg+RSyQuAU7IRfNyhqi0Bo/JeaxLqhqIy5+twpmYlg==}
engines: {node: '>=22.12.0'}
hasBin: true
@@ -5876,6 +5866,9 @@ packages:
pg-protocol@1.15.0:
resolution: {integrity: sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==}
pg-protocol@1.16.0:
resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==}
pg-types@2.2.0:
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
engines: {node: '>=4'}
@@ -5889,6 +5882,15 @@ packages:
pg-native:
optional: true
pg@8.23.0:
resolution: {integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==}
engines: {node: '>= 16.0.0'}
peerDependencies:
pg-native: '>=3.0.1'
peerDependenciesMeta:
pg-native:
optional: true
pgpass@1.0.5:
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
@@ -5914,14 +5916,14 @@ packages:
resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==}
engines: {node: '>=8'}
playwright-core@1.60.0:
resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==}
engines: {node: '>=18'}
playwright-core@1.62.1:
resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==}
engines: {node: '>=20'}
hasBin: true
playwright@1.60.0:
resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==}
engines: {node: '>=18'}
playwright@1.62.1:
resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==}
engines: {node: '>=20'}
hasBin: true
pngjs@5.0.0:
@@ -6035,8 +6037,8 @@ packages:
prettier-plugin-svelte:
optional: true
prettier@3.9.1:
resolution: {integrity: sha512-ppiDo2CSwexck1eyZUwJHg/N3nf1+6IRCv7W/VJ5vaLnVCmB7+3CdRfMwoCHBBX6xTrREDTksZ4OZl5SSf4zXA==}
prettier@3.9.6:
resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==}
engines: {node: '>=14'}
hasBin: true
@@ -6078,8 +6080,8 @@ packages:
resolution: {integrity: sha512-WPn+h9RGEExOKdu4bsF4HksG/uzd3cFq3MFtq8PsFeExPse5Ha/VOjQNyHhjboBFwGXGev6muJYTSPAOkROq2g==}
engines: {node: '>=18'}
protobufjs@7.6.2:
resolution: {integrity: sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ==}
protobufjs@7.6.5:
resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==}
engines: {node: '>=12.0.0'}
proxy-addr@2.0.7:
@@ -6535,8 +6537,8 @@ packages:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
streamx@2.26.0:
resolution: {integrity: sha512-VvNG1K72Po/xwJzxZFnZ++Tbrv4lwSptsbkFuzXCJAYZvCK5nnxsvXU6ajqkv7chyiI1Y0YXq2Jh8Iy8Y7NF/A==}
streamx@2.28.0:
resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==}
strict-event-emitter@0.5.1:
resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==}
@@ -6662,11 +6664,11 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
tar-fs@2.1.4:
resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==}
tar-fs@2.1.5:
resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==}
tar-fs@3.1.2:
resolution: {integrity: sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==}
tar-fs@3.1.3:
resolution: {integrity: sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==}
tar-stream@2.2.0:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
@@ -6684,8 +6686,9 @@ packages:
tesseract.js@7.0.0:
resolution: {integrity: sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==}
testcontainers@12.0.3:
resolution: {integrity: sha512-6vfiL9CWIX0rWhTJitzrzHcv4Q/sUfUdBV12jMz6HZ58Lz6ZtXVecO6jljd1O3ookEWQHNICiYoYNEPpDLzuRw==}
testcontainers@12.1.0:
resolution: {integrity: sha512-YjDLqIITuhGLMnM10yhg3oV6lIG5IMpz1R1DPBZoOOks83q7i7IVpeSWRTiyl7roozjiyLmwIoLK/KY8OnZmIA==}
engines: {node: '>= 22.22'}
text-decoder@1.2.7:
resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
@@ -6842,10 +6845,6 @@ packages:
resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==}
engines: {node: '>=22.19.0'}
undici@8.9.0:
resolution: {integrity: sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==}
engines: {node: '>=22.19.0'}
unicorn-magic@0.3.0:
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
engines: {node: '>=18'}
@@ -7142,10 +7141,6 @@ packages:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
yargs@17.7.3:
resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==}
engines: {node: '>=12'}
@@ -7438,10 +7433,10 @@ snapshots:
'@aws/lambda-invoke-store@0.2.4': {}
'@axe-core/playwright@4.12.1(playwright-core@1.60.0)':
'@axe-core/playwright@4.12.1(playwright-core@1.62.1)':
dependencies:
axe-core: 4.12.1
playwright-core: 1.60.0
playwright-core: 1.62.1
'@babel/code-frame@7.29.7':
dependencies:
@@ -7900,15 +7895,15 @@ snapshots:
dependencies:
lodash.camelcase: 4.3.0
long: 5.3.2
protobufjs: 7.6.2
yargs: 17.7.2
protobufjs: 7.6.5
yargs: 17.7.3
'@grpc/proto-loader@0.8.1':
dependencies:
lodash.camelcase: 4.3.0
long: 5.3.2
protobufjs: 7.6.2
yargs: 17.7.2
protobufjs: 7.6.5
yargs: 17.7.3
'@hexagon/base64@1.1.28': {}
@@ -8848,9 +8843,9 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
'@playwright/test@1.60.0':
'@playwright/test@1.62.1':
dependencies:
playwright: 1.60.0
playwright: 1.62.1
'@polka/url@1.0.0-next.29': {}
@@ -8875,7 +8870,7 @@ snapshots:
'@prisma/config@7.8.0(magicast@0.5.2)':
dependencies:
c12: 3.3.4(magicast@0.5.2)
deepmerge-ts: 7.1.5
deepmerge-ts: 8.0.1
effect: 3.20.0
empathic: 2.0.0
transitivePeerDependencies:
@@ -8969,13 +8964,11 @@ snapshots:
'@protobufjs/float@1.0.2': {}
'@protobufjs/inquire@1.1.2': {}
'@protobufjs/path@1.1.2': {}
'@protobufjs/pool@1.1.0': {}
'@protobufjs/utf8@1.1.1': {}
'@protobufjs/utf8@1.1.2': {}
'@radix-ui/number@1.1.2': {}
@@ -10003,9 +9996,9 @@ snapshots:
'@tanstack/virtual-core@3.17.3': {}
'@testcontainers/postgresql@12.0.3(supports-color@7.2.0)':
'@testcontainers/postgresql@12.1.0(supports-color@7.2.0)':
dependencies:
testcontainers: 12.0.3(supports-color@7.2.0)
testcontainers: 12.1.0(supports-color@7.2.0)
transitivePeerDependencies:
- bare-abort-controller
- bare-buffer
@@ -10593,37 +10586,34 @@ snapshots:
balanced-match@4.0.4: {}
bare-events@2.8.3: {}
bare-events@2.9.1: {}
bare-fs@4.7.1:
bare-fs@4.8.0:
dependencies:
bare-events: 2.8.3
bare-path: 3.0.0
bare-stream: 2.13.1(bare-events@2.8.3)
bare-url: 2.4.3
bare-events: 2.9.1
bare-path: 3.1.1
bare-stream: 2.13.3(bare-events@2.9.1)
bare-url: 2.5.2
fast-fifo: 1.3.2
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
bare-os@3.9.1: {}
bare-path@3.1.1: {}
bare-path@3.0.0:
bare-stream@2.13.3(bare-events@2.9.1):
dependencies:
bare-os: 3.9.1
bare-stream@2.13.1(bare-events@2.8.3):
dependencies:
streamx: 2.26.0
b4a: 1.8.1
streamx: 2.28.0
teex: 1.0.1
optionalDependencies:
bare-events: 2.8.3
bare-events: 2.9.1
transitivePeerDependencies:
- react-native-b4a
bare-url@2.4.3:
bare-url@2.5.2:
dependencies:
bare-path: 3.0.0
bare-path: 3.1.1
base64-arraybuffer@1.0.2:
optional: true
@@ -10678,11 +10668,7 @@ snapshots:
balanced-match: 1.0.2
concat-map: 0.0.1
brace-expansion@2.1.1:
dependencies:
balanced-match: 1.0.2
brace-expansion@2.1.2:
brace-expansion@2.1.4:
dependencies:
balanced-match: 1.0.2
@@ -10910,7 +10896,7 @@ snapshots:
cpu-features@0.0.10:
dependencies:
buildcheck: 0.0.7
nan: 2.27.0
nan: 2.28.0
optional: true
crc-32@1.2.2: {}
@@ -10920,7 +10906,7 @@ snapshots:
crc-32: 1.2.2
readable-stream: 4.7.0
cron-parser@5.6.2:
cron-parser@5.10.0:
dependencies:
luxon: 3.7.2
@@ -11025,7 +11011,7 @@ snapshots:
deep-is@0.1.4: {}
deepmerge-ts@7.1.5: {}
deepmerge-ts@8.0.1: {}
deepmerge@4.3.1: {}
@@ -11081,14 +11067,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
dockerode@5.0.0(supports-color@7.2.0):
dockerode@5.0.1(supports-color@7.2.0):
dependencies:
'@balena/dockerignore': 1.0.2
'@grpc/grpc-js': 1.14.4
'@grpc/proto-loader': 0.7.15
docker-modem: 5.0.7(supports-color@7.2.0)
protobufjs: 7.6.2
tar-fs: 2.1.4
protobufjs: 7.6.5
tar-fs: 2.1.5
transitivePeerDependencies:
- supports-color
@@ -11532,7 +11518,7 @@ snapshots:
events-universal@1.0.1:
dependencies:
bare-events: 2.8.3
bare-events: 2.9.1
transitivePeerDependencies:
- bare-abort-controller
@@ -11827,7 +11813,7 @@ snapshots:
get-port-please@3.2.0: {}
get-port@7.2.0: {}
get-port@5.1.1: {}
get-proto@1.0.1:
dependencies:
@@ -12565,11 +12551,11 @@ snapshots:
minimatch@5.1.9:
dependencies:
brace-expansion: 2.1.1
brace-expansion: 2.1.4
minimatch@9.0.9:
dependencies:
brace-expansion: 2.1.2
brace-expansion: 2.1.4
minimist@1.2.8: {}
@@ -12629,7 +12615,7 @@ snapshots:
dependencies:
lru.min: 1.1.4
nan@2.27.0:
nan@2.28.0:
optional: true
nanoid@3.3.18: {}
@@ -12645,7 +12631,7 @@ snapshots:
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.60.0)(@types/node@26.1.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
next@16.2.11(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(@types/node@26.1.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
'@next/env': 16.2.11
'@swc/helpers': 0.5.15
@@ -12664,7 +12650,7 @@ snapshots:
'@next/swc-linux-x64-musl': 16.2.11
'@next/swc-win32-arm64-msvc': 16.2.11
'@next/swc-win32-x64-msvc': 16.2.11
'@playwright/test': 1.60.0
'@playwright/test': 1.62.1
sharp: 0.35.3(@types/node@26.1.1)
transitivePeerDependencies:
- '@babel/core'
@@ -12957,10 +12943,10 @@ snapshots:
performance-now@2.1.0:
optional: true
pg-boss@12.26.0:
pg-boss@12.27.0:
dependencies:
cron-parser: 5.6.2
pg: 8.22.0
cron-parser: 5.10.0
pg: 8.23.0
serialize-error: 13.0.1
transitivePeerDependencies:
- pg-native
@@ -12976,10 +12962,16 @@ snapshots:
dependencies:
pg: 8.22.0
pg-pool@3.14.0(pg@8.23.0):
dependencies:
pg: 8.23.0
pg-protocol@1.13.0: {}
pg-protocol@1.15.0: {}
pg-protocol@1.16.0: {}
pg-types@2.2.0:
dependencies:
pg-int8: 1.0.1
@@ -12998,6 +12990,16 @@ snapshots:
optionalDependencies:
pg-cloudflare: 1.4.0
pg@8.23.0:
dependencies:
pg-connection-string: 2.14.0
pg-pool: 3.14.0(pg@8.23.0)
pg-protocol: 1.16.0
pg-types: 2.2.0
pgpass: 1.0.5
optionalDependencies:
pg-cloudflare: 1.4.0
pgpass@1.0.5:
dependencies:
split2: 4.2.0
@@ -13020,11 +13022,11 @@ snapshots:
dependencies:
find-up: 3.0.0
playwright-core@1.60.0: {}
playwright-core@1.62.1: {}
playwright@1.60.0:
playwright@1.62.1:
dependencies:
playwright-core: 1.60.0
playwright-core: 1.62.1
optionalDependencies:
fsevents: 2.3.2
@@ -13073,11 +13075,11 @@ snapshots:
prelude-ls@1.2.1: {}
prettier-plugin-tailwindcss@0.8.1(prettier@3.9.1):
prettier-plugin-tailwindcss@0.8.1(prettier@3.9.6):
dependencies:
prettier: 3.9.1
prettier: 3.9.6
prettier@3.9.1: {}
prettier@3.9.6: {}
pretty-ms@9.3.0:
dependencies:
@@ -13128,7 +13130,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
protobufjs@7.6.2:
protobufjs@7.6.5:
dependencies:
'@protobufjs/aspromise': 1.1.2
'@protobufjs/base64': 1.1.2
@@ -13136,10 +13138,9 @@ snapshots:
'@protobufjs/eventemitter': 1.1.1
'@protobufjs/fetch': 1.1.1
'@protobufjs/float': 1.0.2
'@protobufjs/inquire': 1.1.2
'@protobufjs/path': 1.1.2
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.1
'@protobufjs/utf8': 1.1.2
'@types/node': 26.1.1
long: 5.3.2
@@ -13737,7 +13738,7 @@ snapshots:
bcrypt-pbkdf: 1.0.2
optionalDependencies:
cpu-features: 0.0.10
nan: 2.27.0
nan: 2.28.0
stable-hash@0.0.5: {}
@@ -13759,7 +13760,7 @@ snapshots:
es-errors: 1.3.0
internal-slot: 1.1.0
streamx@2.26.0:
streamx@2.28.0:
dependencies:
events-universal: 1.0.1
fast-fifo: 1.3.2
@@ -13900,20 +13901,20 @@ snapshots:
tapable@2.3.3: {}
tar-fs@2.1.4:
tar-fs@2.1.5:
dependencies:
chownr: 1.1.4
mkdirp-classic: 0.5.3
pump: 3.0.4
tar-stream: 2.2.0
tar-fs@3.1.2:
tar-fs@3.1.3:
dependencies:
pump: 3.0.4
tar-stream: 3.2.0
optionalDependencies:
bare-fs: 4.7.1
bare-path: 3.0.0
bare-fs: 4.8.0
bare-path: 3.1.1
transitivePeerDependencies:
- bare-abort-controller
- bare-buffer
@@ -13930,9 +13931,9 @@ snapshots:
tar-stream@3.2.0:
dependencies:
b4a: 1.8.1
bare-fs: 4.7.1
bare-fs: 4.8.0
fast-fifo: 1.3.2
streamx: 2.26.0
streamx: 2.28.0
transitivePeerDependencies:
- bare-abort-controller
- bare-buffer
@@ -13940,7 +13941,7 @@ snapshots:
teex@1.0.1:
dependencies:
streamx: 2.26.0
streamx: 2.28.0
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
@@ -13961,7 +13962,7 @@ snapshots:
transitivePeerDependencies:
- encoding
testcontainers@12.0.3(supports-color@7.2.0):
testcontainers@12.1.0(supports-color@7.2.0):
dependencies:
'@balena/dockerignore': 1.0.2
'@types/dockerode': 4.0.1
@@ -13970,14 +13971,14 @@ snapshots:
byline: 5.0.0
debug: 4.4.3(supports-color@7.2.0)
docker-compose: 1.4.2
dockerode: 5.0.0(supports-color@7.2.0)
get-port: 7.2.0
dockerode: 5.0.1(supports-color@7.2.0)
get-port: 5.1.1
proper-lockfile: 4.1.2
properties-reader: 3.0.1(supports-color@7.2.0)
ssh-remote-port-forward: 1.0.4
tar-fs: 3.1.2
tar-fs: 3.1.3
tmp: 0.2.7
undici: 8.9.0
undici: 8.10.0
transitivePeerDependencies:
- bare-abort-controller
- bare-buffer
@@ -14151,8 +14152,6 @@ snapshots:
undici@8.10.0: {}
undici@8.9.0: {}
unicorn-magic@0.3.0: {}
universal-user-agent@7.0.3: {}
@@ -14458,16 +14457,6 @@ snapshots:
y18n: 4.0.3
yargs-parser: 18.1.3
yargs@17.7.2:
dependencies:
cliui: 8.0.1
escalade: 3.2.0
get-caller-file: 2.0.5
require-directory: 2.1.1
string-width: 4.2.3
y18n: 5.0.8
yargs-parser: 21.1.1
yargs@17.7.3:
dependencies:
cliui: 8.0.1
@@ -14477,7 +14466,6 @@ snapshots:
string-width: 4.2.3
y18n: 5.0.8
yargs-parser: 21.1.1
optional: true
yauzl@2.10.0:
dependencies:
+6
View File
@@ -21,6 +21,12 @@ overrides:
# `pdf-parse` resolves 5.4.296, which predates the flaw and drives the local
# text-extraction path, so it must not be dragged onto a new major.
"pdfjs-dist@>=5.6.83 <6.2.108": "6.2.108"
# Deeply nested input drives DeepmergeTS into unbounded recursion until the
# stack gives out. The copy here is Prisma's: `@prisma/config` still asks for
# 7.1.5 even on the current 7.9.1, so no Prisma bump reaches it and the
# override is the only way through. It runs on the config-loading and CLI
# path, not under a request, but a red audit blocks the whole release train.
"deepmerge-ts@<8.0.0": "^8.0.0"
allowBuilds:
"@prisma/engines": true
@@ -0,0 +1,54 @@
-- Refs #223 + iOS #68 — Vorsorge snooze/skip + the completion ledger.
-- All additive; every existing reminder keeps its exact behaviour.
-- Snooze cursor: non-NULL and in the future = "pushed back until this
-- instant". The snooze write sets next_due_at to the SAME instant, so every
-- due-state consumer moves with it; expiry is the clock passing the value.
ALTER TABLE "measurement_reminders" ADD COLUMN "snoozed_until" TIMESTAMP(3);
-- Last honest skip. A skip re-anchors next_due_at at the skip instant but
-- never moves last_satisfied_at — done and skipped stay distinguishable.
ALTER TABLE "measurement_reminders" ADD COLUMN "last_skipped_at" TIMESTAMP(3);
-- Lifetime skip counter ("skipped N times").
ALTER TABLE "measurement_reminders" ADD COLUMN "skip_count" INTEGER NOT NULL DEFAULT 0;
-- The completion ledger (iOS #68). The engine is single-cursor, so before
-- this table each satisfy overwrote the last and history was unrecoverable.
-- Append-only: one row per satisfy / skip, written by the one satisfy
-- primitive and the skip route. History starts with this release — the
-- cursor holds nothing to backfill from.
CREATE TYPE "measurement_reminder_event_kind" AS ENUM ('SATISFIED', 'SKIPPED');
CREATE TABLE "measurement_reminder_events" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"reminder_id" TEXT NOT NULL,
"kind" "measurement_reminder_event_kind" NOT NULL,
"occurred_at" TIMESTAMP(3) NOT NULL,
"on_time" BOOLEAN NOT NULL,
"source" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "measurement_reminder_events_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "measurement_reminder_events_reminder_id_occurred_at_idx"
ON "measurement_reminder_events"("reminder_id", "occurred_at");
CREATE INDEX "measurement_reminder_events_user_id_idx"
ON "measurement_reminder_events"("user_id");
ALTER TABLE "measurement_reminder_events"
ADD CONSTRAINT "measurement_reminder_events_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "measurement_reminder_events"
ADD CONSTRAINT "measurement_reminder_events_reminder_id_fkey"
FOREIGN KEY ("reminder_id") REFERENCES "measurement_reminders"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
-- Custom-metric entries gain the soft-delete tombstone every peer entry
-- surface already has (undo parity — the one hard delete left).
ALTER TABLE "custom_metric_entries" ADD COLUMN "deleted_at" TIMESTAMP(3);
+121 -8
View File
@@ -102,6 +102,10 @@ model User {
// Codex OAuth (ChatGPT subscription)
codexAccessTokenEncrypted String? @map("codex_access_token_encrypted")
codexRefreshTokenEncrypted String? @map("codex_refresh_token_encrypted")
/// @internal: dead mirror of the expiry inside the encrypted Codex creds —
/// the refresh path reads the decrypted blob's own `expiresAt`
/// (`src/lib/ai/provider.ts`), never this column. Kept (and kept written)
/// for wire/backup compatibility rather than dropped.
codexTokenExpiresAt DateTime? @map("codex_token_expires_at")
codexConnectedAt DateTime? @map("codex_connected_at")
codexConnectionStatus String @default("disconnected") @map("codex_connection_status")
@@ -879,6 +883,9 @@ model User {
// Server computes the canonical `nextDueAt`; the cron fires through the
// existing notification dispatcher. Soft-deleted.
measurementReminders MeasurementReminder[]
// v1.37.20 — completion ledger behind the reminders (iOS #68): one
// append-only row per satisfy / skip. Cascade-deleted with the account.
measurementReminderEvents MeasurementReminderEvent[]
// v1.17.1 — minimal structured lab store (LDL, HbA1c, ferritin, TSH, …)
// that pairs with the Vorsorge "annual blood panel" reminder so a result
// has somewhere to land. Soft-deleted.
@@ -1045,6 +1052,11 @@ model EnvironmentContext {
/// that won precedence).
lat Float
lon Float
/// @internal: human-readable label of the resolved location, stored as
/// operator/debug provenance beside the coordinates; surfaces select the
/// numeric and weather columns only, never this label. The backup selects it
/// (`src/lib/export/environment-backup.ts`) with the rest of the row: a
/// restored reading has to keep saying where it was read.
locationLabel String @map("location_label")
source EnvironmentLocationSource
@@ -2416,6 +2428,13 @@ model PersonalRecord {
/// FK to the Measurement row that achieved the record. SET NULL
/// on delete so the historical fact survives even if the
/// underlying measurement is later removed.
/// @internal: audit-trail provenance written by the PR detection worker; no
/// SURFACE selects it: the record row itself carries the value, unit and
/// achievedAt a reader sees. The backup does select it
/// (`src/lib/export/awards-backup.ts`), because the column is a real foreign
/// key in the database even though no relation is declared here, so a
/// restore has to resolve it against the measurements it wrote instead of
/// writing it blind and failing the constraint.
sourceMeasurementId String? @map("source_measurement_id")
source MeasurementSource @default(MANUAL)
externalId String? @map("external_id")
@@ -3581,10 +3600,16 @@ model McpOAuthConnection {
model InviteToken {
id String @id @default(cuid())
tokenHash String @unique @map("token_hash")
/// Consumed through the `creator` relation: the admin invites list
/// (`src/app/api/admin/invites/route.ts`) selects the creating admin's
/// id + username via it.
createdBy String @map("created_by")
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime @map("expires_at")
usedAt DateTime? @map("used_at")
/// LAST consumer only (overwritten on multi-use invites; the full history
/// lives in `InviteRedemption`). Consumed through the `consumer` relation
/// by the admin invites list (`src/app/api/admin/invites/route.ts`).
usedBy String? @map("used_by")
maxUses Int @default(1) @map("max_uses")
uses Int @default(0)
@@ -3641,8 +3666,11 @@ model RefreshToken {
/// When this token was used (one-time-use). Set during rotation; a second
/// use of the same token is a hard fail and triggers reuse-detection.
usedAt DateTime? @map("used_at")
/// Pointer to the row that replaced this one during rotation. Helps audit
/// trail and reuse-detection ("this token was already exchanged into X").
/// Pointer to the row that replaced this one during rotation ("this token
/// was already exchanged into X").
/// @internal: audit-trail bookkeeping written by the rotation path
/// (`src/lib/auth/refresh-token.ts`); reuse-detection keys on
/// `usedAt`/`tokenHash`, so no code path reads this pointer.
replacedById String? @map("replaced_by_id")
userAgent String? @map("user_agent")
ipAddress String? @map("ip_address")
@@ -4920,12 +4948,18 @@ model ImportJob {
failureReason String? @map("failure_reason")
/// Bytes uploaded — surfaced to the operator audit trail.
uploadBytes Int @map("upload_bytes")
/// SHA-256 hex digest of the uploaded `export.zip`. Lets the
/// kick-off endpoint short-circuit a re-upload of the exact same
/// file back to the existing job id without re-queueing.
/// SHA-256 hex digest of the uploaded `export.zip`. Consumer: both kick-off
/// endpoints (`src/app/api/import/apple-health-export/route.ts` and the
/// admin variant) dedupe on (userId, uploadSha256, parserRevision) and
/// short-circuit a re-upload of the exact same file back to the existing
/// job id without re-queueing. A prior `failed` job is excluded from the
/// match (issue #486), so the same bytes can always be retried.
uploadSha256 String? @map("upload_sha256")
/// Parser semantics revision. Existing archives migrated at revision 1 can
/// be deliberately reprocessed when the current parser revision advances.
/// be deliberately reprocessed when the current parser revision advances:
/// the kick-off dedupe lookup (see `uploadSha256`) pins the CURRENT
/// `APPLE_HEALTH_IMPORT_PARSER_REVISION`, so a job parsed under an older
/// revision never short-circuits a fresh upload of the same bytes.
parserRevision Int @default(1) @map("parser_revision")
/// Apple's `ExportDate` attribute string-formatted, parsed during
/// the unpack phase. NULL until the parser sees it.
@@ -5187,6 +5221,20 @@ model MeasurementReminder {
/// excluded everywhere), whereas a booster is the engine's native case —
/// recurring, satisfiable, cadence-bearing.
vaccinationAntigen String? @map("vaccination_antigen")
/// v1.37.20 — snooze cursor. Non-NULL and in the future = "pushed back until
/// this instant". The snooze write sets `nextDueAt` to the SAME instant, so
/// every due-state consumer (digest, dashboard, cron, AI block) moves with
/// it without knowing snooze exists. Self-expiring: once the instant passes,
/// the clock comparison stops being true and no cleanup is needed. Any
/// recompute of `nextDueAt` (satisfy, skip, cadence PATCH) nulls it.
snoozedUntil DateTime? @map("snoozed_until")
/// v1.37.20 — last honest skip. A skip re-anchors `nextDueAt` at the skip
/// instant but NEVER moves `lastSatisfiedAt` — done and skipped stay
/// distinguishable forever. "Skipped cycle" derives as
/// `lastSkippedAt > lastSatisfiedAt` (or non-NULL with no satisfy yet).
lastSkippedAt DateTime? @map("last_skipped_at")
/// v1.37.20 — lifetime skip counter ("skipped N times").
skipCount Int @default(0) @map("skip_count")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
@@ -5201,6 +5249,10 @@ model MeasurementReminder {
/// this reminder. SetNull on the far side — deleting the reminder leaves
/// the vaccination record intact.
vaccinations VaccinationRecord[]
/// v1.37.20 — the completion ledger (iOS #68): every satisfy and every
/// skip appends one row. Cascade: the ledger has no meaning without its
/// reminder.
events MeasurementReminderEvent[]
@@index([userId, deletedAt])
@@index([userId, nextDueAt])
@@ -5234,6 +5286,55 @@ enum ReminderOrigin {
@@map("reminder_origin")
}
/// v1.37.20 — what a completion-ledger row records: an honest fulfilment
/// (`SATISFIED` — a matching reading landed or the user marked it done) or an
/// honest skip (`SKIPPED` — the user deliberately let this cycle go, which is
/// NOT a completion and never touches `lastSatisfiedAt`).
enum MeasurementReminderEventKind {
SATISFIED
SKIPPED
@@map("measurement_reminder_event_kind")
}
/// v1.37.20 — the Vorsorge completion ledger (iOS #68). The reminder engine is
/// single-cursor (`nextDueAt` + `lastSatisfiedAt`), so before this table each
/// satisfy overwrote the previous one and history was unrecoverable. Every
/// path through the ONE satisfy primitive (`satisfyReminder`) and the skip
/// route appends one row here; nothing ever updates or deletes a row short of
/// deleting the reminder itself. History starts at the release that ships
/// this — the single cursor holds nothing to backfill from, which the iOS #68
/// thread records as agreed.
model MeasurementReminderEvent {
id String @id @default(cuid())
userId String @map("user_id")
reminderId String @map("reminder_id")
kind MeasurementReminderEventKind
/// The event instant: the satisfy instant (which may be the backdated
/// reading's own timestamp) or the skip instant (always the server clock).
occurredAt DateTime @map("occurred_at")
/// Derived AT WRITE TIME against the reminder's `nextDueAt` as it stood
/// before the event re-anchored it: `true` when the event landed at or
/// before the due instant (or the cadence had no computable due). Clients
/// (web + iOS) consume this resolved value and never re-derive it — the
/// pre-event `nextDueAt` is gone after the write, so write time is the only
/// honest moment to answer the question.
onTime Boolean @map("on_time")
/// Which path produced the event — a closed set of engine call sites
/// (`manual`, `auto_measurement`, `auto_lab`, `telegram`, `vaccination`,
/// `encounter`, `skip`), asserted in code, not free text.
source String
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
reminder MeasurementReminder @relation(fields: [reminderId], references: [id], onDelete: Cascade)
/// The history endpoint reads newest-first per reminder.
@@index([reminderId, occurredAt])
@@index([userId])
@@map("measurement_reminder_events")
}
// ─── Lab results ─────────────────────────────────────────────
/// v1.17.1 — minimal structured lab store pairing with the Vorsorge "annual
@@ -5432,6 +5533,10 @@ model CustomMetricEntry {
measuredAt DateTime @map("measured_at")
note String?
createdAt DateTime @default(now()) @map("created_at")
/// v1.37.20 — soft-delete tombstone, peer parity with Measurement /
/// MoodEntry (every other entry surface deletes recoverably with an undo
/// toast; custom-metric entries were the one hard delete). NULL = live.
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
metric CustomMetric @relation(fields: [customMetricId], references: [id], onDelete: Cascade)
@@ -5695,6 +5800,8 @@ model CoachConversation {
// conversation keeps memory of what came before without re-sending every
// turn to the provider. AES-256-GCM (same codec as CoachMessage.encrypted).
summaryEncrypted Bytes? @map("summary_encrypted")
/// @internal: diagnostic timestamp of the last summary rewrite — the elision
/// logic keys on `summaryTurnCount`, not on this instant; nothing reads it.
summaryUpdatedAt DateTime? @map("summary_updated_at")
summaryTurnCount Int @default(0) @map("summary_turn_count")
@@ -7713,7 +7820,12 @@ model DocumentContentIndex {
/// Provider type that produced the text (diagnostic only), or null on the
/// text-ocr path.
providerType String? @map("provider_type")
/// Tokeniser algorithm version — bump to force a re-index on a scheme change.
/// Tokeniser algorithm version — bump `CONTENT_TOKENIZER_VERSION`
/// (`src/lib/documents/content-index.ts`) to force a re-index on a scheme
/// change. Consumer: the content-index backfill job re-tokenises any row
/// whose stored version trails the current one from the decrypted
/// `textEncrypted` (`retokeniseStaleRows` in
/// `src/lib/jobs/document-content-index-backfill.ts`).
tokenizerVersion String @map("tokenizer_version")
createdAt DateTime @default(now()) @map("created_at")
@@ -7750,7 +7862,8 @@ model DocumentThumbnail {
height Int
/// Plaintext byte length of the JPEG (pre-encryption), for `Content-Length`.
byteSize Int @map("byte_size")
/// Source MIME the preview was rendered from (diagnostic only).
/// @internal: source MIME the preview was rendered from — operator-side
/// diagnostic provenance only; no query selects it and no client surfaces it.
sourceMime String @map("source_mime")
createdAt DateTime @default(now()) @map("created_at")
+4 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.37.15";
/* @sw-version-fallback */ "v1.37.26";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
@@ -79,6 +79,9 @@ const MAX_DATA_ENTRIES = 60;
// and the version probe are eligible.
const API_READ_ALLOWLIST = [
"/api/dashboard/snapshot",
// v1.37.20 — daily digest: the Today rail's offline read. Keep in
// lockstep with src/lib/pwa/sw-cache-policy.ts.
"/api/daily/digest",
"/api/dashboard/widgets",
"/api/measurements",
"/api/medications",
@@ -0,0 +1,73 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
/**
* The demo password is published in the README so anyone can sign in to
* demo.healthlog.dev. Its Argon2id hash lives in two places that must agree:
*
* - scripts/seed-demo.ts writes it when a demo tenant is seeded
* - scripts/migrate-demo-edge01.sh overwrites it after a migration load
*
* Public issue #805: the migration used to carry the SOURCE account's hash
* across instead, so once the operator rotated their own demo password the
* credentials the README advertises stopped working, and nothing noticed until
* a visitor filed a bug. These assertions make the two copies move together.
*/
const REPO_ROOT = join(__dirname, "..", "..");
const SEED = join(REPO_ROOT, "scripts", "seed-demo.ts");
const MIGRATE = join(REPO_ROOT, "scripts", "migrate-demo-edge01.sh");
/** Every Argon2id hash literal in a file, in source order. */
function argon2Hashes(path: string): string[] {
const source = readFileSync(path, "utf8");
return source.match(/\$argon2id\$[^"'\s]+/g) ?? [];
}
describe("published demo password", () => {
it("is the same hash in the seed script and the edge01 migration", () => {
const seeded = argon2Hashes(SEED);
const migrated = argon2Hashes(MIGRATE);
// An empty match set would make every assertion below vacuously true —
// the failure mode this repo keeps rediscovering. Demand a hit first.
expect(seeded.length).toBeGreaterThan(0);
expect(migrated.length).toBeGreaterThan(0);
// Each file states the hash exactly once, so a stray second copy (the
// thing that lets the two drift again) is a failure in its own right.
expect(new Set(seeded).size).toBe(1);
expect(new Set(migrated).size).toBe(1);
expect(migrated[0]).toBe(seeded[0]);
});
it("is written by the migration rather than carried from the source", () => {
const script = readFileSync(MIGRATE, "utf8");
// The overwrite must exist...
expect(script).toMatch(
/UPDATE users SET password_hash = '\$\{?PUBLIC_DEMO_PASSWORD_HASH\}?'/,
);
// ...and it must be proven, not merely attempted. Without the check a
// mangled hash would reach a visitor's login screen instead of the script.
expect(script).toContain(
"ABORT/FAIL: demo password_hash on edge01 is not the published literal",
);
});
it("keeps the overwrite off the ssh command line", () => {
const script = readFileSync(MIGRATE, "utf8");
// The hash carries '$argon2id', '$v', '$m', '$t', '$p'. Interpolated into
// a double-quoted remote command those expand to empty strings and the
// stored hash matches nothing. It has to travel on stdin.
const sshWithHashInline = new RegExp(
String.raw`ssh\s+"\$DST_SSH"\s+"[^"]*PUBLIC_DEMO_PASSWORD_HASH`,
);
expect(script).not.toMatch(sshWithHashInline);
});
});
+11 -1
View File
@@ -68,7 +68,17 @@ reset to `'disconnected'`), `ai_anthropic_key_encrypted`,
Forced flags: `telegram_enabled=false`,
`role='ADMIN'`.
**Kept:** `password_hash` (demo login works), `avatar_bytes` /
**Overwritten:** `password_hash`. The row arrives carrying the source
account's hash, and the load is immediately followed by an `UPDATE` that sets
the Argon2id hash of the demo password the README publishes, then asserts the
stored value matches it byte for byte and aborts if it does not. The source is
the operator's own instance, so its demo password can be rotated at any time;
carrying that hash across is what silently broke the advertised demo login in
issue #805. The statement travels on psql's stdin, never inside the ssh command
string, because the hash contains `$argon2id` / `$v` / `$m` / `$t` / `$p`
segments that a remote shell would expand to nothing.
**Kept:** `avatar_bytes` /
`avatar_content_type` / `avatar_updated_at` (profile photo), and all
display / preference / layout JSON (`thresholds_json`,
`dashboard_widgets_json`, `insights_layout_json`, `coach_prefs_json`,
+49
View File
@@ -102,6 +102,23 @@ readonly SRC_DB_CONTAINER
readonly PG_USER="healthlog"
readonly PG_DB="healthlog"
# Argon2id hash of the demo password the README publishes ("demo123demo123").
# Not a secret: it is printed in the README so anyone can sign in to the demo,
# and it is the same literal that scripts/seed-demo.ts writes on a fresh seed.
# The transfer still carries the source column, but the load is followed by an
# explicit overwrite with this literal (see "FORCE THE PUBLISHED DEMO PASSWORD"
# below). The source is the operator's own instance and its demo password can
# be rotated at any time; carrying it across silently invalidated the published
# credentials once already (public issue #805). Overwriting afterwards makes the
# documented login true by construction after every migration.
#
# The overwrite travels on psql's STDIN, never inside the ssh command string:
# the hash contains '$' segments that a remote shell would expand away.
#
# scripts/__tests__/demo-published-password-guard.test.ts pins this literal to
# the one in scripts/seed-demo.ts, so editing one without the other fails.
readonly PUBLIC_DEMO_PASSWORD_HASH='$argon2id$v=19$m=65536,t=3,p=4$Kips6OxPAl0vmspO9SoKZQ$oX9gLgwHVnnENCqBloyM13ewuqmhPnw8EpLoemS3MNI'
# psql invocation builders. -v ON_ERROR_STOP=1 makes any SQL error a non-zero
# exit so `set -e` aborts the pipeline. -X skips ~/.psqlrc.
psql_src() {
@@ -466,6 +483,38 @@ copy_table() {
copy_table "users" "$USERS_SELECT" \
"users WHERE id = '$DEMO_USER_ID'" "$USERS_COLS"
# ----------------------------------------------------------------------------
# FORCE THE PUBLISHED DEMO PASSWORD
# ----------------------------------------------------------------------------
# The row just loaded carries the SOURCE account's password_hash. The source is
# the operator's own instance, so that hash is whatever the operator last set —
# not necessarily the password the README publishes. Overwrite it here so the
# documented demo login works after every migration, not just the first one.
#
# The statement goes in on psql's STDIN. It must NOT be interpolated into the
# ssh command string: the Argon2id hash contains '$argon2id', '$v', '$m', '$t'
# and '$p' segments, and a remote shell would expand each of those to an empty
# string and store a mangled hash that no password can ever match.
echo
echo "## FORCE published demo password on the loaded row ..."
printf '%s\n' \
"UPDATE users SET password_hash = '$PUBLIC_DEMO_PASSWORD_HASH' WHERE id = '$DEMO_USER_ID';" \
| ssh "$DST_SSH" "docker exec -i $DST_DB_CONTAINER \
psql -X -v ON_ERROR_STOP=1 -U $PG_USER -d $PG_DB"
# Prove the stored hash is byte-for-byte the published one. A mangled or
# carried-over hash fails here instead of at some visitor's login screen.
PW_OK="$(printf '%s\n' \
"SELECT count(*) FROM users WHERE id = '$DEMO_USER_ID' AND password_hash = '$PUBLIC_DEMO_PASSWORD_HASH';" \
| ssh "$DST_SSH" "docker exec -i $DST_DB_CONTAINER \
psql -X -tA -v ON_ERROR_STOP=1 -U $PG_USER -d $PG_DB" | tr -d '[:space:]')"
if [[ "$PW_OK" != "1" ]]; then
echo "ABORT/FAIL: demo password_hash on edge01 is not the published literal (matched rows: $PW_OK)."
echo " The demo login would be broken. Fix before serving the demo."
exit 1
fi
echo " demo password_hash matches the published literal."
# 2. measurements (live rows only).
copy_table "measurements" "$MEAS_COLS" \
"measurements WHERE user_id = '$DEMO_USER_ID' AND deleted_at IS NULL" "$MEAS_COLS"
@@ -0,0 +1,72 @@
/**
* The error classes live in ONE leaf module — the cycle stays broken.
*
* `api-handler.ts` imports the record-session fence, and the fence throws
* `RecordSessionChangedError` / `SharingAccessDeniedError`. When those
* classes lived inside api-handler the two modules imported each other and
* the loop worked only by evaluation-order luck. This guard pins the split:
*
* 1. identity — the class an importer gets from `api-handler` IS the class
* the fence throws (from `api-errors`), so `instanceof` across the two
* import paths can never diverge;
* 2. structure — the fence carries no VALUE import from api-handler
* (a type-only import is erased and allowed), and the leaf imports
* nothing but the fence contract.
*
* Watched red: re-declaring `HttpError` inside api-handler (the pre-split
* shape) fails the identity leg; adding a value import of api-handler back
* into the fence fails the structural leg.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import * as apiErrors from "@/lib/api-errors";
import {
HttpError,
RecordSessionChangedError,
SharingAccessDeniedError,
SharingAuthError,
SharingNotPermittedError,
StepUpRequiredError,
} from "@/lib/api-handler";
const SRC = join(__dirname, "..");
describe("api-errors leaf module", () => {
it("api-handler re-exports the identical class objects", () => {
expect(HttpError).toBe(apiErrors.HttpError);
expect(SharingAuthError).toBe(apiErrors.SharingAuthError);
expect(SharingAccessDeniedError).toBe(apiErrors.SharingAccessDeniedError);
expect(SharingNotPermittedError).toBe(apiErrors.SharingNotPermittedError);
expect(RecordSessionChangedError).toBe(apiErrors.RecordSessionChangedError);
expect(StepUpRequiredError).toBe(apiErrors.StepUpRequiredError);
});
it("the record-session fence carries no value import from api-handler", () => {
const source = readFileSync(
join(SRC, "lib/sharing/record-session-fence.ts"),
"utf8",
);
const imports = source.match(/^import[^;]+from "@\/lib\/api-handler";/gm);
for (const statement of imports ?? []) {
expect(
statement.startsWith("import type"),
`record-session-fence.ts imports a VALUE from api-handler — that is the cycle the api-errors leaf removed:\n${statement}`,
).toBe(true);
}
});
it("the leaf imports nothing but the fence contract", () => {
const source = readFileSync(join(SRC, "lib/api-errors.ts"), "utf8");
const imports = source.match(/^import[^;]+;/gm) ?? [];
// Self-check: the walker found the one expected import.
expect(imports.length).toBeGreaterThan(0);
for (const statement of imports) {
expect(
statement.includes("record-session-fence-contract"),
`api-errors.ts must stay a leaf; unexpected import:\n${statement}`,
).toBe(true);
}
});
});
@@ -462,6 +462,20 @@ describe("every backed-up model travels both ways, or is named as debt", () => {
).toEqual([]);
});
it("proves the reminder engine models are on the covered side", () => {
// v1.37.20 (#223 / iOS #68) — the reminder came off the debt register and
// the completion ledger landed carried from the release that introduced
// it. Pinned by name so a future edit cannot quietly move either back to
// COVERAGE_PENDING: the ledger is the only copy of every satisfy and skip
// before the latest one, so "pending" would be a silent history loss, not
// a deferral.
for (const model of ["MeasurementReminder", "MeasurementReminderEvent"]) {
expect(BACKED_UP_MODELS).toContain(model);
expect(TWO_ENDED_MODELS).toContain(model);
expect(COVERAGE_PENDING).not.toHaveProperty(model);
}
});
it("proves the structured-profile models are on the covered side", () => {
// These account-owned rows are exported and restored explicitly rather
// than being treated as reconstructible data.
@@ -87,8 +87,11 @@ describe("T1 — the Bearer resolution set is frozen", () => {
// three releases without this guard ever seeing it. A matcher that misses
// a real resolver is green because it matched nothing, not because
// nothing was there.
// `tokenHash` may sit anywhere inside the `where` object — demanding it
// first would let a formatting change (another key ahead of it) walk a
// resolver out of the guard's sight.
const resolvers = filesMatching(
/resolveBearerToken|apiToken\s*\.\s*findUnique\(\s*\{[\s\S]*?where:\s*\{\s*tokenHash/,
/resolveBearerToken|apiToken\s*\.\s*findUnique\(\s*\{[\s\S]*?where:\s*\{[^}]*?tokenHash/,
);
// Non-zero proof: an empty match set must fail rather than agree with an
@@ -149,10 +152,16 @@ describe("T3 — the mint sites are frozen", () => {
};
it("only the known files create ApiToken rows", () => {
const minters = filesMatching(/apiToken\.create\(|issueApiToken\(/).filter(
// `issue-token.ts` defines the helper; the others call it.
(rel) => rel !== "lib/auth/issue-token.ts" || true,
// Whitespace-tolerant like the T1 resolver matcher — the lesson this
// very file documents: a prettier-wrapped `apiToken\n .create({` was
// invisible to the literal matcher, and a guard that matches nothing
// is green for the wrong reason.
const minters = filesMatching(
/apiToken\s*\.\s*create\s*\(|issueApiToken\s*\(/,
);
// Non-zero proof: an empty match set must fail rather than agree with
// an emptied site table.
expect(minters.length).toBeGreaterThan(0);
expect(minters).toEqual(Object.keys(MINT_SITES).sort());
});
@@ -374,3 +374,41 @@ describe("the mood prognosis never states a cause", () => {
expect(de.toLowerCase()).toContain("zu erwarten gewesen");
});
});
describe("the MCP token copy never claims the mint is read-only", () => {
/**
* 5. MCP TOKEN SCOPE. The mint card sits directly above the
* health:write toggle (`mcp-section.tsx`), and `requireAuth` in
* api-handler.ts honours that scope — a minted token writes when the
* toggle was on. The card's own description and scope note therefore
* must not describe the mint as read-only; "read-only by default /
* unless enabled" is the honest shape.
*
* Watched red: restoring the pre-fix strings ("Mint a read-only token
* to connect an assistant." et al.) makes the matching locale fail.
*/
const READONLY_CLAIMS: Record<string, readonly string[]> = {
en: ["read-only token"],
de: ["schreibgeschütztes token", "schreibgeschütztes-token"],
es: ["token de solo lectura"],
fr: ["jeton en lecture seule"],
it: ["token in sola lettura"],
pl: ["token tylko do odczytu"],
};
for (const locale of LOCALES) {
it(`${locale} mint copy does not promise a read-only token`, () => {
const b = bundle(locale);
const description = resolve(b, "settings.mcp.tokensDescription");
const scopeNote = resolve(b, "settings.mcp.scopeNote");
for (const claim of READONLY_CLAIMS[locale]) {
for (const copy of [description, scopeNote]) {
expect(
copy.toLowerCase(),
`${locale} settings.mcp copy claims the minted token is read-only while the write toggle sits beneath it`,
).not.toContain(claim);
}
}
});
}
});
@@ -280,4 +280,36 @@ describe("data-wipe completeness", () => {
OR: [{ recordUserId: "u1" }, { recipientUserId: "u1" }],
});
});
it("freezes the set of user-linked tables that carry no foreign key", () => {
// v1.37.20 (A6-19) — NO_FK_BY_DESIGN, as an assertion instead of a pair
// of FK migrations. The two Telegram plumbing tables link to an account
// by a bare `userId` string on purpose: both hold a bounded, short-lived
// window of chat state (scheduled message deletions, prompt contexts)
// that a cron already reaps, and a cascade would buy nothing the wipe
// plan does not already do — rule 1 catches them because the column NAME
// matches the convention. That is the exact fragility this test freezes:
// a THIRD such table would also pass rule 1 silently, without anyone
// deciding it should. So the set is closed here; growing it is a
// decision made in this file, with a reason, or by adding the FK.
const NO_FK_BY_DESIGN = new Set([
"TelegramScheduledDeletion",
"TelegramPromptContext",
]);
const bareUserIdModels = [...models.entries()]
.filter(
([name, shape]) =>
name !== "User" &&
shape.scalars.includes("userId") &&
!shape.relations.some((rel) => rel.target === "User"),
)
.map(([name]) => name)
.sort();
expect(bareUserIdModels).toEqual([...NO_FK_BY_DESIGN].sort());
// And both stay in the wipe plan — the assertion above is about schema
// shape, this one about the plan actually reaching them.
for (const model of NO_FK_BY_DESIGN) {
expect(WIPE_MODELS).toContain(model);
}
});
});
@@ -0,0 +1,101 @@
/**
* The two override mechanisms agree, or the build fails here.
*
* The image carries two independent installs. `pnpm install` builds the app
* tree and honours the `overrides` block in `pnpm-workspace.yaml`. A second,
* npm-owned install builds `/opt/prisma-cli` (Dockerfile) and honours nothing
* from that file — it reads its own `overrides` field instead.
*
* On 2026-08-21 only the first one was set. The dependency audit over the app
* tree passed, and the Trivy image scan reported CVE-2026-40345 against
* `deepmerge-ts@7.1.5` under `/opt/prisma-cli`, pulled in by
* `prisma -> @prisma/config`. A pin that covers one of two installs reads
* exactly like a pin that covers both, right up until something scans the
* artefact rather than the lockfile.
*
* So: every package pinned in BOTH places must be pinned to the same range,
* and the Dockerfile's pin must survive as long as the workspace one does.
* This does NOT assert that every workspace override is mirrored — most exist
* for packages `/opt/prisma-cli` never installs, and demanding a mirror there
* would encode noise. It asserts agreement where both files speak, which is
* the failure that actually happened.
*
* Limit, written down so the next reader does not over-trust a green run: this
* compares declared ranges. It cannot see a NEW vulnerable transitive that
* neither file mentions. Only the image scan can, and that job runs on
* main-push, not on pull requests.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const ROOT = process.cwd();
const DOCKERFILE = readFileSync(join(ROOT, "Dockerfile"), "utf8");
const WORKSPACE = readFileSync(join(ROOT, "pnpm-workspace.yaml"), "utf8");
/** `npm pkg set 'overrides.<name>=<range>'` occurrences in the Dockerfile. */
function dockerfileOverrides(): Map<string, string> {
const found = new Map<string, string>();
const pattern = /npm\s+pkg\s+set\s+['"]overrides\.([^='"]+)=([^'"]+)['"]/g;
for (const match of DOCKERFILE.matchAll(pattern)) {
found.set(match[1].trim(), match[2].trim());
}
return found;
}
/**
* `"<name>@<selector>": "<range>"` entries under the workspace `overrides:`
* block. The key carries a version selector, so `deepmerge-ts@<8.0.0` and a
* bare `deepmerge-ts` both have to reduce to the same package name.
*/
function workspaceOverrides(): Map<string, string> {
const found = new Map<string, string>();
const pattern = /^\s+"([^"@][^"]*?)(?:@[^"]*)?"\s*:\s*"([^"]+)"/gm;
const block = WORKSPACE.split(/^allowBuilds:/m)[0];
for (const match of block.matchAll(pattern)) {
found.set(match[1].trim(), match[2].trim());
}
return found;
}
describe("dependency overrides — pnpm workspace and the npm install agree", () => {
it("finds the Dockerfile's npm-side overrides at all", () => {
const docker = dockerfileOverrides();
// A zero-match regex would make every assertion below vacuously true.
// This is the check that keeps the guard honest about its own matcher.
expect(docker.size).toBeGreaterThan(0);
expect(docker.has("deepmerge-ts")).toBe(true);
});
it("finds the workspace overrides at all", () => {
const workspace = workspaceOverrides();
expect(workspace.size).toBeGreaterThan(0);
expect(workspace.has("deepmerge-ts")).toBe(true);
});
it("pins the same range wherever both files name the same package", () => {
const docker = dockerfileOverrides();
const workspace = workspaceOverrides();
const shared = [...docker.keys()].filter((name) => workspace.has(name));
expect(shared.length).toBeGreaterThan(0);
for (const name of shared) {
expect(
docker.get(name),
`Dockerfile pins ${name} to ${docker.get(name)} while ` +
`pnpm-workspace.yaml pins it to ${workspace.get(name)}. The image ` +
`carries both installs, so the looser of the two is what ships.`,
).toBe(workspace.get(name));
}
});
it("keeps the override ahead of the version Prisma's config asks for", () => {
// `@prisma/config` requests deepmerge-ts 7.x; the advisory is fixed in
// 8.0.0. Anything that lets a 7.x back in re-opens CVE-2026-40345.
const range = dockerfileOverrides().get("deepmerge-ts");
expect(range).toBeDefined();
expect(range).toMatch(/\^?8\./);
});
});
@@ -97,6 +97,9 @@ function reminderRow(
location: null,
nextDueAt: at,
lastSatisfiedAt: null,
snoozedUntil: null,
lastSkippedAt: null,
skipCount: 0,
enabled: true,
vaccinationAntigen: null,
createdAt: at,
@@ -152,6 +155,27 @@ const BY_ID_SITES: ReadonlyArray<{
sites: 1,
surface: "the manual satisfy action",
},
{
// v1.37.20 (#223) — skipping an appointment would move a date the visit
// record still believes it owns; snoozing one likewise. Both refuse in
// the lookup, before any write. This entry went in RED (the routes did
// not exist yet) and turned green when they landed with the exclusion.
file: "src/app/api/measurement-reminders/[id]/skip/route.ts",
sites: 1,
surface: "the honest skip action",
},
{
file: "src/app/api/measurement-reminders/[id]/snooze/route.ts",
sites: 1,
surface: "the snooze action",
},
{
// v1.37.20 (iOS #68) — the ledger read answers 404 for an appointment
// row like every by-id sibling.
file: "src/app/api/measurement-reminders/[id]/history/route.ts",
sites: 1,
surface: "the completion-ledger history read",
},
{
file: "src/lib/telegram-webhook-handlers.ts",
sites: 2,
@@ -161,9 +185,9 @@ const BY_ID_SITES: ReadonlyArray<{
describe("an appointment reminder is not addressable by id", () => {
it("names a by-id set that is not empty (no vacuous pass)", () => {
expect(BY_ID_SITES.length).toBe(4);
expect(BY_ID_SITES.length).toBe(7);
expect(BY_ID_SITES.reduce((total, entry) => total + entry.sites, 0)).toBe(
7,
10,
);
});
@@ -204,6 +204,15 @@ describe("i18n English-leak guard", () => {
flattenValues(en, "", enFlat);
const enMap = new Map(enFlat);
it("actually reads the bundles (non-zero floor)", () => {
// The walker is the guard's eyes. If `flattenValues` rots — a shape
// change, a broken recursion — every leak test above it goes green over
// locales it never read. The bundle carries ~7800 keys; a floor well
// below that but far above zero fails on rot without failing on
// ordinary key churn.
expect(enFlat.length).toBeGreaterThan(3000);
});
it.each(LEAK_LOCALES.map((locale) => ({ locale })))(
"$locale does not echo English for multi-word strings",
({ locale }) => {
@@ -212,6 +221,9 @@ describe("i18n English-leak guard", () => {
) as Record<string, unknown>;
const flat: [string, string][] = [];
flattenValues(data, "", flat);
// Same non-zero proof per locale — an unreadable or empty bundle must
// fail loudly, never pass as "no leaks found".
expect(flat.length).toBeGreaterThan(3000);
const leaks = flat
.filter(([key, value]) => {
@@ -0,0 +1,189 @@
/**
* Every idempotent route says so in the contract, and only those do.
*
* `withIdempotency` has wrapped writes since v1.5.x. `Idempotency-Key` appeared
* in a dozen route comments and in no published schema, so an app generated
* against `docs/api/openapi.yaml` could not tell that a retry was safe — which
* is exactly what the iOS side asked for. Publishing it once is easy; keeping
* it published as routes come and go is what this guard is for.
*
* Both directions matter, and for different reasons:
*
* - A route that gains `withIdempotency` and forgets to publish it leaves the
* capability invisible, which is today's defect repeating itself.
* - A route that publishes the parameter and drops the wrapper is worse: the
* contract then promises a retry is safe when it is not, and a client that
* believes it writes twice.
*
* The wrapper is the source of truth here, not this file. Nothing is
* hand-listed: the expected set is read out of the route tree on every run, so
* the guard cannot fall behind the code it watches.
*/
import { readFileSync, readdirSync } from "node:fs";
import { join, relative, sep } from "node:path";
import { describe, expect, it } from "vitest";
import { buildOpenApiDocument } from "@/lib/openapi/registry";
const API_ROOT = join(process.cwd(), "src", "app", "api");
/** Every `route.ts` under `src/app/api`, tests excluded. */
function routeFiles(dir: string): string[] {
const found: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "__tests__") continue;
found.push(...routeFiles(full));
continue;
}
if (entry.name === "route.ts") found.push(full);
}
return found;
}
/** `src/app/api/medications/[id]/intake/route.ts` -> `/api/medications/{id}/intake` */
function toOpenApiPath(file: string): string {
const rel = relative(API_ROOT, file).split(sep).slice(0, -1);
const segments = rel.map((s) =>
s.startsWith("[") && s.endsWith("]") ? `{${s.slice(1, -1)}}` : s,
);
return `/api/${segments.join("/")}`;
}
/**
* Method-level, not path-level, and the difference is the whole point.
*
* `/api/measurements` exports GET, POST, PUT and DELETE, and only POST is
* wrapped:
*
* export const POST = apiHandler(withIdempotency<[NextRequest]>(postMeasurement));
* export const PUT = apiHandler(putMeasurement);
*
* A path-level check would go green on that path the moment ANY operation
* declared the header, and publishing it on PUT as well would promise a safe
* retry the server does not honour. So the unit here is `POST /api/x`, and the
* matcher requires the wrapper in the same assignment as the export.
*/
function idempotentOperationsFromSource(): Set<string> {
const found = new Set<string>();
// `export const POST = ...withIdempotency...` up to the end of that
// statement. Non-greedy to the next top-level `export const`, so a wrapped
// POST cannot lend its wrapper to the PUT declared below it.
const pattern =
/export\s+const\s+(POST|PUT|PATCH|DELETE)\s*=\s*([\s\S]*?)(?=\n(?:export\s+const|export\s+async\s+function|$))/g;
for (const file of routeFiles(API_ROOT)) {
const source = readFileSync(file, "utf8");
if (!source.includes("withIdempotency")) continue;
for (const match of source.matchAll(pattern)) {
if (!/\bwithIdempotency\s*[(<]/.test(match[2])) continue;
found.add(`${match[1].toLowerCase()} ${toOpenApiPath(file)}`);
}
}
return found;
}
/** Operations in the emitted document that declare the `Idempotency-Key`. */
function idempotentOperationsFromContract(): Set<string> {
const doc = buildOpenApiDocument() as {
paths?: Record<string, Record<string, unknown>>;
};
const found = new Set<string>();
for (const [path, item] of Object.entries(doc.paths ?? {})) {
for (const [method, operation] of Object.entries(item)) {
const parameters = (operation as { parameters?: unknown })?.parameters;
if (!Array.isArray(parameters)) continue;
const declares = parameters.some(
(p) => (p as { name?: string })?.name === "Idempotency-Key",
);
if (declares) found.add(`${method.toLowerCase()} ${path}`);
}
}
return found;
}
/**
* Idempotent operations that cannot be published, because the PATH is not in
* the document at all. A header cannot hang on a path that does not exist.
*
* One entry, and the list was four until the test below refused it. The first
* draft claimed the whole `/api/medications` family was missing, on the
* strength of a grep for path literals in `src/lib/openapi/routes/` that came
* back empty. It came back empty because those paths are not written as
* searchable literals there — the emitted document carries them, including
* every one the iOS client actually argues about. Measure the artefact, not
* the source that builds it.
*
* `/api/insights/feedback` left this list when the route joined the route
* table, and the leg below is what made that a diff rather than a permanent
* exemption: the moment the path appeared in the document, the entry claiming
* it could not be published became false and the run went red. An exemption
* nobody removes is how a gap becomes furniture.
*
* Listed rather than filtered silently, so the exclusion stays visible in the
* file that would otherwise claim full coverage.
*/
const UNPUBLISHED_PATHS: Readonly<Record<string, string>> = {
"/api/admin/backups/{id}/restore":
"Admin surface: cookie-only by construction, so arguably out of scope for a client contract.",
};
describe("idempotency — the contract matches the wrapper", () => {
it("finds idempotent operations in the source tree at all", () => {
// Without this the two comparisons below could both pass on empty sets,
// which is the failure mode this whole file exists to prevent elsewhere.
const fromSource = idempotentOperationsFromSource();
expect(fromSource.size).toBeGreaterThan(20);
});
it("publishes the header on every route that wraps withIdempotency", () => {
const fromSource = idempotentOperationsFromSource();
const fromContract = idempotentOperationsFromContract();
const unpublished = [...fromSource]
.filter((p) => !fromContract.has(p))
.filter((p) => !(p.split(" ")[1] in UNPUBLISHED_PATHS))
.sort();
expect(
unpublished,
`These routes accept an Idempotency-Key and do not say so in the ` +
`contract, so a generated client cannot know a retry is safe. Add ` +
`\`idempotencyKeyParameter\` to the write operation and ` +
`\`...idempotentWrite()\` to its responses.`,
).toEqual([]);
});
it("keeps the unpublished list honest — every entry is still absent", () => {
const doc = buildOpenApiDocument() as { paths?: Record<string, unknown> };
const present = Object.keys(doc.paths ?? {});
const nowPublished = Object.keys(UNPUBLISHED_PATHS)
.filter((p) => present.includes(p))
.sort();
expect(
nowPublished,
`These paths are in the contract now, so their exemption above is stale. ` +
`Delete the entry and add \`idempotencyKeyParameter\` to the operation ` +
`— an exemption nobody removes is how a gap becomes permanent.`,
).toEqual([]);
});
it("does not promise idempotency on a route that dropped the wrapper", () => {
const fromSource = idempotentOperationsFromSource();
const fromContract = idempotentOperationsFromContract();
const overclaimed = [...fromContract]
.filter((p) => !fromSource.has(p))
.sort();
expect(
overclaimed,
`The contract offers an Idempotency-Key on these paths and no handler ` +
`honours it. A client that trusts this writes twice on a retry — the ` +
`more dangerous of the two directions.`,
).toEqual([]);
});
});
@@ -0,0 +1,526 @@
/**
* Every route is published, declared unpublished, or registered as retired.
*
* `pnpm openapi:check` compares the registry against `docs/api/openapi.yaml`
* and fails on drift between them. It has never compared the ROUTES against
* the registry, so a route that was never registered at all produced no drift
* and no failure: the spec was internally consistent and silently incomplete.
* That is how two hundred and twenty-two paths, forty-three of them called by
* the native client, ended up undocumented while the contract check ran green
* on every commit for months. The first count taken of this gap said
* forty-five, because it compared against the paths the YAML already listed
* rather than against the verbs the routes export.
*
* This file closes the third side of the triangle. It walks `src/app` for
* route modules, reads the HTTP verbs each one actually exports, and requires
* every verb to be either present in the route table or named in
* {@link UNPUBLISHED} with a reason. Adding a route without touching either
* side fails here, which is the only place it can be caught before a client
* goes looking for a contract that was never written.
*
* ## The third state: retired
*
* Published and exempted both describe a route that exists. Removing one used
* to leave exactly one legal move — drop it from the contract too — and that
* move is invisible to every client already calling it. The native app spent
* twelve days rendering a permanent error card over a chart because
* `/api/auth/me/research-mode` went from published to absent with no third
* thing in between, and a bare 404 reads as a broken deployment rather than a
* decision.
*
* So retirement is a state here rather than a deletion. A path in
* `RETIRED_ROUTES` is required to be gone from `src/app` AND present in the
* contract, which is the one combination the fourth case below used to reject
* outright. Removing a route now forces a choice: drop it from the contract, or
* register it as retired and let the proxy answer 410 for it. What is no longer
* available is doing neither and finding out from a client.
*
* Be exact about what that is worth, because the obvious overclaim is easy to
* write and was written here first. This guard reads the tree as it stands. It
* cannot see a deletion, so it cannot insist that a removal SHOULD have been
* registered: a route deleted from `src/app` and from its route module in one
* commit leaves nothing behind to notice, and no check over a single commit's
* state can recover the fact that something used to be there. Proving otherwise
* needs an anchor outside the commit — the previous release tag — and the job
* that runs this suite checks out at depth one with no tags, so a history
* comparison here would be a check that silently cannot fail.
*
* What the third state does buy, and it is not nothing: deleting a route while
* it is still published now has TWO legal answers instead of one, and the
* failure message names both. Before, the only way past the fourth case was to
* erase the path — the move that is invisible to every client. Now the cheaper
* move is to leave the path standing as a tombstone, which is the move that
* reaches them. And once registered, the registration is held in three
* directions by the cases below, so it cannot rot into a lie about a route that
* came back or a retirement the contract never mentions.
*
* ## Why the exemption list carries prose
*
* A bare allowlist of paths decays into a place to put things. Each entry here
* says WHY the route is absent from the public contract, so the next reader can
* disagree with the reason rather than guess at it, and so an entry whose
* reason has stopped being true is visible as text rather than as a path in a
* list. The reasons fall into a small number of kinds — browser-only
* navigation, an OAuth callback the provider drives, a webhook authenticated by
* a shared secret, an internal cron trigger — and a route that does not fit one
* of those kinds probably belongs in the spec.
*
* ## What this guard does NOT prove
*
* - It proves a verb is PRESENT in the table, not that the operation is
* accurate. A published path whose request schema has drifted from the
* handler passes here. That is the limit of a structural check; the accuracy
* comes from the schemas being imported from `src/lib/validations/*` rather
* than retyped.
* - It reads exports with a regex over comment-stripped source. A verb exported
* through an indirection this matcher cannot see would read as absent, which
* fails loudly rather than passing quietly, and is the direction an
* imprecise matcher should fail in.
* - `globSync` skips dot-directories by default, which once hid fourteen guards
* from `src/app/.well-known`. The sweep below passes an explicit pattern list
* that includes it, and asserts a floor on the number of modules found so an
* enumeration that silently matches nothing cannot read as full coverage.
*
* Mutation checks, each run and confirmed red:
*
* 1. Delete any single entry from {@link UNPUBLISHED} → the third case fails
* naming that path.
* 2. Delete a path from a route module → the second case fails naming the
* verbs that lost their contract.
* 3. Delete a live route module that is published — `src/app/api/version` —
* → the fourth case fails naming `/api/version` as a published path no
* route serves. This is the seam the third state opens: the failure now
* has two ways out, erase the path or register the retirement, and the
* message says so.
* 4. Recreate `src/app/api/auth/me/research-mode/route.ts` with a GET export
* → the fifth case fails naming it, because a tombstone over a live route
* is wrong in three places at once (this contract, the capability list,
* and the 410 the proxy answers before the route can ever run).
* 5. Drop `...retiredPaths` from the route-table index → the sixth case fails
* naming every registered path as answered by the server and absent from
* the contract.
*
* Deliberately NOT claimed: deleting an entry from `RETIRED_ROUTES` does not
* fail anything here, because the contract entries are generated from that same
* constant and both sides disappear together. That is the limit stated above,
* written down so the next reader does not mistake this file for a deletion
* detector.
*/
import { readFileSync } from "node:fs";
import { globSync } from "node:fs";
import { relative } from "node:path";
import { describe, expect, it } from "vitest";
import { openApiPaths } from "@/lib/openapi/routes";
import { RETIRED_ROUTES } from "@/lib/http/retired-routes";
const ROOT = process.cwd();
/**
* The verbs a route module can export. `HEAD` and `OPTIONS` are deliberately
* out: Next.js answers both from the others, and neither is a contract a client
* writes against.
*/
const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
type HttpMethod = (typeof HTTP_METHODS)[number];
/**
* The kinds of route that are deliberately outside the published contract.
*
* A kind rather than a free-text reason per path, because these fall into a
* small number of genuinely different shapes and writing the same sentence
* seventy-three times would hide the one entry whose sentence is different. A
* route that fits none of these kinds belongs in the spec.
*/
const REASONS = {
/**
* `requireAdmin()` is cookie-only by construction (`src/lib/api-handler.ts`),
* so a Bearer token cannot reach these however wide its scope. The only
* caller that can is the operator's own browser on the admin console, which
* ships with the server and does not read a spec to find out what it talks
* to. Publishing them would describe an API surface that no API client can
* use.
*/
adminConsole:
"cookie-only admin console; requireAdmin() refuses every Bearer caller",
/**
* A browser navigation, not a request a client makes and reads. The response
* is a redirect to the provider's own authorise page; the client that starts
* it is a link, and the thing that follows it is the address bar.
*/
browserHandoff:
"browser navigation that ends at the provider's authorise page",
/**
* Driven by the provider, arriving with the provider's own query string. A
* client never constructs one, and the contract that governs its shape is
* the provider's, not this one.
*/
oauthCallback: "provider-driven redirect back from an OAuth authorise step",
/**
* Called by the provider, authenticated by a shared secret rather than by a
* session or a token. The payload shape is the provider's to define, and
* documenting it here would suggest a caller could construct one.
*/
providerWebhook:
"provider-called, authenticated by a shared secret rather than a session",
/**
* Serves the deployment itself: crash reports, web vitals, the analytics
* proxy, the deploy hook. No user-facing client calls these, and two of them
* accept a body defined by a browser reporting standard rather than by us.
*/
internalOps: "serves the deployment itself, not any user-facing client",
/**
* The Model Context Protocol transport and the OAuth dance in front of it.
* These speak wire formats that MCP and RFC 8414 / RFC 7591 define, including
* error bodies the standard response envelope cannot express, which is why
* they are the documented exception to the `apiHandler` rule as well.
*/
mcpTransport: "speaks the MCP and OAuth wire formats, not this envelope",
/**
* A clinician share link, authenticated by the URL credential itself. The
* audience is a person who was sent a link, and the whole point is that they
* need nothing else — no account, no token, and no contract to read.
*/
shareLink:
"authenticated by the URL credential, for a recipient with no account",
/**
* A discovery document whose format belongs to somebody else: Apple's
* associated-domains file, and the two OAuth metadata documents. Their shape
* is fixed by an external specification, so restating it here would create a
* second source of truth that can only be wrong.
*/
wellKnown: "format fixed by an external specification",
} as const;
type ExemptionKind = keyof typeof REASONS;
interface Exemption {
kind: ExemptionKind;
/** The verbs this exemption covers. Any other verb must be published. */
methods: readonly HttpMethod[];
}
/**
* Routes that are deliberately not in the public contract.
*
* Keyed by path, listing the verbs the exemption covers, so a route that grows
* a new verb has to justify it rather than inherit somebody else's reason.
*/
const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
"/api/admin/ai-quality": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/ai-settings": { kind: "adminConsole", methods: ["GET", "PUT"] },
"/api/admin/app-logs": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/audit-log": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/audit-log/actions": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/backup/test": { kind: "adminConsole", methods: ["POST"] },
"/api/admin/backups": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/backups/run": { kind: "adminConsole", methods: ["POST"] },
"/api/admin/backups/upload": { kind: "adminConsole", methods: ["POST"] },
"/api/admin/backups/{id}/download": {
kind: "adminConsole",
methods: ["GET"],
},
"/api/admin/backups/{id}/restore": {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/backups/{id}/summary": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/central-codex": {
kind: "adminConsole",
methods: ["DELETE", "GET"],
},
"/api/admin/central-codex/device-poll": {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/central-codex/device-start": {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/data": { kind: "adminConsole", methods: ["DELETE"] },
"/api/admin/drain-per-sample-cumulative": {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/encryption/rotate": { kind: "adminConsole", methods: ["POST"] },
"/api/admin/encryption/status": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/host-metrics": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/import-apple-health-export": {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/monitoring/glitchtip-test": {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/monitoring/umami-test": {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/notifications/health": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/notifications/reminder-check": {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/notifications/test": { kind: "adminConsole", methods: ["POST"] },
"/api/admin/rollups/recompute": { kind: "adminConsole", methods: ["POST"] },
"/api/admin/settings": { kind: "adminConsole", methods: ["GET", "PUT"] },
"/api/admin/settings/assistant-flags": {
kind: "adminConsole",
methods: ["GET", "PUT"],
},
"/api/admin/settings/module-availability": {
kind: "adminConsole",
methods: ["GET", "PATCH"],
},
"/api/admin/settings/web-push-vapid/generate": {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/status": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/tokens": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/users": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/users/{id}": { kind: "adminConsole", methods: ["PUT"] },
"/api/admin/users/{id}/force-logout": {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/users/{id}/reset-password": {
kind: "adminConsole",
methods: ["POST"],
},
"/api/fitbit/connect": { kind: "browserHandoff", methods: ["GET"] },
"/api/google-health/connect": { kind: "browserHandoff", methods: ["GET"] },
"/api/nightscout/connect": { kind: "browserHandoff", methods: ["POST"] },
"/api/oura/connect": { kind: "browserHandoff", methods: ["GET"] },
"/api/polar/connect": { kind: "browserHandoff", methods: ["GET"] },
"/api/strava/connect": { kind: "browserHandoff", methods: ["GET"] },
"/api/whoop/connect": { kind: "browserHandoff", methods: ["GET"] },
"/api/withings/connect": { kind: "browserHandoff", methods: ["GET"] },
"/api/internal/deploy-webhook": {
kind: "internalOps",
methods: ["GET", "POST"],
},
"/api/internal/web-vitals": { kind: "internalOps", methods: ["POST"] },
"/api/monitoring/csp-report": { kind: "internalOps", methods: ["POST"] },
"/api/monitoring/glitchtip": { kind: "internalOps", methods: ["POST"] },
"/api/monitoring/settings": { kind: "internalOps", methods: ["GET"] },
"/api/monitoring/umami-script": { kind: "internalOps", methods: ["GET"] },
"/api/send": { kind: "internalOps", methods: ["POST"] },
"/api/mcp/oauth/authorize": {
kind: "mcpTransport",
methods: ["GET", "POST"],
},
"/api/mcp/oauth/register": { kind: "mcpTransport", methods: ["POST"] },
"/api/mcp/oauth/token": { kind: "mcpTransport", methods: ["POST"] },
"/mcp": { kind: "mcpTransport", methods: ["DELETE", "GET", "POST"] },
"/api/auth/oidc/callback": { kind: "oauthCallback", methods: ["GET"] },
"/api/fitbit/callback": { kind: "oauthCallback", methods: ["GET"] },
"/api/google-health/callback": { kind: "oauthCallback", methods: ["GET"] },
"/api/oura/callback": { kind: "oauthCallback", methods: ["GET"] },
"/api/polar/callback": { kind: "oauthCallback", methods: ["GET"] },
"/api/strava/callback": { kind: "oauthCallback", methods: ["GET"] },
"/api/whoop/callback": { kind: "oauthCallback", methods: ["GET"] },
"/api/withings/callback": { kind: "oauthCallback", methods: ["GET"] },
"/api/telegram/webhook": {
kind: "providerWebhook",
methods: ["GET", "POST"],
},
"/api/whoop/webhook/{token}": { kind: "providerWebhook", methods: ["POST"] },
"/api/withings/webhook": {
kind: "providerWebhook",
methods: ["GET", "POST"],
},
"/api/withings/webhook/{token}": {
kind: "providerWebhook",
methods: ["GET", "POST"],
},
"/c/{token}/fhir": { kind: "shareLink", methods: ["GET"] },
"/c/{token}/report.pdf": { kind: "shareLink", methods: ["GET"] },
"/.well-known/apple-app-site-association": {
kind: "wellKnown",
methods: ["GET"],
},
"/.well-known/oauth-authorization-server": {
kind: "wellKnown",
methods: ["GET"],
},
"/.well-known/oauth-protected-resource": {
kind: "wellKnown",
methods: ["GET"],
},
};
function stripComments(text: string): string {
return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|\s)\/\/.*$/gm, "$1");
}
/** `src/app/api/foo/[id]/route.ts` → `/api/foo/{id}`. */
function toApiPath(file: string): string {
return (
"/" +
relative("src/app", file)
.replace(/\/route\.ts$/, "")
.replace(/\[\.\.\.([A-Za-z0-9_$]+)\]/g, "{$1}")
.replace(/\[([A-Za-z0-9_$]+)\]/g, "{$1}")
);
}
function exportedMethods(source: string): HttpMethod[] {
return HTTP_METHODS.filter((method) =>
new RegExp(
`export\\s+(?:async\\s+)?(?:function\\s+${method}\\b|const\\s+${method}\\b)`,
).test(source),
);
}
/**
* Every route module under `src/app`, dot-directories included.
*
* The two patterns are not redundant: `globSync("src/app/**")` does not descend
* into `.well-known`, and a sweep that quietly skips it is exactly the failure
* this project has already had once.
*/
function routeModules(): string[] {
const found = new Set<string>([
...globSync("src/app/**/route.ts", { cwd: ROOT }),
...globSync("src/app/**/.*/**/route.ts", { cwd: ROOT }),
]);
return [...found].sort();
}
interface DiskRoute {
path: string;
methods: HttpMethod[];
}
function routesOnDisk(): DiskRoute[] {
const routes: DiskRoute[] = [];
for (const file of routeModules()) {
const methods = exportedMethods(stripComments(readFileSync(file, "utf8")));
if (methods.length === 0) continue;
routes.push({ path: toApiPath(file), methods });
}
return routes;
}
describe("every route is published, unpublished, or retired", () => {
const disk = routesOnDisk();
const retiredPathSet = new Set(RETIRED_ROUTES.map((route) => route.path));
it("finds the route modules at all", () => {
// A floor, not a count: the point is that an enumeration returning nothing
// cannot read as "everything is covered". The number moves with the tree,
// so it sits well below the real one.
expect(
disk.length,
"the route sweep found almost nothing, which means the pattern is wrong rather than the tree empty",
).toBeGreaterThan(300);
});
it("no route verb is missing from the contract without a reason", () => {
const table = openApiPaths as Record<string, Record<string, unknown>>;
const undocumented: string[] = [];
for (const { path, methods } of disk) {
const exempt = UNPUBLISHED[path];
const published = table[path] ?? {};
for (const method of methods) {
if (exempt?.methods.includes(method)) continue;
if (method.toLowerCase() in published) continue;
undocumented.push(`${method} ${path}`);
}
}
expect(
undocumented,
"these routes answer requests and no client can read what they answer with",
).toEqual([]);
});
it("the exemption list names routes that exist", () => {
const byPath = new Map(disk.map((route) => [route.path, route.methods]));
const stale: string[] = [];
for (const [path, exempt] of Object.entries(UNPUBLISHED)) {
const methods = byPath.get(path);
if (!methods) {
stale.push(`${path} (no such route)`);
continue;
}
for (const method of exempt.methods) {
if (!methods.includes(method)) {
stale.push(`${method} ${path} (route does not export it)`);
}
}
}
expect(
stale,
"an exemption for a route that is gone hides the next route that needs one",
).toEqual([]);
});
it("the contract does not publish a path that no route serves, unless it is retired", () => {
const byPath = new Set(disk.map((route) => route.path));
const phantom = Object.keys(openApiPaths).filter(
(path) => !byPath.has(path) && !retiredPathSet.has(path),
);
expect(
phantom,
"a published path with no route behind it is a promise the deployment cannot keep — register it in RETIRED_ROUTES if it is gone on purpose",
).toEqual([]);
});
it("a retired path is really gone from src/app", () => {
// The other direction of the exemption above. A tombstone standing over a
// route that still answers would publish 410 as the only response while
// the proxy short-circuits the live handler — the contract, the capability
// list and the runtime would all be wrong together, and each of them looks
// internally consistent on its own.
const byPath = new Set(disk.map((route) => route.path));
const undead = RETIRED_ROUTES.map((route) => route.path).filter((path) =>
byPath.has(path),
);
expect(
undead,
"these paths are registered as retired and a route module still exports verbs for them",
).toEqual([]);
});
it("every retired path is published as gone", () => {
// Registering a retirement and not publishing it puts a client back where
// it started: the proxy answers 410, and a client generated from the
// contract has never heard of the path. The retirement is only worth
// anything if it reaches the reader.
const table = openApiPaths as Record<string, Record<string, unknown>>;
const unpublished: string[] = [];
for (const route of RETIRED_ROUTES) {
const published = table[route.path];
if (!published) {
unpublished.push(`${route.path} (absent from the contract)`);
continue;
}
for (const method of route.methods) {
const operation = published[method.toLowerCase()] as
{ responses?: Record<string, unknown> } | undefined;
if (!operation) {
unpublished.push(`${method} ${route.path} (verb not published)`);
continue;
}
if (!operation.responses?.["410"]) {
unpublished.push(`${method} ${route.path} (published without a 410)`);
}
}
}
expect(
unpublished,
"these retirements are answered by the server and invisible in the contract",
).toEqual([]);
});
});
@@ -0,0 +1,184 @@
/**
* Every published operation says how a caller authenticates, or says it does
* not have to.
*
* The document defined `bearerAuth` and `cookieAuth` under
* `components.securitySchemes` and then referenced neither. There was no
* document-level `security` and no operation-level one, so the schemes were
* decoration: a client generated from the contract wires no `Authorization`
* header and no cookie, and reads all of it as an open API. The schemes being
* present is what made it invisible, because the obvious check ("are the
* schemes declared?") passed.
*
* So this guard checks the reference, not the declaration. Each operation must
* resolve to exactly one of two states:
*
* - it inherits the document-level `security`, which offers the two schemes
* as alternatives, or
* - it carries `security: []`, the explicit way OpenAPI says "no credential
* required", and its path appears in {@link UNAUTHENTICATED} below.
*
* ## Why the allowlist is derived from handlers, not from the proxy
*
* `PUBLIC_PATHS` in `src/proxy.ts` is a different question. It says which paths
* the edge lets through without a session; it does not say the handler behind
* one accepts an anonymous caller. `/api/ingest/medication` is on that list and
* resolves a Bearer token itself, so it is authenticated in the contract sense
* while being public at the edge. Reading the proxy list as the answer would
* publish that route as open.
*
* ## What this does NOT prove
*
* It proves the operation names a scheme, not that the server enforces the one
* it names. A route documented as `cookieAuth` that in fact admits a Bearer
* would pass here; `bearer-scope-enforcement-guard.test.ts` and
* `session-surface-guard.test.ts` are what hold that end.
*
* Mutation check: drop the document-level `security` from the registry and
* every authenticated operation fails here; add `security: []` to an operation
* that is not on the list below and it fails naming that operation.
*/
import { describe, expect, it } from "vitest";
import { openApiPaths } from "@/lib/openapi/routes";
import { buildOpenApiDocument } from "@/lib/openapi/registry";
import { RETIRED_ROUTES } from "@/lib/http/retired-routes";
const HTTP_METHODS = ["get", "post", "put", "patch", "delete"] as const;
/**
* Operations that genuinely take no credential, with what makes each one so.
*
* Every entry is a route whose handler serves an anonymous caller. A route that
* merely sits on the proxy's public-path list does not belong here.
*/
const UNAUTHENTICATED: Readonly<Record<string, string>> = {
// `/api/auth/oidc/callback` is deliberately absent. It is anonymous, but it
// is not published at all: the provider drives it with its own query string
// and no client constructs one, so it is exempted from the contract by the
// coverage guard rather than published with an opt-out. This test is what
// caught the entry when it was written from the proxy's public-path list
// instead of from the published set.
"/api/health":
"Container healthcheck. Answers before any user exists and outside the response envelope.",
"/api/version":
"Build identity. Read by operators and by the update check; carries nothing account-scoped.",
"/api/auth/login":
"Mints the session. There is no credential to present yet.",
"/api/auth/register":
"Creates the first credential. Refused outright when registration is closed.",
"/api/auth/registration-status":
"Says whether registration is open, so the sign-in screen can decide what to offer.",
"/api/auth/passkey/login-options":
"The WebAuthn challenge that precedes a passkey sign-in.",
"/api/auth/passkey/login-verify":
"Completes the passkey sign-in; the assertion is the credential.",
"/api/auth/oidc/login":
"Starts the identity-provider handoff, for the browser and for the native client.",
"/api/auth/oidc/status":
"Says whether single sign-on is configured, so the sign-in screen can offer it.",
"/api/notifications/vapid":
"The instance's public Web Push key. Public by definition.",
// The tombstones, sourced from the registry rather than restated one by one.
// A retired path answers 410 from the proxy before any handler runs, and that
// answer never reads the request's cookies or Authorization header — so it is
// genuinely credential-free, and publishing it as authenticated would tell a
// client to sign in to be told the path is gone. Sourced rather than listed
// because a hand-written entry per retirement is a step someone forgets, and
// forgetting it here would fail the opt-out check rather than the retirement.
...Object.fromEntries(
RETIRED_ROUTES.map((route) => [
route.path,
`Removed in v${route.removedIn}. The 410 is produced at the edge and reads no credential.`,
]),
),
};
describe("the contract says how each operation is authenticated", () => {
const doc = buildOpenApiDocument() as unknown as {
security?: unknown[];
components?: { securitySchemes?: Record<string, unknown> };
};
const paths = openApiPaths as Record<string, Record<string, unknown>>;
it("declares a document-level default that references the schemes", () => {
const schemes = Object.keys(doc.components?.securitySchemes ?? {});
expect(
schemes.length,
"the schemes themselves went missing, which is a different failure",
).toBeGreaterThan(0);
const declared = JSON.stringify(doc.security ?? []);
const unreferenced = schemes.filter(
(scheme) => !declared.includes(`"${scheme}"`),
);
expect(
unreferenced,
"these schemes are defined and referenced by nothing, so a generated client ignores them",
).toEqual([]);
});
it("every operation either inherits that default or opts out on purpose", () => {
const undeclaredOptOut: string[] = [];
for (const [path, item] of Object.entries(paths)) {
for (const method of HTTP_METHODS) {
const operation = item[method] as { security?: unknown[] } | undefined;
if (!operation) continue;
// No `security` key at all means the document-level default applies,
// which is the authenticated case and needs nothing here.
if (operation.security === undefined) continue;
if (
Array.isArray(operation.security) &&
operation.security.length === 0 &&
!(path in UNAUTHENTICATED)
) {
undeclaredOptOut.push(`${method.toUpperCase()} ${path}`);
}
}
}
expect(
undeclaredOptOut,
"these operations publish themselves as needing no credential without a reason on record",
).toEqual([]);
});
it("the opt-out list names operations that are published", () => {
const stale = Object.keys(UNAUTHENTICATED).filter(
(path) => !(path in paths),
);
expect(
stale,
"an opt-out for a path the document does not publish hides the next one that needs a look",
).toEqual([]);
});
it("each opted-out operation actually carries the empty array", () => {
// The counterpart to the check above. A path listed as unauthenticated but
// silently inheriting the authenticated default publishes the opposite of
// what this file records, and the list would read as if it had been applied.
const missing: string[] = [];
for (const path of Object.keys(UNAUTHENTICATED)) {
const item = paths[path];
if (!item) continue;
const declared = HTTP_METHODS.filter((method) => item[method]).filter(
(method) => {
const operation = item[method] as { security?: unknown[] };
return (
Array.isArray(operation.security) && operation.security.length === 0
);
},
);
if (declared.length === 0) missing.push(path);
}
expect(
missing,
"these paths are recorded as needing no credential and do not say so in the document",
).toEqual([]);
});
});
@@ -61,6 +61,10 @@ import { describe, expect, it } from "vitest";
import { openApiPaths } from "@/lib/openapi/routes";
import { SHARING_ACCESS_DENIED_DESCRIPTION } from "@/lib/openapi/routes/shared";
import { RETIRED_ROUTES } from "@/lib/http/retired-routes";
/** Published paths that deliberately have no handler behind them. */
const retiredPaths = new Set(RETIRED_ROUTES.map((route) => route.path));
const ROOT = process.cwd();
const HTTP_METHODS = ["get", "post", "put", "patch", "delete"] as const;
@@ -148,6 +152,13 @@ function publishedOperations(): PublishedOperation[] {
const operations: PublishedOperation[] = [];
const missing: string[] = [];
for (const [path, item] of Object.entries(openApiPaths)) {
// A retired path is published deliberately and has no route module by
// definition — that is the whole state (`src/lib/http/retired-routes.ts`).
// It resolves no fence because it reaches no handler: the proxy answers 410
// before routing, for every caller and every verb, so there is no record to
// refuse access to. Skipped by the registry rather than by a path pattern,
// so a live route can never fall through this hole.
if (retiredPaths.has(path)) continue;
const modulePath = routeModulePath(path);
if (!existsSync(modulePath)) {
missing.push(path);
@@ -0,0 +1,100 @@
/**
* A redirect must not name the host the process happens to be bound to.
*
* `NextResponse.redirect()` requires an absolute URL, and the obvious way to
* build one is `new URL("/somewhere", req.url)`. Behind a reverse proxy that
* does not pass `Host` through, `req.url` is the bind address, so the redirect
* comes back as `https://0.0.0.0:3000/...`. A browser cannot follow it and iOS
* refuses the port outright.
*
* It shipped on the native sign-in handoff, which is the primary onboarding
* path. Two things made it invisible:
*
* - every error branch of that route builds a fixed `healthlog://` scheme
* with no host in it, so the route was correct on every failure and broken
* only on success, and
* - a test that builds its own `Request` names a host that matches, so the
* absolute URL it produced looked right.
*
* This file therefore checks the two halves separately. The behavioural test
* calls the real route with a request whose own URL points somewhere the
* public origin is not, which is what a proxy hop looks like from inside the
* handler. The structural test freezes the pattern out of the four files that
* had it, because a behavioural test can only cover the route somebody
* remembered to write one for.
*
* Mutation check: put `NextResponse.redirect(new URL("/auth/login?flow=native",
* req.url))` back in the login route and both tests fail, the first on the
* host appearing in `Location` and the second on the file list.
*/
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const REPO_ROOT = resolve(__dirname, "../..");
/**
* The files that carried the pattern when it was found. Frozen as a list
* rather than a repo-wide sweep so a new occurrence somewhere else is a
* failure of the sweep below rather than a silent pass here.
*/
const FILES_THAT_HAD_IT = [
"src/app/api/auth/native/login/route.ts",
"src/app/api/withings/connect/route.ts",
"src/app/api/whoop/connect/route.ts",
"src/app/api/fitbit/connect/route.ts",
] as const;
function read(relativePath: string): string {
return readFileSync(resolve(REPO_ROOT, relativePath), "utf8");
}
describe("redirects to this deployment do not name a host", () => {
it("no route builds a redirect target out of the request URL", () => {
// Matches `new URL(<anything>, req.url)` and the `request.url` spelling,
// across a line break, which is how prettier formats the longer ones.
const pattern = /new URL\([\s\S]*?,\s*(?:req|request)\.url\s*\)/g;
const offenders: string[] = [];
for (const file of FILES_THAT_HAD_IT) {
const source = read(file);
const hits = source.match(pattern);
if (hits) offenders.push(`${file}: ${hits.length}`);
}
expect(
offenders,
"these files built a redirect out of the request URL, which is the bind address behind a proxy",
).toEqual([]);
});
it("each of those files reaches for the relative helper instead", () => {
// The counterpart to the check above: proving the pattern is gone says
// nothing about whether the redirect still happens. A file that simply
// deleted its redirect would pass the first test and fail its users.
const missing = FILES_THAT_HAD_IT.filter(
(file) => !read(file).includes("relativeRedirect("),
);
expect(
missing,
"a file that lost the pattern without gaining the helper has lost its redirect",
).toEqual([]);
});
it("the helper refuses a value that is a host in disguise", async () => {
const { relativeRedirect } = await import("@/lib/http/relative-redirect");
// `//evil.example` is protocol-relative: a browser reads it as a host.
expect(() => relativeRedirect("//evil.example/auth/login")).toThrow(
/root-relative/,
);
// Without the leading slash it resolves against the current path instead
// of the root, which lands somewhere nobody intended.
expect(() => relativeRedirect("auth/login")).toThrow(/root-relative/);
const ok = relativeRedirect("/auth/login?flow=native");
expect(ok.status).toBe(307);
expect(ok.headers.get("location")).toBe("/auth/login?flow=native");
});
});
+160
View File
@@ -0,0 +1,160 @@
import { describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import {
RETIRED_ROUTES,
RETIRED_ROUTE_ERROR_CODE,
} from "@/lib/http/retired-routes";
import { apiError } from "@/lib/api-response";
/**
* A retired path answers 410; nothing else changes.
*
* The failure this pins is not a crash — it is an answer that carries no
* information. `/api/auth/me/research-mode` was removed on purpose and
* answered a bare 404 for twelve days, which the native client read as a
* transient fault and retried behind a button that could never succeed. 410 is
* the status whose meaning is "this existed and is gone", so a client learns to
* stop from the status line alone; the envelope carries the removing version
* and the replacement for one that reads further.
*
* The three cases that matter are the three answers a path can get: retired,
* live, and never-existed. The last one is the one a careless fix breaks —
* turning every unknown path into a 410 would be worse than the 404 it
* replaced, because it would assert history that never happened.
*
* Break-proofs, each run and confirmed red:
*
* - Remove the retirement block from `src/proxy.ts` → five cases fail.
* - Answer 404 instead of 410 → three fail, including the anonymous one.
* - Turn the exact lookup into a `startsWith` → only the unknown-path case
* fails, which is the case that exists for exactly that mistake.
* - Drop `errorCode` from the envelope → the first case and the envelope
* equivalence both fail.
*/
vi.mock("@/lib/process-type", () => ({
shouldRunWeb: () => true,
}));
import { proxy } from "../proxy";
function request(pathname: string, method = "GET"): NextRequest {
return new NextRequest(`http://localhost${pathname}`, { method });
}
async function body(response: Response) {
return (await response.json()) as {
data: null;
error: string;
meta: {
errorCode: string;
removedIn: string;
replacedBy: string | null;
};
};
}
describe("the proxy answers a retired path with 410", () => {
it("answers 410 with the code, the removing version and the replacement", async () => {
const response = proxy(request("/api/auth/me/research-mode"));
expect(response.status).toBe(410);
const payload = await body(response);
expect(payload.data).toBeNull();
expect(payload.meta.errorCode).toBe(RETIRED_ROUTE_ERROR_CODE);
expect(payload.meta.removedIn).toBe("1.37.2");
expect(payload.meta.replacedBy).toBeNull();
expect(payload.error).toMatch(/removed in v1\.37\.2/);
});
it("carries the replacement path for a route that moved rather than went", async () => {
const payload = await body(proxy(request("/api/fhir/$everything")));
expect(payload.meta.replacedBy).toBe("/api/fhir/Patient/$everything");
});
it("answers every retired path, whatever the verb", async () => {
// The path was retired, not a selection of methods on it. A client that
// POSTed to `/api/bugreport` must not get a 404 for the verb it used while
// a GET gets the tombstone.
for (const route of RETIRED_ROUTES) {
for (const method of ["GET", "POST", "PUT", "PATCH", "DELETE"]) {
const response = proxy(request(route.path, method));
expect(
response.status,
`${method} ${route.path} did not answer 410`,
).toBe(410);
}
}
});
it("needs no session and reads no credential", async () => {
// The request below carries nothing: no session cookie, no Authorization
// header. If the answer ever depended on one, a retired path would become
// a way to tell an authenticated caller from an anonymous one.
const anonymous = proxy(request("/api/auth/me/research-mode"));
const withSession = new NextRequest(
"http://localhost/api/auth/me/research-mode",
);
withSession.cookies.set("healthlog_session", "whatever");
expect(anonymous.status).toBe(410);
expect(proxy(withSession).status).toBe(410);
expect(await body(anonymous)).toEqual(await body(proxy(withSession)));
});
it("carries the baseline security headers like every other early exit", () => {
const response = proxy(request("/api/auth/me/research-mode"));
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
expect(response.headers.get("X-Frame-Options")).toBe("DENY");
});
it("leaves a live path alone", () => {
// `/api/measurements` exists. The proxy must fall through to it, not decide
// anything about it.
expect(proxy(request("/api/measurements")).status).not.toBe(410);
expect(proxy(request("/api/auth/me")).status).not.toBe(410);
// The replacement for a moved route is a live path and must stay one.
expect(proxy(request("/api/fhir/Patient/$everything")).status).not.toBe(
410,
);
});
it("leaves a genuinely unknown path to the ordinary 404", () => {
// Nothing here claims this path ever existed, so the proxy must not answer
// it at all — Next serves its own 404 downstream of the fall-through.
for (const unknown of [
"/api/auth/me/research-modes",
"/api/auth/me/research-mode/extra",
"/api/no-such-thing",
"/api/bugreport-x",
]) {
expect(proxy(request(unknown)).status, unknown).not.toBe(410);
}
});
});
describe("the 410 body is the standard error envelope", () => {
it("is byte-identical to what apiError would have produced", async () => {
// The proxy bundles separately from the route tree, so the envelope is
// built as a plain object rather than by calling `apiError`. That is a
// duplicated shape unless something holds the two together; this is that
// something. A field added to the envelope builder and not to the
// retirement answer fails here.
const route = RETIRED_ROUTES[0];
const fromProxy = await proxy(request(route.path)).json();
const fromEnvelope = await apiError(
`This endpoint was removed in v${route.removedIn} and is not coming back. Do not retry.`,
410,
{
errorCode: RETIRED_ROUTE_ERROR_CODE,
removedIn: route.removedIn,
replacedBy: route.replacedBy,
},
).json();
expect(JSON.stringify(fromProxy)).toBe(JSON.stringify(fromEnvelope));
});
});
@@ -82,7 +82,6 @@ describe("managed record settings configuration contract", () => {
tone: "warm",
verbosity: "default",
excludeMetrics: [],
showEvidenceByDefault: false,
defaultWindow: "allTime",
},
},
@@ -100,6 +99,24 @@ describe("managed record settings configuration contract", () => {
expect(() => parseManagedRecordSettingsPatch(family, patch)).not.toThrow();
});
// Watched red: re-adding `showEvidenceByDefault: true` to the managed
// coach pick makes the strict schema accept the key again and this
// fails. The evidence disclosure stopped honouring the flag in v1.4.27
// (F14); a guardian toggle over a pref nothing reads is a dead control.
it("refuses the retired showEvidenceByDefault pref", () => {
expect(() =>
parseManagedRecordSettingsPatch("coach", {
preferences: {
tone: "warm",
verbosity: "default",
excludeMetrics: [],
defaultWindow: "allTime",
showEvidenceByDefault: true,
},
}),
).toThrow();
});
it("uses the complete canonical module inventory for a fresh record", () => {
expect(Object.keys(MANAGED_RECORD_SETTINGS_MODULE_DEFAULTS)).toEqual(
WRITABLE_MODULE_KEYS,
@@ -67,7 +67,12 @@ describe("record settings contract", () => {
guardianWritable: false,
});
expect(classifySettingsDestination("ai").kind).toBe("unavailable");
expect(classifySettingsDestination("dashboard").kind).toBe(
// v1.37.19 — "dashboard" left SETTINGS_SECTION_SLUGS (its URL is
// owned by a permanent redirect to /settings/layout/dashboard), so an
// unknown destination falls closed to plain unavailable; "layout" is
// the surviving adult-shared-unavailable representative.
expect(classifySettingsDestination("dashboard").kind).toBe("unavailable");
expect(classifySettingsDestination("layout").kind).toBe(
"adult-shared-unavailable",
);
expect(isGuardianSettingsWriteAllowed("account")).toBe(true);
+37 -3
View File
@@ -875,6 +875,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "mind",
why: "The record's screener history. The module gate resolves against the record, so a delegate sees the surface only where the owner switched it on; the rate limit on the POST arm is untouched and that arm still refuses under a switch.",
},
"app/api/mental-health/assessments/[id]/route.ts": {
domain: "mind",
why: "One administration of the record, fetch-then-guard against the resolved user, WITH its decrypted per-item answers. Admitted at the same level as the list beside it deliberately: the mind read grant already serves the mood diary's decrypted prose and the list already publishes the item-9 flag, so the per-item values sit inside what the domain shows, not beyond it. The route comment carries the argument; the module gate resolves against the record like the sibling.",
},
"app/api/anamnesis/facts/route.ts": {
domain: "profile",
why: "The record's health-profile facts, read through `readHealthProfileFacts(user.id)`. The module holds write helpers, and the GET calls none of them.",
@@ -1140,6 +1144,18 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "measurements",
why: "The same primitive from the other caller. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
},
"app/api/measurement-reminders/[id]/skip/route.ts": {
domain: "measurements",
why: "Honestly skipping the current due cycle (v1.37.20, #223) — the counterpart of complete/satisfy, restarting the interval without claiming fulfilment. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
},
"app/api/measurement-reminders/[id]/snooze/route.ts": {
domain: "measurements",
why: "Pushing one due date back to a calendar day (v1.37.20, #223), the cadence untouched. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
},
"app/api/measurement-reminders/[id]/history/route.ts": {
domain: "measurements",
why: "The completion ledger behind one reminder (v1.37.20, iOS #68), read-only and newest-first. The lookup binds the resolved user id and refuses an appointment row like every by-id sibling.",
},
"app/api/labs/restore/route.ts": {
domain: "labs",
why: "Restoring tombstoned results, scoped so a foreign or live id is a no-op. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
@@ -1198,7 +1214,7 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
},
"app/api/medications/[id]/glp1/route.ts": {
domain: "medications",
why: "Recording a titration step. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.",
why: "Reading the titration history, the recent injections and the supply math, and recording a titration step. The READ arm joined the MANAGE one when the route was published: the write had resolved the record since v1.37.0 while the read still resolved the caller, so a manager could add a dose the history refused them — may write, may not read, which the model does not produce. The read is at the level `/inventory`, `/side-effects` and `/cadence` use for the same medication's data and is strictly narrower than the write already admitted; the argument for the write is the manage literal's reason line.",
},
"app/api/medications/[id]/intake/[eventId]/route.ts": {
domain: "medications",
@@ -1477,6 +1493,16 @@ const DELEGABLE_MANAGE_ROUTES: Record<string, ManageEntry> = {
conditions: [],
why: "The same primitive from the other caller.",
},
"app/api/measurement-reminders/[id]/skip/route.ts": {
domain: "measurements",
conditions: [],
why: "Honestly letting one due cycle go (v1.37.20, #223). Additive management of a schedule the level admits — the interval restarts, lastSatisfiedAt is never touched.",
},
"app/api/measurement-reminders/[id]/snooze/route.ts": {
domain: "measurements",
conditions: [],
why: "Pushing one due date back (v1.37.20, #223). Additive, self-expiring, the anchor untouched.",
},
"app/api/labs/[id]/route.ts": {
domain: "labs",
conditions: ["C4"],
@@ -1911,8 +1937,16 @@ const ACTOR_ROUTES: Record<string, string> = {
* v1.38.0 -- the immunization log adds six: two reads and one restore on the
* record list, one create on the write literal, and the edit/delete pair plus
* the restore on the manage literal. 215 -> 221.
*
* v1.37.20 (#223 / iOS #68) -- Vorsorge skip/snooze adds five: skip, snooze
* and the completion-ledger history read on the record list, plus skip and
* snooze on the manage literal. 224 -> 229.
*
* The screener detail read adds one: the per-administration read that
* decrypts the stored item answers, on the record list at the same level as
* the screener history beside it. 229 -> 230.
*/
const FROZEN_ENTRY_COUNT = 224;
const FROZEN_ENTRY_COUNT = 230;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
@@ -2673,7 +2707,7 @@ describe("(g) the MANAGE route set is frozen", () => {
it("keeps the admitted mutation inventory complete and discoverable", () => {
expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0);
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(75);
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(77);
const expected = ADMITTED_MUTATING_HANDLERS.map(
({ handlerModule, action, level }) =>
@@ -140,7 +140,9 @@ const PINNED_AFFORDANCES: Record<
"toast.success": 1,
},
"src/components/custom-metrics/custom-metric-history-list.tsx": {
"toast.success": 2,
// v1.37.20 (A3-11) — the third success is the restore confirmation
// behind the delete toast's Undo action.
"toast.success": 3,
},
"src/components/cycle/cycle-phase-crosstab.tsx": { "text-success": 1 },
// v1.36.x — two call sites, one decision: the intake toast routes through
@@ -194,7 +196,6 @@ const PINNED_AFFORDANCES: Record<
"text-success": 1,
},
"src/components/insights/personal-record-badge.tsx": { "text-success": 1 },
"src/components/insights/recommendation-feedback.tsx": { "text-success": 3 },
"src/components/labs/biomarker-form.tsx": { "toast.success": 1 },
"src/components/labs/biomarker-manager.tsx": { "toast.success": 2 },
"src/components/labs/lab-biomarker-detail.tsx": { "toast.success": 1 },
@@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => {
});
it("keeps every admitted mutation uniquely addressable and fully controlled", () => {
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(75);
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(77);
expect(
new Set(
ADMITTED_MUTATING_HANDLERS.map(
@@ -292,6 +292,29 @@ function sourceClient() {
encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) },
vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
// The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the
// same reason as the visit tables above.
measurementReminder: { findMany: vi.fn().mockResolvedValue([]) },
measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) },
// The bests, the badges, and the environmental history with the location
// periods that explain it. Empty for the same reason as the tables above.
personalRecord: { findMany: vi.fn().mockResolvedValue([]) },
userAchievement: { findMany: vi.fn().mockResolvedValue([]) },
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) },
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
moodTagCategory: { findMany: vi.fn().mockResolvedValue([]) },
moodTagHidden: { findMany: vi.fn().mockResolvedValue([]) },
mentalHealthAssessment: { findMany: vi.fn().mockResolvedValue([]) },
consentReceipt: { findMany: vi.fn().mockResolvedValue([]) },
coachFact: { findMany: vi.fn().mockResolvedValue([]) },
coachPlan: { findMany: vi.fn().mockResolvedValue([]) },
coachReminder: { findMany: vi.fn().mockResolvedValue([]) },
// What a document was filed against, and what was read out of it.
// Empty for the same reason as the sections above.
documentConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
extractedFact: { findMany: vi.fn().mockResolvedValue([]) },
ecgRecording: { findMany: vi.fn().mockResolvedValue([]) },
customMetric: {
findMany: vi.fn().mockResolvedValue([
{
+557 -11
View File
@@ -50,6 +50,16 @@ import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup
import { restoreHealthScoreData } from "@/lib/export/health-score-backup";
import { restoreVisitsData } from "@/lib/export/visits-backup";
import { restoreVaccinationsData } from "@/lib/export/vaccinations-backup";
import { restoreSensitiveData } from "@/lib/export/sensitive-backup";
import {
restoreCoachData,
restoreCoachMemoryData,
} from "@/lib/export/coach-backup";
import { restoreRemindersData } from "@/lib/export/reminders-backup";
import { restoreDocumentFilingData } from "@/lib/export/document-filing-backup";
import { restoreAwardsData } from "@/lib/export/awards-backup";
import { restoreEnvironmentData } from "@/lib/export/environment-backup";
import { restoreEcgData } from "@/lib/export/ecg-backup";
import { invalidateUserData } from "@/lib/cache/invalidate";
export const dynamic = "force-dynamic";
@@ -84,6 +94,8 @@ interface RestoreResponse {
familyHistory: number;
workouts: number;
documents: number;
documentConditionLinks: number;
extractedFacts: number;
healthProfile: number;
healthProfileFactRevisions: number;
customMetrics: number;
@@ -95,6 +107,21 @@ interface RestoreResponse {
encounterLinks: number;
vaccinations: number;
vaccinationLinks: number;
measurementReminders: number;
measurementReminderEvents: number;
coachConversations: number;
coachMessages: number;
coachConversationDocuments: number;
coachFacts: number;
coachPlans: number;
coachReminders: number;
mentalHealthAssessments: number;
consentReceipts: number;
personalRecords: number;
userAchievements: number;
environmentContexts: number;
environmentTravelLocations: number;
ecgRecordings: number;
};
}
@@ -539,6 +566,23 @@ const handler = apiHandler(
const medByName = new Map<string, string>();
const restoredMedicationIds = new Set<string>();
// The efficacy targets cannot ride inside the medication create
// beside the schedules and the packs: `biomarkerId` is a foreign key
// into a catalogue this transaction has not restored yet. They are
// collected here against the id the drug ACTUALLY got and written in
// a second pass once the biomarkers exist; the pass itself says why
// hoisting the biomarker restore above the medications is the worse
// of the two orderings.
const pendingEfficacyTargets: Array<{
medicationId: string;
target: (typeof payload.medications)[number]["efficacyTargets"][number];
}> = [];
// Supersede pointers the file states and the restore cannot honour.
// Reported as JSON paths into the file rather than as ids: the file
// is where an operator has to go to see what the position meant, and
// an id would be one the restore never wrote.
const unresolvedRevisionLinks: string[] = [];
let medicationIndex = 0;
for (const m of payload.medications) {
const created = await tx.medication.create({
data: {
@@ -621,11 +665,216 @@ const handler = apiHandler(
: {}),
})),
},
// Nested for the same reason as the two above — the FK binds
// to the id this drug actually got, so a portable file that
// mints fresh ids needs no remap.
//
// `resumedAt` passes through as null when the era is open. It
// is not defaulted to the restore time: a pause that was still
// running when the backup was taken is still running after the
// restore, and closing it here would silently convert an
// ongoing decision into a historical one.
pauseEras: {
create: m.pauseEras.map((p) => ({
...(p.id ? { id: p.id } : {}),
userId: ownerId,
pausedAt: new Date(p.pausedAt),
resumedAt: p.resumedAt ? new Date(p.resumedAt) : null,
...(p.createdAt
? { createdAt: new Date(p.createdAt) }
: {}),
})),
},
// The titration steps, nested for the same reason. The note
// takes the measurement contract next to it: ciphertext when
// the file has it, legacy plaintext encrypted on the way in,
// and the plaintext column left null either way.
doseChanges: {
create: m.doseChanges.map((d) => ({
...(d.id ? { id: d.id } : {}),
effectiveFrom: new Date(d.effectiveFrom),
doseValue: d.doseValue,
doseUnit: d.doseUnit,
note: null,
noteEncrypted:
d.noteEncrypted == null
? encryptNote(d.note ?? null)
: decodeEncryptedBytes(d.noteEncrypted),
...(d.createdAt
? { createdAt: new Date(d.createdAt) }
: {}),
})),
},
// The packs, and the ledger behind their counts. Nested for
// the same reason as everything above it, and `unitsRemaining`
// is written VERBATIM rather than recomputed from the events —
// it is what the server had resolved and what the person was
// shown, and recomputing here would silently correct a count
// they may have adjusted by hand.
inventoryItems: {
create: m.inventoryItems.map((i) => ({
...(i.id ? { id: i.id } : {}),
userId: ownerId,
state: (i.state ?? "ACTIVE") as never,
containerType: (i.containerType ?? "OTHER") as never,
unitsTotal: i.unitsTotal,
unitsRemaining: i.unitsRemaining,
firstUseAt: i.firstUseAt ? new Date(i.firstUseAt) : null,
expiresAt: i.expiresAt ? new Date(i.expiresAt) : null,
printedExpiry: i.printedExpiry
? new Date(i.printedExpiry)
: null,
purchasedAt: i.purchasedAt ? new Date(i.purchasedAt) : null,
manufacturer: i.manufacturer ?? null,
doseStrength: i.doseStrength ?? null,
notes: null,
notesEncrypted:
i.notesEncrypted == null
? encryptNote(i.notes ?? null)
: decodeEncryptedBytes(i.notesEncrypted),
...(i.createdAt
? { createdAt: new Date(i.createdAt) }
: {}),
...(i.updatedAt
? { updatedAt: new Date(i.updatedAt) }
: {}),
})),
},
// One row or none, so `create` on the optional side rather
// than a list. A file that says nothing leaves the drug
// untuned, which is what it was.
...(m.phaseConfig
? {
phaseConfig: {
create: {
...(m.phaseConfig.id ? { id: m.phaseConfig.id } : {}),
...(m.phaseConfig.greenValue !== undefined
? { greenValue: m.phaseConfig.greenValue }
: {}),
...(m.phaseConfig.greenMode
? { greenMode: m.phaseConfig.greenMode }
: {}),
...(m.phaseConfig.yellowValue !== undefined
? { yellowValue: m.phaseConfig.yellowValue }
: {}),
...(m.phaseConfig.yellowMode
? { yellowMode: m.phaseConfig.yellowMode }
: {}),
...(m.phaseConfig.orangeValue !== undefined
? { orangeValue: m.phaseConfig.orangeValue }
: {}),
...(m.phaseConfig.orangeMode
? { orangeMode: m.phaseConfig.orangeMode }
: {}),
...(m.phaseConfig.redValue !== undefined
? { redValue: m.phaseConfig.redValue }
: {}),
...(m.phaseConfig.redMode
? { redMode: m.phaseConfig.redMode }
: {}),
},
},
}
: {}),
inventoryEvents: {
create: m.inventoryEvents.map((e) => ({
...(e.id ? { id: e.id } : {}),
delta: e.delta,
reason: e.reason,
occurredAt: new Date(e.occurredAt),
})),
},
},
});
restoredMedicationIds.add(created.id);
if (!medByName.has(m.name)) medByName.set(m.name, created.id);
// ── The archived schedule eras, in two passes ────────────────
//
// An era carries `supersededByRevisionId`, the pointer from a
// corrected ARCHIVED row to the MANUAL one that replaced it. The
// column has no `@relation`, so a value addressing nothing costs
// no error — it just stops meaning anything, which is the more
// dangerous shape here, because every era consumer SKIPS a row
// that carries the pointer. Lose it and the superseded original
// goes live again beside its own correction: two overlapping eras
// for one window, and past days minted against a plan the account
// had already corrected.
//
// A portable restore mints fresh ids, so the pointer cannot travel
// as one. It travels as a POSITION in this drug's own ordered era
// list, because that list is the only thing both ends agree on:
// the builder sorts it totally (validFrom, then createdAt, then
// id) so two eras sharing an instant cannot swap places between
// two exports of the same account, and the pointer never crosses a
// drug — every route that writes it scopes the update to one
// medication — so the drug's own list is a complete address space.
//
// Pass one writes every era with the link NULL, because the row a
// link addresses is as often written after the row addressing it
// as before. Pass two patches the links once every position has an
// id.
const revisionIds: string[] = [];
for (const revision of m.scheduleRevisions) {
const row = await tx.medicationScheduleRevision.create({
data: {
...(revision.id ? { id: revision.id } : {}),
medicationId: created.id,
validFrom: new Date(revision.validFrom),
validUntil: new Date(revision.validUntil),
payload: toJson(revision.payload),
source: revision.source ?? "ARCHIVED",
supersededByRevisionId: null,
...(revision.createdAt
? { createdAt: new Date(revision.createdAt) }
: {}),
},
select: { id: true },
});
revisionIds.push(row.id);
}
for (const [index, revision] of m.scheduleRevisions.entries()) {
const to = revision.supersededByIndex;
if (to === null || to === undefined) continue;
// A hand-edited file can name a position this drug does not
// have, or the era's OWN position — and a row that supersedes
// itself is skipped by every consumer, so the era would
// disappear from the timeline while still sitting in the table.
// Neither can be honoured and neither may be guessed at, so the
// link stays NULL and the position is reported. The era still
// restores: the window and the plan it held are the history, and
// the correction pointer is the smaller loss.
if (
!Number.isInteger(to) ||
to < 0 ||
to >= revisionIds.length ||
to === index
) {
unresolvedRevisionLinks.push(
`medications[${medicationIndex}].scheduleRevisions[${index}].supersededByIndex=${to}`,
);
continue;
}
await tx.medicationScheduleRevision.update({
where: { id: revisionIds[index] },
data: { supersededByRevisionId: revisionIds[to] },
});
}
for (const target of m.efficacyTargets) {
pendingEfficacyTargets.push({
medicationId: created.id,
target,
});
}
medicationIndex += 1;
}
recordUnknownKeys(
skips,
"scheduleRevisionLink",
[...new Set(unresolvedRevisionLinks)],
unresolvedRevisionLinks,
);
if (payload.intakeEvents.length > 0) {
// An event whose medication did not come back used to be mapped to
@@ -698,6 +947,33 @@ const handler = apiHandler(
(entry) => entry.structuredTags,
);
const tagKeys = [...new Set(referencedTagKeys)];
// The account's own CATEGORIES go in before its tags. A custom
// tag's `categoryId` is a real foreign key, so writing the tag
// first threw `mood_tags_category_id_fkey` and rolled the entire
// restore back — an account that had grouped its own mood factors
// could not be restored at all, and the failure named a constraint
// rather than the thing that was missing.
//
// Upserted by key for the same reason the tags below are: `key` is
// globally unique, so a seeded category that later claims the key
// must be left alone rather than overwritten by a restore.
for (const category of payload.customMoodTagCategories) {
await tx.moodTagCategory.upsert({
where: { key: category.key },
create: {
id: category.id,
userId: ownerId,
key: category.key,
labelKey: category.labelKey,
icon: category.icon ?? null,
sortOrder: category.sortOrder ?? 0,
isActive: category.isActive ?? true,
labelEncrypted: category.labelEncrypted ?? null,
},
update: {},
});
}
// Re-create the account's own tag definitions first. The lookup
// below used to ask only for the seeded catalogue (`userId: null`),
// so a single custom rated tag made the whole restore throw
@@ -726,6 +1002,53 @@ const handler = apiHandler(
});
}
// Which tags the account hid. Delete-then-recreate like every
// other section, and resolved by KEY: what people hide is almost
// always a seeded tag, whose id differs on every instance.
//
// A key this instance no longer knows costs one hidden row and is
// REPORTED. Dropping it silently would un-hide a factor the person
// had deliberately taken out of their mood editor, and they would
// have no way to know why it came back.
await tx.moodTagHidden.deleteMany({ where: { userId: ownerId } });
if (payload.hiddenMoodTags.length > 0) {
const hiddenKeys = payload.hiddenMoodTags.map((h) => h.key);
const hiddenRows = await tx.moodTag.findMany({
where: {
key: { in: [...new Set(hiddenKeys)] },
OR: [{ userId: null }, { userId: ownerId }],
},
select: { id: true, key: true },
});
const hiddenByKey = new Map(
hiddenRows.map((row) => [row.key, row.id]),
);
const unresolvedHidden: string[] = [];
const writableHidden = payload.hiddenMoodTags.filter((entry) => {
if (hiddenByKey.has(entry.key)) return true;
unresolvedHidden.push(entry.key);
return false;
});
if (writableHidden.length > 0) {
await tx.moodTagHidden.createMany({
data: writableHidden.map((entry) => ({
userId: ownerId,
moodTagId: hiddenByKey.get(entry.key)!,
...(entry.createdAt
? { createdAt: new Date(entry.createdAt) }
: {}),
})),
skipDuplicates: true,
});
}
recordUnknownKeys(
skips,
"moodTag",
[...new Set(unresolvedHidden)],
unresolvedHidden,
);
}
const factorRows =
factorKeys.length === 0
? []
@@ -1006,6 +1329,69 @@ const handler = apiHandler(
restoredBiomarkerIds.add(created.id);
}
// Collected for the staged facts further down: an approved fact's
// `committedRecordId` names the lab result it was committed to, and
// that reference is resolved against the rows this loop writes.
const restoredLabResultIds = new Set<string>();
// What each drug was supposed to move, written now that both ends
// exist. The drugs came back several hundred lines up and the
// analytes only just now, and one of those two had to move for a
// target to reach the database at all.
//
// The second pass wins over hoisting the biomarker restore above the
// medications, for two reasons. `biomarkerByName` and
// `restoredBiomarkerIds` are read by the lab-result loop directly
// below, so moving the block would separate a map from its only
// other consumer and leave a future edit free to re-order them back
// without anything noticing. And the direction of the dependency
// would read backwards: a pinned target is a leaf of the medication
// tree, and letting a leaf dictate where the clinical record is
// rebuilt inverts what the file is about. A second pass states the
// constraint where it applies instead of hiding it in a line order.
//
// A named analyte the restore did not put back cannot be written:
// `biomarkerId` is a real foreign key, so a dangling value would
// fail the constraint and roll the entire account back over one
// override. The row is dropped and NAMED. Dropping rather than
// nulling is deliberate here: the override IS the reference, and a
// row with neither arm set is what the resolver already reads as "no
// override", so keeping it would restore something that means
// nothing while still claiming the primary slot. A target that
// carried no name in the first place is a genuine live state (the
// analyte was deleted before the export) and comes back as written.
const unresolvedTargetAnalytes: string[] = [];
const writableTargets = pendingEfficacyTargets.filter((pending) => {
const name = pending.target.biomarkerName;
if (!name || biomarkerByName.has(name)) return true;
unresolvedTargetAnalytes.push(name);
return false;
});
if (writableTargets.length > 0) {
await tx.medicationEfficacyTarget.createMany({
data: writableTargets.map(({ medicationId, target }) => ({
...(target.id ? { id: target.id } : {}),
medicationId,
measurementType: target.measurementType ?? null,
biomarkerId: target.biomarkerName
? (biomarkerByName.get(target.biomarkerName) ?? null)
: null,
primary: target.primary ?? true,
...(target.createdAt
? { createdAt: new Date(target.createdAt) }
: {}),
...(target.updatedAt
? { updatedAt: new Date(target.updatedAt) }
: {}),
})),
});
}
recordUnknownKeys(
skips,
"medicationTarget",
[...new Set(unresolvedTargetAnalytes)],
unresolvedTargetAnalytes,
);
for (const lab of payload.labResults) {
const biomarkerId =
lab.biomarkerId !== undefined
@@ -1022,7 +1408,7 @@ const handler = apiHandler(
`Unknown biomarker reference: ${lab.biomarkerId ?? lab.biomarkerName}`,
);
}
await tx.labResult.create({
const createdLab = await tx.labResult.create({
data: {
...(lab.id ? { id: lab.id } : {}),
userId: ownerId,
@@ -1056,6 +1442,7 @@ const handler = apiHandler(
: {}),
},
});
restoredLabResultIds.add(createdLab.id);
}
const episodeIds = new Set(
@@ -1329,12 +1716,56 @@ const handler = apiHandler(
});
}
// Visits, the address book and the three link tables. Last on
// purpose: a link is written only when both of its ends exist, and
// the documents above are the final far side to land. Both ends of
// this section live in `src/lib/export/visits-backup.ts`, so a grep
// of THIS file alone will not find them — the same delegation as the
// cycle, profile, intraday and score restores above.
// What the vault was filed against, and what was read out of it.
// AFTER the documents and the condition episodes because both are
// foreign keys here — a filing written before either exists does not
// drop quietly, it violates a constraint and costs the operator the
// whole restore. AFTER the lab results and the medications for a
// second reason: an approved fact's `committedRecordId` is resolved
// against the rows those branches wrote, and resolving before they
// exist would null every commitment and still report success. Both
// ends of this section live in
// `src/lib/export/document-filing-backup.ts`.
const documentFilingCleared = await restoreDocumentFilingData(
tx,
ownerId,
payload,
{
documentIds: new Set(
payload.documents.map((document) => document.id),
),
episodeIds,
committedRecordIds: new Set([
...restoredLabResultIds,
...episodeIds,
...restoredMedicationIds,
]),
},
skips,
);
// The Vorsorge reminders and their completion ledger (v1.37.20,
// #223 / iOS #68), BEFORE the visits and the vaccinations: both of
// those remap a `reminderId` against the reminders in the database,
// so the reminders have to be in the database first or every
// appointment and booster reference would read as unresolvable and
// drop. Both ends of this section live in
// `src/lib/export/reminders-backup.ts`.
const remindersCleared = await restoreRemindersData(
tx,
ownerId,
payload,
skips,
);
// Visits, the address book and the three link tables. After the
// documents and the reminders on purpose: a link is written only
// when both of its ends exist, and the encounter's reminder
// reference remaps against the rows the branch above has just
// written. Both ends of this section live in
// `src/lib/export/visits-backup.ts`, so a grep of THIS file alone
// will not find them — the same delegation as the cycle, profile,
// intraday and score restores above.
const visitsCleared = await restoreVisitsData(
tx,
ownerId,
@@ -1343,10 +1774,10 @@ const handler = apiHandler(
);
// The immunization log, after the visits: a dose remaps its
// practitioner and its encounter against rows the branch above has
// just written, and its document link needs the documents that
// landed before that. Both ends of this section live in
// `src/lib/export/vaccinations-backup.ts`.
// practitioner, its encounter and its booster reminder against rows
// the branches above have just written, and its document link needs
// the documents that landed before that. Both ends of this section
// live in `src/lib/export/vaccinations-backup.ts`.
const vaccinationsCleared = await restoreVaccinationsData(
tx,
ownerId,
@@ -1354,6 +1785,94 @@ const handler = apiHandler(
skips,
);
// The Coach's threads, turns and document links. AFTER the
// documents on purpose: an attachment carries a `documentId` that
// is a foreign key against them, so running earlier would make
// every attachment unresolvable and drop the provenance that makes
// an old answer checkable. The id set is threaded in rather than
// re-queried so the function stays a pure reader of this
// transaction. Both ends of this section live in
// `src/lib/export/coach-backup.ts`.
const coachCleared = await restoreCoachData(
tx,
ownerId,
payload,
new Set(payload.documents.map((document) => document.id)),
skips,
);
// What the Coach keeps between threads. AFTER the transcript,
// because all three carry a `sourceConversationId` that is resolved
// against the threads the branch above has just written — resolving
// earlier would cut every fact loose from the conversation it came
// out of and still report success.
const coachMemoryCleared = await restoreCoachMemoryData(
tx,
ownerId,
payload,
new Set(payload.coachConversations.map((c) => c.id)),
skips,
);
// The screener history and the consent record. Neither references
// anything but the account, so the position here is free; it sits
// last because that is where the section was added, not because
// anything above it matters to these two.
const sensitiveCleared = await restoreSensitiveData(
tx,
ownerId,
payload,
);
// The bests and the badges. AFTER the measurements, and this one is
// not a preference: `PersonalRecord.sourceMeasurementId` is a real
// foreign key against `measurements` (migration 0054) even though
// `prisma/schema.prisma` declares no relation for it, so a pointer
// resolved before the measurements exist would not drop quietly.
// Postgres would refuse the insert and roll the whole restore back
// over one provenance column. The id set is the measurements this
// transaction actually wrote with a stable id, threaded in rather
// than re-queried so the function stays a pure reader of it. Both
// ends of this section live in `src/lib/export/awards-backup.ts`.
const awardsCleared = await restoreAwardsData(
tx,
ownerId,
payload,
new Set(stableRows.map((row) => row.id)),
skips,
);
// The ECG strips. AFTER the measurements on purpose: a recording
// carries a `measurementId` that is a real foreign key against the
// EVENT row it was filed with, so a reference written before that
// row exists does not merely dangle — it violates the constraint
// and rolls the whole restore back. The id set is the one the
// measurement branch actually wrote, threaded in rather than
// re-queried so the function stays a pure reader of this
// transaction. Both ends of this section live in
// `src/lib/export/ecg-backup.ts`.
const ecgCleared = await restoreEcgData(
tx,
ownerId,
payload,
new Set(stableRows.map((row) => row.id)),
skips,
);
// The per-day readings and the location periods that explain them.
// Neither references anything but the account, so this section has
// no ordering constraint against any other and sits here beside the
// rest. What it does owe is atomicity WITH ITSELF, which is why one
// function writes both: readings restored without their periods get
// re-resolved to the home location and overwritten by the next
// environment refresh. Both ends of this section live in
// `src/lib/export/environment-backup.ts`.
const environmentCleared = await restoreEnvironmentData(
tx,
ownerId,
payload,
);
const cleared = {
measurements: measurements.count,
medications: meds.count,
@@ -1373,6 +1892,9 @@ const handler = apiHandler(
familyHistory: familyHistory.count,
workouts: workouts.count,
documents: documents.count,
documentConditionLinks:
documentFilingCleared.documentConditionLinks,
extractedFacts: documentFilingCleared.extractedFacts,
healthProfile: profileCleared.healthProfile,
healthProfileFactRevisions:
profileCleared.healthProfileFactRevisions,
@@ -1385,6 +1907,23 @@ const handler = apiHandler(
encounterLinks: visitsCleared.encounterLinks,
vaccinations: vaccinationsCleared.vaccinations,
vaccinationLinks: vaccinationsCleared.vaccinationLinks,
measurementReminders: remindersCleared.measurementReminders,
measurementReminderEvents:
remindersCleared.measurementReminderEvents,
coachConversations: coachCleared.coachConversations,
coachMessages: coachCleared.coachMessages,
coachConversationDocuments: coachCleared.coachConversationDocuments,
coachFacts: coachMemoryCleared.coachFacts,
coachPlans: coachMemoryCleared.coachPlans,
coachReminders: coachMemoryCleared.coachReminders,
mentalHealthAssessments: sensitiveCleared.mentalHealthAssessments,
consentReceipts: sensitiveCleared.consentReceipts,
personalRecords: awardsCleared.personalRecords,
userAchievements: awardsCleared.userAchievements,
environmentContexts: environmentCleared.environmentContexts,
environmentTravelLocations:
environmentCleared.environmentTravelLocations,
ecgRecordings: ecgCleared.ecgRecordings,
};
return { cleared, skipped: summarizeRestoreSkips(skips) };
},
@@ -1532,6 +2071,13 @@ const handler = apiHandler(
customMetrics: summary.customMetrics,
customMetricEntries: summary.customMetricEntries,
correlationPatterns: summary.correlationPatterns,
practitioners: summary.practitioners,
encounters: summary.encounters,
encounterLinks: summary.encounterLinks,
vaccinations: summary.vaccinations,
vaccinationLinks: summary.vaccinationLinks,
measurementReminders: summary.measurementReminders,
measurementReminderEvents: summary.measurementReminderEvents,
},
},
});
@@ -0,0 +1,95 @@
/**
* v1.37.20 — restore preview: the summary read decrypts and validates the
* stored file through the SAME helpers the restore uses, and only ever
* reads. Watched red: written against the route before its annotate landed;
* the not-found and decrypt-failure arms were asserted first.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAdmin: vi.fn(async () => ({ user: { id: "admin-1" } })),
HttpError: class HttpError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
}
},
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
const findUniqueMock = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
dataBackup: { findUnique: (...a: unknown[]) => findUniqueMock(...a) },
},
}));
const decryptMock = vi.fn();
vi.mock("@/lib/crypto", () => ({
decrypt: (...a: unknown[]) => decryptMock(...a),
}));
const parseMock = vi.fn();
const summarizeMock = vi.fn();
vi.mock("@/lib/validations/backup", () => ({
parseBackupPayload: (...a: unknown[]) => parseMock(...a),
summarizeBackup: (...a: unknown[]) => summarizeMock(...a),
isCompatibleSchemaVersion: vi.fn(() => true),
}));
import { GET } from "../route";
const request = new NextRequest(
"http://localhost/api/admin/backups/b1/summary",
);
const params = { params: Promise.resolve({ id: "b1" }) };
beforeEach(() => {
vi.clearAllMocks();
findUniqueMock.mockResolvedValue({
id: "b1",
userId: "u1",
data: "ciphertext",
createdAt: new Date("2026-08-01T00:00:00Z"),
user: { id: "u1", username: "self-hoster" },
});
decryptMock.mockReturnValue("{}");
parseMock.mockReturnValue({ schemaVersion: 3 });
summarizeMock.mockReturnValue({ measurements: 12, moodEntries: 3 });
});
describe("GET /api/admin/backups/[id]/summary", () => {
it("answers the file's counts without writing anything", async () => {
const res = await GET(request, params);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.summary).toEqual({ measurements: 12, moodEntries: 3 });
expect(body.data.owner).toBe("self-hoster");
});
it("404s an unknown backup id", async () => {
findUniqueMock.mockResolvedValue(null);
await expect(GET(request, params)).rejects.toMatchObject({ status: 404 });
});
it("degrades to an error response when the file cannot be decrypted", async () => {
decryptMock.mockImplementation(() => {
throw new Error("bad key");
});
const res = await GET(request, params);
expect(res.status).toBe(500);
});
it("422s a file that fails schema validation", async () => {
parseMock.mockImplementation(() => {
throw new Error("nope");
});
const res = await GET(request, params);
expect(res.status).toBe(422);
});
});
@@ -0,0 +1,79 @@
/**
* v1.37.20 — restore preview: what a backup file contains, BEFORE the
* operator confirms a restore.
*
* The restore dialog used to ask for the typed confirmation on nothing but
* a filename and a date; the counts only appeared in the audit row after
* the rows were already replaced. This read decrypts and schema-validates
* the stored file exactly the way the restore itself does (same decrypt,
* same `parseBackupPayload`, same compatibility gate) and answers with the
* `summarizeBackup` counts — so what the operator confirms is what the
* restore will write, derived from the same code path. Read-only; nothing
* is touched.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { decrypt } from "@/lib/crypto";
import { annotate } from "@/lib/logging/context";
import {
isCompatibleSchemaVersion,
parseBackupPayload,
summarizeBackup,
} from "@/lib/validations/backup";
export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user: admin } = await requireAdmin();
void admin;
const { id } = await params;
const backup = await prisma.dataBackup.findUnique({
where: { id },
include: { user: { select: { id: true, username: true } } },
});
if (!backup) {
throw new HttpError(404, "Backup not found");
}
let plaintext: string;
try {
plaintext = decrypt(backup.data);
} catch {
return apiError("Failed to decrypt backup payload", 500);
}
let payload;
try {
payload = parseBackupPayload(plaintext);
} catch {
return apiError("Backup payload failed schema validation", 422);
}
if (!isCompatibleSchemaVersion(payload.schemaVersion)) {
return apiError(
`Backup schema version ${payload.schemaVersion} is not restorable by this release`,
422,
);
}
const summary = summarizeBackup(payload);
annotate({
action: { name: "admin.backups.summary" },
meta: { backupId: id, ownerId: backup.userId },
});
return apiSuccess({
summary,
owner: backup.user?.username ?? null,
createdAt: backup.createdAt.toISOString(),
});
},
);
+2 -28
View File
@@ -1,5 +1,4 @@
import type { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
resolveProviderForTest,
@@ -16,6 +15,7 @@ import {
reserveBudget,
resolveDailyCap,
} from "@/lib/ai/coach/budget";
import { aiTestOverrideSchema } from "@/lib/validations/ai-provider";
/**
* Output ceiling of the probe below. Mirrors the `maxTokens` on the completion
@@ -39,32 +39,6 @@ const ONE_DAY_MS = 24 * 60 * 60 * 1000;
export const dynamic = "force-dynamic";
const overrideSchema = z
.object({
provider: z
.enum([
"OPENAI",
"ANTHROPIC",
"LOCAL",
"OPENAI_COMPATIBLE",
"CHATGPT_OAUTH",
])
.optional()
.nullable(),
model: z.string().min(1).max(120).optional().nullable(),
baseUrl: z.string().url().max(2048).optional().nullable(),
anthropicKey: z.string().min(1).max(500).optional().nullable(),
localKey: z.string().min(1).max(500).optional().nullable(),
openaiKey: z.string().min(1).max(500).optional().nullable(),
// v1.33.1 (#470) — the OpenAI-compatible gateway's own fields, kept
// separate from `baseUrl` / `openaiKey` so testing an unsaved gateway
// config can never reach the pinned OpenAI arm and vice versa.
compatBaseUrl: z.string().url().max(2048).optional().nullable(),
compatKey: z.string().min(1).max(500).optional().nullable(),
compatModel: z.string().min(1).max(120).optional().nullable(),
})
.strict();
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "ai.test" } });
@@ -93,7 +67,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
maxBytes: 64 * 1024,
});
if (error) return error;
const parsed = overrideSchema.safeParse(data);
const parsed = aiTestOverrideSchema.safeParse(data);
if (!parsed.success) {
return apiError("Invalid override payload", 422);
}
+46 -1
View File
@@ -329,7 +329,6 @@ describe("GET /api/analytics", () => {
r2_7: 0.4,
slope30: -0.02,
r2_30: 0.3,
slope90: -0.01,
r2_90: 0.2,
},
] as never)
@@ -451,6 +450,52 @@ describe("GET /api/analytics", () => {
// slice critical path).
});
/**
* `?slice=` is a closed set, and the cost of it not being one was the point.
*
* The route compared the raw string against `"summaries"` and fell through to
* the DEFAULT body on anything else, so `?slice=summary` answered 200 after
* running the heaviest chain on the surface. A client could sit on that typo
* indefinitely and see nothing but a slow, correct-looking response — the
* failure mode was invisible and expensive at the same time.
*/
describe("GET /api/analytics — the slice parameter is a closed set", () => {
const call = (url: string) =>
(GET as unknown as (request: Request) => Promise<Response>)(
new Request(url),
);
it.each(["summary", "SUMMARIES", "default", ""])(
"refuses ?slice=%s rather than serving the expensive default body",
async (value) => {
const res = await call(
`http://localhost/api/analytics?slice=${encodeURIComponent(value)}`,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
meta?: { errorCode?: string };
details?: { issues?: Array<{ path: string }> };
};
expect(body.meta?.errorCode).toBe("analytics.invalid_query");
expect(body.details?.issues?.map((i) => i.path)).toContain("slice");
},
);
it("still serves the default body when the parameter is absent", async () => {
const res = await call("http://localhost/api/analytics");
expect(res.status).toBe(200);
});
it("still serves the slim body for the one legal value", async () => {
const res = await call("http://localhost/api/analytics?slice=summaries");
expect(res.status).toBe(200);
const body = (await res.json()) as { data: Record<string, unknown> };
// The slim slice is recognisable by what it does NOT carry.
expect(body.data).not.toHaveProperty("healthScore");
expect(body.data).toHaveProperty("summaries");
});
});
/**
* v1.34.5 — the blood-pressure score's basis has to ARRIVE, not merely exist.
*
+35 -13
View File
@@ -1,7 +1,8 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { analyticsQuerySchema } from "@/lib/validations/analytics";
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
@@ -46,13 +47,17 @@ export const dynamic = "force-dynamic";
* tests instantiate `new Request("http://localhost/api/analytics")`
* and cast through). Falls back to the raw `URL(request.url)` parse
* so the slim slice branch is reachable from both call shapes.
*
* Returns the object the query schema parses: an absent parameter is an
* absent KEY, not an empty string, so `slice` stays optional rather than
* failing the enum on every unparameterised call.
*/
function readSliceParam(request: Request | undefined): string | null {
if (!request) return null;
function readQueryParams(request: Request | undefined): Record<string, string> {
if (!request) return {};
try {
return new URL(request.url).searchParams.get("slice");
return Object.fromEntries(new URL(request.url).searchParams);
} catch {
return null;
return {};
}
}
@@ -60,6 +65,21 @@ export const GET = apiHandler(async (request?: Request) => {
const { user } = await requireAuth();
annotate({ action: { name: "analytics.get" } });
// A `slice` outside the closed set is refused rather than served the
// default body. Falling through was the expensive failure: `?slice=summary`
// ran the full 30-query chain and answered 200, so a client could sit on a
// typo indefinitely while paying for the heaviest read on the surface.
const parsedQuery = analyticsQuerySchema.safeParse(readQueryParams(request));
if (!parsedQuery.success) {
annotate({
action: { name: "analytics.get" },
meta: { outcome: "invalid_query" },
});
return returnAllZodIssues(parsedQuery.error, 422, {
errorCode: "analytics.invalid_query",
});
}
// v1.4.33 C1 — slim summaries slice for the dashboard tile strip.
// The default path runs 30+ chunked findMany reads to feed
// `summarize()` per type; under `?slice=summaries` the route
@@ -72,7 +92,7 @@ export const GET = apiHandler(async (request?: Request) => {
// behind a hover-prefetch from the Coach drawer + the correlation
// tile's `<InView>` boundary. Consumers that need only the headline
// tile values land on this branch and skip the heavy chain.
if (readSliceParam(request) === "summaries") {
if (parsedQuery.data.slice === "summaries") {
// v1.4.34 IW-G — read-through the analytics cache keyed on
// (userId, slice). The slim slice is the dashboard tile strip's hot
// path; multiple dashboard mounts inside a 60-second TTL all hit a
@@ -233,19 +253,21 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
// identical to the previous fan-out output for every field the
// dashboard actually consumes:
// - `count / latest / min / max / mean` from DAY buckets
// - `avg7 / avg30 / slope7 / slope30 / slope90` from the narrow query
// - `avg7 / avg30 / slope7 / slope30` from the narrow query
// - `avg30LastMonth` from the same narrow query (added in this
// release; previously only the live walk produced it)
// - `avg30LastYear` populated for any type whose WMY tier carries
// the year-ago window
// - `lastSeenByType` from the `DISTINCT ON (type)` latest read
//
// The two fields the slim path leaves at default values are
// `anomalyCount` (always 0) — the insights pipeline consumes it from
// its own `comprehensive-aggregator` narrow query, never from this
// route — and `avg30LastYear` on types with no WMY coverage, which
// matches the pre-fix behaviour for tenants whose year-ago window
// happened to fall outside the 425-day floor.
// `anomalyCount` and `slope90` left this wire in v1.37.19: the slim
// path could never compute the former (it always reported 0) and no
// client component read the latter — the insights pipeline computes
// both from its own `summarize()` / comprehensive-aggregator reads.
// The one field the slim path leaves at a default value is
// `avg30LastYear` on types with no WMY coverage, which matches the
// pre-fix behaviour for tenants whose year-ago window happened to
// fall outside the 425-day floor.
const slim = await computeSummariesSlice(user.id);
const results = slim.summaries;
const lastSeenByType = slim.lastSeenByType;
@@ -20,8 +20,6 @@
* field-by-field write (no mass assignment). Idempotent — always returns the
* resolved next state so the client can hard-set the optimistic update.
*/
import { z } from "zod";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
@@ -36,10 +34,7 @@ import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant";
import { prisma } from "@/lib/db";
import { enqueueSummaryCatchUp } from "@/lib/jobs/document-summary-catchup";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
const patchBodySchema = z.object({
documentsAutoAiRead: z.boolean(),
});
import { documentsAutoAiReadPatchSchema } from "@/lib/validations/user-prefs";
export const dynamic = "force-dynamic";
@@ -87,7 +82,7 @@ export const PATCH = apiHandler(async (req: Request) => {
});
if (jsonError) return jsonError;
const parsed = patchBodySchema.safeParse(body);
const parsed = documentsAutoAiReadPatchSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "auth.me.documentsAutoAiRead.patch.invalid_shape" },
@@ -18,8 +18,6 @@
* limit, Zod safeParse → 422 via `returnAllZodIssues`, audit-log row,
* field-by-field write (no mass assignment).
*/
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
@@ -33,10 +31,10 @@ import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import {
injectionSiteEnum,
INJECTION_SITE_VALUES,
type InjectionSiteValue,
} from "@/lib/validations/medication";
import { injectionSitePrefsPatchSchema } from "@/lib/validations/user-prefs";
import { NextRequest } from "next/server";
export const dynamic = "force-dynamic";
@@ -44,18 +42,6 @@ export const dynamic = "force-dynamic";
const PATCH_RATE_LIMIT = 60;
const PATCH_WINDOW_MS = 60_000;
const patchBodySchema = z.object({
// Dedup at write time; cap at the full enum size. An empty array
// clears the exclusion.
globalExcludedInjectionSites: z
.array(injectionSiteEnum)
.max(INJECTION_SITE_VALUES.length)
.meta({
description:
"User-level injection-site deny-list. Sites here are never offered for any medication and rejected at intake (deny wins). Empty array clears the exclusion.",
}),
});
interface InjectionSitePrefsResponse {
globalExcludedInjectionSites: InjectionSiteValue[];
}
@@ -96,7 +82,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
});
if (jsonError) return jsonError;
const parsed = patchBodySchema.safeParse(body);
const parsed = injectionSitePrefsPatchSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "auth.me.injection-site-prefs.patch.invalid_shape" },
+2 -7
View File
@@ -16,8 +16,6 @@
* field-by-field write (no mass assignment). Idempotent — always returns the
* resolved next state so the client can hard-set the optimistic update.
*/
import { z } from "zod";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
@@ -30,10 +28,7 @@ import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
const patchBodySchema = z.object({
labsLocalOcrEnabled: z.boolean(),
});
import { labsLocalOcrPatchSchema } from "@/lib/validations/user-prefs";
export const dynamic = "force-dynamic";
@@ -81,7 +76,7 @@ export const PATCH = apiHandler(async (req: Request) => {
});
if (jsonError) return jsonError;
const parsed = patchBodySchema.safeParse(body);
const parsed = labsLocalOcrPatchSchema.safeParse(body);
if (!parsed.success) {
annotate({ action: { name: "auth.me.labsLocalOcr.patch.invalid_shape" } });
return returnAllZodIssues(parsed.error, 422);
+4
View File
@@ -110,6 +110,10 @@ export const GET = apiHandler(async () => {
// when never acknowledged; the onboarding welcome gate reads this to
// decide whether to require the acknowledgment before "Get started".
disclaimerAcknowledgedAt: user.disclaimerAcknowledgedAt,
// The version that acknowledgment was given for. The welcome gate
// compares it against the current DISCLAIMER_VERSION so a revised
// disclaimer re-prompts instead of riding an old acknowledgment.
disclaimerAcknowledgedVersion: user.disclaimerAcknowledgedVersion,
// v1.18.6 — resumable module-tour progress. Null when the user has
// not started the tour; otherwise the resume point the launcher
// seeds its index from. Fail-soft parse: a corrupt blob degrades to
@@ -23,6 +23,14 @@ vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
// v1.37.19 (A6-12) — the zone change enqueues a compliance re-fold.
const refoldMock = vi.hoisted(() =>
vi.fn(async () => ({ enqueued: true, error: null })),
);
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
enqueueUserMedicationComplianceBackfill: refoldMock,
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -96,6 +104,34 @@ describe("PUT /api/auth/me/timezone", () => {
expect(res.status).toBe(422);
});
// Watched red: with the re-fold enqueue removed from the route (the
// pre-v1.37.19 write), the changed-zone case fails — the compliance
// rollup's day keys were minted under the old zone and nothing ever
// re-bucketed them (the coverage probe compares counts, not keys).
it("enqueues a compliance re-fold when the zone actually changes", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Europe/Berlin",
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await PUT(mkReq({ timezone: "America/New_York" }));
expect(res.status).toBe(200);
expect(refoldMock).toHaveBeenCalledWith("user-1");
});
it("does not re-fold when the zone is unchanged", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Europe/Berlin",
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await PUT(mkReq({ timezone: "Europe/Berlin" }));
expect(res.status).toBe(200);
expect(refoldMock).not.toHaveBeenCalled();
});
it("trims whitespace before validating", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
+19
View File
@@ -29,6 +29,7 @@ import {
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserTimezone, isValidTimezone } from "@/lib/tz/resolver";
import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups";
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -60,6 +61,24 @@ export const PUT = apiHandler(async (request: NextRequest) => {
invalidateUserTimezone(user.id);
// v1.37.19 (A6-12) — the medication-compliance rollup keys its rows by
// the LOCAL day the dose fell on, minted under the timezone in force at
// write time. A zone change re-buckets those days, so the stored keys go
// stale (and the coverage probe compares counts, not keys — it cannot
// notice). Re-fold the trailing window through the ordinary backfill
// job; best-effort, the singleton key collapses rapid re-picks.
if ((before?.timezone ?? null) !== tz) {
const refold = await enqueueUserMedicationComplianceBackfill(user.id);
annotate({
meta: {
timezone_compliance_refold_enqueued: refold.enqueued,
...(refold.error
? { timezone_compliance_refold_error: refold.error }
: {}),
},
});
}
await auditLog("user.timezone.update", {
userId: user.id,
ipAddress: getClientIp(request),
@@ -148,7 +148,15 @@ describe("PATCH /api/auth/me/unit-preference", () => {
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects malformed JSON with 422", async () => {
// The three cases below pin the capped `safeJson` read. Before it, the
// handler called `request.json()` bare: malformed JSON came back 422 rather
// than the 400 every sibling scalar answers, a wrong content type was parsed
// rather than refused, and there was NO body-size limit — this was the one
// endpoint in the family that would materialise whatever it was handed. The
// malformed-JSON assertion used to say 422 and was changed deliberately: it
// pinned the inconsistency rather than a contract, and the only caller
// branches on `res.ok` alone.
it("rejects malformed JSON with 400, like its sibling scalars", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const req = new Request("http://localhost/api/auth/me/unit-preference", {
method: "PATCH",
@@ -156,7 +164,36 @@ describe("PATCH /api/auth/me/unit-preference", () => {
headers: { "Content-Type": "application/json" },
});
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
expect(res.status).toBe(422);
expect(res.status).toBe(400);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("refuses a body over the 1 KB cap with 413", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// Valid JSON, valid enum value, and far too big: the cap has to be what
// rejects it, not the schema.
const req = new Request("http://localhost/api/auth/me/unit-preference", {
method: "PATCH",
body: JSON.stringify({
unitPreference: "imperial",
padding: "x".repeat(2048),
}),
headers: { "Content-Type": "application/json" },
});
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
expect(res.status).toBe(413);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("refuses a non-JSON content type with 415", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const req = new Request("http://localhost/api/auth/me/unit-preference", {
method: "PATCH",
body: "unitPreference=imperial",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
expect(res.status).toBe(415);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
+18 -14
View File
@@ -15,25 +15,29 @@
* Mirrors the `disable-coach` per-user-scalar pattern: 60/min rate
* limit, Zod safeParse → 422 via `returnAllZodIssues`, audit-log row,
* field-by-field write (no mass assignment).
*
* The body goes through `safeJson` with a 1 KB cap, like the
* `documents-auto-ai-read` scalar beside it. It used to read a bare
* `request.json()`, which applied no size limit at all — the one endpoint in
* this family that would materialise whatever it was sent — and answered a
* malformed body with 422 where every sibling answers 400. Both were
* accidents of the original copy, not a contract: the only caller checks
* `res.ok` and never the status.
*/
import { z } from "zod";
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { DEFAULT_UNIT_PREFERENCE } from "@/lib/measurements/display-transform";
const patchBodySchema = z.object({
unitPreference: z.enum(["metric", "imperial"]),
});
import { unitPreferencePatchSchema } from "@/lib/validations/user-prefs";
export const dynamic = "force-dynamic";
@@ -78,14 +82,14 @@ export const PATCH = apiHandler(async (req: Request) => {
return response;
}
let body: unknown;
try {
body = await req.json();
} catch {
throw new HttpError(422, "unit-preference.body.invalid_json");
}
// The body is a single enum value — bound the parse so a malformed or
// oversized payload is rejected before it is materialised.
const { data: body, error: jsonError } = await safeJson(req, {
maxBytes: 1024,
});
if (jsonError) return jsonError;
const parsed = patchBodySchema.safeParse(body);
const parsed = unitPreferencePatchSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "auth.me.unit-preference.patch.invalid_shape" },
@@ -16,8 +16,6 @@
* `returnAllZodIssues`, audit-log row, field-by-field write (no mass
* assignment). Idempotent — always returns the resolved next state.
*/
import { z } from "zod";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
@@ -30,10 +28,7 @@ import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
const patchBodySchema = z.object({
useCentralCodex: z.boolean(),
});
import { useCentralCodexPatchSchema } from "@/lib/validations/user-prefs";
export const dynamic = "force-dynamic";
@@ -79,7 +74,7 @@ export const PATCH = apiHandler(async (req: Request) => {
});
if (jsonError) return jsonError;
const parsed = patchBodySchema.safeParse(body);
const parsed = useCentralCodexPatchSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "auth.me.useCentralCodex.patch.invalid_shape" },
@@ -49,7 +49,11 @@ describe("GET /api/auth/native/login", () => {
it("valid challenge → 302 to the web login page with flow=native + state cookie", async () => {
const res = await GET(req());
const location = res.headers.get("location")!;
expect(location).toContain("/auth/login?flow=native");
// Exact, not `toContain`. The looser form was what let iOS #96 ship: it is
// satisfied by "http://0.0.0.0:3000/auth/login?flow=native" just as
// happily, so it asserted the path was in there rather than that a host
// was not.
expect(location).toBe("/auth/login?flow=native");
// No custom scheme on the success path.
expect(location.startsWith("healthlog://")).toBe(false);
@@ -81,6 +85,35 @@ describe("GET /api/auth/native/login", () => {
);
});
it("behind a proxy that drops Host, the redirect still points at the caller's origin", async () => {
// What a proxy hop looks like from inside the handler: the request the
// route sees names the address the Node process bound to, because the
// public Host header never made it through. Building the redirect out of
// that URL is how iOS #96 happened, and `ASWebAuthenticationSession`
// refuses the resulting address outright.
//
// Every other test in this file builds `http://localhost/...`, where the
// host is harmless and the absolute URL looked correct. That is why the
// route shipped broken.
const res = await GET(
new NextRequest(
`http://0.0.0.0:3000/api/auth/native/login?code_challenge=${VALID_CHALLENGE}`,
),
);
const location = res.headers.get("location")!;
expect(location).toBe("/auth/login?flow=native");
expect(
location,
"a relative Location resolves against the address the client actually called",
).not.toMatch(/^https?:\/\//);
expect(location).not.toContain("0.0.0.0");
// The state cookie still rides the refusal-free path, since the whole
// point is that the success branch keeps working behind a proxy.
expect(res.cookies.get(NATIVE_HANDOFF_STATE_COOKIE)?.value).toBeTruthy();
});
it("rate-limited → scheme error, no state cookie, no DB write", async () => {
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: false,
+2 -3
View File
@@ -14,6 +14,7 @@
* carries no code or session, so it leaks nothing.
*/
import { NextRequest, NextResponse } from "next/server";
import { relativeRedirect } from "@/lib/http/relative-redirect";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
@@ -58,9 +59,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
// `Session.createdAt` (also DB-side) at completion, with no app/DB skew.
const startedAt = await nativeHandoffDbNow();
const response = NextResponse.redirect(
new URL("/auth/login?flow=native", req.url),
);
const response = relativeRedirect("/auth/login?flow=native");
response.cookies.set(
NATIVE_HANDOFF_STATE_COOKIE,
encodeNativeHandoffState({
@@ -0,0 +1,157 @@
/**
* `PATCH /api/auth/passkeys/{id}` — the rename's refusal has to name the field.
*
* The handler used to answer `apiError("Invalid request", 422)` and throw the
* Zod issues away, which is the one thing every sibling body-parsing route in
* this tree does not do. A person renaming a passkey to an empty string and a
* person pasting 200 characters got the same three words, so the form had
* nothing to put beside the field and no way to say which rule was broken.
*
* These cases pin the multi-issue envelope by its CONTENT — the issue list has
* to be present and has to point at `name` — rather than by status alone. A
* status-only assertion would have passed against the flat error it replaced.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: (fn: unknown) => fn,
requireAuth: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: {
passkey: {
findUnique: vi.fn(),
update: vi.fn(),
count: vi.fn(),
delete: vi.fn(),
},
},
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
import { PATCH } from "../route";
import { requireAuth } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
const USER = { id: "user-1", username: "u", passwordHash: "argon2id$…" };
const ROW = {
id: "pk-1",
name: "Phone",
credentialDeviceType: "multiDevice",
credentialBackedUp: true,
createdAt: new Date("2026-01-01T00:00:00Z"),
lastUsedAt: null,
};
type IssueEnvelope = {
error: string;
details?: { issues?: Array<{ path?: unknown; message?: string }> };
};
function mkPatch(body: unknown): Request {
return new Request("http://localhost/api/auth/passkeys/pk-1", {
method: "PATCH",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
const params = Promise.resolve({ id: "pk-1" });
type Handler = (
r: Request,
ctx: { params: Promise<{ id: string }> },
) => Promise<Response>;
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(requireAuth).mockResolvedValue({ user: USER } as never);
});
describe("PATCH /api/auth/passkeys/{id} — the rename refusal", () => {
it("returns the multi-issue envelope for an empty name", async () => {
const res = await (PATCH as unknown as Handler)(mkPatch({ name: "" }), {
params,
});
expect(res.status).toBe(422);
const env = (await res.json()) as IssueEnvelope;
// The flat refusal this replaces said "Invalid request" and carried no
// `details` at all, so both halves matter.
expect(env.error).toBe("Validation failed");
expect(env.details?.issues?.length).toBeGreaterThan(0);
expect(JSON.stringify(env.details?.issues)).toContain("name");
expect(prisma.passkey.update).not.toHaveBeenCalled();
});
it("returns the multi-issue envelope for a name over 64 characters", async () => {
const res = await (PATCH as unknown as Handler)(
mkPatch({ name: "x".repeat(65) }),
{ params },
);
expect(res.status).toBe(422);
const env = (await res.json()) as IssueEnvelope;
expect(env.details?.issues?.length).toBeGreaterThan(0);
expect(JSON.stringify(env.details?.issues)).toContain("name");
expect(prisma.passkey.update).not.toHaveBeenCalled();
});
it("distinguishes the two refusals from each other", async () => {
// The point of the change: a client can tell WHICH rule was broken. Under
// the flat error both bodies were byte-identical.
const tooShort = await (PATCH as unknown as Handler)(
mkPatch({ name: "" }),
{ params },
);
const tooLong = await (PATCH as unknown as Handler)(
mkPatch({ name: "x".repeat(65) }),
{ params },
);
expect(JSON.stringify(await tooShort.json())).not.toBe(
JSON.stringify(await tooLong.json()),
);
});
it("still renames on a valid name, trimming it first", async () => {
vi.mocked(prisma.passkey.findUnique).mockResolvedValue({
userId: USER.id,
} as never);
vi.mocked(prisma.passkey.update).mockResolvedValue({
...ROW,
name: "Work laptop",
} as never);
const res = await (PATCH as unknown as Handler)(
mkPatch({ name: " Work laptop " }),
{ params },
);
expect(res.status).toBe(200);
expect(prisma.passkey.update).toHaveBeenCalledWith(
expect.objectContaining({ data: { name: "Work laptop" } }),
);
});
it("does not leak another account's passkey through the rename", async () => {
vi.mocked(prisma.passkey.findUnique).mockResolvedValue({
userId: "someone-else",
} as never);
const res = await (PATCH as unknown as Handler)(
mkPatch({ name: "Mine now" }),
{ params },
);
expect(res.status).toBe(404);
expect(prisma.passkey.update).not.toHaveBeenCalled();
});
});
+8 -5
View File
@@ -4,15 +4,13 @@ import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { webauthnKeyNameSchema } from "@/lib/validations/mfa";
import { z } from "zod/v4";
const passkeyRenameSchema = z.object({ name: webauthnKeyNameSchema });
import { passkeyRenameSchema } from "@/lib/validations/auth";
export const PATCH = apiHandler(
async (
@@ -29,7 +27,12 @@ export const PATCH = apiHandler(
const parsed = passkeyRenameSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
// Multi-issue, like every sibling that parses a body. The flat
// `apiError("Invalid request", 422)` this replaces discarded the issues,
// so a person renaming a passkey could not tell an empty name from one
// over the 64-character limit — the form had nothing to put beside the
// field.
return returnAllZodIssues(parsed.error, 422);
}
const passkey = await prisma.passkey.findUnique({
@@ -37,7 +37,12 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({
encryptToBytes: (s: string) => new Uint8Array(Buffer.from(`enc:${s}`)),
}));
vi.mock("@/lib/measurement-reminders/scheduling", () => ({
computeReminderNextDueAt: vi.fn(() => new Date("2027-06-27T09:00:00Z")),
// Now-anchored: the fixed 2027 date was a fuse — on that day the mocked
// next-due flipped from future to past and every due/overdue assertion
// downstream inverted.
computeReminderNextDueAt: vi.fn(
() => new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
),
}));
vi.mock("@/lib/measurement-reminders/dto", () => ({
toMeasurementReminderDto: vi.fn((r: { id: string }) => ({ id: r.id })),
@@ -22,8 +22,11 @@ import { updateCustomMetricEntrySchema } from "@/lib/validations/custom-metrics"
*
* PATCH applies a partial edit (`data` built field-by-field; an explicit `null`
* on `note` clears it, an omitted key leaves the column untouched). DELETE
* hard-deletes the value. Every read/write is narrowed by both `userId` and the
* parent `customMetricId`; a cross-user or mismatched-parent id surfaces as 404.
* tombstones the value (v1.37.20, A3-11 — undo parity with every peer entry
* surface; the sibling `restore` route clears the tombstone for the toast's
* Undo). Every read/write is narrowed by both `userId` and the parent
* `customMetricId`; a cross-user, tombstoned or mismatched-parent id surfaces
* as 404.
*/
type RouteParams = { params: Promise<{ id: string; entryId: string }> };
@@ -34,7 +37,12 @@ export const PATCH = apiHandler(
const { id, entryId } = await params;
const existing = await prisma.customMetricEntry.findFirst({
where: { id: entryId, userId: user.id, customMetricId: id },
where: {
id: entryId,
userId: user.id,
customMetricId: id,
deletedAt: null,
},
});
if (!existing) {
return apiError("Custom metric entry not found", 404);
@@ -104,13 +112,22 @@ export const DELETE = apiHandler(
const { id, entryId } = await params;
const existing = await prisma.customMetricEntry.findFirst({
where: { id: entryId, userId: user.id, customMetricId: id },
where: {
id: entryId,
userId: user.id,
customMetricId: id,
deletedAt: null,
},
});
if (!existing) {
return apiError("Custom metric entry not found", 404);
}
await prisma.customMetricEntry.delete({ where: { id: entryId } });
// Tombstone, not a hard delete: the Undo toast's restore clears it.
await prisma.customMetricEntry.update({
where: { id: entryId },
data: { deletedAt: new Date() },
});
await auditLog("customMetricEntry.delete", {
userId: user.id,
@@ -0,0 +1,173 @@
/**
* v1.37.20 (A3-11) — custom-metric entries move from hard delete to the
* tombstone + Undo contract every peer entry surface carries.
*
* Watched red: before the conversion the DELETE verb called
* `customMetricEntry.delete` and no restore route existed — the first two
* suites below fail against that state (no `update` with a `deletedAt`
* stamp; module not found), which is the proof the assertions bite.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
customMetricEntry: {
findFirst: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
delete: vi.fn(),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserCorrelationPatterns: vi.fn(),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { DELETE } from "../[entryId]/route";
import { POST as RESTORE } from "../restore/route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "u", role: "USER" as const, locale: "en" },
};
const ENTRY = {
id: "e-1",
userId: "user-1",
customMetricId: "cm-1",
value: 42,
unit: "kg",
measuredAt: new Date("2026-06-10T00:00:00.000Z"),
note: null,
createdAt: new Date("2026-06-10T00:00:00.000Z"),
deletedAt: null,
};
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
});
describe("DELETE /api/custom-metrics/[id]/entries/[entryId]", () => {
it("tombstones instead of hard-deleting", async () => {
vi.mocked(prisma.customMetricEntry.findFirst).mockResolvedValue(
ENTRY as never,
);
vi.mocked(prisma.customMetricEntry.update).mockResolvedValue({} as never);
const res = await DELETE(
new NextRequest("http://localhost/api/custom-metrics/cm-1/entries/e-1", {
method: "DELETE",
}),
{ params: Promise.resolve({ id: "cm-1", entryId: "e-1" }) },
);
expect(res.status).toBe(200);
expect(prisma.customMetricEntry.delete).not.toHaveBeenCalled();
const call = vi.mocked(prisma.customMetricEntry.update).mock.calls[0][0];
expect(call.where).toEqual({ id: "e-1" });
expect(call.data.deletedAt).toBeInstanceOf(Date);
});
it("404s an already-tombstoned entry (the lookup filters the tombstone)", async () => {
vi.mocked(prisma.customMetricEntry.findFirst).mockResolvedValue(null);
const res = await DELETE(
new NextRequest("http://localhost/api/custom-metrics/cm-1/entries/e-1", {
method: "DELETE",
}),
{ params: Promise.resolve({ id: "cm-1", entryId: "e-1" }) },
);
expect(res.status).toBe(404);
const where = vi.mocked(prisma.customMetricEntry.findFirst).mock
.calls[0]![0]!.where!;
expect(where.deletedAt).toBeNull();
expect(prisma.customMetricEntry.update).not.toHaveBeenCalled();
});
});
describe("POST /api/custom-metrics/[id]/entries/restore", () => {
function restoreRequest(body: unknown): NextRequest {
return new NextRequest(
"http://localhost/api/custom-metrics/cm-1/entries/restore",
{
method: "POST",
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
},
);
}
it("clears the tombstone scoped to owner + parent metric", async () => {
vi.mocked(prisma.customMetricEntry.updateMany).mockResolvedValue({
count: 1,
} as never);
const res = await RESTORE(restoreRequest({ ids: ["e-1"] }), {
params: Promise.resolve({ id: "cm-1" }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data).toEqual({ restored: 1 });
const call = vi.mocked(prisma.customMetricEntry.updateMany).mock
.calls[0][0];
expect(call.where).toEqual({
id: { in: ["e-1"] },
userId: "user-1",
customMetricId: "cm-1",
deletedAt: { not: null },
});
expect(call.data).toEqual({ deletedAt: null });
});
it("answers restored: 0 for foreign / live ids — a silent no-op, not a leak", async () => {
vi.mocked(prisma.customMetricEntry.updateMany).mockResolvedValue({
count: 0,
} as never);
const res = await RESTORE(restoreRequest({ ids: ["not-mine"] }), {
params: Promise.resolve({ id: "cm-1" }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data).toEqual({ restored: 0 });
});
it("422s an empty id list", async () => {
const res = await RESTORE(restoreRequest({ ids: [] }), {
params: Promise.resolve({ id: "cm-1" }),
});
expect(res.status).toBe(422);
expect(prisma.customMetricEntry.updateMany).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,79 @@
/**
* `POST /api/custom-metrics/{id}/entries/restore` — un-tombstone for the
* entry delete-Undo affordance (v1.37.20, A3-11).
*
* Body: `{ ids: string[] }` — 1..200 entry ids, scoped to the caller and the
* parent metric.
*
* Clears `deletedAt` on every owned, currently-tombstoned row of THIS metric
* in one `updateMany`, so the value re-surfaces in normal reads. A forged /
* foreign / live / mismatched-parent id is a silent no-op (never a 404
* existence leak): the mutation `where` pins `userId` and `customMetricId`,
* so it only ever touches the caller's rows. Mirrors `/api/labs/restore`.
* Same session auth as the delete beside it — custom-metric entry writes are
* not delegable.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
const MAX_IDS_PER_BATCH = 200;
const restoreSchema = z.object({
ids: z.array(z.string().min(1)).min(1).max(MAX_IDS_PER_BATCH),
});
type RouteParams = { params: Promise<{ id: string }> };
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = restoreSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
const { count } = await prisma.customMetricEntry.updateMany({
where: {
id: { in: parsed.data.ids },
userId: user.id,
customMetricId: id,
deletedAt: { not: null },
},
data: { deletedAt: null },
});
if (count > 0) invalidateUserCorrelationPatterns(user.id);
await auditLog("customMetricEntry.restore", {
userId: user.id,
ipAddress: getClientIp(request),
details: { customMetricId: id, restored: count },
});
annotate({
action: { name: "custom-metric.entry.restore" },
meta: { customMetricId: id, restored: count },
});
return apiSuccess({ restored: count });
},
);
@@ -57,7 +57,7 @@ export const GET = apiHandler(
}
const { limit, offset, sortDir } = parsed.data;
const where = { userId: user.id, customMetricId: id };
const where = { userId: user.id, customMetricId: id, deletedAt: null };
const [rows, total] = await Promise.all([
prisma.customMetricEntry.findMany({
+4 -3
View File
@@ -36,11 +36,12 @@ export const GET = apiHandler(async () => {
orderBy: { name: "asc" },
include: {
entries: {
where: { deletedAt: null },
orderBy: { measuredAt: "desc" },
take: 1,
select: { value: true, unit: true, measuredAt: true },
},
_count: { select: { entries: true } },
_count: { select: { entries: { where: { deletedAt: null } } } },
},
});
@@ -164,12 +165,12 @@ async function postCustomMetric(request: NextRequest) {
if (existing) {
const [latestEntry, count] = await Promise.all([
prisma.customMetricEntry.findFirst({
where: { userId: user.id, customMetricId: created.id },
where: { userId: user.id, customMetricId: created.id, deletedAt: null },
orderBy: { measuredAt: "desc" },
select: { value: true, unit: true, measuredAt: true },
}),
prisma.customMetricEntry.count({
where: { userId: user.id, customMetricId: created.id },
where: { userId: user.id, customMetricId: created.id, deletedAt: null },
}),
]);
latest = latestEntry ?? null;
@@ -967,3 +967,93 @@ describe("DELETE /api/dashboard/widgets — web-owned reset scope", () => {
expect(body.data.updatedAt).toBe(advanced.toISOString());
});
});
describe("dashboard widgets — hero primary content", () => {
const weightOnly = [
{ id: "weight" as const, visible: true, tileVisible: true, order: 0 },
];
it("accepts and persists hero: 'reminders'", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await callPut(
makeReq({ version: 1, widgets: weightOnly, hero: "reminders" }),
);
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: { dashboardWidgetsJson: { hero?: string } };
};
expect(updateArg.data.dashboardWidgetsJson.hero).toBe("reminders");
const body = (await res.json()) as { data: { hero?: string } };
expect(body.data.hero).toBe("reminders");
});
it("rejects a hero value outside the closed enum with 422", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await callPut(
makeReq({ version: 1, widgets: weightOnly, hero: "banner" }),
);
expect(res.status).toBe(422);
expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled();
});
it("keeps the stored hero choice when the client omits the field", async () => {
// A layout save from a client that predates the field (the native
// client, or a stale web tab) must not silently reset the choice.
const stored: DashboardLayout = serializeDashboardLayout({
version: 1,
widgets: weightOnly,
hero: "reminders",
});
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: stored,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await callPut(makeReq({ version: 1, widgets: weightOnly }));
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: { dashboardWidgetsJson: { hero?: string } };
};
expect(updateArg.data.dashboardWidgetsJson.hero).toBe("reminders");
});
it("honours an explicitly sent 'score' over the stored 'reminders'", async () => {
const stored: DashboardLayout = serializeDashboardLayout({
version: 1,
widgets: weightOnly,
hero: "reminders",
});
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: stored,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await callPut(
makeReq({ version: 1, widgets: weightOnly, hero: "score" }),
);
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: { dashboardWidgetsJson: { hero?: string } };
};
// "score" is the default and serializes as an omitted field.
expect(updateArg.data.dashboardWidgetsJson.hero).toBeUndefined();
const body = (await res.json()) as { data: { hero?: string } };
expect(body.data.hero).toBeUndefined();
});
});
+10
View File
@@ -31,6 +31,7 @@ import {
MAX_SELECTED_SCORE_RINGS,
HERO_RING_IDS,
MAX_HERO_RING_ORDER,
HERO_PRIMARY_CONTENTS,
layoutNeedsPreserveRead,
mergePreservedLayoutFields,
type DashboardLayout,
@@ -158,6 +159,15 @@ const layoutSchema = z.object({
.array(z.enum(PRIORITY_ITEM_KINDS))
.max(PRIORITY_ITEM_KINDS.length)
.optional(),
// Hero primary content — what the dashboard hero card leads with:
// "score" (default) or "reminders" (the worth-a-look rail promoted into
// the hero slot). This widens the stored `dashboardWidgetsJson` shape
// ADDITIVELY: the field is optional on the wire and omitted from storage
// at the default, so the native client (which mirrors this blob) can
// keep round-tripping layouts untouched until it learns the field.
// Same preserve-when-absent contract as `comparisonBaseline`; the
// resolver drops unknown stored values back to "score" on read.
hero: z.enum(HERO_PRIMARY_CONTENTS).optional(),
// v1.32.16 (issue #581) — the optimistic-concurrency base token used to
// live here. v1.32.21 (R5a) moved it to the shared `takeBaseToken` helper,
// which strips it BEFORE this parse: the token is transport, not a layout
+28 -31
View File
@@ -631,40 +631,37 @@ export const GET = apiHandler(async (request: Request) => {
}
}
// Condition links for the page in ONE grouped query (no N+1).
const [linkMap, visitLinkMap] = await Promise.all([
loadConditionLinks(
user.id,
page.map((d) => d.id),
),
loadDocumentEncounterLinks(
user.id,
page.map((d) => d.id),
),
// Condition/visit links + the content-index and thumbnail probes for the
// page, all in ONE Promise.all — four independent grouped queries (no
// N+1, never a ciphertext/blob column), and the vault polls this list
// every few seconds so two serialized round-trips were pure added
// latency (A5-4).
const pageIds = page.map((d) => d.id);
const [linkMap, visitLinkMap, indexedRows, thumbRows] = await Promise.all([
loadConditionLinks(user.id, pageIds),
loadDocumentEncounterLinks(user.id, pageIds),
// Which of the page's documents have a content index (drives the
// searchable status + the provenance the UI reads to tell an AI-read
// document from a locally-indexed one).
page.length > 0
? prisma.documentContentIndex.findMany({
where: { userId: user.id, documentId: { in: pageIds } },
select: { documentId: true, source: true },
})
: Promise.resolve([]),
// Which of the page's documents have a preview thumbnail (gates the
// card's <img>). Grouped on the 1:1 side table.
page.length > 0
? prisma.documentThumbnail.findMany({
where: { userId: user.id, documentId: { in: pageIds } },
select: { documentId: true },
})
: Promise.resolve([]),
]);
// Which of the page's documents have a content index (drives the searchable
// status + the provenance the UI reads to tell an AI-read document from a
// locally-indexed one). One grouped query; never the ciphertext.
const indexSources = new Map<string, string>();
if (page.length > 0) {
const indexed = await prisma.documentContentIndex.findMany({
where: { userId: user.id, documentId: { in: page.map((d) => d.id) } },
select: { documentId: true, source: true },
});
for (const row of indexed) indexSources.set(row.documentId, row.source);
}
// Which of the page's documents have a preview thumbnail (gates the card's
// <img>). One grouped query on the 1:1 side table; never the blob column.
for (const row of indexedRows) indexSources.set(row.documentId, row.source);
const thumbnailIds = new Set<string>();
if (page.length > 0) {
const thumbs = await prisma.documentThumbnail.findMany({
where: { userId: user.id, documentId: { in: page.map((d) => d.id) } },
select: { documentId: true },
});
for (const row of thumbs) thumbnailIds.add(row.documentId);
}
for (const row of thumbRows) thumbnailIds.add(row.documentId);
annotate({
action: { name: "documents.inbound.list" },
@@ -38,6 +38,29 @@ vi.mock("@/lib/db", () => ({
encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) },
vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
// The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the
// same reason as the visit tables above.
measurementReminder: { findMany: vi.fn().mockResolvedValue([]) },
measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) },
// The bests, the badges, and the environmental history with the location
// periods that explain it. Empty for the same reason as the tables above.
personalRecord: { findMany: vi.fn().mockResolvedValue([]) },
userAchievement: { findMany: vi.fn().mockResolvedValue([]) },
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) },
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
moodTagCategory: { findMany: vi.fn().mockResolvedValue([]) },
moodTagHidden: { findMany: vi.fn().mockResolvedValue([]) },
mentalHealthAssessment: { findMany: vi.fn().mockResolvedValue([]) },
consentReceipt: { findMany: vi.fn().mockResolvedValue([]) },
coachFact: { findMany: vi.fn().mockResolvedValue([]) },
coachPlan: { findMany: vi.fn().mockResolvedValue([]) },
coachReminder: { findMany: vi.fn().mockResolvedValue([]) },
// What a document was filed against, and what was read out of it.
// Empty for the same reason as the sections above.
documentConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
extractedFact: { findMany: vi.fn().mockResolvedValue([]) },
ecgRecording: { findMany: vi.fn().mockResolvedValue([]) },
// v1.15.0 — cycle tables read by the full-backup helper.
cycleProfile: { findUnique: vi.fn() },
menstrualCycle: { findMany: vi.fn() },
@@ -121,6 +144,31 @@ beforeEach(() => {
vi.mocked(prisma.vaccinationDocumentLink.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.measurementReminder.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.measurementReminderEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.coachConversation.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.coachFact.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.coachPlan.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.coachReminder.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.ecgRecording.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodTagCategory.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodTagHidden.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.mentalHealthAssessment.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.consentReceipt.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.extractedFact.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.personalRecord.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.userAchievement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.environmentContext.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.environmentTravelLocation.findMany).mockResolvedValue(
[] as never,
);
});
afterEach(() => {
@@ -25,6 +25,10 @@ vi.mock("@/lib/db", () => ({
medicationIntakeEvent: { findMany: vi.fn(), count: vi.fn() },
moodEntry: { findMany: vi.fn(), count: vi.fn() },
moodTag: { findMany: vi.fn() },
moodTagCategory: { findMany: vi.fn() },
moodTagHidden: { findMany: vi.fn() },
mentalHealthAssessment: { findMany: vi.fn() },
consentReceipt: { findMany: vi.fn() },
nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) },
user: { findUnique: vi.fn() },
passkey: { findMany: vi.fn() },
@@ -56,6 +60,10 @@ vi.mock("@/lib/db", () => ({
familyHistoryEntry: { findMany: vi.fn() },
workout: { findMany: vi.fn() },
inboundDocument: { findMany: vi.fn() },
// What a document was filed against, and what was read out of it.
documentConditionLink: { findMany: vi.fn() },
extractedFact: { findMany: vi.fn() },
ecgRecording: { findMany: vi.fn() },
},
}));
@@ -134,6 +142,13 @@ beforeEach(() => {
);
vi.mocked(prisma.medicationIntakeEvent.count).mockResolvedValue(0);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodTag.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodTagCategory.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodTagHidden.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.mentalHealthAssessment.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.consentReceipt.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodEntry.count).mockResolvedValue(0);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
heightCm: null,
@@ -156,6 +171,11 @@ beforeEach(() => {
vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.extractedFact.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.ecgRecording.findMany).mockResolvedValue([] as never);
});
describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
@@ -36,6 +36,29 @@ vi.mock("@/lib/db", () => ({
encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) },
vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
// The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the
// same reason as the visit tables above.
measurementReminder: { findMany: vi.fn().mockResolvedValue([]) },
measurementReminderEvent: { findMany: vi.fn().mockResolvedValue([]) },
// The bests, the badges, and the environmental history with the location
// periods that explain it. Empty for the same reason as the tables above.
personalRecord: { findMany: vi.fn().mockResolvedValue([]) },
userAchievement: { findMany: vi.fn().mockResolvedValue([]) },
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) },
coachConversation: { findMany: vi.fn().mockResolvedValue([]) },
moodTagCategory: { findMany: vi.fn().mockResolvedValue([]) },
moodTagHidden: { findMany: vi.fn().mockResolvedValue([]) },
mentalHealthAssessment: { findMany: vi.fn().mockResolvedValue([]) },
consentReceipt: { findMany: vi.fn().mockResolvedValue([]) },
coachFact: { findMany: vi.fn().mockResolvedValue([]) },
coachPlan: { findMany: vi.fn().mockResolvedValue([]) },
coachReminder: { findMany: vi.fn().mockResolvedValue([]) },
// What a document was filed against, and what was read out of it.
// Empty for the same reason as the sections above.
documentConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
extractedFact: { findMany: vi.fn().mockResolvedValue([]) },
ecgRecording: { findMany: vi.fn().mockResolvedValue([]) },
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
@@ -205,6 +205,18 @@ describe("POST /api/export/health-record — validation", () => {
expect(res.status).toBe(422);
});
it("rejects a selection that admits nothing with 422", async () => {
// The panel disables its own submit in that state, so only a hand-built
// request gets here. Rendering an empty report and spending a rate-limit
// slot on it would be the wrong answer; this route has no documents-only
// mode that would make an empty leaf list a legitimate scope.
const { POST } = await import("../route");
const res = await POST(mkReq({ format: "pdf", selection: sel() }));
expect(res.status).toBe(422);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("export.selection.empty");
});
it("rejects a userId smuggled into the body with 422", async () => {
const { POST } = await import("../route");
const res = await POST(
@@ -540,15 +552,11 @@ describe("POST /api/export/health-record — the selection reaches every format"
// leaves: the totals must stay in the database.
expect(await observationCodes(sel("LAB_RESULTS"))).toEqual([]);
// And an empty selection — the "I said nothing" case, which used to mean
// "give me everything".
// "give me everything" and is now refused outright, so the totals cannot
// ride out on a scope nobody chose.
const { POST } = await import("../route");
const res = await POST(mkReq({ format: "fhir", selection: sel() }));
const bundle = (await res.json()) as {
entry: { resource: { resourceType: string } }[];
};
expect(
bundle.entry.filter((e) => e.resource.resourceType === "Observation"),
).toEqual([]);
expect(res.status).toBe(422);
// Asked for by name, they are emitted — the data is withheld, not lost.
const asked = await observationCodes(sel("PHQ9_SCORE", "GAD7_SCORE"));
+11
View File
@@ -129,6 +129,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
}
const selection = minted.selection;
// A selection that admits nothing would render an empty report and still
// spend a rate-limit slot on it. The panel disables its own submit button
// in that state; a hand-built request has to be refused here, because this
// route has no documents-only mode that would make an empty leaf list a
// legitimate scope (the share-link route, which does, mints EMPTY itself).
if (selection.leaves.length === 0) {
annotate({ meta: { empty_selection: true } });
return apiError("Select at least one section to export", 422, {
errorCode: "export.selection.empty",
});
}
const range = normaliseDateRange(input.range ?? undefined);
const practiceName = sanitisePracticeName(input.practiceName);
+5 -4
View File
@@ -10,6 +10,7 @@ import {
mintFitbitOAuthStateNonce,
} from "@/lib/fitbit/oauth-state";
import { NextRequest, NextResponse } from "next/server";
import { relativeRedirect } from "@/lib/http/relative-redirect";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
/**
@@ -59,8 +60,8 @@ export const GET = apiHandler(async (req: NextRequest) => {
const creds = await getUserFitbitCredentials(user.id);
if (!creds) {
annotate({ action: { name: "fitbit.connect.no_credentials" } });
return NextResponse.redirect(
new URL("/settings/integrations?fitbit=error&reason=nocreds", req.url),
return relativeRedirect(
"/settings/integrations?fitbit=error&reason=nocreds",
);
}
@@ -78,8 +79,8 @@ export const GET = apiHandler(async (req: NextRequest) => {
} catch (err) {
getEvent()?.setError(err);
annotate({ action: { name: "fitbit.connect.create_failed" } });
return NextResponse.redirect(
new URL("/settings/integrations?fitbit=error&reason=connect", req.url),
return relativeRedirect(
"/settings/integrations?fitbit=error&reason=connect",
);
}
+4 -12
View File
@@ -2,6 +2,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { readSyncTriggerBody } from "@/lib/integrations/sync-request";
import { syncUserFitbit } from "@/lib/fitbit/sync";
import { resolveSyncOutcome } from "@/lib/outcome/written-outcome";
import { NextRequest } from "next/server";
@@ -30,18 +31,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
let fullSync = false;
try {
const raw = await request.text();
// Flag-only payload — cap the parse cost (mirrors safeJson maxBytes).
if (raw.length > 64 * 1024) {
return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
}
const body = JSON.parse(raw);
fullSync = body?.fullSync === true;
} catch {
// no body provided -> default incremental sync
}
const body = await readSyncTriggerBody(request);
if (body.error) return body.error;
const { fullSync } = body;
// The full-history walk is the expensive path (four paginated resource
// walkers); cap it well below the incremental bucket so a re-trigger loop
+1 -12
View File
@@ -6,21 +6,10 @@ import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { markDisconnected } from "@/lib/integrations/status";
import { googleHealthCredentialsSchema } from "@/lib/validations/google-health";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
/**
* Per-user Google Health BYO-key credentials. Each self-hoster registers their
* own Google Cloud OAuth client (the Restricted-scope brand verification + CASA
* assessment is per-OAuth-client, so a single shared app is unworkable for a
* multi-operator product) and pastes the client id/secret into Settings. Stored
* encrypted on `User`.
*/
const googleHealthCredentialsSchema = z.object({
clientId: z.string().min(1).max(200),
clientSecret: z.string().min(1).max(200),
});
/**
* Check whether the user has Google Health BYO-key credentials stored
* (v1.26.0).
+4 -12
View File
@@ -2,6 +2,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { readSyncTriggerBody } from "@/lib/integrations/sync-request";
import { syncUserGoogleHealth } from "@/lib/google-health/sync";
import type {
GoogleHealthReasonCode,
@@ -83,18 +84,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
let fullSync = false;
try {
const raw = await request.text();
// Flag-only payload — cap the parse cost (mirrors safeJson maxBytes).
if (raw.length > 64 * 1024) {
return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
}
const body = JSON.parse(raw);
fullSync = body?.fullSync === true;
} catch {
// no body provided -> default incremental sync
}
const body = await readSyncTriggerBody(request);
if (body.error) return body.error;
const { fullSync } = body;
// The full-history walk is the expensive path; cap it well below the
// incremental bucket so a re-trigger loop cannot churn the Google quota.
@@ -32,6 +32,7 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/auth/hmac", () => ({ hashToken: (s: string) => `hash(${s})` }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
checkAuthSurfaceRateLimit: vi.fn(),
rateLimitHeaders: () => ({}),
}));
vi.mock("@/lib/app-settings", () => ({
@@ -62,16 +63,19 @@ vi.mock("next/headers", () => ({
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
import { checkAuthSurfaceRateLimit, checkRateLimit } from "@/lib/rate-limit";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
function postReq(body: unknown): NextRequest {
function postReq(
body: unknown,
bearer: string | null = "hlk_test_token",
): NextRequest {
return new NextRequest("http://localhost/api/ingest/medication", {
method: "POST",
headers: {
"content-type": "application/json",
authorization: "Bearer hlk_test_token",
...(bearer === null ? {} : { authorization: `Bearer ${bearer}` }),
},
body: JSON.stringify(body),
});
@@ -80,6 +84,10 @@ function postReq(body: unknown): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
ip: "203.0.113.7",
} as never);
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "tok-1",
@@ -190,6 +198,10 @@ describe("POST /api/ingest/medication — slot attribution + convergence (v1.16.
function wireHappyPath() {
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
ip: "203.0.113.7",
} as never);
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "tok-1",
@@ -433,3 +445,136 @@ describe("POST /api/ingest/medication — idempotency-key stability floor", () =
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1);
});
});
/**
* A bridge is identified by its TOKEN, not by the address it dials from.
*
* The bucket used to be `ingest:${ip}`, so a household running a chat bridge,
* a button and a home-automation rule — the normal deployment for this route,
* not an edge case — put all three in one 60/min cell and let the chattiest
* of them starve the other two. What makes it a defect rather than a
* trade-off is that the route already resolves a per-caller identity two
* lines further down and threw it away.
*
* The fallback matters as much as the fix: a request that resolves no token
* has no identity but its address, and must stay capped on it. Keying the
* failure path on anything shared — or on nothing — would hand an
* unauthenticated flood a free pass, or collapse every anonymous caller in
* the world into one cell, which is worse than what it replaces.
*/
describe("POST /api/ingest/medication — the rate-limit bucket", () => {
it("keys the bucket on the token, not on the client IP", async () => {
vi.mocked(prisma.medication.findFirst).mockResolvedValue(null as never);
await POST(
postReq({ medicationName: "Metformin", idempotencyKey: "k-stable-001" }),
);
expect(vi.mocked(checkRateLimit)).toHaveBeenCalledTimes(1);
const [key, limit, windowMs] = vi.mocked(checkRateLimit).mock.calls[0];
expect(key).toBe("ingest:token:tok-1");
expect(limit).toBe(60);
expect(windowMs).toBe(60_000);
// The address must not appear in the key at all — that is the whole
// point. A NAT'd sibling on the same address gets its own cell.
expect(key).not.toContain("203.0.113");
});
it("gives two tokens on one address two independent buckets", async () => {
vi.mocked(prisma.medication.findFirst).mockResolvedValue(null as never);
vi.mocked(prisma.apiToken.findUnique)
.mockResolvedValueOnce({
id: "tok-bridge",
userId: "user-1",
permissions: ["*"],
revoked: false,
expiresAt: null,
lastUsedAt: null,
} as never)
.mockResolvedValueOnce({
id: "tok-button",
userId: "user-1",
permissions: ["*"],
revoked: false,
expiresAt: null,
lastUsedAt: null,
} as never);
await POST(postReq({ medicationName: "A", idempotencyKey: "k-aaaa-001" }));
await POST(postReq({ medicationName: "B", idempotencyKey: "k-bbbb-001" }));
const keys = vi.mocked(checkRateLimit).mock.calls.map(([k]) => k);
expect(keys).toEqual([
"ingest:token:tok-bridge",
"ingest:token:tok-button",
]);
expect(new Set(keys).size).toBe(2);
});
it("refuses an exhausted token bucket with the standard envelope", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: Date.now() + 30_000,
} as never);
const res = await POST(
postReq({ medicationName: "Metformin", idempotencyKey: "k-stable-002" }),
);
expect(res.status).toBe(429);
const body = (await res.json()) as {
data: null;
error: string;
meta?: { errorCode?: string };
};
expect(body.data).toBeNull();
// The 429 was hand-rolled through `NextResponse.json` and carried no
// `meta`, so a client branching on `errorCode` could not see it.
expect(body.meta?.errorCode).toBe("ingest.rate_limited");
expect(prisma.medication.findFirst).not.toHaveBeenCalled();
});
it("falls back to the per-IP bucket when no token is presented", async () => {
const res = await POST(
postReq(
{ medicationName: "Metformin", idempotencyKey: "k-anon-0001" },
null,
),
);
expect(res.status).toBe(401);
// The anonymous path must still be capped, and on the address — the only
// identity such a request has.
expect(vi.mocked(checkAuthSurfaceRateLimit)).toHaveBeenCalledTimes(1);
const [, prefix, limit, windowMs] = vi.mocked(checkAuthSurfaceRateLimit)
.mock.calls[0];
expect(prefix).toBe("ingest:ip");
expect(limit).toBe(60);
expect(windowMs).toBe(60_000);
// No token resolved, so no token bucket was charged.
expect(vi.mocked(checkRateLimit)).not.toHaveBeenCalled();
});
it("falls back to the per-IP bucket when the token does not resolve", async () => {
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue(null as never);
const res = await POST(
postReq({ medicationName: "Metformin", idempotencyKey: "k-bogus-001" }),
);
expect(res.status).toBe(401);
expect(vi.mocked(checkAuthSurfaceRateLimit)).toHaveBeenCalledTimes(1);
expect(vi.mocked(checkRateLimit)).not.toHaveBeenCalled();
});
it("caps an invalid-token flood before it can answer 401 forever", async () => {
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue(null as never);
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: Date.now() + 30_000,
ip: "203.0.113.7",
} as never);
const res = await POST(
postReq({ medicationName: "Metformin", idempotencyKey: "k-flood-001" }),
);
expect(res.status).toBe(429);
});
});
+65 -15
View File
@@ -11,7 +11,12 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { hashToken } from "@/lib/auth/hmac";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import {
checkAuthSurfaceRateLimit,
checkRateLimit,
rateLimitHeaders,
type RateLimitResult,
} from "@/lib/rate-limit";
import { externalIntakeSchema } from "@/lib/validations/medication";
import {
unstableExternalIdMeta,
@@ -26,13 +31,49 @@ import {
import { consumeForIntake } from "@/lib/medications/inventory/consumption";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { NextRequest, NextResponse } from "next/server";
import { NextRequest } from "next/server";
const RATE_LIMIT = 60;
const RATE_WINDOW_MS = 60 * 1000;
/** The 429, with the standard headers and the standard error envelope. */
function tooManyRequests(rl: RateLimitResult) {
const response = apiError("Rate limit exceeded", 429, {
errorCode: "ingest.rate_limited",
});
for (const [key, value] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(key, value);
}
return response;
}
/**
* Refuse a caller that presented no usable token, capped on the way out.
*
* The bucket is the caller's ADDRESS, because that is the only identity an
* unauthenticated request has. `checkAuthSurfaceRateLimit` is the shared
* helper for exactly this shape: it keys on the trusted-proxy IP normally,
* and when the proxy chain does not match the configured hop count it routes
* every anonymous caller into one tight global bucket instead of the
* free-for-all that a shared `"unknown"` cell would be.
*/
async function refuseUnauthenticated(request: NextRequest, message: string) {
const rl = await checkAuthSurfaceRateLimit(
request,
"ingest:ip",
RATE_LIMIT,
RATE_WINDOW_MS,
);
if (!rl.allowed) return tooManyRequests(rl);
return apiError(message, 401);
}
/**
* External medication ingest endpoint.
* Auth: Bearer token (hashed and looked up in api_tokens table).
* Idempotent via idempotencyKey.
* Rate limit: 60 requests per minute per IP.
* Rate limit: 60 requests per minute per TOKEN (per client IP for a request
* that presents no valid one).
*/
export const POST = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "ingest.medication" } });
@@ -43,19 +84,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
const ip = getClientIp(request);
// Rate limiting: 60 requests per minute per IP
const rl = await checkRateLimit(`ingest:${ip}`, 60, 60 * 1000);
if (!rl.allowed) {
return NextResponse.json(
{ data: null, error: "Rate limit exceeded" },
{ status: 429, headers: rateLimitHeaders(rl) },
);
}
// Extract bearer token
const authHeader = request.headers.get("authorization");
if (!authHeader?.startsWith("Bearer ")) {
return apiError("Authorization header required", 401);
return refuseUnauthenticated(request, "Authorization header required");
}
const token = authHeader.slice(7);
@@ -74,13 +106,31 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
if (!apiToken || apiToken.revoked) {
return apiError("Invalid or revoked token", 401);
return refuseUnauthenticated(request, "Invalid or revoked token");
}
if (apiToken.expiresAt && apiToken.expiresAt < new Date()) {
return apiError("Token expired", 401);
return refuseUnauthenticated(request, "Token expired");
}
// A bridge is identified by its TOKEN, not by the address it dials from.
// The bucket used to be `ingest:${ip}`, so every integration behind one
// NAT shared a single 60/min cell and one chatty bridge starved the rest —
// and a household running a chat bridge, a button and a home-automation
// rule is the normal deployment here, not an edge case.
//
// The identity is only known after the lookup, so the lookup now runs
// before the limiter: one indexed single-row read ahead of what was
// already an upsert against `rate_limits`. A caller whose token does not
// resolve is still capped on the per-IP bucket it was always capped on,
// one step earlier than it reaches anything expensive.
const rl = await checkRateLimit(
`ingest:token:${apiToken.id}`,
RATE_LIMIT,
RATE_WINDOW_MS,
);
if (!rl.allowed) return tooManyRequests(rl);
if (
!apiToken.permissions.includes("*") &&
!apiToken.permissions.includes("medication:ingest")
+1 -1
View File
@@ -253,7 +253,7 @@ export const GET = apiHandler(async () => {
bpPctInTarget,
weightSlope30: summaries.WEIGHT?.slope30?.slope ?? null,
pulseAvg30: summaries.PULSE?.avg30 ?? null,
pulseAnomalyCount: summaries.PULSE?.anomalyCount,
pulseAnomalyCount: summaries.PULSE?.anomalyCount ?? 0,
medications: medicationCompliance,
});
@@ -0,0 +1,409 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* REGRESSION — when the Coach's numeric grounding guard is armed, and when it
* deliberately is not.
*
* The guard rewrites any figure in the reply that the turn's Grounding Ledger
* cannot account for. Its activation used to key solely off `loop.toolResults`,
* and a tool that found nothing resolves to `{ present: false, reason: "none" }`
* with no `available` payload, which `loop.ts` drops (pinned in
* `tools-loop-miss-payloads.test.ts`). So the guard disarmed itself exactly
* when the record was empty, and a fabricated figure shipped verbatim with no
* withheld-figure marker.
*
* `toolTrace` now discriminates the two cases the old condition conflated:
*
* - tools RAN and every one missed -> grade. An empty ledger is the finding,
* not a reason to skip, so every magnitude is unreconciled. A reply that
* loses a figure this way is replaced with the honest "nothing recorded"
* answer rather than shipping elided prose that still asserts a series.
* - the model called NO tool -> stay dormant. This is the v1.32.1
* contract: the tool-mode base prompt deliberately carries no pre-computed
* figures, and flagging a legitimately recalled one was a real regression.
*/
const SNAPSHOT_JSON = '{"bp":{"aggregate":{"mean":128}}}';
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })),
HttpError: class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
},
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
isModuleEnabled: vi.fn(async () => true),
}));
vi.mock("@/lib/feature-flags", () => ({
requireAssistantSurface: vi.fn(async () => undefined),
}));
vi.mock("@/lib/api-response", () => ({
apiError: (error: string, status: number) => ({ data: null, error, status }),
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(),
}));
vi.mock("@/lib/logging/redact", () => ({
redactSecrets: (s: string) => s,
redactOptional: (s: unknown) => s,
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn(async () => ({ coachPrefsJson: null })) },
coachConversation: { findFirst: vi.fn(async () => ({ id: "c1" })) },
},
}));
const { checkRateLimit } = vi.hoisted(() => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit }));
vi.mock("@/lib/i18n/server-locale", () => ({
resolveServerLocale: vi.fn(async () => "en"),
}));
const { runStreamingRawCompletionWithFallback } = vi.hoisted(() => ({
runStreamingRawCompletionWithFallback: vi.fn(),
}));
vi.mock("@/lib/ai/provider-runner", () => ({
AllProvidersFailedError: class extends Error {},
runStreamingRawCompletionWithFallback,
}));
const { resolveProviderChain } = vi.hoisted(() => ({
resolveProviderChain: vi.fn(),
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain,
resolveProvider: vi.fn(),
}));
const { assertConsentForChain } = vi.hoisted(() => ({
assertConsentForChain: vi.fn(async () => undefined),
}));
vi.mock("@/lib/ai/consent-guard", () => ({ assertConsentForChain }));
vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" }));
vi.mock("@/lib/ai/ai-budgets", () => ({
AI_BUDGETS: { coach: { maxTokens: 1500, temperature: 0.4 } },
}));
vi.mock("@/lib/ai/coach/types", async () => {
const actual = await vi.importActual<typeof import("@/lib/ai/coach/types")>(
"@/lib/ai/coach/types",
);
return actual;
});
const { fetchConversationWithMessages } = vi.hoisted(() => ({
fetchConversationWithMessages: vi.fn(),
}));
vi.mock("@/lib/ai/coach/persistence", () => ({
appendMessage: vi.fn(async () => ({ id: "m1" })),
createConversation: vi.fn(async () => ({ id: "c1" })),
fetchConversationWithMessages,
listConversations: vi.fn(),
}));
vi.mock("@/lib/ai/coach/coach-memory-shared", () => ({
enqueueCoachMemoryRefresh: vi.fn(),
}));
vi.mock("@/lib/ai/coach/facts", () => ({
storeDeterministicFacts: vi.fn(async () => undefined),
}));
// Guard II — the schedule read. Empty here; the schedule-gated dose rule is
// unit-tested in the outbound-screen suite.
vi.mock("@/lib/medications/scheduled-doses", () => ({
getScheduledDoseValues: vi.fn(async () => []),
}));
const { reserveBudget, reconcileSpend } = vi.hoisted(() => ({
reserveBudget: vi.fn(async () => ({ allowed: true, reserved: 3000 })),
reconcileSpend: vi.fn(async () => undefined),
}));
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-07-23"),
reserveBudget,
reconcileSpend,
resolveDailyCap: vi.fn(() => 2_000_000),
}));
const { detectRefusal } = vi.hoisted(() => ({
detectRefusal: vi.fn(() => ({ refuse: false })),
}));
vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal }));
vi.mock("@/lib/ai/coach/outbound-guard", () => ({
screenCoachReply: vi.fn(() => ({ block: false })),
coachOutboundFallback: vi.fn(() => "fallback"),
}));
vi.mock("@/lib/ai/coach/system-prompt", () => ({
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(async () => null),
}));
vi.mock("@/lib/ai/coach/snapshot", () => ({
buildCoachSnapshot: vi.fn(async () => ({
snapshotJson: SNAPSHOT_JSON,
sections: { bloodPressure: { aggregate: { mean: 128 } } },
provenance: { windows: ["last30days"], metrics: ["bp"] },
referenceGrounding: "REFERENCE RANGES",
})),
}));
vi.mock("@/lib/workouts/hr-series", () => ({
buildWorkoutHrSeries: vi.fn(async () => null),
}));
vi.mock("@/lib/workouts/zones", () => ({
computeZones: vi.fn(() => null),
hrMaxFromAge: vi.fn(() => 185),
parseWhoopZoneDurations: vi.fn(() => null),
}));
vi.mock("@/lib/workouts/sport-context", () => ({
buildSportContext: vi.fn(async () => null),
}));
const {
buildCoachDataInventory,
renderDataInventory,
renderFocusHint,
runCoachToolLoop,
} = vi.hoisted(() => ({
buildCoachDataInventory: vi.fn(async () => ({
entries: [],
restMode: false,
cycleEnabled: false,
window: "last30days",
probeScope: { sources: ["bp"], window: "last30days" },
})),
renderDataInventory: vi.fn(() => "DATA INVENTORY\n- blood pressure: present"),
renderFocusHint: vi.fn(() => ""),
runCoachToolLoop: vi.fn(),
}));
vi.mock("@/lib/ai/coach/tools", () => ({
COACH_TOOL_DEFS: [{ name: "get_metric_series" }],
MAX_ROUNDS: 3,
buildCoachDataInventory,
renderDataInventory,
renderFocusHint,
buildToolModeAddendum: vi.fn(() => "TOOL ADDENDUM"),
runCoachToolLoop,
}));
const { parseKeyValuesSentinel } = vi.hoisted(() => ({
parseKeyValuesSentinel: vi.fn(),
}));
vi.mock("@/lib/ai/coach/keyvalues", () => ({ parseKeyValuesSentinel }));
const { parseSuggestReminder } = vi.hoisted(() => ({
parseSuggestReminder: vi.fn(),
}));
vi.mock("@/lib/ai/coach/suggest-reminder", () => ({ parseSuggestReminder }));
vi.mock("@/lib/ai/coach/suggest-gate", () => ({ gateSuggestion: vi.fn() }));
vi.mock("@/lib/validations/coach-prefs", () => ({
parseCoachPrefs: vi.fn(() => ({ defaultWindow: undefined })),
DEFAULT_REMINDER_SUGGESTION_PREFS: {},
}));
const { appendMessage } = await import("@/lib/ai/coach/persistence");
const sse = vi.hoisted(() => ({ done: Promise.resolve() as Promise<unknown> }));
vi.mock("@/lib/sse/create-stream", () => ({
createSseStream: (
producer: (c: {
signal: { aborted: boolean };
enqueue: () => void;
}) => void | Promise<void>,
) => {
sse.done = Promise.resolve(
producer({ signal: { aborted: false }, enqueue: () => {} }),
);
return new ReadableStream();
},
}));
import { POST } from "../route";
const post = POST as unknown as (req: Request) => Promise<Response>;
function chatReq(body: Record<string, unknown>): Request {
return new Request("http://localhost/api/insights/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
/** Force the model's assembled prose through the sentinel/suggest parsers. */
function stubReply(prose: string, toolResults: unknown[]): void {
parseKeyValuesSentinel.mockReturnValue({
prose,
keyValues: [],
malformed: false,
malformedEntries: [],
});
parseSuggestReminder.mockReturnValue({ prose });
runCoachToolLoop.mockImplementation(async () => ({
result: { content: prose, tokensUsed: 80, model: "m" },
workingProviderType: "anthropic",
totalTokens: 80,
rounds: 1,
toolTrace: [{ name: "get_metric_series", present: true }],
toolResults,
}));
}
function assistantContent(): string {
const calls = (appendMessage as ReturnType<typeof vi.fn>).mock.calls;
const assistant = calls.find(
(c) => (c[0] as { role: string }).role === "assistant",
);
return (assistant?.[0] as { content: string }).content;
}
function assistantProvenance(): Record<string, unknown> | null | undefined {
const calls = (appendMessage as ReturnType<typeof vi.fn>).mock.calls;
const assistant = calls.find(
(c) => (c[0] as { role: string }).role === "assistant",
);
return (
assistant?.[0] as {
metricSource?: Record<string, unknown> | null;
}
).metricSource;
}
describe("coach chat — grounding-guard activation", () => {
beforeEach(() => {
vi.clearAllMocks();
reserveBudget.mockResolvedValue({ allowed: true, reserved: 3000 });
detectRefusal.mockReturnValue({ refuse: false });
checkRateLimit.mockResolvedValue({ allowed: true });
assertConsentForChain.mockResolvedValue(undefined);
resolveProviderChain.mockResolvedValue([
{ providerType: "anthropic", instance: {} },
]);
fetchConversationWithMessages.mockResolvedValue({
id: "c1",
attachmentCount: 0,
summary: null,
messages: [],
});
});
/** Drive the loop with an explicit trace so a MISS is modelled faithfully. */
function stubMissedTurn(prose: string): void {
parseKeyValuesSentinel.mockReturnValue({
prose,
keyValues: [],
malformed: false,
malformedEntries: [],
});
parseSuggestReminder.mockReturnValue({ prose });
runCoachToolLoop.mockImplementation(async () => ({
result: { content: prose, tokensUsed: 80, model: "m" },
workingProviderType: "anthropic",
totalTokens: 80,
rounds: 1,
// The tool ran and found nothing; `loop.ts` drops the pure miss from
// `toolResults` but the trace still records the call.
toolTrace: [{ name: "get_sleep", present: false }],
toolResults: [],
}));
}
it("CONTROL — one present tool result arms the guard: the invented figure is stripped", async () => {
stubReply("Your sleep averaged 432 minutes last week.", [
{ present: true, data: { metric: "bp", aggregate: { avgSys30: 128 } } },
]);
await post(chatReq({ conversationId: "c1", message: "how did I sleep?" }));
await sse.done;
expect(assistantContent()).not.toContain("432");
expect(assistantContent()).toContain("[…]");
expect(assistantProvenance()?.unverifiedFigures).toBe(1);
});
it("every tool missed: the fabricated figure is caught and the turn is replaced", async () => {
stubMissedTurn("Your sleep averaged 432 minutes last week.");
await post(chatReq({ conversationId: "c1", message: "how did I sleep?" }));
await sse.done;
const content = assistantContent();
expect(content).not.toContain("432");
// Not the elided prose either — the sentence asserted a series the record
// does not hold, so the whole turn is replaced with the honest answer.
expect(content).not.toContain("[…]");
expect(content).toContain("recorded readings");
// The replacement copy says it in words, so the count-based notice is off.
expect(assistantProvenance()?.unverifiedFigures).toBeUndefined();
});
it("every tool missed but the figure is grounded by a prior turn: left alone", async () => {
// Turn 1 fetched systolic 128 and persisted it as a tool figure. This turn
// every tool misses, but recalling 128 still reconciles, so nothing is
// stripped and the reply is NOT replaced.
fetchConversationWithMessages.mockResolvedValue({
id: "c1",
attachmentCount: 0,
summary: null,
messages: [
{ role: "user", content: "How is my BP?" },
{
role: "assistant",
content: "Your systolic averaged 128.",
metricSource: { groundedFigures: [128] },
},
],
});
stubMissedTurn(
"No sleep readings yet. Your systolic 128 average is unrelated to that.",
);
await post(chatReq({ conversationId: "c1", message: "and my sleep?" }));
await sse.done;
const content = assistantContent();
expect(content).toContain("128");
expect(content).not.toContain("[…]");
expect(content).not.toContain("recorded readings");
});
it("the model called no tool at all: the guard stays dormant (v1.32.1)", async () => {
// Deliberately unchanged behaviour. The tool-mode base prompt carries no
// pre-computed figures, so a number here came from the transcript or the
// model's own recall, and grading it off the counts-only inventory flagged
// legitimate figures as ungrounded. `toolTrace` is empty, so the widened
// activation above does not reach this case.
const prose = "Your resting heart rate has been averaging 58 bpm.";
parseKeyValuesSentinel.mockReturnValue({
prose,
keyValues: [],
malformed: false,
malformedEntries: [],
});
parseSuggestReminder.mockReturnValue({ prose });
runCoachToolLoop.mockImplementation(async () => ({
result: { content: prose, tokensUsed: 80, model: "m" },
workingProviderType: "anthropic",
totalTokens: 80,
rounds: 1,
toolTrace: [],
toolResults: [],
}));
await post(chatReq({ conversationId: "c1", message: "how is my RHR?" }));
await sse.done;
expect(assistantContent()).toBe(prose);
expect(assistantProvenance()?.unverifiedFigures).toBeUndefined();
});
});
+76 -2
View File
@@ -639,6 +639,13 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
// delivered this turn (`includeFullSnapshot`); on a cheap follow-up the block
// was not re-sent, so there is no fresh authoritative set to grade against.
let noToolsSnapshotPayloads: unknown[] = [];
// The DATA INVENTORY manifest (per-domain sample counts) that rode this
// turn's tool-mode prompt. Hoisted out of the tool branch because the
// all-missed activation below needs it as a WIDENER: the counts were in
// front of the model, so a plain count restatement ("you've logged 42 BP
// readings") must stay grounded even on a turn whose every tool missed.
// Never an ACTIVATOR on its own — see the v1.32.1 note below.
let inventoryPayloads: unknown[] = [];
let totalTokensSpent: number;
// v1.21.0 (F3) — cached-input tokens to subtract at reconcile (prompt-cached
// input the user did not re-pay for must not be billed to the daily meter).
@@ -705,6 +712,7 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
// out-of-window aggregate rule 3 lets the model cite. Ground it.
...(loop.toolResults ?? []).map((r) => r.data ?? r.available),
];
inventoryPayloads = [inventory.entries];
toolResultPayloads =
presentToolPayloads.length > 0
? [...presentToolPayloads, inventory.entries]
@@ -993,17 +1001,43 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
// active grading — they never ACTIVATE it, so a snapshot figure the model
// cited on a no-tool turn is still left alone (the v1.32.1 regression guard
// holds). Assistant prose is never a ledger source (D3).
//
// The turn that CALLED tools and got nothing back is graded too. It used to
// be the one turn the verifier sat out: a pure miss carries no `data` and no
// `available`, `runCoachToolLoop` drops that shape from `toolResults`, and
// the no-tools snapshot fallback below is populated only in the non-tool
// branch — so `activatingPayloads` came out empty and every figure in the
// reply shipped unchecked. That is backwards. A turn whose every tool
// reported `{ present: false }` is not an absence of evidence about the
// reply, it is evidence that the record holds nothing to cite, which is
// exactly when a fabricated figure is both likeliest and most harmful.
//
// `toolTrace` is the discriminant, because it records every tool that ran
// INCLUDING the pure misses. It separates the two cases the old condition
// conflated: tools ran and found nothing (grade — the model was told the
// record is empty and answered with numbers anyway), versus the model
// answered without calling a tool at all (stay dormant — the v1.32.1
// regression, where the base prompt deliberately carries no pre-computed
// figures and flagging a legitimately recalled one was a real defect).
const missedEveryTool =
toolMode && toolTrace.length > 0 && toolResultPayloads.length === 0;
const activatingPayloads =
toolResultPayloads.length > 0
? toolResultPayloads
: noToolsSnapshotPayloads;
: missedEveryTool
? inventoryPayloads
: noToolsSnapshotPayloads;
let groundedFigures: number[] = [];
// v1.32.14 — count of figures withheld from THIS reply (each rewritten to the
// elision mark). Hoisted out of the guard block so it can ride the provenance
// envelope and drive the quiet per-message notice. Stays 0 on a blocked turn
// (the guard is skipped, its fallback prose carries no figures).
let unverifiedStripped = 0;
if (activatingPayloads.length > 0) {
// True when the guard stripped a figure on a turn whose every tool missed —
// i.e. the model put a number on a record that holds none. Drives the
// honest-replacement below.
let fabricatedOnEmptyRecord = false;
if (activatingPayloads.length > 0 || missedEveryTool) {
const ledger = buildGroundingLedger({
toolPayloads: activatingPayloads,
priorToolFigures,
@@ -1026,6 +1060,9 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
replyText,
ledger,
locale,
// On an all-missed turn an EMPTY ledger is the finding, not a reason
// to skip: nothing was retrievable, so nothing can reconcile.
{ gradeAgainstEmptyLedger: missedEveryTool },
);
if (unverified.length > 0) {
const { prose: corrected, stripped } = stripUnverifiedNumbers(
@@ -1034,6 +1071,7 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
);
replyText = corrected;
unverifiedStripped = stripped;
fabricatedOnEmptyRecord = missedEveryTool && stripped > 0;
annotate({
action: { name: "coach.prose.number_unverified" },
meta: {
@@ -1048,6 +1086,42 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
}
}
// A stripped reply on an empty record is not worth sending. Eliding the
// digits leaves the sentences that carried them — "your sleep averaged […]
// minutes, up from […]" still asserts a series, an average and a direction
// for a metric the tools just reported has no readings at all. The false
// claim survives the strip; only its precision goes. So replace the turn
// with the honest answer instead of shipping the elided mush.
//
// Scoped tightly: this fires only when every tool missed AND the guard
// actually stripped something. A reply that cited a legitimately grounded
// figure (a prior turn's tool result, an inventory count) reconciles
// against the ledger, strips nothing, and is left alone.
//
// REPLACE, not withhold — the same policy the outbound screen uses on this
// surface, for the same reason: the user is waiting on a synchronous answer
// and silence reads as a failure.
//
// Accepted trade-off: a reply mixing one grounded recall with one
// fabrication is replaced wholesale, losing the good half. That is the
// right way round — the alternative leaves an invented figure's sentence
// on screen.
if (fabricatedOnEmptyRecord) {
replyText = getServerTranslator(locale).t("coach.noRecordedData");
// The reply now carries no figures at all, so the withheld-figure notice
// would be describing prose the reader can no longer see. The replacement
// copy states the same thing in plain words.
unverifiedStripped = 0;
annotate({
action: { name: "coach.prose.empty_record_replaced" },
meta: { promptVersion: PROMPT_VERSION },
});
await auditLog("insights.coach.empty_record_replaced", {
userId,
details: { conversationId: workingConversationId },
});
}
// v1.21.0 (NEW-C C-3) — Learn-link post-filter. The prompt instructs the
// model to only link a published `/learn/<slug>`, but that is guidance, not
// enforcement: a fabricated `/learn/<invented-slug>` would otherwise ship as
@@ -0,0 +1,95 @@
/**
* GET /api/insights/coach/nudge-status — the NEXT_APP_OPEN evaluation
* hook. This poll mounts with the app chrome, which makes it the
* app-open signal, so the route runs the context evaluation before it
* computes the unread flag (owner only — a delegate opening the record
* is not the owner opening their app).
*
* Watched red: with the `evaluateCoachContextReminders` call removed
* from the route the first test fails on the hook assertion — the
* pre-fix state where NEXT_APP_OPEN reminders could never fire.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const { requireRecordAuth, evaluateContext, readStatus } = vi.hoisted(() => ({
requireRecordAuth: vi.fn(),
evaluateContext: vi.fn(async () => ({ surfaced: 0, errored: 0 })),
readStatus: vi.fn(async () => ({ unread: false, nudgedAt: null })),
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireRecordAuth,
}));
vi.mock("@/lib/feature-flags", () => ({
requireAssistantSurface: vi.fn(async () => undefined),
}));
vi.mock("@/lib/ai/coach/nudge-status", () => ({
readCoachNudgeStatus: readStatus,
}));
vi.mock("@/lib/ai/coach/context-reminders", () => ({
evaluateCoachContextReminders: evaluateContext,
}));
vi.mock("@/lib/db", () => ({ prisma: { __tag: "prisma" } }));
vi.mock("@/lib/api-response", () => ({
apiSuccess: (data: unknown) => ({ data, error: null }),
}));
import { GET } from "../route";
beforeEach(() => {
vi.clearAllMocks();
readStatus.mockResolvedValue({ unread: false, nudgedAt: null });
});
describe("nudge-status — NEXT_APP_OPEN hook", () => {
it("runs the app-open evaluation for the owner BEFORE computing the unread flag", async () => {
requireRecordAuth.mockResolvedValue({
user: { id: "u1" },
actor: { id: "u1" },
});
await (GET as unknown as () => Promise<unknown>)();
expect(evaluateContext).toHaveBeenCalledTimes(1);
const [prismaArg, userId, trigger] = evaluateContext.mock
.calls[0] as unknown as [{ __tag: string }, string, string];
expect(prismaArg.__tag).toBe("prisma");
expect(userId).toBe("u1");
expect(trigger).toBe("app-open");
// Ordering: the surfaced message must be visible to this very read.
expect(evaluateContext.mock.invocationCallOrder[0]).toBeLessThan(
readStatus.mock.invocationCallOrder[0]!,
);
});
it("never satisfies the cue for a switched caller", async () => {
requireRecordAuth.mockResolvedValue({
user: { id: "owner" },
actor: { id: "delegate" },
});
await (GET as unknown as () => Promise<unknown>)();
expect(evaluateContext).not.toHaveBeenCalled();
expect(readStatus).toHaveBeenCalledWith("owner");
});
it("keeps the badge alive when the evaluator fails", async () => {
requireRecordAuth.mockResolvedValue({
user: { id: "u1" },
actor: { id: "u1" },
});
evaluateContext.mockRejectedValueOnce(new Error("evaluator down"));
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const res = (await (GET as unknown as () => Promise<unknown>)()) as {
error: null;
};
expect(res.error).toBeNull();
expect(readStatus).toHaveBeenCalledWith("u1");
} finally {
warn.mockRestore();
}
});
});
Loaded 100 of 529 files, more files were not shown because too many files have changed in this diff. Show more