Commit Graph
20 Commits
Author SHA1 Message Date
Marc-André Bombeck 9642c749c9 fix(auth): a new email address on an account with a second factor needs that factor
The address is what single sign-on links an existing account by, and
changing it took the password or a session younger than five minutes on
every account. A remembered browser skips the second factor at sign-in and
still makes a fresh session, so the password alone could move the address
of an account the second factor was meant to protect.

On an account with a second factor only a second factor or passkey
completed in the last five minutes counts now; the password is not
checked. The refusal names it (second_factor_required in rejectedFields,
or 401 profile.update.emailSecondFactorRequired when the address was all
that changed), and the profile form opens the re-proof dialog with the
factors the account holds, then saves again, no longer offering the
password field. Accounts without a second factor keep the password or a
recent sign-in. A token cannot change the address of such an account;
the app does not change it.
2026-09-27 00:59:06 +02:00
Marc-André Bombeck b10072b85e style(auth): format the re-proof and profile-update helpers 2026-09-26 16:35:42 +02:00
Marc-André Bombeck 8da7583ec6 fix(auth): link single sign-on to an existing account only after its own sign-in
The SSO callback bound a verified provider email to the local account with
the same address on the spot. The local address is not verified: anyone
could register with it, or type it into their profile, and the SSO user
then landed in an account the other person still held the password to.

The callback now links nothing. It records the link it would make in a
sealed, ten-minute, HttpOnly cookie and sends the browser to the sign-in
page, and the link is made only when that browser then signs in to that
same account with its password (and second factor) or passkey. Under an
SSO-only policy the password form opens for exactly that account. The
native sign-in answers oidc_link_required; the link is confirmed once on
the web.

Moving an account to a new email address now also needs a fresh proof: a
sign-in or second factor in the last five minutes, or the current password
sent alongside the change. Without it the address is left as it was and
reported in rejectedFields; the rest of the save still lands.
2026-09-26 16:10:32 +02:00
Marc-André Bombeck 625763982b Narrow a spent email budget to the field, and record both refusals
A refused address change returned before the update was built, so every
other field in the same request went with it. The settings form posts
the whole profile at once, so somebody who had spent the budget could
not save a timezone or a height for an hour and was told it was about
email addresses. The address is now dropped and the rest of the save
lands, reported through the existing rejectedFields key with the code
`rate_limited`; the 429 is kept for a request that asked for nothing
else, where a partial success would be a lie.

The comment claiming an audit row beside each question was inverted:
both refusals returned before the row is written, so only the answers
that told the caller nothing were being recorded. The two answers worth
detecting write their own rows now.

The stored address is normalised before the "has it changed" comparison.
Registration writes it as typed, so an account created with capitals did
not match its own address and paid for its first ordinary save.
2026-09-11 14:36:58 +02:00
Marc-André Bombeck fe420f76d8 Cap the profile email-address conflict answer
Changing the profile address asks whether another account already holds
it, and the answer is 409 versus 200 — an existence check over every
address on the instance, open to any signed-in caller. Neither route
that reaches it had a ceiling of any kind and the API wrapper supplies
no default, so the only bound on a sweep was the network.

Both routes now share one per-account bucket, ten an hour, charged
before the question is asked so a refused caller learns nothing. Only a
request whose address actually differs from the one on file is counted,
so re-saving a settings form that carries the unchanged address stays
free. The refusal is the standard 429 with Retry-After and the
X-RateLimit triple, carries `profile.update.emailRateLimited`, and is
published on both operations.
2026-09-11 14:05:25 +02:00
Marc-André Bombeck 45599c527e fix(demo): the profile route writes only the baseline fields in the demo
Allowlisting `PUT /api/auth/profile` so the setup flow's baseline step could
finish also handed every visitor the shared demo account's contact email, its
display name, its full name and its insurer fields: the allowlist admits a
path and a method, and every field the handler behind it writes comes with
them. The justification beside the entry named three fields; the entry
admitted eleven.

The write is narrowed on the server, in `applyProfileUpdate`, to height, date
of birth and sex while `DEMO_MODE` is set — not in the client, which is the
party the allowlist exists to distrust. It happens ahead of the email conflict
check, which would otherwise answer "is this address registered here?" to
anybody who can open the demo. The display-name box goes read-only there, as
the anamnesis card already is, so nothing accepts input it will discard.

