Commit Graph
150 Commits
Author SHA1 Message Date
Marc-André Bombeck 81be61b6d1 fix(seed): give the demo every area of health the score can span
The demo seeds no waist measurement and no wellbeing screener, so two of
the seven pillars have never had data and the score rests on three areas
instead of five. Adiposity and wellbeing render as empty rows on the card
that is meant to show the feature.

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

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

The dates were never the problem. Every timestamp the seed writes is
already relative to the run, and the header says so. What ages is the
seeded DATA — the pillar windows keep moving after a seed, so four weeks
on, activity and sleep fall out of theirs and the score narrows to
whatever cardiometabolic data is still fresh. Since v1.38 that is a
labelled partial score rather than a blank card, but it is still not the
demo at its best, and the header now says that too.
2026-08-30 17:08:29 +02:00
Marc-André Bombeck 5196aff766 Merge pull request #846 from MBombeck/fix/restore-perms-trust-proxy
fix: write restored PHI 0o600 and let TRUST_PROXY_HOPS reach the container
2026-08-29 18:15:06 +02:00
Marc-André Bombeck 0037cc1bcd Write restored PHI 0o600 and let TRUST_PROXY_HOPS reach the container
Two findings from the docs truth audit, both operator-facing.

scripts/restore-backup.ts wrote the decrypted backup, which is health
data in plaintext, with the process default file mode, leaving it
world-readable on the operator's disk while it sat there. It is now
written 0o600.

TRUST_PROXY_HOPS is read from the environment to size the trusted
X-Forwarded-For chain, but it was absent from the compose environment
whitelist, so an operator value in .env never reached the container and
was silently ignored. It now rides all four surfaces: the compose
whitelist, both env examples, and the manifest that pnpm check-env
reads.
2026-08-27 15:21:50 +02:00
aucun6352 f1b0420479 feat(i18n): add Korean as a seventh end-to-end locale
Register `ko` in the locale list, language names, and labels, and ship
messages/ko.json so the whole UI — dashboard, settings, share views,
clinician PDF, notifications, and AI prompts — renders in Korean.

Extend the locale-parity, English-leak, plural-completeness, and copy
guard tests to cover the new locale, and update README/CONTRIBUTING to
list Korean among the supported languages.
2026-08-26 16:42:45 +09:00
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 8e16df6894 feat(documents): make the document-AI rate limit an operator setting with honest accounting
The document-AI actions (read/index, extract, suggest, summarise) shared a
hard-coded 6/hour bucket that lied twice: the 429 said "try again in a few
minutes" while the window is an hour, and several failure paths burned a
slot before any provider was dispatched.

- DOCUMENT_AI_LIMIT_PER_HOUR configures the shared per-user ceiling
  (default 6, clamped 1-1000), wired end to end: compose environment
  whitelist, .env.production.example, env manifest, operator docs.
- INSIGHTS_RATE_LIMIT_PER_HOUR joins the compose whitelist and manifest;
  it was documented but never listed, so an operator value could not reach
  the container.
- The 429 mirrors the bucket's reset instant into meta.retryAt (plus the
  existing X-RateLimit-Reset header); the client derives "try again in
  about N minutes" from it in all six locales, with a plural-aware key.
- A slot is consumed only when a request actually reaches the provider (or
  the local index/echo work): malformed bodies, unreadable originals,
  refused consent, and exhausted daily budgets refund the charged slot via
  a window-scoped refundRateLimit primitive. Consistent across all four
  routes sharing the bucket.
2026-08-13 00:33:14 +02:00
Marc-André Bombeck 772da42348 feat(backup): carry emergency profile through rotation and backup
Register the three encrypted emergency columns in the rotation registry
and the key-rotation script so a legacy-key drop cannot strand them.
Carry all six new columns through the profile backup builder and restore
(the enums by value, the free text as ciphertext in a DR payload or
decrypted plaintext in a portable export) and extend the wire schema.
The full-backup round-trip integration test seeds the six columns and
asserts each survives an export and restore.
2026-08-10 05:32:59 +02:00
Marc-André Bombeck 0a2cacb8b3 Document the runtime GeoLite2 fetch path
Cover the new MAXMIND_LICENSE_KEY route in the self-hosting guide as the
first of the three ways to get the offline databases (fetch, mount, bake),
and note that the monthly runtime refresh keeps a keyed host current on its
own. Add the variable to both env examples and to the env-check manifest so
the optional group lists it.

