Commit Graph
35 Commits
Author SHA1 Message Date
Marc-André Bombeck 5fd5447069 Make the band a database type instead of a text check
The delete-all-data guard seeds every table straight from information_schema
so a table added next year is asserted without anyone remembering. A CHECK
constraint defeats that: the generic seeder has no way to guess a value the
constraint accepts, and the whole suite failed on the new table. A Postgres
enum carries the same closed set, the seeder reads it from pg_enum, and
Prisma now types the column instead of trusting a string.

Dropped the day-key format and non-empty-composition checks with it. The
writer builds the key through the timezone helper and refuses an empty
composition before inserting, and the restore validates both against the
wire schema before the transaction opens, so a third copy in the database
bought nothing and cost a hand-written line in a fixture whose point is not
having one. The composite range check stays: free to check, and a number
outside nought to a hundred renders as a score nobody can read.
2026-07-31 15:38:50 +02:00
Marc-André Bombeck cd7c6f44c8 Teach the export test mocks about the recorded score days
Four suites hand-roll a Prisma stand-in for the backup builder, so a new
section reaches them as an undefined delegate rather than as an empty
result. Each gains the delegate, and the admin restore round trip gains an
assertion that the day comes back with its composition and per-pillar
scores, not just its headline: a row restored without those looks restored
and answers nothing.
2026-07-31 15:31:47 +02:00
Marc-André Bombeck ab8e9a164d Carry the recorded score days through wipe, backup and restore
A new user-scoped table is outside all three by default, and the two
structural guards said so the moment the model landed: the wipe plan and
the backup verdict list both named it as unclassified. It now joins the
wipe list, rides the backup payload as its own spread section, and comes
back through the restore route.

The classification guards only prove a helper exists in a declared file, so
they would pass an unreferenced restore helper. The integration round trip
goes through the real admin restore route instead, with the table emptied
first so a restore that carries nothing cannot pass by leaving what was
already there.
2026-07-31 15:13:47 +02:00
Marc-André Bombeck 80c8674ee1 Remove the Health-Score explainer switch, which switched nothing
The admin panel offered a toggle for the Health-Score explainer and
described it as the ? next to the delta line. That ? had already been
taken off the insights surface a release earlier, and the caption that
replaced it went away entirely when the health score was replaced by
the reference composite. Since then an operator could turn the switch
off, get a saved confirmation, and change absolutely nothing.

The toggle, its wording in all six languages, its place in the flag
matrix the app and the iOS client both read, and its column on
app_settings are all gone. Nothing is left to promise a control that
does not exist.

A guard goes in with it: every remaining assistant sub-flag has to be
read by some surface, and the panel and the plumbing that carry all of
them by construction do not count as proof.
2026-07-31 08:06:07 +02:00
Marc-André Bombeck 3aff2c16db fix(insights): scope generation guard to prompt fields, clear cache on restore 2026-07-28 21:11:43 +02:00
Marc-André Bombeck 666a85d311 feat(insights): persist correlation patterns 2026-07-28 14:01:11 +02:00
Marc-André Bombeck a2f2a30ac2 fix(quality): close backup and localization guard gaps 2026-07-28 12:03:02 +02:00
Marc-André Bombeck 81825ce23b merge: forward-port v1.33.2 into release/v1.34.0 2026-07-28 11:43:44 +02:00
Marc-André Bombeck ddbce306be fix(backup): drop the manifest key the records spread already carries
The assembly picked `manifest` out of the records section by hand, one line
under the comment explaining that both sections are spread precisely so nobody
has to pick their keys. It resolved to the same value the spread had already
placed, at the same position, so the emitted file is byte-identical either way.

Left in, it is the pattern that lost the custom cycle symptoms sitting next to
the note about how that happened.
2026-07-28 00:09:10 +02:00
Marc-André Bombeck 51dbfefbcb fix(backup): carry the intraday day shape through export and restore
`IntradayCumulativeProfile` shipped classified as backed up and carried by
nothing. The drain folds the per-sample rows into twenty-four running totals
and deletes the originals in the same transaction, so past the grace window
the table is the only copy of a day's curve: a restore rebuilt the account
without it, reported success, and left the same-time baseline reading "still
learning" for a fortnight.