The entry's comment now lists what the handler writes and what is admitted.
2026-09-11 12:22:47 +02:00
Marc-André Bombeck 356888134d fix(profile): localize the email-conflict message too
The 409 email-conflict branch had a safe, specific message already,
but no errorCode - the account-section fallback would have had nothing
to translate it through once it stopped echoing json.error verbatim.
Give it profile.update.emailInUse so it renders in the active locale
like every other profile-save outcome, and propagate errorCode through
both route handlers even on the (non-issues) conflict path.

Also updates the /api/user/profile route test for the reported
scenario directly: a valid timeFormat now lands even when gender in
the same PATCH is unsupported, instead of the whole request failing.
2026-07-30 23:25:18 +02:00
Marc-André Bombeck e49bd866dd fix(profile): save every valid field even when a sibling is invalid
A profile PUT/PATCH validated as one safeParse and wrote as one
transaction, so a single rejected field took every sibling change down
with it - editing height and gender together lost both, and onboarding
lost a whole baseline step to one field. The schema has no cross-field
dependency, so applyProfileUpdate now re-validates each present field
against its own schema and writes whatever validates; only a body where
nothing validates is refused outright, naming the field that blocked it.

The top-level error string also stopped being a raw Zod message
(issues[0].message) that could read "Invalid option: expected one of
"MALE"|"FEMALE"|"OTHER"" on a person's screen. It's now a person-safe
sentence plus a stable errorCode for the web client to localize; the
full per-field detail is relocated (not deleted) into details.issues,
matching the multi-issue envelope the iOS contract already expects from
other write routes.
2026-07-30 23:25:18 +02:00
Marc-André Bombeck 86a9ca2e1e feat(profile): persist and expose the date-format preference
Accept dateFormat on the profile PATCH field-by-field, echo it from
the profile + /me reads, mirror it into the client auth user, and add
the field to the OpenAPI request/response schemas.
2026-06-26 12:15:09 +02:00
Marc-André Bombeck aaecd141ee fix(medications): every intake path converges on the slot, and caches cannot resurrect a pre-write body
The external ingest endpoint was the one intake writer that never
attributed to the minted band: it stored a bare row beside the pending
slot, so an early take through the API showed as the last intake while
the slot kept demanding a dose, the ledger recorded an extra plus a
miss, and the day tally counted both. It now resolves the band and
converges onto the pending row like every other path, the Telegram
confirm and skip buttons do the same preferring the reminder's own
slot, and the nightly convergence pass heals the pairs this has
already produced — one band match, one live anchor row, both days'
rollups recomputed. The cache layer fences in-flight builders on
invalidation so a read can neither join a pre-write build nor have a
background refresh re-cache a pre-write body as fresh. Ad-hoc rows no
longer resolve a different slot through the six-hour radius, a bounded
early grace credits a take up to an hour before the window without
crossing the previous band, day-scale cadences taken early downgrade
the slot-day prompt instead of inviting a double dose, the overdue
pill ignores the projector's pending mints, created intakes carry the
same plausibility bounds as edits, slot moves keep the recorded
injection site and dose, both affected days recompute on a moved
toggle, deliberate slot-less skips converge instead of orphaning, the
coach reads adherence from the ledger, the cards and quick-add run in
the user's timezone with the served next-due as the gate, and
schedule-revision, wipe and restore writes invalidate the caches they
feed.
2026-06-12 10:43:32 +02:00
Marc-André Bombeck f790e14edd fix(api,lib): consent before reuse, tighter inputs and caches that tell on themselves
Per-status texts check AI consent before the unchanged-data refresh can
re-stamp them, the comprehensive fingerprint folds in the comparison
baseline and generation language so toggling either regenerates once,
and profile, threshold and source-priority writes evict the analytics,
targets and derived caches they feed. CSV export neutralises spreadsheet
formula prefixes on free-text cells while numeric columns stay typed,
the derived batch validates its type token against the measurement enum
and carries a per-user rate limit, and a dose synced from the phone app
counts as the interactive write it is. Background cache refresh
failures log a structured event instead of vanishing, the warm queue
runs visit-triggered jobs alongside the nightly walk, the formatter
memo evicts one entry instead of flushing wholesale with its option
objects frozen, the targets cold build bounds its fan-out, and time in
bed unions every writer's envelope while stages stay with the richest
recorder.
2026-06-11 23:20:38 +02:00
Marc-André Bombeck aabc7915cb feat(settings): a time-format preference and one home for insight pills
The clock format followed the UI language with no way out — an English
interface forced AM/PM everywhere. A new profile preference (automatic,
24-hour, 12-hour) feeds every formatter through the shared locale
context, persists on the user row and rides the profile endpoints. The
insight pill list in settings gains the same eye toggle the overview
tiles have, so ordering and visibility live in one place; the page-side
'manage detail pages' block folds into a link there. Includes the
locale bundles for both features and the regenerated API contract.
2026-06-10 13:34:46 +02:00
Marc-André Bombeck b99b07ad38 feat(profile): capture the insurer IK number
Add an optional German insurer institution number (IKNR, 9 digits) to
the user profile, alongside the existing insurer name and KVNR. The
IKNR is a public registry identifier for the institution, not a
per-person quasi-identifier, so it is stored plaintext, matching
insurer_name rather than the encrypted KVNR column.