Refs #659
2026-08-09 22:09:12 +02:00
Marc-André Bombeck e1a4b7631c Merge branch 'main' into feat/vaccinations-phase-1
The visit stage's second half landed while this was in flight and both
touched the same three places. Resolved as one set: the link facade
carries the reverse document-to-visit direction and the new dose pair
side by side, the sharing literals hold both sets of admissions with the
count re-added on top, and the language bundles take the shorter module
description the trunk settled on.
2026-08-08 17:07:58 +02:00
Marc-André Bombeck 73ea1b03f3 fix(scripts): drop the retired research-mode columns from the demo rebuild
The demo edge01 rebuild copies the users table by an explicit column list,
and three of the names in it — research_mode_enabled and its two
acknowledgment stamps — were dropped from the schema when Research Mode was
retired. The COPY and its matching SELECT both targeted columns that no
longer exist, so the rebuild would fail on the first users row. Removed from
both lists, which stay aligned.
2026-08-08 16:07:06 +02:00
Marc-André Bombeck 1bd97de943 feat(vaccinations): write the backup, wipe and rotation verdicts
- both models are backed up and claimed two-ended: an immunization
  history has no other copy once the paper Pass has been transcribed
- the document link is wiped ahead of the dose, and the dose ahead of
  the practitioner it points at with SetNull, so the audit row's
  per-model counts stay truthful
- the dose note joins ENCRYPTED_COLUMNS and the rotation walk; the batch
  code and the other structured columns stay queryable plaintext
2026-08-08 13:39:06 +02:00
Marc-André Bombeck a83be074ca Merge branch 'main' into feat/encounter-phase-1 2026-08-08 12:22:04 +02:00
Marc-André Bombeck 24425e9aff Add the visit record and the carriers that keep it
A person's whole 'which doctor' history was one free-text column on a
reminder: they typed the practice name once and could reach nothing else
that happened there. This turns that string into a record.

Five tables in migration 0320, all additive: an encounter that happened
or is booked, the account's own address book of practitioners, and three
links from a visit to the documents, lab results and conditions it
produced. The encounter is slim on purpose — the diagnosis stays on the
linked condition, the address on the linked practitioner, and the reason
each field was kept or cut is written at the model. No module gate, and
the schema says why rather than leaving the absence to be read as an
oversight.

Every carrier lands with the tables rather than after them. All five are
classified as travelling both ways and are proven doing it: a seeded row
of each goes out through the real export builder, the account is deleted
so the schema's cascade decides what survives, and the rows are counted
back through the real restore route. Two references deliberately do not
survive and are named instead of dropped quietly — the reminder a visit
points at is not in the backup, and a link whose far side a portable
export does not carry cannot resolve. The wipe plan deletes the links
before the lab results and documents they hang off, or the audit row
would report zero for rows that existed. The three encrypted columns are
registered for key rotation, without which dropping a legacy key would
make those notes permanently unreadable.

Widening the reminder origin enum is a type error at the reminder DTO,
which is the right outcome: the mapper now refuses an appointment row
outright rather than relabelling it as a checkup.
2026-08-08 07:14:02 +02:00
Marc-André Bombeck 7a4ee19c68 feat(mood): add the MoodContext sub-row and its encrypted note
- migration 0311 creates mood_contexts, keyed by the entry, cascading from
  both the entry and the owner
- work, contacts, leisure and one notable event; every field nullable, and an
  entry with nothing filled in gets no row at all
- notes_encrypted registered in ENCRYPTED_COLUMNS and in the rotation script,
  so a key rotation cannot strand it