Builder and restore sit together in one file, the way the profile and cycle
pairs do, because the restore route delegates and a grep of the route alone
lies. The section spreads into the payload rather than being copied key by
key. Two curves for the same metric and day, or a curve that is not a whole
day, throw naming the day instead of writing a row every reader drops.

The pair is proven end to end rather than at each end: the round-trip suite
builds a real payload from a populated account, serialises it, parses it back
through the real parser, drives the real restore, and asserts on the rows
written. Remove the read and it goes red.
2026-07-28 00:07:10 +02:00
Marc-André Bombeck b14943ea1b merge: s8-baselines into release/v1.34.0 2026-07-27 23:41:34 +02:00
Marc-André Bombeck e5b958cd86 fix(backup): carry the account's own cycle symptoms into the payload
The section builder returned them, the wire schema declared them, and the
restore has read them since v1.33.1 — including the throw that replaced the
silent drop. The payload assembly copied its keys out one by one and was never
extended, so every backup written carried an empty list. An account with a
custom symptom did not get the silent drop that release removed; it got a
restore that failed outright on a key nothing had written back.

Spread the cycle and records sections instead of hand-picking their keys, so a
field added to either section interface reaches the wire by construction. Declare
labelEncrypted on the cycle section, which the builder already emitted.
2026-07-27 23:40:44 +02:00
Marc-André Bombeck 6bd8d387ae feat(insights): surface the same-time baseline on Today and in the grid
The rail gains a same_time_baseline kind and a server builder, which is the
growth path that type documents for itself. It speaks only when today is
actually outside the typical band: a card that reports doing exactly what you
usually do teaches people to stop reading the rail. Dismissing it is keyed by
day and metric, not by the hour, so it stays dismissed instead of coming back
every sixty minutes.

The Insights grid gains a section of four tiles, one per cumulative metric,
each honestly quiet until the history accrues.

The two-trajectory chart it needs did not exist, so DeltaSparkline gains a
second mode rather than a second chart family: the comparison curve rides the
same container, the same fixed height and the same shared y-domain as a dashed
muted line behind the live one, which is the only way the two can be read
against each other.

Six locales. The wipe and backup plans learn about the new table: the profile
cannot be rebuilt once the samples are gone, so it is carried, not derived.
2026-07-27 23:32:22 +02:00
Marc-André Bombeck faa89ef54b test(backup): guard that a backed-up model travels both ways
The classification guard proved every model has an opinion and would have
passed unchanged while a model classified as carried was read into the payload
and never written back — which is what happened to the nutrient day totals, the
custom mood tags and the custom cycle symptoms, three releases running.

Split BACKED_UP into the models with both ends and the models without, each of
the latter carrying what an account loses meanwhile, so the debt is a register
rather than a silence. The check reads declared writer and restore files rather
than grepping for a route, because the route delegates: a review that grepped
only the route concluded the cycle data was never restored and had to be
retracted. Declaring the file list caught its own first omission — measurements
are read through the pager, not directly.

Adds a round trip that builds a real payload, parses it through the wire schema
and drives the real restore, asserting on the rows written rather than on the
payload. Dropping the cycle section spread makes it fail the way production
did: the restore throws on a symptom key nothing wrote back.
2026-07-27 23:19:58 +02:00
Marc-André Bombeck c6593b292f feat(backup): carry the health profile and custom metrics through export and restore
Both were classified as backed up when the backup plan was written and carried
by nothing. The custom metric is live data loss: a series the account defined
itself is the one kind no integration can re-sync, so a restore rebuilt the
account without the metric and every one of its readings, and reported success.

Builder and restore live in one file beside each other, the way the cycle pair
does — a grep of the restore route alone does not find either, because the route
delegates to both.

