Commit Graph
261 Commits
Author SHA1 Message Date
Marc-André Bombeck 34372b57bd fix(01-11): use canonical compliance status fixture
- model resolved rolling doses with the valid upcoming status
- keep take-all suppression aligned with production DoseStatus values
2026-07-29 14:25:06 +02:00
Marc-André Bombeck 25612294d1 test(01-11): pin rolling overdue consumer parity
- compare persisted weekly occurrence across list, dashboard, GLP-1, and take-all
- protect exact intake retries and unrelated occurrence isolation
2026-07-29 14:23:52 +02:00
Marc-André Bombeck 6f84629482 test(01-13): add red Google sync terminal contracts
- cover authenticated status polling and bounded resource outcomes
- prove committed workouts invalidate server and client cache surfaces
2026-07-29 14:22:49 +02:00
Marc-André Bombeck 6cc3de8248 test(01-12): add failing Apple ECG import contracts
- pin bounded path-safe archive and parser behavior
- require encrypted idempotent fail-soft persistence and WHOOP regressions
2026-07-29 14:20:56 +02:00
Marc-André Bombeck d8e52b831d test(01-13): add red Google sync progress contracts
- pin workout-first fairness and bounded resource outcomes
- require owner-scoped race-safe current-run progress in PostgreSQL
2026-07-29 14:18:20 +02:00
Marc-André Bombeck 81893ac972 test(01-11): reproduce cross-midnight reminder identity bug
- pin prior-day occurrence lookup across timezone and DST boundaries
- prove real worker dedup and distinct next-dose behavior with PostgreSQL
2026-07-29 14:15:48 +02:00
Marc-André Bombeck b7b18a6da2 fix(01-08): align passkey request fixture typing
- construct NextRequest options with the framework-compatible init type
- preserve every enrollment security assertion unchanged
2026-07-29 13:55:57 +02:00
Marc-André Bombeck 0c29e6e6b0 test(01-01): keep bearer regression lint-clean
- avoid React hook-name classification for token fixture helpers
2026-07-29 13:44:28 +02:00
Marc-André Bombeck 390c718b74 test(01-01): reproduce MCP refresh replay race
- coordinate concurrent exchanges with a real PostgreSQL row-lock barrier
- verify family-wide successor revocation and cross-connection isolation
2026-07-29 13:42:30 +02:00
Marc-André Bombeck abea48026b test(01-03): add atomic reset revocation contracts
- execute the operator CLI against real PostgreSQL credential tables
- require complete target revocation and unrelated-admin isolation
- force a mid-transaction database failure to prove rollback
2026-07-29 13:42:04 +02:00
Marc-André Bombeck 9f6cd86c0d test(01-03): add passkey enrollment security contracts
- require fresh existing-factor proof on both cookie-only legs
- bind registration challenges to user, purpose, session, expiry and one use
- cover bearer, ambient-cookie, stale, foreign and replay attacks
2026-07-29 13:38:53 +02:00
Marc-André Bombeck 6c0f47acad test(01-01): pin MCP read scope boundaries
- cover health read and wildcard positive capability paths
- reject unrelated scopes before server registration and database reads
2026-07-29 13:38:41 +02:00
Marc-André Bombeck 1d784c6367 fix(analytics,ci): merge health-score meta annotations, migrate integration tests, remove dead exports, raise bundle budget 2026-07-28 23:02:00 +02:00
Marc-André Bombeck d9880b0562 test: cover every measurement source write path 2026-07-28 21:11:50 +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 f201fb32e7 test: cover every measurement source write path 2026-07-28 15:12:23 +02:00
Marc-André Bombeck 666a85d311 feat(insights): persist correlation patterns 2026-07-28 14:01:11 +02:00
Marc-André Bombeck 8ece6f39cc fix(status): expose background failures 2026-07-28 14:00:44 +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 dd3a7915d6 test(backup): cover the assembly a custom cycle symptom has to cross
Both ends of this defect had passing tests for the whole time it was broken.
The section builder returned the symptoms, the wire schema declared them, the
restore read them, and each of those three was covered on its own. Nothing
covered the object literal in between, which copied section keys out one at a
time and never listed `customSymptoms`.

So the test spans the seam and asserts on rows. It populates an account with a
symptom of its own on a day-log, builds the file through the real
`buildFullBackupPayload`, serialises it, parses it back through the real
`parseBackupPayload`, and drives the real admin restore. Asserting that the
payload carries the field would only re-test the writer that was already green.