2026-08-08 06:51:42 +02:00
Marc-André Bombeck c6246092ff Merge branch 'main' into refactor/settings-admin-one-piece 2026-08-08 05:20:04 +02:00
Marc-André Bombeck ea4d284cc9 ci: carry the report per shard and point the guard at the running job
The determinism guard reads the job that runs the browser, which is now
the shard matrix rather than the gate that only carries the required
check name. The report artifact is named per shard so the two uploads
cannot clobber each other.
2026-08-08 04:45:04 +02:00
Marc-André Bombeck 6dd70f532a Merge branch 'main' into refactor/settings-admin-one-piece 2026-08-08 03:05:27 +02:00
Marc-André Bombeck e532210b21 Merge branch 'main' into refactor/settings-admin-one-piece 2026-08-08 00:06:26 +02:00
Marc-André Bombeck 272c426bea fix(ui): close the holes the audit found in this work's own guards
Every item here is a check that could pass while the thing it checks is
broken, or a margin the sweep's codemod could not reach.

Guards:

- The access-payload matcher accepted `.level` on ANY object. A chart's zoom
  level or a log record's level would have satisfied it and reported a
  consumer that does not exist. It now names the bindings the entry actually
  arrives under; a third binding is a deliberate edit rather than a silent
  widening. Re-falsified: rename both readers, it goes red.
- The `pl-7` sweep skipped any line CONTAINING `//`, so a class string sharing
  a line with a URL was waved through. Only the part after a real line-comment
  marker is exempt now. Proven by putting `pl-7` on a line with an
  `https://` comment: caught.
- `LEGIT_IDENTICAL` in the English-leak guard had no staleness check, unlike
  both of its siblings, and carried two dead keys — one from a removal long
  before this work, one from the duplicated version fields that came off
  `/admin`. Both pruned, the check added, falsified with a made-up key.
- `split-description.mjs` split inside `z. B.`, `d. h.`, `u. a.` and `p. ex.`
  and produced a head ending mid-abbreviation. Both halves have to be caught,
  which is why the rule is a shape (single letter + stop) rather than a list,
  and the merge re-tests its own tail. Exercised against eight cases including
  the ones that used to break.

Margins the codemod could not see, because the child lives in a different
component from the card that spaces it: six form roots under managed record
settings (one of which also carried the banned `space-y-5` step), the
low-stock status line, the avatar card's explainer, and the backups restore
report. Each was adding a margin on top of the card's own gap.

Two facts the text diet cut too close, both restored inside the guideline:
that an excluded injection site wins over a medication that prefers it, and
that the privacy card's second button leads to a full health record.
2026-08-07 23:20:39 +02:00
Marc-André Bombeck 2941d56238 fix(sync): enforce the plausibility domain on every provider write path
Every interactive path, every import, MCP and Telegram checked an incoming
measurement against VALUE_RANGES before storing it. The provider sync writers
did not, so a decode slip or a glitched sensor landed a row the application
itself calls impossible, and every mean, median and personal band derived from
it inherited the impossibility. Filtering the read side hid the row. It kept
landing.

The gate is one module reading the domain the app already declares, applied at
two kinds of seam. reconcileExternalMeasurement is the shared identity writer
for Polar, Oura, WHOOP, the Withings sleep leg, the Apple export importer and
the batch endpoint, so gating there means a provider added later inherits the
refusal rather than having to remember a check it never knew about; the new
rejected_range verdict carries no row, which is what makes the compiler force
every caller to handle it. The five writers that hold Prisma themselves gate at
their entry, before the existence probe decides insert versus overwrite, so an
impossible reading can no longer replace a good stored value.

An out-of-band value is dropped. Not clamped, because a clamp invents a reading
nobody took. Not stored behind a flag, because a quarantined row is still a row
and every consumer would have to learn the flag. Not fatal to the batch, because
one bad sample must not strand the run's other readings behind a held watermark.
Absence reads as absence.

A silent drop, though, is indistinguishable from a provider that sent nothing,
so every drop is tallied on the wide event under measurement.range_rejected with
the metric, the provider and the edge it broke. Counts only: the reading itself
is health data and never enters the log line, and the direction plus the
metric's own declared bound is what a diagnosis actually needs. The tally
accumulates rather than replacing, because a provider gates once per resource
leg and the reconciler once per row. One warning per provider lifts the event to
warn so a drop does not hide inside a green line.