Readings nest inside the metric that owns them rather than referencing it by
name, so the restore has no parent to look up and no lookup to miss. A duplicate
metric name and an undecodable ciphertext field both throw and name the value.
Intake events whose medication did not restore now throw with the unresolved
reference instead of being filtered away into a dose history that comes back
short while the restore reports success.
2026-07-27 23:04:44 +02:00
Marc-André Bombeck ffe97d9ad9 fix(backup): carry the account's own cycle symptoms into the payload
The section builder returned them, the wire schema declared them, and the
restore has read them since v1.33.1 — including the throw that replaced the
silent drop. The payload assembly copied its keys out one by one and was never
extended, so every backup written carried an empty list. An account with a
custom symptom did not get the silent drop that release removed; it got a
restore that failed outright on a key nothing had written back.

Spread the cycle and records sections instead of hand-picking their keys, so a
field added to either section interface reaches the wire by construction. Declare
labelEncrypted on the cycle section, which the builder already emitted.
2026-07-27 22:55:05 +02:00
Marc-André Bombeck ea55886445 fix(tz): close the hole that let a display option pass for a timezone
The guard tested whether the argument list contained the substring "timeZone".
`timeZoneName: "short"` contains it. That option appends "GMT+1" to the output
and leaves the zone as the browser's, so a formatter written that way sailed
through while being exactly as wrong as a bare one — and looking more careful
than a bare one, which is worse.

Tightening it to a colon-terminated property then reported nine correct call
sites, because the shorthand `{ timeZone }` is what most of this tree writes.
The honest response to that would have been to allowlist all nine, which is
how a guard stops guarding. It accepts both forms now, and a test pins both
directions rather than leaving the regex to be trusted.

Two exclusion reasons in the backup plan aged inside their own release and are
corrected: push attempts now also hold the reminder dedup anchors this release
introduced, and the thumbnail's "rebuilds from the stored bytes" only holds on
the disaster-recovery path, since a portable export carries no bytes to rebuild
from.
2026-07-27 16:10:30 +02:00
Marc-André Bombeck 4ad498dbff fix(backup): carry a custom tag's own words and its scale, and stop claiming a guard that does not exist
Four things an independent read found, three of them things this release said
about itself that were not true.

A custom mood tag travelled without its label, its icon, its sort order and
its SCALE. The label is the smaller loss: the person who named a factor
"Migräne" would have got a key back. The scale is the real one — the inverse
flag marks a factor where a high value is bad, and dropping it does not lose a
label, it silently reverses the meaning of every rating already recorded
against that tag. Custom cycle symptoms lost their label the same way. Both
now carry the ciphertext verbatim, like every other encrypted column in a
disaster-recovery payload.

The plan file claimed, in the present tense, that a guard requires both a
payload reader and a restore branch for every carried model. No such guard
exists. The commit that introduced the file said so honestly; the file itself
said the opposite, and roughly 31 models have neither end. BACKED_UP means
"must be", not "is", and both the plan and its test now say that plainly. The
wrong sentence is recorded rather than quietly swapped, because it is the same
defect the file was written to prevent.

The export route still told operators that labs, allergies, illness episodes
and workouts could be exported but never restored. The restore route
re-creates all of them, and since this release nutrient day totals too. The
scope now points at the plan instead of repeating it beside the code, which is
how that sentence came to be wrong.

And the export card promised "every record you own". It does not, so it no
longer says so — six locales.
2026-07-27 15:20:12 +02:00
Marc-André Bombeck cd30cb3a52 fix(backup): carry the mood tags an account made, so its own file can be restored
The restore resolves a mood entry's rated factors by key and asks only for the
seeded catalogue. A tag the user created is not in it, so the lookup came up
short and the whole restore threw 'Unknown mood factor keys'. The file was
complete; it simply could not be read back.

That is the same cause as the cycle symptoms two commits ago, with the
opposite ending. There, an unresolvable key was filtered out and the loss was
silent. Here it stopped everything. One shape of missing definition, two
answers, neither of them the right one.

The account's own tag definitions travel now, get written before the factors
resolve, and the lookup accepts the seeded catalogue or the owner's own. The
seeded rows stay out of the file for the same reason as before: a restore must
not rewrite another instance's reference data.