Deleting the symptom row before the restore is the case the field exists for,
not a contrivance: `restoreCycleData` clears day-logs, cycles, and the profile
but never the symptom catalogue, so a restore onto the same instance still
finds the row sitting there. That is why the older round-trip test stayed green
through the whole thing.

Mutation check: hand-pick the cycle section's keys again and the restore
answers 500 on `Unknown cycle symptom keys: custom:sacroiliac-ache`, which is
the production failure verbatim.
2026-07-28 00:09:22 +02:00
Marc-André Bombeck be348f8820 test(score): pin the weight yardstick, the mood gate and rollup/live parity
Covers what the required parameters now make impossible to forget: the
scorer takes the band whole and never widens it, a missing target scores
the bare trend, the resolved yardstick rides the weight component, the
shared builder refuses the reference band that sits under an override,
the snapshot gates its mood pillar on the module map, an explicit target
reaches both the score and the dashboard bands, and weight/BMI record
direction follows the user's own goal.

F1 adds the golden the score has never had: the rollup read branch and
the live read branch scored against one series encoded both ways. On the
canonical one-weigh-in-a-day cadence they agree exactly. On an uneven
cadence they part company, and the test pins that the whole difference is
the per-day mean collapse - the rollup branch equals the live branch fed
those same daily means - with a companion assertion proving the fixture
is not a no-op.
2026-07-27 23:15:38 +02:00
Marc-André Bombeck eba9f15539 test(integrations): prove the leg backfill against rows that carry the old value
A migration container starts empty, so applying 0281 there proves the
statements parse and nothing else — the backfill's WHERE matches no rows and a
broken expression passes green. Every self-hoster runs it against rows that do
carry a leg, and those are the only rows it exists for.

Seed the pre-migration shape, run the migration's own UPDATE read out of the
committed file, and assert an attributed row comes out still knowing which leg
broke, an unattributed one is left alone, and a second run does not nest the
array.
2026-07-27 14:35:34 +02:00
Marc-André Bombeck 4e95df9e8d feat(integrations): tell the Apple Health card how the last sync arrived
The batch route has accepted a `syncTrigger` since v1.32.8 and put it on the
wide event, where no human reads it. Half of what was promised got built: the
value is written and appears in three places in the codebase, none of which is
a screen anyone looks at.

Persist it on the account, alongside the instant of the most recent batch that
arrived without the app being opened, and publish both from
`GET /api/integrations/healthkit`. The card now names how the last sync arrived
and when data last came in on its own. `healthKitLastSyncedAt` alone reads
identically whether the phone delivers by itself or only while someone has the
app open, which is why "is background delivery working" could not be settled
from either side.

A batch that declares no trigger writes null rather than leaving the previous
one standing — the field describes this sync, and an older client's silence is
not evidence about it. A foreground batch leaves the background timestamp
alone; overwriting it would erase the only answer the card has.

Also here: WHOOP, Fitbit and Google Health join the workout-freshness table.
All three write workouts, and without an entry their cards could report every
measurement type fresh while the workout leg had been silent for weeks. And the
status pill drops a generic `aria-label` that replaced its own accessible name,
so a screen reader announced the same words for a healthy connection as for a
broken one.
2026-07-27 14:35:33 +02:00
Marc-André Bombeck 2a9b5ba59d test(coach): prove the availability probe's SQL against Postgres
The unit suite mocks Prisma, so it proves the discriminant and nothing about the
query underneath it — a wrong column would have left every test green and every
account answering no_data_unconfirmed in production. Seeds the reported case and
the four other shapes: an empty record, another account's rows, tombstoned rows,
and a history that mixes units.

Rounds the out-of-window mean to one decimal, like every other mean the snapshot
surfaces, and annotates a failed manifest probe rather than letting it degrade in
silence.
2026-07-26 18:34:42 +02:00
Marc-André Bombeck 9db7df1d4d test(db): follow the inventory repair to its new number 2026-07-26 18:02:35 +02:00
Marc-André Bombeck 7b59388887 test(medications): make the repair's idempotency assertion able to fail
The comparison now includes `updated_at`, so a second application that
rewrites an already-correct row is visible even when the value it writes is
identical. Without it the assertion passed whether or not the guard that
skips untouched rows was there.
2026-07-26 18:00:08 +02:00
Marc-André Bombeck f226a907cb fix(medications): clear the pending clock deadlines the repair would have missed
The first pass of migration 0276 restored the rows the in-use clock had
already flipped, but every clock-less container still IN_USE carried the same
clock-derived `expires_at`, most of them with the deadline still ahead. The
daily expire scan would have worked through them over the following weeks and
reproduced the whole defect one row at a time.