A structural guard enumerates all twenty measurement writers with the reason
each is allowed to write, so a new one has to answer the question before it
ships. scripts/diagnose-implausible-measurements.ts reports the rows already
stored, aggregated per metric and source with no value beyond the bucket's own
extremes. It repairs nothing: whether a stored impossible row is a decode slip
or a band that is too narrow is not a call a script gets to make.
2026-08-07 22:19:43 +02:00
Marc-André Bombeck 5a103ea096 test(e2e): measure that Settings and Admin read as one surface
Thirty-four routes, both consoles, English and German, desktop and phone.
The claims this program makes are geometric, so they are checked with
geometry rather than by looking at screenshots.

What it found, which is the reason it exists:

- `SettingsCard as="section"` sat one spacing step tighter than its
  neighbour on every desktop width. The `<Card>`-backed variant inherits
  `gap-4 md:gap-6`; the landmark variant wrote a flat `gap-4`. Seventy-five
  of eighty-six cards measured 24 px and eleven measured 16 px, directly
  above and below each other. The expected value in the spec is a function
  of the viewport for exactly this reason — a constant would have agreed
  with whichever half it was written against.
- One German page subtitle wrapped to a second line where English fit on
  one. Shortened in all six languages.

After both: heading top 24.0 px on every route, spread 0.0 px, in both
languages and at both widths — that is the no-jump claim, measured. Card
header-to-body distance equals the card's own gap on all 86 cards, and no
card body sits off the card's inner edge.

The header's HEIGHT is pinned only where one line is achievable. At 393 px a
70-character sentence needs about 380 px of column, so most explainers wrap
there; the spec asserts the wrap away at desktop width and only forbids a
third line on a phone. The alternative is truncating the sentence, which is
the defect this program removed.
2026-08-07 22:16:31 +02:00
Marc-André Bombeck ba0cd63cf5 refactor(i18n): a description is one sentence, in all six languages
Measured before the sweep: 317 description and hint strings under the
`settings` and `admin` namespaces, median 83 characters, 113 of them two
sentences or more. The rule the design standards now carry is one sentence,
guideline 120 characters, and this is the sweep that makes it true.

Almost nothing was retranslated. A second sentence is already written in
every language — it is just in the wrong slot. `scripts/split-description.mjs`
cuts a description at its first sentence boundary in all six bundles at once
and either drops the tail or files it under its own key; the twenty-one keys
that moved now render in the card body as foreground `text-sm`, which is
where multi-sentence prose belongs and where a reader can actually read it
(the slot is muted `text-xs`). Eight tails were dropped outright, because
"Off by default" under a switch, "Topics and frequency are adjustable" above
the topic switches, and "Only admins can create invitations" on an
admin-gated route are not information.

Twenty-eight keys are gone: the privacy page's intro, which was a table of
contents for the four cards under it; two About descriptions that listed
verbatim the labels and the buttons beneath them; a card description that
repeated its own title; the API-token sentence that said what the paragraph
below it said with the actual instruction; and the eight integration
secondary sentences. Six descriptions were rewritten shorter in all six
languages where trimming was honest rather than lossy.

On the strings that changed: 4391 characters of English down to 2290, and 32
multi-sentence descriptions down to zero.

The rule is now a gate. `card-description-length.test.ts` reads the keys that
actually reach a card header's description slot — statically, from the JSX —
and checks them in EN and DE, because a rule that only holds in English
produces a slot that wraps to two lines in half the locales. Its exception
list is empty and carries a reason requirement plus a test that removes a
stale entry. Verified by putting a second sentence back: it fails.
2026-08-07 20:53:05 +02:00
Marc-André Bombeck 8a1585a391 Merge pull request #723 from MBombeck/fix/cycle-correctness
Cycle correctness: overdue verdicts, backfills, deletes that leave no torso
2026-08-07 19:58:39 +02:00
Marc-André Bombeck ce9b2814e6 feat(dashboard): reach shared records from the account switcher, not a card
The "looking after" block sat directly under the greeting and above this
account's own day, permanently, for anyone with a single shared record. The
dashboard is this account's day; other people's records are a destination,
and the account switcher in the top bar and the sidebar already is that
door — with the same access level and record kind on each entry.