Four more test mocks needed the new delegate, which is again the compiler and
the suite naming the call sites rather than me hunting for them.
2026-07-27 12:41:45 +02:00
Marc-André Bombeck a2e9587781 feat(backup): declare what a backup carries, and force a verdict on the rest
The mirror of the wipe plan, for the same reason and against a worse failure:
a list that was right when written and stops being right every time a model is
added. On the delete side that leaves data behind; here it means data does not
come back, and nothing says so until someone is restoring a backup because
something already went wrong.

The list is longer than the wipe list by sixteen models, and that gap is the
point. A wipe may lean on the database — deleting a medication cascades to its
schedules, dose changes and inventory events — but a restore has no cascade,
so every child has to be named here.

Every exclusion carries its reason in prose, and the guard enforces a length
floor on it. That floor is not pedantry: the first draft of this file said
'Same as WithingsConnection' four times, which is a pointer rather than a
reason, and a pointer silently inherits whatever the thing it points at
becomes. Writing the decision where it is made is the only way it stays
reviewable.

The guard found two things in its own author's work on the first run: a model
with no verdict at all, and a reason too thin to be one.

The restore side of the guard — that every carried model also has a restore
branch — lands with the payload work it will police.
2026-07-27 05:14:39 +02:00
Marc-André Bombeck 5e490f31ec feat(export): make the doctor report carry only what was chosen
The report resolved an absent key to "included". A caller that sent no
sections object, or an empty group inside one, received the full record at
server-chosen defaults: roughly fifty metrics plus the medication list, labs,
allergies and family history. The web panel sent every key explicitly so it
looked fine from the browser, but the share link, the FHIR REST face, the MCP
doctor-visit resource and the cycle-only export card all went through paths
where absence meant yes. No arrangement of booleans fixes a default that means
yes, so the shape goes.

A selection is now an inclusion list of leaf ids. A leaf is the smallest thing
that can be released or withheld: every measurement type, and thirteen
structured sections — identity, insurer, the glucose panel, labs, the drug
list, the administration ledger, adherence, the GLP-1 block, allergies,
illness episodes, family history, mood, cycle. Membership is inclusion,
absence is exclusion, and there is no defaults constant anywhere to drift.

Three locks keep a metric added later off a report chosen earlier. A stored
selection is a list of what was chosen, so a leaf that did not exist then is
not in it. The catalogue is keyed over the measurement enum, so adding a
member without deciding its group fails typecheck in the same commit. And the
aggregator's filter inverts: a type the catalogue does not know is excluded and
annotated, where it used to be passed through.

The aggregator's third parameter is now required and brand-typed, which turned
the three surfaces that had no scope at all into compile errors. Each one had
to name a source, and each now replays the owner's saved selection — the same
object reviewed in Settings. An account that never saved one gets nothing
there rather than a guess.

Identity and insurance become selectable, and a share link refuses the
insurance leaf outright. The share view never decrypted the number; that is
structural now instead of incidental. The KVNR is not even read unless the
leaf was chosen.

Two surfaces gain a record they always implied. A share link serves its frozen
selection as a FHIR bundle and as the PDF, behind the same unlock cookie the
document route uses, with its own rate bucket, its own audit row and the same
blunt 404 for every miss. That is what the removed FHIR toggle promised, in
the form a practice actually uses.

The AI summary leaves the report. Its text came from the cached briefing with
no relation to the selection, so its scope could not be proven, and prose whose
scope cannot be proven has no place on a document whose whole point is a proven
scope.

The PDF table stops being a nine-type whitelist. It prints every selected leaf
that has data, sub-headed by group, in the same order the panel shows — so
selecting resting heart rate no longer means seeing it in the bundle and not in
the PDF. The clinician view is grouped the same way and reads real labels
instead of "Phq9 score".

Migration 0273 adds the saved-selection column, drops the orphaned prefs column
and the two retired share-link scope columns, and revokes every live record
share link minted before this model. Their frozen scope was "everything except
mood and cycle", which is a scope nobody chose; narrowing it would still be a
scope nobody chose. Documents-only shares are untouched. The sharing list shows
each closed link in a pick-again state with its label, window and expiry kept.