A clock-less container's `expires_at` is now set from its printed expiry —
usually nothing, which is how it stays out of that scan. Pens and ampoules
keep theirs; their clock is real.
2026-07-26 18:00:08 +02:00
Marc-André Bombeck bb1e01e456 fix(medications): restore the containers the in-use clock wrongly wrote off
Migration 0276 reverses the rows that reached EXPIRED only through the
post-opening clock on a container that never had one. It moves a row back to
IN_USE when an opening date is recorded and ACTIVE when none is, and sets
`expires_at` from the printed expiry — which for these rows is usually
nothing at all, because they recorded none.

The predicate is exact rather than approximate: nothing but the printed
expiry and the in-use clock can write EXPIRED, so a clock-less container
whose printed expiry is absent or still ahead can only have got there through
the clock. Pens and ampoules keep their EXPIRED, so does anything whose
printed date genuinely lapsed, and so does a drained container. No date is
invented and no printed expiry is fabricated.

The integration test seeds the before-state, applies the migration SQL from
disk, and asserts both what moves and what stays — the testcontainer starts
empty, so the migration running at boot proves nothing on its own.
2026-07-26 18:00:07 +02:00
Marc-André Bombeck b627b92809 feat(medications): rule on each export status for what it actually is
The seven documented statuses are four kinds of thing, so they get four
rulings instead of one.

Taken and Skipped state a decision about a dose and each maps onto something
already recorded here — a row with a take time, and a row marked skipped. That
second mapping was checked rather than assumed: skipped is documented as a
deliberate skip and held apart from autoMissed, which is the marker for a dose
nobody ever acted on, and the compliance re-fold counts a skipped slot in its
own column and never as taken. So a deliberate skip arrives as one.

Not Interacted, Not Logged and Unspecified are absences rather than
statements. Written as taken they would invent a dose; written as skipped they
would invent a decision, because not answering a notification is not choosing
not to take something; written as pending they would put outstanding doses
from years ago in front of someone today. Snoozed describes what happened to a
reminder. Notification Not Sent is about the software. None of the five
becomes a row.

All five are refused, but each under its own name, because "the file says
nothing about this dose" and "this row is about a reminder" are different
things and a person acts on them differently. Three reasons where there was
one, each with its own sentence in six locales.

An inactive medication's history imports and the medication stays inactive.
Preserving what happened and putting a medication back on the active list are
two acts, and only the first belongs to an import.
2026-07-26 16:37:10 +02:00
Marc-André Bombeck 34a220421c test(medications): prove the reported export lands, row for row
The fixture is the export a self-hoster attached, de-identified as it
arrived: 3,395 rows, sixteen medications, three and a half years, two UTC
offsets, three statuses, two units, blank cells in four columns. A
hand-built fixture would have carried one status and one offset and proved
nothing about the file that prompted the work.

The integration pass runs it through the real derivation into a real
Postgres, because the claim is about rows: 3,387 land, 8 are refused because
the file records no decision about them, and imported plus refused equals
every row read. 2,799 of the doses were taken after they were due, which is
the fact that survives only because the scheduled slot and the take are two
columns.

Structural cover so the verdicts cannot rot: every column of both shapes has
exactly one ruling, and every parse-time refusal is spelled the same as a job
skip reason — a reason that exists in only one vocabulary renders as "reason
not recorded", which is a refusal nobody can act on.
2026-07-26 16:37:10 +02:00
Marc-André Bombeck 11e6402b5a feat(medications): read a dose-history export directly
The intake importer took a hand-written list of dates and times for one
medication. A self-hoster with a ten-column export covering sixteen
medications and years of history was left translating it by hand, which is
where the scheduled time and the time a dose was actually taken get
flattened into one — the distinction the record wants most.

The export keeps them as two columns, so read the file's own shape.
`MedicationIntakeEvent` already models that pair as `scheduledFor` +
`takenAt`; an imported dose now populates both instead of anchoring itself
on the take. `Skipped` becomes a deliberate skip with no take time, which is
how every other skip path writes it. A dose whose actual amount differs from
the amount the schedule expected carries the difference as a per-dose
override; an unremarkable amount stays absent, which is what absent means
there.