Drop the card, its test, and the five strings only it used, across all six
locales.

The access-payload consumer guard named that one component as the reader of
`level` / `sections` / `recordKind`, so it went red on the deletion, which is
what it is for. It now names the surfaces that actually adopt those fields —
the presentation resolver and the switcher — and matches the property access
rather than one variable name, so a rename of the binding cannot make it
match nothing and pass. Verified by removing every reader: it fails.

Adds `scripts/i18n-key-tool.mjs` for dropping or rewriting a key across all
six bundles in one go.
2026-08-07 19:24:46 +02:00
Marc-André Bombeck 30cf1da1d3 chore(cycle): add a read-only report for records the module stopped reading
Two defects put a record into a state that looks, from the app, like no
period was ever logged. Both are fixed going forward; neither repairs
the rows already made, and neither is visible from the surface it broke.

A closed torso is a live cycle carrying an end date with no live cycle
after it. A cycle is closed by its successor, so closed with no
successor means the successor is gone, and a record whose last cycle is
closed has no open cycle for the engine to forecast from. The signature
is exact and needs no audit trail.

A period with no cycle row is bleeding that only ever existed as a
day-log, from the flow chips, the bulk drain or an import. The engine
reads cycle rows, so the record holds periods and reports none.

The report imports the bleeding-day rule from the write path rather than
restating it, so it and the app cannot drift on what counts as a period
start. Checked against seeded records: it names the torso, names the
three flow-only starts while passing over a day two and a spotting day,
and says nothing about a healthy record.

Nothing is written.
2026-08-07 15:52:59 +02:00
Marc-André Bombeck d97e3c0976 fix(measurements): store a hand-entered night in the unit the field asks for
The manual sleep field is labelled in hours. The column stores minutes, because
that is what every sync source delivers and because a single stage segment is
only a few of them. The form sent the typed number straight through, so a night
entered as 7.5 was filed as seven and a half minutes and the list showed eight
minutes of sleep.

Nothing rejected it. Seven and a half minutes is inside the column's
plausibility band — a stage really can be that short — so the entry looked
accepted and the loss was silent.

Both ends were already right: the field says hours, the list formats minutes as
hours and minutes. The assembly between them was where the factor went missing,
which is why the round-trip test runs the whole of it — the form's conversion,
the shipped POST, the stored row, and the formatter the list paints with.

The storage unit stays minutes. The conversion sits at the entry boundary in
one place with its inverse beside it, so a future editing surface cannot land
on a different answer, and it rounds to whole minutes rather than storing
437.99999999999994 for 7.3 hours.

The value field also lost "7,5" outright. `type="number"` sanitises a decimal
comma away on the engines that do not localise the field, so the entry arrived
as an empty string with no explanation, and `parseFloat` would have read it as
7 on the engines where it survived. The field now takes the same shape the labs
and custom-metric fields use — a numeric keypad and a submit path that reads
the comma — so every decimal metric on the form gains it, not only sleep.

`scripts/diagnose-manual-sleep-hours.ts` lists the rows already stored this
way. It reports and does not repair: an eight-minute stage and a night typed as
eight hours are the same number in the same column, and only the person who
entered it knows which it was.
2026-08-07 14:30:10 +02:00
Marc-André Bombeck a15c6fc553 fix: reconcile sharing migrations
Seven migrations, 0296 through 0302, apply to an empty database in order and
install both triggers. The allocation had been written down as four, and every
later document that quoted it inherited a range that had already moved.

`scripts/verify-v137-adversarial.ts` is the allocation as a runnable artifact
rather than a table somebody keeps in step by hand. It names all seven, refuses
a number allocated twice, asserts each trigger is in the migration that claims
it, and counts every release inventory against a written-down number instead of
against "more than zero" — a list that lost half its entries satisfies a
non-empty check and fails this one. It also refuses to report success when it
ran no checks at all.