The retired allowFhirApi and resourceTypes stay on the create and list
responses as the constant false and empty list. The request refuses them. They
come off the response once the native client confirms it does not decode them.

Six files are split at seams this change already touched: the aggregator into
its gate, its dense-bucket query, its series, its glucose panel, its medication
ledger and its clinical records; the export route's three emitters into their
own module; the clinician view into its sections, its document list and its
downloads.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck 99b8dfd566 test(guards): bind the account payload and the backup select to their pairs
Every finding behind this work is a two-ended change whose ends shipped in
different releases with nothing holding them together, and every gate the repo
runs proves one end: typecheck proves the export exists, knip proves it is
imported, the OpenAPI check proves the schema matches. None of them notice when
the second end never arrives.

Two questions here can be answered exactly, so they become tests.

The account endpoint rides every app boot and carries the user's preferences.
Its field names are read out of the response literal and each one must be read
by a client module — property access or destructuring, comments stripped,
because a doc comment claiming a consumer is what the remembered practice name
had for its whole dead life. That field would have failed this on the day it
shipped. One field is allowlisted today with its reason written out: the
insurer institution number has no web surface at all, which the entry keeps
visible rather than papering over.

The measurement backup select is now a named constant, compared field by field
against the model in the schema. Both sides are literals, so the comparison is
exact — no name heuristics, nothing that can drift towards accepting. A column
added to the model and forgotten by the backup fails at the commit that adds
it, which is how the aggregation provenance went missing. Scoped to the one
model; extend it as each further model becomes backup-relevant.
2026-07-25 16:24:12 +02:00
Marc-André Bombeck 2a396dd23e fix(backup): carry the aggregation provenance through backup and restore
The disaster-recovery backup selected every Measurement scalar except the
aggregation fields, so a restore brought every `stats:` aggregate back with no
provenance and the Apple Health authority ladder came back flat: an export.xml
source-day estimate could then overwrite a native HealthKit statistic.

`aggregationProvenance` now rides the select, the disaster-recovery
serialization, the payload schema and the restore mapping, following
`externalSourceVersion` exactly — including staying out of the reduced
portable shape, which carries the human-readable subset only. The schema field
is optional, so every backup written before this parses unchanged.

Restoring one of those older backups no longer recreates the ambiguity
migration 0263 repaired: an Apple Health row under a `stats:` identity with no
provenance comes back as LEGACY_UNKNOWN, the sentinel the cumulative drain
already writes in that situation, so a native sync or archive reprocess can
repair it. The gate is scoped as 0263 scoped its backfill — other providers
mint `stats:` identities for their cumulative dailies and have never carried
provenance, and ordinary point rows keep NULL.
2026-07-25 15:20:47 +02:00
Marc-André Bombeck d0b45e36b3 feat(integrations): remove the moodLog integration
Mood has been a first-class module here for a long time, with entries,
structured tags, rated factors and notes. The bridge to the external moodLog
service had nothing left to add, and its Settings card was withdrawn in
a56e6fe61 on the promise of a dedicated removal. This delivers it.

Removed: the hourly `moodlog-sync` queue, cron and handler binding; the
inbound webhook and its shared-secret handling; the reverse push and its four
call sites; the pull-sync module; the per-user credential routes and the
instance-wide toggle; the entry on the `/api/integrations/status` envelope,
the `IntegrationKey` union, the cadence map and the display-name table; the
boot-time secret rotation pass; 22 message keys per locale.

Migration 0271 drops the five `users` credential columns and
`app_settings.mood_log_global`, clears the ledger row for the provider, and
deletes the retired pg-boss schedule so no cron keeps enqueuing work no
handler drains.

Mood data is untouched. `mood_entries` is not named anywhere in the migration
and cannot be reached from it: the dropped columns carry no index, constraint
or foreign key, and `mood_entries.user_id` references `users(id)`, not any of
them. Rows the bridge imported keep `source = 'MOODLOG'`, and every read path
- list filter, source badge, exports, backups - still resolves that value.