Timestamps carry their own UTC offset (`+1030` and `+0930` on adjacent rows
of one file) and it is read literally: the epoch is computed from the stated
offset rather than handed to the platform's lenient date parser, and a value
without an offset is refused instead of assumed to be the server's zone.

An export covers a whole regimen, so the job is account-wide and each
payload entry names its own medication — migration 0276 widens
`medication_intake_import_jobs.medication_id` to nullable for it. The
per-medication route is unchanged and keeps naming its medication on the job
row. A medication is never created from an import file; unmatched names come
back verbatim so they can be added properly and the file re-run.

Every column gets a verdict, and `?dryRun=1` returns the whole one before
anything is written.
2026-07-26 16:37:10 +02:00
Marc-André Bombeck f0c5b5ae94 fix(medications): identify an imported dose by when it was taken
The intake import built its replay key from the row's optional `zaehler`
field. That reads as a per-row counter — the documented example showed one —
but nothing required it to be unique and nothing checked. A reported export
carried the dose quantity there instead: 1 tablet, on all 28 rows. Every entry
queued under the same key, one dose reached the ledger, and the other 27 came
back reported as duplicates of a dose they had nothing to do with.

The key is the instant now, and `zaehler` is out of the contract rather than
accepted-and-ignored. Nothing is lost by dropping it: the live-row unique on
`(user_id, medication_id, scheduled_for, source)` already makes an imported
dose unique per instant, so a re-import of a file first imported under the old
key still dedups.

The telling is the other half. Two different facts were counted as one number:
entries of a submission that collide with each other, and doses the ledger
already holds. They are separate reasons now, grouped with a count, and the
dialog renders them through the same classifier and the same outcome line as
the CSV and JSON import cards — so an import that wrote nothing can no longer
answer in success tone. That rule shipped for the settings import panel and
this surface was not part of it; the guard now lists every import surface by
path, so a fourth one has to be added deliberately. `skippedInvalid` is gone:
it was hardcoded to zero on every path and reported a capability the importer
never had.

Alongside it, the other half of the environment report — a scheduled job that
fails every run has to say so. `recordError()` increments one unnamed counter,
so the admin status page could show a number and not which queue produced it,
and the module's own screen showed a zero day count with nothing to
distinguish "nothing stored yet" from "every run failed". pg-boss has held the
answer the whole time: a job that exhausts its retries keeps its queue name,
its error and its timestamp. The admin status now names the failing queues
with counts and the last error, the environment overview reports its own
account's failed runs, and neither needed a change at any of the thirty
`recordError()` call sites — a visibility fix that has to be remembered per
call site is the same failure one level up.

Refs #650, #646
2026-07-26 14:37:57 +02:00
Marc-André Bombeck a8d798442f fix(environment): name the enum type the database actually has
Migration 0217 created the type as `environment_location_source`; the
datamodel declared `EnvironmentLocationSource` with no `@@map`, so the client
compiled every write against `public."EnvironmentLocationSource"`. Postgres
answered 42704, the upsert rolled the transaction back, and the module sat at
"0 days recorded" from the day it shipped — one enum out of forty-nine, and the
only one without a map.

No migration follows. `prisma migrate diff` against a database built from the
committed migrations reports the enum rename and the `environment_contexts.source`
column rebuild before the map and nothing after it: the database was never the
side that drifted, and renaming a live type to match a datamodel typo would be
the wrong direction.

Two guards, because the two questions are different. The unit guard replays
CREATE TYPE / ALTER TYPE RENAME / DROP TYPE across the migration set and holds
every enum to a type that survives it. The integration guard compares all three
name classes Prisma emits — enum type, table, column — against a real migrated
Postgres, which is the only place a name mismatch is observable at all: it
survives generation, typecheck, and every mocked test untouched.

Refs #646
2026-07-26 14:37:57 +02:00
Marc-André Bombeck 527dd6c1fa fix(integrations): escalate a long transient failure streak, and scope a success to its own leg
A sync failure the classifier bucketed as `transient` had no exit. The park
test read `kind === "persistent"` and the persistent streak anchor was only
ever stamped by a persistent failure, so a streak could run for a thousand
attempts with the persistent counter at zero, the state frozen at
`error_transient`, and no ladder it could climb. `transient` is a hypothesis
about retryability, and the classifier defaults every timeout, 429, 5xx and
unrecognised response to it precisely because the wire cannot tell a reboot
from a permanent break. That default is right for one attempt and wrong for a
thousand: an unbroken streak retried hourly for a full day has refuted it by
experiment. Record it as `persistent` from there, which feeds the park ladder
that already exists — 48 h of unbroken failure end to end before an
integration stops attempting.