It found two things on its first run. Eleven handler entries looked like
duplicates because the key ignored the action, and an `[id]` route carries an
update and a delete as separate contracts; the key includes the action now. The
journey-count leg was then falsified deliberately by expecting nine, which
failed on the count and exited non-zero.

The `push_attempts` principals are declared optional in the schema and NOT NULL
in the database, and that is deliberate rather than drift. The attribution guard
is a BEFORE INSERT trigger, so a write omitting both has them filled from the
legacy mirror before the constraint is checked; requiring them in the schema
would force every established self-delivery writer to name a pair it has no
opinion about, and relaxing the columns would let a partial pair reach the
table, which is what the trigger refuses. `migrate diff` reports the difference
because it cannot see triggers, so the reason is written at the columns and
names the test that proves the accommodation still works.

Measured rather than assumed: applying trunk's chain to its own empty database
reports the same classes of diff — truncated index names, timestamp precision,
foreign keys re-stated. The release adds eight lines to that, seven of them in
classes trunk already carries and one of them the deliberate pair above.
2026-08-07 06:21:55 +02:00
Marc-André Bombeck e4870f3b5f fix: close two gaps the last audit round left open
- give the delegated-action inventory a leg that can falsify its level
  axis: the walked module set must strictly exceed the manage-only one,
  anchored on a route that never asks for manage
- declare AUDIT_LOG_RETENTION_DAYS and SESSION_COOKIE_SECURE in the env
  manifest, so the pre-deploy check reports the two settings operators
  are told to set, and hold manifest, compose whitelist and both example
  files in step
2026-08-07 02:26:29 +02:00
Marc-André Bombeck d07b9249c8 fix: complete dashboard hydration 2026-08-05 23:03:17 +02:00
Marc-André Bombeck f54555d187 fix(build): stabilize release bundle gates 2026-07-29 16:19:30 +02:00
Marc-André Bombeck 75aa850eae style: satisfy v1.34.1 format gate 2026-07-29 15:59:12 +02:00
Marc-André Bombeck 4ba4ee38cd test(01-15): reconcile audit finding ledger
- deduplicate research, correctness, live, and performance findings
- enforce required evidence fields and privacy-safe report content
2026-07-29 14:43:04 +02:00
Marc-André Bombeck eccacd91ed test(01-15): record release bundle evidence
- pin build and benchmark results to one isolated commit
- record gzip budgets, headroom, and unresolved route hypotheses
2026-07-29 14:39:00 +02:00
Marc-André Bombeck c0321d2f27 test(01-15): add bounded performance fixture baseline
- benchmark observed Google, reminder, and score workload shapes
- record local cold and warm timing without production access
2026-07-29 14:31:07 +02:00
Marc-André Bombeck 9c661dcc5e fix(01-08): revoke credentials atomically on password reset
- lock the resolved account and rotate its password in one transaction
- revoke sessions, tokens, trusted devices, and elevations with rollback safety
2026-07-29 13:55:11 +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 189024cc00 fix(geo): make GEOLITE2_DIR reachable and tell self-hosters what they can do
The runtime has always read GEOLITE2_DIR, but the variable was absent
from the compose `environment:` whitelist, from both env examples, and
from the docs. A variable that is not on that whitelist never reaches
the container even when the operator's `.env` sets it, so the override
existed in code and was unreachable from the documented path.

Put it on the whitelist, document it in both examples and the manifest,
and add `docs/self-hosting/geolite2.md`: fetch the databases with a free
MaxMind account, mount the directory read-only, point the variable at
it. The compose file carries the bind-mount line commented out beside
the app service.

The admin notification told the reader to set MAXMIND_LICENSE_KEY in
this repository's Actions secrets — something nobody running the
published image can do, about a fallback that sends their sign-in
addresses to a third party. It now names the mount, names the online
provider it is actually querying, and links the runbook. Six locales.
It also no longer claims the carrier column stays empty; the online
tier has filled the carrier from the ISP field since v1.25.8.