`src/lib/validations/moodlog.ts` held the native mood-entry schemas alongside
the bridge's own; it is now `validations/mood.ts` and carries only the former.
2026-07-25 11:36:18 +02:00
Marc-André Bombeck b1e36d9fb0 reconcile: fold in the outstanding hardening and data-integrity fixes
Merge the eight reviewed audit fixes that were cut a week ago and never
landed. Main has moved on since, so each fix was re-checked against the
current tree and each of the five conflicts resolved by understanding
both sides.

Kept, still open on main:
- Proxy early exits now carry the transport security header subset via a
  shared helper. Reconciled with main's /api/health worker-only carve-out.
- Batch measurement ingest bounds the entry instant so a future-dated row
  can no longer win "latest reading" or poison the rollup buckets.
- Web Push dials through the pinned safeFetch dispatcher with
  requirePublicHost, closing the last user-controlled-host egress.
- Mood bulk upsert and its tag/factor links run in one transaction, so a
  rejected rating rolls the mood row back instead of leaving a tagless row.
- Full backup carries NutrientIntakeDay with its unit and source. Folded
  into main's portable-export / disaster-recovery split.
- Live daily-series fallback restores the min/max spread band the tiered
  read already emitted.
- Coach stream aborts its in-flight reader on unmount and resets send state
  on a conversation switch.
- Manual water writes share the day-bound helper, nutrient add clamps after
  the increment, and the thiamin cap drops to a real supplement ceiling.
- Compliance point window widens symmetrically instead of wrapping overnight.
- Oura, Polar and Fitbit token helpers classify an empty or non-JSON 2xx
  body as a handled transient error.
- WebAuthn relying-party fallback to localhost is gated to development.
- Chart-token strip list is derived from the allowlist and pinned by a test.
- Regression tests pin the snapshot-cache tenant key, the GlitchTip
  path-secret redaction and the Withings credentials delete.

Superseded by main, tests re-pointed at the current shape:
- The intake CSV import was rewritten on main to run through a background
  job with a single-active-job admission gate. The future-date bound is
  re-applied at request validation and the per-user limiter is kept for
  parity with the bulk twin; the old synchronous-write test is replaced.