The counters could not accumulate anyway. `IntegrationStatus` is keyed
(user, integration) while Withings runs four legs on four crons and WHOOP four
resources on four more, and a success cleared the error, every bucket, the
streak anchor and the state whatever leg had recorded them. The ECG tick at
:41 wiped what the sleep tick recorded at :15 — including a reauth park — so
the strike ladder was reset several times an hour and the card painted a green
"connected · 12 minutes ago" over a data class that had stopped arriving. A
success now clears only what it is entitled to clear.

Two columns carry it: `failing_since_at` dates the streak, `failing_leg` names
its owner. The migration backfills the anchor for rows already in an error
state so a connection broken for weeks escalates on its next attempt rather
than restarting its clock.
2026-07-26 14:19:13 +02:00
Marc-André Bombeck fb9a4ee616 test(sync): follow the tombstone purge's batched outcome shape 2026-07-26 14:04:44 +02:00
Marc-André Bombeck dccd2f320b test(settings): prove the wipe against real Postgres, and state what deletion means
The defect is about rows that survive, so a mocked client proves nothing. The
new integration test seeds a row into every table in the schema, driven by
information_schema and pg_constraint rather than a fixture, runs the wipe and
asks the database what is left. A table added later is seeded and asserted
without anyone remembering to.

It holds all four halves: nothing in the wipe list survives, everything in the
exemption list does, everything reached only by a database cascade is gone,
and the account row comes back reset but able to sign in. A second file pins
the allergy and checkup-reminder deletes actually removing their row.

The reproductive-health note gains a Deletion section. It taught people to
reason carefully about a host being imaged and never said what pressing
delete does; now it says which of the three deletions removes the row and
which only hides it.
2026-07-26 14:04:44 +02:00
Marc-André Bombeck f42934487d test(export): prove the scope holds at the artefact, not just at the switch
Five layers, because the defect had five places to hide.

The catalogue guard checks what the compiler cannot state: every leaf resolves
a label a human can read, every group resolves one, the fenced tier stays last,
and the shipped template intersects the fence at nothing.

The selection guard pins the three minting functions against each other. A
stored blob this build cannot read resolves to the empty selection, never to a
default set — that is what makes the revoked legacy links revocable rather than
silently re-scoped. A request naming an id the server does not know is refused
by name rather than dropped, because dropping it would narrow a scope the
caller did express.

The egress guard freezes the set of aggregator callers with the source of each
one's selection written down. The required brand-typed parameter already stops
a new path compiling without an answer; this stops one being added without a
human writing the answer down, which is the moment a reviewer asks the
question.

The behavioural sweep aggregates every leaf in and out against a fixture that
carries a row for each, and asserts the payload differs. It is parametrised
over the catalogue, so a leaf added later is covered the day it is added. Its
positive control asserts the all-on payload is genuinely populated first, so a
"no difference" result means the gate is inert and not that the account is
empty. Beside it: an unmapped measurement type is excluded rather than passed
through, no table behind an unchosen leaf is queried at all, and an empty
selection produces an empty record.

The artefact layer runs the real route and reads what comes out — the FHIR
bundle, the PDF text and the zip's contents — with a positive control beside
every exclusion.

Mutation-checked: making the gate admit everything reddens the sweep for every
leaf; passing an unmapped type through reddens the fail-closed case; dropping
the mood query's condition reddens the zero-read case; returning the BMI
unconditionally reddens the sweep naming BODY_MASS_INDEX; adding a fenced leaf
to the template reddens the purity guard; removing the panel's seeding block
reddens the first-run and repeat-run cases; dropping the insurance refusal
reddens the share-link case; adding a caller outside the frozen list reddens
the egress guard naming the file.

The panel suite grew past the practice name it was written for and is renamed
for what it now covers: the three states, the fenced tier having no group
control, and the scope line naming its sensitive inclusions.

Integration coverage moves to the leaf model, including the case this release
exists for: an account with data in every domain and a caller that named
nothing gets an empty record.

The OpenAPI contract catches up with the routes — the required selection, the
refused keys, the share summary's new fields, the moved `Patient/$everything`,
the Coverage search and the five typed reads.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck 208a2248cd test(import): cover the HealthKit glucose shape against the relaxed constraint
A HealthKit blood-glucose sample carries no context either — the mapping has
no field for one — so the batch ingest was building the same row the old
CHECK refused. Fold that source into the case that pins the contextless
shape.