A blank GEOLITE2_DIR now resolves to the default instead of the process
working directory: compose substitutes an unset variable to the empty
string, which is not nullish.

Refs #659
2026-07-27 14:47:55 +02:00
Marc-André Bombeck 6d800f277d fix(rotation): keep the gateway bearer's coverage guard real
The rotation-coverage guard greps the script for a quoted column name.
Listing the new column in the header comment satisfied that grep without
the script rotating anything, which made the guard pass on a script that
would have left the column behind. The comment now names it unquoted and
says why.
2026-07-26 22:27:48 +02:00
Marc-André Bombeck d11019aebc feat(ai): add the storage for an OpenAI-compatible gateway provider
Three dedicated User columns — base URL, optional bearer, model — for a
provider whose endpoint the user chooses. They are separate from the
OpenAI columns on purpose: the OpenAI arm pins api.openai.com, so it must
share no column with a user-supplied host, and a gateway must never
receive the key issued for OpenAI.

The bearer joins the encrypted-column registry and the rotation script;
the three columns join the account-wipe reset set.
2026-07-26 22:27:48 +02:00
Marc-André Bombeck c2d6adaff5 chore(audit): sweep for write-only columns from the release battery
Whether a column has a reader anywhere in the tree is a whole-program property,
and grep is the only cheap approximation. Grep is unsound both ways here:
generic names match unrelated identifiers and read as consumed, while
consumption through a select-less read plus whole-row serialization reads as
unconsumed. A gate built on that would drift towards green and stop meaning
anything, which is the failure this whole line of work exists to prevent. So it
stays a review tool with a human reading the output, run from the deep-audit
step and never in CI. The script says so itself and prints the false-positive
sources under every run.

It also carries the convention that makes the tool useful: a change that adds a
column, payload field, or preference contains its consumer, or the schema
comment carries `@internal` for something deliberately never surfaced or
`@pending-consumer(#issue)` for one still coming. The second marker is
re-listed on every run, so a follow-up that never happened surfaces at the next
release audit instead of never. Recorded in the contributor notes alongside the
two questions that are exact enough to be gates.
2026-07-25 16:24:24 +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 ddc598afcb test(measurements): cover the unit transform, MCP unit hardening, and a display guard
Add affine-correctness tests for the transform registry (°C→°F absolute vs a
delta that must stay offset-free, lb/in factors, inverse round-trips), a
resolver test for the inbound unit aliases, a preferred-unit entry-form label
test, and MCP cases for converting a lb hint to canonical kg and refusing an
unknown unit. Add the structural guard that trips if any dashboard or insight
surface reintroduces a hardcoded transformed-type unit or value scale. Ship the
diagnose/convert script pair for legacy non-canonical measurement rows.
2026-07-25 00:21:36 +02:00
Marc-André Bombeck 40aae4b66d chore(scripts): apply prettier to the phantom-med cleanup script
Formatting only; the format:check gate flagged the file. No behaviour change.
2026-07-24 23:18:26 +02:00
Marc-André Bombeck 6c8e921faa chore(scripts): add read-only diagnose + guarded cleanup for phantom Apple-Health mirror meds
The diagnose script reports the provenance, external-id values, and intake/schedule
counts of medications by name. The cleanup script deletes only name-matched
Apple-Health mirror rows with zero intakes and zero schedules, dry-run by default,
replicating the delete route (category cleanup, token revoke, audit, cache bust).
2026-07-24 23:02:03 +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 403eabb888 fix: restore runtime Prisma migration loading (#574) 2026-07-21 06:36:13 +02:00
Marc-André Bombeck 085aa3f5c5 fix: harden pnpm supply chain and runtime image (#572) 2026-07-21 04:36:53 +02:00
Marc-André Bombeck 5fe952b63d fix(ci): harden release and e2e verification 2026-07-21 02:30:40 +02:00
Marc-André Bombeck b38f41b99d chore(release): prepare v1.31.4 2026-07-21 01:34:29 +02:00
Marc-André Bombeck 3ce6726646 fix(ci): harden test determinism and dependencies 2026-07-21 00:36:40 +02:00