2026-07-23 17:15:21 +02:00
Marc-André Bombeck 62eb431d66 release: v1.32.0 — hardening and scalability (#580)
* release: prepare v1.32.0

* test: stabilize water capture re-entry
2026-07-22 04:14:15 +02:00
Marc-André Bombeck e9507a0c93 fix(backups): address restore review findings 2026-07-20 13:42:38 +02:00
Marc-André Bombeck cd6b5f0cc6 fix(backups): preserve canonical restore identity 2026-07-20 13:25:37 +02:00
Marc-André Bombeck 2716f302c7 chore: align hardening tests and formatting 2026-07-20 12:24:15 +02:00
Marc-André Bombeck f8b0a64d69 fix(backups): preserve canonical recovery integrity 2026-07-20 11:44:29 +02:00
Marc-André Bombeck 452d1a2ae3 fix(backups): restore canonical payloads completely 2026-07-20 11:23:57 +02:00
Marc-André Bombeck 94157a50c7 fix(nutrients): bound the manual water writes and put nutrient totals in the backup
The water quick-add validated calendar realism but never the upper day bound the
batch route applies, so a far-future day was accepted and — every read filters
`day >= since` with no ceiling — became the permanent latest day on the settings
card and the dashboard tile. Hoist the bound into one shared helper both routes
use.

`mode: "add"` bounded a single request to the catalog's plausible daily max but
nothing across requests, so repeated quick-adds drove the stored total
arbitrarily high while the batch route enforced the same cap strictly. Clamp
after the increment so the write stays atomic.

Thiamin's cap sat at exactly 1000 mg while its PRI is exactly 1.0 mg, so a
microgram-read-as-milligram sample landed on precisely the cap and the `>`
comparison let it through. 500 still clears any real supplement dose.

NutrientIntakeDay was in no export path, which contradicted the schema's own
reason for denormalising the unit column. Carry it in the full backup with its
unit and source — source is part of the composite key, so without it a restore
cannot tell a manual entry from a synced day total.
2026-07-19 13:40:03 +02:00
Marc-André Bombeck 3dd5dbeb42 feat(export): make the full backup actually full
The full-backup export (plaintext + passphrase-encrypted, and the weekly
auto-backup) only ever covered measurements, medications, mood, and cycle
data. Labs, illness episodes, allergies, family history, and workouts were
silently missing, and the export UI implied "everything" was included.

Adds a shared records-backup section (src/lib/export/records-backup.ts) that
both the on-demand export routes and the weekly data-backup worker read from:
lab results + the biomarker catalog, illness episodes (including flares via
parentConditionId) with their day-logs, allergies, family history, and
workout summaries. Every read is owner-scoped and soft-delete-filtered;
encrypted free-text columns decrypt fail-soft so one bad row never aborts
the whole backup.

Document original files and workout GPS/per-sample time series stay
deliberately out of the JSON payload (size). The payload now carries an
explicit manifest disclosing exactly that, and the export UI states it in
plain text instead of implying completeness. These new domains are
export-only for now — admin restore still only recreates the original
measurements/medications/mood/cycle domains; the payload validates and
reports counts for the rest, but restoring a snapshot does not yet recreate
them.

Also fixes the admin backup-upload total to count every domain, not just
the original four.
2026-07-18 02:09:33 +02:00
Marc-André Bombeck 9c63d3ce20 chore(release): v1.28.25 — every integration held to the same standard
Platform-wide hardening: the failure classes found live this week hunted down
across every integration.

Fitbit carried the same tombstone wedge fixed for Google — probe now matches
soft-deleted rows and the update resurrects them; the comment describing a
partial unique index that never existed is corrected. Sleep segment ids are
stable across re-scoring for Withings (session+segment-start), WHOOP/Polar
(one segment per stage, no positional index) and Oura (run-start keyed), each
with a session-scoped sweep that soft-deletes rows a re-score orphaned — so
legacy and affected nights heal as they are re-read.

One platform rule for deleted rows: source-owned readings resurrect on
re-import (Withings, WHOOP, Oura, Polar, Nightscout, mood webhook, CSV
re-imports, Apple stats day-totals); Apple sample-grain deletions stick per
the paired-client contract, and the mood bulk route's tombstone match is now
a true no-op reported as duplicate. Import counts reconcile against actual
createMany counts.

Google lifecycle: a dead token registers on the cycle's hard-fail ledger, so
an expired connection parks instead of stamping success hourly; backfill and
sleep-repair markers stamp only after a clean pass (pg-boss retries are now
real); failed measurement writes hold the watermark; no-op updates are
skipped; mapping.md tells the current truth.

Scale: doctor-report reads only consumed columns and enabled sections;
exports and backups keyset-paginate; achievements tally via SQL buckets;
series charts day-bucket dense kinds beyond 90 days; BP pairing is a linear
two-pointer; distinct-type scans run server-side; the graded-series cold
fallback is bounded; the fitbit defer tracker loops instead of spreading.
2026-07-11 01:54:12 +02:00
Marc-André Bombeck 0ba77778d5 feat(export): offer a passphrase-encrypted archive
Add an opt-in to download the full backup as an encrypted archive instead
of a plaintext JSON file. The user supplies a passphrase over the request;
the server derives a 256-bit key with Argon2id and seals the export bytes
with AES-256-GCM into a self-describing HLX1 archive (magic, version, KDF
params, salt, iv, tag, ciphertext), mirroring the off-host backup envelope.

POST /api/export/encrypted returns the binary archive and is step-up gated
for accounts with a second factor; single-factor accounts use a normal
session. The passphrase is never stored and never logged, and there is no
server-side recovery — the UI states this. A standalone decryptor
(scripts/decrypt-export.ts) opens the archive with the passphrase outside
the app. The plaintext full-backup route now shares one payload builder so
both variants emit the same restore-compatible shape.
2026-06-27 20:27:07 +02:00