Refs #640
2026-07-25 21:57:06 +02:00
Marc-André Bombeck 4e95bd28f8 fix(import): stop the Apple Health card ticking an archive that landed nothing
Same shape as the CSV and JSON cards: reaching the done state rendered the
success tick whether or not a single row was upserted. The archive's count
picks the tone now, and an archive that wrote nothing says so.

The clinical-record count stays out of that decision. Skipping
electrocardiograms and lab documents is a documented, deliberate exclusion,
not a refused row, so it must not read as a warning.

Refs #640
2026-07-25 21:45:10 +02:00
Marc-André Bombeck 18691d4550 fix(medications): scope the intake idempotency key to its owner
`medication_intake_events.idempotency_key` carried a GLOBAL unique from
0001_init. The value is client-supplied, so two people running the same
client can mint the same key — and on a global unique the second one is
simply refused by the constraint. Their dose is not written and nothing
tells them so. The bulk drain made it worse: its replay probe read the
key with no user scope, resolved the first user's row, and reported the
second user's entry as a duplicate carrying a stranger's row id.

Migration 0273 replaces the global unique with `(user_id,
idempotency_key)`, the grain every other client-supplied identity on the
table already uses. Both unscoped reads in the bulk drain now key on the
composite; the other four reads were already narrowed by user.

On existing data this is a relaxation, so nothing needs reconciling: the
old constraint is strictly stronger than the new one, and no pair of rows
can violate an index the previous one already forbade. The migration
still carries a reconciliation step for a database whose global index was
dropped by hand at some point. It never deletes or rewrites a dose — it
clears the dedup HINT on the later row of a colliding pair and keeps
every intake event, timestamp, attribution and inventory stamp intact.
The new index is created before the old one is dropped, so a failure
aborts with the old protection still in place.

A structural guard freezes the read side: no `medicationIntakeEvent`
query may filter on the key without narrowing by `userId`.
2026-07-25 18:41:40 +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 f8f460bb66 refactor(measurements): drop the write-only aggregation columns (migration 0272)
Migration 0263 added three columns beside the Apple Health `stats:*`
authority ladder. Only `aggregation_provenance` is ever read back: the
external-measurement reconciler selects it and refuses a lower-authority write
against a higher-authority canonical row, and the cumulative drain reads it
too. That column stays.

`aggregation_contributor_count` and `aggregation_selected_source_hash` are
written by three call sites and read by none. No select, no raw SQL, no export,
no backup, no API contract, no repair path. The import-time selection keeps its
deterministic tiebreak, which compares an in-memory map key computed per flush
rather than a value read back from the table.

Both are plain nullable scalars with no index, unique, check or foreign key, so
the drop is a catalog-only operation that touches no measured value.
2026-07-25 15:03:44 +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 574367737f fix(coach-prefs): return the pure default with no token for a never-saved user
The GET spread the optimistic-concurrency `updatedAt` into the response even
when the user had never saved preferences, which broke the strict-equality
contract that a never-saved read is exactly `DEFAULT_COACH_PREFS`. Mirror the
insights-layout never-saved GET: a null `coachPrefsJson` returns the pure
default with no token (the first save is the tokenless unconditional write), and
only a saved row and the PUT response carry `updatedAt`. Update the integration
round-trip to expect the token on the saved GET and PUT while keeping the
`coachPrefsJson` column assertion pure.
2026-07-24 18:28:41 +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 7779dabfed test(oidc): cover the session resolver behind the duplicate-callback path
Integration coverage for `validateSessionFromCookieValue` against real
Postgres: a live secret-backed session resolves to its user, an expired one
and an unknown/forged/empty value return null, and a legacy cuid cookie
resolves only while its row still carries no hashed secret. This is the
hashing + row lookup + expiry the callback's duplicate detection turns on,
which the unit suite stubs out.

Refs #600
2026-07-23 15:16:48 +02:00
Marc-André Bombeck 8119cc6c82 Merge remote-tracking branch 'origin/main' into work/native-stepup-rebase 2026-07-23 09:04:43 +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 b20f1b5c66 fix: harden imports and daily medication state (#577) 2026-07-21 13:08:28 +02:00
Marc-André Bombeck b38f41b99d chore(release): prepare v1.31.4 2026-07-21 01:34:29 +02:00