The profile GET echoes the field; PATCH/PUT accept it via Zod —
optional, empty string maps to null, and a non-empty value must be
exactly 9 digits (422 otherwise, no checksum). The write is built
field-by-field. /api/user/profile gains an OpenAPI registration with
the profile read/write schemas.

Migration 0102 adds the nullable column, NULL-defaulted, forward-only.
2026-06-01 23:24:10 +02:00
Marc-André BombeckandClaude Opus 4.8 50e0a65228 chore(release): v1.7.0
v1.7.0 — health-record export (PDF + FHIR), flexible medication schedules, first-paint dashboard snapshot, full HealthKit coverage, offline sync delta feed, Coach data clustering. See CHANGELOG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 14:36:17 +02:00
Marc-André Bombeck 9b6e237f5f chore(release): v1.4.42 — Knip enforcing, queryKey factory closed, iOS Workouts dedup, Withings off-response classification
Six touch-disjoint waves landed on develop before this release:

- W1-KNIP — exports/types tiers flipped to enforcing-mode on main;
  35 unused exports + 52 unused types triaged to 0/0; shadcn surface
  + zod-infer contract types scoped via knip.json ignore-block.
- W2-ZOD-MULTI-ISSUE — `returnAllZodIssues` shared helper +
  `/api/dashboard/widgets` 422 now returns every Zod issue + worker
  audit-ledger breadcrumb. 41 sibling routes documented for v1.4.43.
- W3-QUERYKEY-LONGTAIL — 40 settings/medications/admin/hooks files
  migrated to the queryKeys factory; 9 new factory entries; ESLint
  rule + test-guard scope extended in lockstep.
- W4-ARCH-HYGIENE — BERLIN_DAY_FORMATTER consolidated across nine
  sites (seven status helpers + bucket-series + bp-in-target);
  Suspense double-comment consolidated; doctor-report sanitiser regex
  rewritten to escape-form so diffs are reviewable; pr-detection-
  worker soft-delete filter; offhost-backup DR-intent comment.
- W5-IOS-WORKOUTS — `dedupeWorkoutBatch` write-time cross-source
  dedup helper for the v1.5 iOS HealthKit observer ingest; dashboard
  tile-strip placeholder parity with the live TrendCard chrome.
- W6-WITHINGS-OFF + ENV-CHECK — Withings off-response classifier
  (transient/reauth/persistent) + WithingsApiError typed throw + the
  `pnpm check-env` CLI. APNs `allOrNone: true` closes the AP-2
  silent-disable gap the wave was conceived to close.

No migration, no env-var change, no API contract break for iOS v0.5.4.
The new `/api/dashboard/widgets` 422 envelope is additively extended.

pnpm test --run green at 4815 passing / 1 skipped.
pnpm typecheck, pnpm lint, pnpm knip (no --include flag) green.
2026-05-21 19:33:07 +02:00
Marc-André Bombeck d224811a34 chore(release): v1.4.38.2
Mood-reminder hotfix bundle on top of v1.4.38.1 plus the missing
Settings toggle. Six-axis review of the v0.5.4 iOS-coord patch
surfaced enough blockers that the feature could not have been used
safely as shipped:

- Locale resolver demoted 4 of 6 locales (es/fr/it/pl) to English.
- Dedup ledger committed before delivery — a transient APNs blip
  silently nuked the day's nudge and blocked retries.
- DST-unsafe arithmetic drifted the local-22:00 baseline by an hour
  twice a year for both the mood-reminder push and the medication
  iOS-snooze action.
- APNs payload forwarded the Telegram `replyMarkup` inline-keyboard
  object and other channel-specific extras to Apple's servers and
  the iOS notification handler.
- No Settings UI for the per-user opt-in flag — the feature was
  unreachable for end users regardless of the other fixes.

v1.4.38.2 closes every item above plus adds the missing
`MoodReminderCard` Switch, a 90-day retention sweep for the dispatch
ledger, and a per-user `try` wrapper so one bad row cannot abort the
22:00 tick.

See CHANGELOG.md.
2026-05-17 23:46:09 +02:00
Marc-André Bombeck a550031a84 chore(release): v1.4.38
Robustness sweep, perf hotspots, full localization, coach gate
cascade closure.

- /api/dashboard/summary cold-mount 4.6 s → ~500 ms via DAY-bucket
  sparkline + 60 s response cache.
- Coach API gate inventory test surfaced and closed two orphan
  routes (chat[id] GET+DELETE, chat-message feedback POST).
- Cross-tz fast-path runtime guard falls back to live for users
  more than ±3 h from UTC; proper bucket minting stays v1.5.
- es / fr / it / pl coverage 27 % → 63 % (~3 400 new strings) plus
  three medication-cluster placeholder restorations.
- IANA timezone validation closes the User.timezone self-DoS
  regression the new dashboard SQL would have surfaced.
- Unit suite 4520 → 4524.

See CHANGELOG.md for the full set.
2026-05-17 19:07:03 +02:00
Marc-André BombeckandClaude Opus 4.7 3048dd6637 style(prettier): sweep formatting across the tree
Pre-existing format drift accumulated across v1.4.x; running
prettier brings the whole tree back to baseline so future diffs
read clean. No behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:05:40 +02:00
Marc-André BombeckandClaude Opus 4.7 75c74f1c49 feat(achievements): worker re-evaluates new + hidden achievements on data change
Two trigger fixes so the new achievements actually fire end-to-end.

The doctor-PDF hidden Easter-egg used to watch a non-existent
`doctor-report.export` audit action. The real route emits two:
`doctor-report.generate` (the JSON helper) and
`doctor-report.pdf.generate` (the PDF route). Either now counts.

The locale-flip hidden Easter-egg used to watch
`settings.locale.update`, which no endpoint emits — the locale
field shares the catch-all `profile.update` audit row with every
other profile field. Profile-update now emits a separate
`settings.locale.update` row when (and only when) the locale value
actually changes (compares the new value to the prior persisted
locale, suppresses the row when they match), with the from/to
values in the details payload for traceability.

Already-existing TanStack Query invalidation map covers the new
metrics: `measurementDependentKeys`, `moodDependentKeys`, and
`medicationDependentKeys` already include
`queryKeys.gamificationAchievements()`, so any measurement / mood /
intake mutation triggers a refetch and the route persists the new
unlock to `UserAchievement` (existing v1.4.15 mechanism).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:34:32 +02:00
Marc-André BombeckandClaude Opus 4.7 98e209b6f7 refactor(auth/profile): extract validation/persistence into shared helper
`PUT /api/auth/profile` now delegates to `applyProfileUpdate()` in
`src/lib/auth/profile-update.ts`. Validation, email-collision check,
update, audit-log — all unchanged, just lifted out of the route handler
so a second consumer can reuse it without forking the path.

Adds `GET/PATCH /api/user/profile` as that second consumer, exposing the
same shape under a slightly different verb that better suits headless
clients (PATCH partial updates, GET reads). Both endpoints touch the
same `applyProfileUpdate` so any future field stays in sync.

Existing PUT response shape is unchanged; the existing PUT consumer
(web settings page) is unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:53:11 +02:00