Commit Graph
830 Commits
Author SHA1 Message Date
Marc-André Bombeck ba5a17a54e fix(daily): keep the coach check-in constants out of the client bundle
The Today hero and the check-in hook imported the check-in intent constants
from digest.ts, which — since the milestone slice — transitively reaches the
database through the streak baseline engine. That pulled the Postgres driver
into the client bundle (a Turbopack 'Can't resolve net/tls/dns' build failure
the worktree build couldn't surface). Move the four constants to a tiny,
dependency-free coach-checkin-intents module; the client imports them from
there, and digest.ts (the server builder) stays server-only.
2026-07-17 09:37:39 +02:00
Marc-André Bombeck a33527da57 feat(insights): intraday pulse shape and elevated-at-rest tension window
Add an on-demand intraday layer to the pulse insight: 10-minute mean
heart rate for the viewed day, folded from raw samples via the read-swap
pattern (never persisted as 10-minute rollups for all history). Draw the
personal resting baseline and softly shade any sustained
elevated-at-rest stretch.

The tension signal is not raw heart rate — it is heart rate held above
the personal resting baseline while movement is low (the elevated-at-rest
window), so a workout or a walk never reads as tension. Detection is
deliberately conservative: baseline maturity, per-bucket sample density,
a mandatory step-based low-movement gate, and a minimum sustained
duration must all hold before anything is reported. Silence over
speculation.

Surface the same signal as a calm, non-diagnostic tension_window item in
the daily digest — at most once per day, honest-absent otherwise, gated
on the insights module and yielding the rail to time-sensitive actions.
Cautious copy across all six locales; framed as "possible tension", never
a clinical stress verdict.
2026-07-17 08:16:32 +02:00
Marc-André Bombeck d470d406a7 Merge remote-tracking branch 'origin/main' into feat/documents-coach-bundle 2026-07-17 00:02:51 +02:00
Marc-André Bombeck 73de8f6a52 feat(documents): bidirectional labs ↔ documents cross-link
Two directions, folding the vault and labs pipelines into one linking path
without weakening either manual flow.

Vault document → lab values (auto-stage). After the per-document auto-index
job transcribes a stored document, a lab-looking document is run through the
same inbound extraction the manual "Extract" button uses, but the facts land
PENDING for the existing human review — nothing is committed. Gated on both
the documents and labs modules plus the auto-AI-read consent gate the
auto-index external path already uses; a text-structuring pass over the
transcription the index just produced, so no second vision call is spent. The
staging is a guarded STORED→EXTRACTED transition, and a keyword heuristic (or
an explicit LAB_RESULT kind) keeps an arbitrary photo from burning a call.
Everyone without the opt-in keeps the manual extract button.

Lab scan → vault. A lab report scanned in the labs area is now also filed
into the Documents vault (kind LAB_RESULT) through the existing upload —
encrypted at rest, EXIF-stripped, thumbnailed, sha256-deduped — and the
committed labs are cross-linked back to it with the same APPROVED-observation
provenance the manual vault → labs path produces. Text-mode scans keep the
image on-device and file nothing. Idempotent: a re-commit inserts no rows and
links nothing new, and the vault dedupe means no duplicate document.

Owner-scoped and module-gated on both sides; the cross-link is best-effort so
it never fails the authoritative write.
2026-07-17 00:01:45 +02:00
Marc-André Bombeck 502783a3ea feat(coach): attach multiple documents to a conversation
Replace the scalar document_id discriminator on a coach conversation with a
CoachConversationDocument join table and a sticky documentScoped flag, so a
conversation can be grounded on several stored documents at once while the
prompt-injection fence stays intact across the data-model change.

The fence is re-established at both enforcement points, fail-closed:

- Client: resolveCoachSendTarget routes any turn on a fenced conversation (or a
  fresh chat with staged attachments) to the new /api/insights/chat/fenced
  endpoint; the fenced body is strict and never carries scope, guided-question,
  or prefill hints.
- Server: the tool route fetches with documentScoped:false AND 404s when the
  fetched row still carries an attachment (flag/join drift), emitting a
  fence_drift audit alarm. Document text now enters an LLM prompt in exactly one
  code path — the fenced pipeline — with no tools and no health snapshot; a
  module-graph test pins that the tool loop is unreachable from it.

The fenced turn fences each document separately with a server-built, scrubbed
header line (title and filename are untrusted text), runs a per-document egress
consent check on the single picked provider, and grounds numerically over the
live attachment union only. Attachments are capped at five documents and 192 KB
of combined text with honest truncation.

Attach and detach ride dedicated endpoints: attaching validates the document is
owned, live, indexed, and within cap, then flips the conversation fenced (the
one legal, privilege-reducing transition, audit-logged); detaching removes the
document but never clears the flag. The document-sheet chat keeps its public
wire contract and shares the same fenced pipeline over the join.

Migration 0245 adds the join table and the flag, backfills every legacy
single-document chat into a one-attachment fenced conversation, and drops the
scalar column in the same transaction — no dual-truth window.
2026-07-16 23:32:28 +02:00
Marc-André Bombeck b6ded4d1f9 feat(daily): finalise the morning digest when last night's sleep lands
The nightly insight pre-pass runs ~04:30, before last night's sleep has
synced, so the morning digest and health score reference the day without
the current sleep. Close the gap the event-driven way instead of an
on-mount generation.

A day's digest is now provisional until the sleep-dependent output has
been regenerated with last night's sleep, and final after. The lifecycle
is driven by a real marker (User.morningDigestRefreshedOn, the user-local
date the refresh completed), not a heuristic: it flips the instant the
refresh runs, ahead of the snapshot cache that feeds the sleep last-seen
fallback. A day with no sleep device stays honestly provisional and keeps
the sleep-pending note rather than showing a stale final score.

When a completed sleep segment for last night lands from any of the three
sleep transports — Withings sync-sleep, the WHOOP sync, the Apple-Health
measurement batch — a debounced morning-digest-refresh job is enqueued.
"Last night" is judged in the user's profile timezone, so a full-history
re-sync never re-triggers for an old night. The queue singletonKey plus
the marker collapse the many samples of one night, across all sources,
into one refresh per user per local morning; the job re-runs the same
comprehensive pipeline the nightly cron uses (forced, so the content-hash
gate turns a no-change regen into a cheap no-op) and stamps the marker. A
hard generation failure leaves the day provisional and hands recovery to
the existing 45-minute briefing-retry machinery.
2026-07-16 22:57:27 +02:00
Marc-André Bombeck b7a6b4c898 feat(coach): close the plan check-in loop on the Today rail
Every accepted coach plan now earns a gentle weekly check-in. Confirming a
plan (proposed → active) without an explicit review date defaults one to a
week out, so a plan is always reviewed, not only the coach's own experiments.

The daily digest emits a single `coach_checkin` rail item when a standing
plan's review has come due — its pinned review date, or a defaulted
activation + 7 days for plans that predate it, or the read-back state a
reviewed plan carries. Capped at one check-in a day across all plans
(earliest-due wins); the rest wait for a following day. It reads existing
plan state only — no fresh generation, nothing warms on mount, and it is
gated on the coach module.

The card offers three one-tap actions through the existing plan-lifecycle
route: keep it (re-arm for another week), adjust it (open the coach), or let
it go (a guilt-free, respected end — the calm inversion of a streak). An
ignored check-in sits quiet and stops resurfacing after about two cycles;
its status is never changed behind the user's back.

Copy lands in all six locales.
2026-07-16 22:30:46 +02:00
Marc-André Bombeck 1d592bef78 refactor(dashboard): retire the legacy opt-in hero
The digest-driven Today hero is the single promoted hero at the top of
the dashboard, so the old opt-in hero band is redundant — a user who had
opted in and has Insights on saw two heroes. Remove the legacy hero and
its heroVisible layout flag.

- Drop the DashboardHero / DashboardHeroSkeleton mount from the
  dashboard page; the header now renders the greeting unconditionally
  (its showGreeting gate is gone with the hero that owned it).
- Retire the heroVisible field from the dashboard layout (type,
  defaults, resolver, serializer) and the widgets PUT schema. The
  resolver is a whitelist constructor, so a stored heroVisible is
  dropped on read and never re-persisted — no stored layout breaks.
- Delete the now-orphaned hero components (band, skeleton, ring
  carousel, briefing spotlight) and their tests; drop the retired
  dashboard.hero copy across all locales.
- Remove the hero-visibility toggle from Settings → Dashboard. The
  score-ring picker no longer hangs off that flag and stays available,
  since the rings it configures still ride the server-computed
  dashboard snapshot.
- Repoint the phone-width density guard at the Today hero and mock the
  daily digest so it renders.
- Repoint the preserve-when-absent doc analogy off the removed field.
2026-07-16 21:34:06 +02:00
Marc-André Bombeck ba6c0990bf feat(daily): add the daily-digest spine and priority-card primitive
Introduce the two foundational primitives of the unified daily-value
system, with no user surface yet:

- PriorityItem: the one wire-serialisable model behind every "worth a
  look" rail item, coach check-in, milestone, and device-verdict pointer.
  Its Lucide glyph is derived from the closed `kind` enum in the renderer
  so the model itself stays JSON-serialisable across the API and future
  native-client boundaries.
- PriorityCard: a calm, token-only, reduced-motion-aware card composed
  entirely from the shipped Card / TileHeader / ProseBlocks primitives —
  no new visual language, semantic-token status wash only, no markdown.
- buildDailyDigest: one pure, deterministic server composer that assembles
  the day's read from data that already exists — the nightly briefing
  lifted read-only from the insights cache plus the dashboard-snapshot
  health score, meds-today, and sleep freshness. It computes no analytics
  and never reaches an AI provider. It emits the DailyDigest DTO the Today
  surface, the daily push, and a future iOS widget will all consume.
  Deterministic item builders cover overdue doses, broken integrations,
  and due Vorsorge reminders; the push line prefers the cached briefing
  lead with a deterministic score floor so a keyless self-hoster still
  gets a first-class line.

The read seam GET /api/daily/digest gathers the cached inputs, gates on
the insights module (cookie or Bearer; userId from the session), and
returns the digest. OpenAPI regenerated; the route is covered by the
module gate inventory. Digest strings ship in all six locales.
2026-07-16 19:13:01 +02:00
Marc-André Bombeck 5b24ca0fa1 feat(coach): document chats live in the Coach view and history
Asking the Coach about a document opened a separate, half-finished chat panel
that never appeared in Coach history. The bespoke drawer is gone; a document
conversation now opens the real Coach view, appears in the Coach list with a
document badge and a "Chatting about: <title>" note, and is read back through
the same history rail as any other thread.

The prompt-injection fence is unchanged and stays server-side. A document turn
is still sent through the hardened fenced endpoint (no tools, no health
snapshot, grounded in the document text only); the coach tool route continues
to fetch with documentId null, so a document-scoped conversation can never be
continued through the tool loop even from a hostile client. Only the surface
moved into the Coach; the boundary did not.

The relaxed conversation-list and detail filters drop only the documentId
predicate — userId scoping is unchanged, so a document thread is still visible
only to its owner. CoachConversationDTO gains documentId + documentTitle
(server-derived, title/filename only); no schema change (documentId exists
since 0230). OpenAPI regenerated.
2026-07-16 11:01:59 +02:00
Marc-André Bombeck 49ed0f41df feat(insights): view ECG recordings
ECG recordings synced from a compatible watch were stored encrypted but
shown nowhere. They now have a surface in Insights: a list of recordings,
each opening the full single-lead waveform on a familiar ECG grid, with the
date, duration, average heart rate, and the result the device recorded.

The read routes narrow ownership in the query and decrypt the waveform
through the existing fail-closed codec; the overview is min/max-decimated so
the R-wave peaks survive the reduction, with a raw path for a calibrated
view. The result shown is the recording device's own classification,
attributed to the device — HealthLog does not read or interpret the trace
and produces no verdict of its own. A permanent note states this, and a
non-normal result carries a line to discuss the recording with a clinician.
2026-07-16 09:55:35 +02:00
Marc-André Bombeck a9a318d4a4 chore(release): v1.28.48 — security follow-ups
From the campaign's security re-verification pass (posture otherwise held
across the 10-release week — no Critical/High):

- MEDIUM: the OIDC auto-provision branch re-introduced the first-admin race
  the password-register route fixed in v1.28.42 — count-then-create with no
  lock, so two concurrent OIDC first-logins (or an OIDC first-login racing a
  password first-registration) in the empty-DB window could both mint ADMIN.
  Now wraps count+role+create in a $transaction taking the SAME advisory lock
  key ('register:first-admin') and re-counts inside, so the two paths
  serialise; every provisioned field preserved.
- LOW: the nutrients batch catch echoed the raw exception message into the
  client-facing reason (violating its closed reason-set). Now returns the
  fixed 'upsert_failed' member and logs the detail server-side only.

No migration (advisory lock is runtime).
2026-07-16 08:03:12 +02:00
Marc-André Bombeck 0796d9fe14 chore(release): v1.28.45 — doctor-report + privacy fixes
Three CONFIRMED findings from the campaign's feature-wiring audit + structural
verification:

- Sleep rendered nothing in the doctor-report PDF: the sleep section is
  default-on and the data layer reconstructs per-night SLEEP_DURATION rows, but
  DOCTOR_REPORT_VITAL_TYPES omitted SLEEP_DURATION, so the vitals table showed
  nothing (FHIR already emitted it). Added it to the allowlist with a
  minutes-to-hours conversion; corrected the stale 'intentionally excluded'
  comment.
- Glucose had no doctor-report section toggle — it rendered whenever the global
  glucose module was on, with no report-level privacy control. The toggle
  actually already existed in the UI (with i18n) but was dead because the prefs
  fold dropped the key. Wired it end to end (glucose added to the prefs Zod
  schema + defaults, default-on to preserve behaviour; gated the render loop +
  the query). Also fixed a grouped-vs-flat prefs mis-detection: 'glucose' in
  GROUPED_SECTION_KEYS would have flagged flat blobs (incl. EMPTY prefs) as
  grouped and defaulted them all on. No migration (prefs is a JSON blob).
- environment fed the coach with no exclusion: MODULE_EXCLUDED_SOURCES omitted
  it, so module-off still leaked audio_env/audio_headphone/audio_event/daylight/
  skin_temp to the snapshot. Added, scoped exactly to the environment cluster's
  five sources.

Regression test per fix.
2026-07-16 05:51:04 +02:00
Marc-André Bombeck d4be568625 chore(release): v1.28.42 — snappier lists, correct mood refresh, safer first sign-up
Performance + correctness fixes from the campaign's verified perf-correctness
audit (the two server-query findings — comprehensive-generate + boot-discovery
distinct — are deferred to a careful separate perf release):

- format-locale built a fresh Intl.DateTimeFormat/NumberFormat per call
  (main-thread stall across every history list). Routed through the existing
  process-wide formatter-cache keyed on locale + options (timeZone + hour-cycle
  in the key); output byte-identical.
- measurement-list rendered every row twice (desktop table + mobile cards, one
  CSS-hidden), unvirtualized. Now renders one layout via the SSR-safe
  useIsMobile(md) hook; row markup + every data-slot unchanged.
- moodDependentKeys omitted dashboardSnapshot(), so a mood write left the
  dashboard tile/score stale ~120s (server cache was already correct). Added it.
- register first-user admin was a count-then-create race (two concurrent
  first sign-ups could both become ADMIN). Now an advisory lock + in-transaction
  re-count makes first-admin atomic; P2002 maps to 409 instead of 500.

Regression test per fix. No migration (0243 still free).
2026-07-16 03:18:55 +02:00
Marc-André Bombeck 82aa66b400 chore(release): v1.28.36 — re-importing after a failed Apple Health import actually retries
The v1.28.33 rollup + staging fixes were correct but never reached the
reporter's case: the upload dedup keys on the content SHA-256 and the
lookup matched a previously FAILED job too, so re-uploading the same
export.zip returned the dead job's id and replayed its stale
failureReason without ever staging or sending again. The dedup now
excludes failed rows (short-circuits only on queued/unpacking/parsing/
upserting/done); the same bytes retry cleanly. A redundant staged upload
is unlinked on a dedup hit instead of leaking in the staging dir.

reconcileOrphanImportJobs no longer unconditionally fails every
non-terminal ImportJob at boot: it now checks pg-boss job state plus a
progress heartbeat (new import_jobs.updated_at, migration 0242), so a
split or multi-worker deployment cannot kill an import running in
another worker. The missing-staging message names the separate-container
cause explicitly, with a matching note in docs/self-hosting/scaling.md.
2026-07-15 22:07:25 +02:00
Marc-André Bombeck a614542cc3 Merge pull request #485 from balajiv113/feat/oidc-sso-login
feat(auth): add OIDC SSO login
2026-07-15 21:37:20 +02:00
Marc-André Bombeck 5445fc67ea chore(release): v1.28.34 — nutrient intake lands as quiet context (server side)
Supplement-style intake written to Apple Health by nutrition apps —
vitamins, minerals, water, caffeine — can now sync as daily totals into a
dedicated NutrientIntakeDay store (migration 0241): composite (user, day,
nutrient) identity, re-post replaces, no soft delete, no rollup coupling.
A code-side catalog carries the 26 nutrient codes with canonical units,
plausibility caps, and EFSA dietary reference values (direction-aware;
chromium cites NIH ODS where EFSA declined a DRV).

Deliberately not a nutrition feature: no diary, no calories, no dashboard
surface. POST /api/nutrients/batch is gated behind the new opt-in
nutrients module (default off — with the module off the server refuses
ingest entirely), rate-limited, idempotency-wrapped, with per-entry
unit-mismatch and out-of-range skips. GET /api/nutrients serves a
catalog-ordered summary; the only visible surface is a read-only card
under Settings → Sources. Coach/correlation access is a later slice; the
iOS reading side is coordinated in the companion-app tracker.
2026-07-14 13:10:47 +02:00
Marc-André Bombeck 6d8fe06148 chore(release): v1.28.33 — re-importing a cumulative Apple Health export works again
Rollup writes were delete-then-createMany inside a transaction; under READ
COMMITTED a concurrent recompute racing the import's full fold on the same
(user, type, granularity, bucket, source) partition tripped the composite
PK — and the P2002 swallow dropped the loser's freshly computed aggregate,
leaving a stale bucket. persistRollupRows now writes chunked
INSERT … ON CONFLICT DO UPDATE with deterministic (type, source, bucket)
lock ordering inside one transaction: a race can neither fail the import
nor lose an aggregate, and previously-staled buckets self-heal on their
next recompute. The vanished-source delete and in-transaction guarantees
are preserved; the now-unreachable P2002 swallow is removed.

The masked ENOENT is gone with it: long imports outlived the queue's
15-minute expiry, pg-boss redelivered the still-running job, and the
redelivery re-opened the consumed staging file — overwriting the real
terminal state with a raw ENOENT. Import sends now carry retryLimit 0 +
6h expiry (a retry after the staging file is consumed can only mask),
deliveries for terminal ImportJob rows are no-ops, a genuinely missing
staging file reports an honest re-upload message, and the extracted XML
is cleaned up on failure too.
2026-07-14 11:56:29 +02:00
Marc-André Bombeck 7592c73947 fix(auth): harden the OIDC token path and state-cookie lifecycle
Token verification: jwtVerify now carries a closed asymmetric algorithm
allowlist (RS256/PS256/ES256/EdDSA — an HMAC alg would let anyone with
the client secret forge identities) and a 60 s clock tolerance for the
usual self-host drift between IdP and app box. The JWKS fetch — jose's
own, outside safeFetch by design — is disciplined instead by pinning
the jwks_uri host to the configured issuer's host byte-exact and
passing the same 15 s timeout safeFetch defaults to. requirePublicHost
stays deliberately unused on this surface: LAN/Tailscale-only IdPs are
a legitimate topology and the issuer is operator config, not user
input.

Discovery: the issuer comparison now tolerates exactly one trailing
slash on either side (a recurring IdP/operator mismatch); token
verification still matches the provider-sent issuer byte-exact.

State cookie: the cookie is set under Path=/api/auth/oidc but was
deleted without a path — RFC 6265 keys cookies by (name, domain,
path), so the deletion never matched and the single-use blob survived
its whole TTL. Every delete now repeats the exact path, via a shared
constant.

Also: the IdP error code recorded in the wide event is whitelisted
against the closed OAuth error-code set (unknown values collapse to
"other"); the status route drops an unreachable try/catch and a
duplicated annotate; the passkey conditional-UI autofill stays unarmed
under OIDC_ONLY (passkey login is a server-enforced dead end there);
env-manifest gains the three optional OIDC_* tuning vars; the env
example notes why the issuer should be HTTPS.
2026-07-14 11:53:53 +02:00
Marc-André Bombeck 9b8b31c864 fix(auth): OIDC sessions never satisfy MFA step-up; 2FA accounts get the challenge step
Two second-factor gaps in the OIDC callback:

The session was minted with mfaVerifiedAt stamped to the login instant,
letting a bare SSO login satisfy requireFreshMfa step-up (MFA disable,
encryption-key rotation, encrypted export, account deletion). OIDC is a
delegated factor — whatever the IdP checked, this app did not see a
second factor — so the stamp is now null, the same treatment a
trusted-device login gets.

An account with a confirmed TOTP secret or a registered security key
was logged straight in, silently downgrading its native 2FA to
IdP-only. The callback now mints the same single-use MFA ticket
password login uses and hands it to the login page through a
short-lived, login-page-scoped cookie (the callback ends on a browser
redirect, so the ticket cannot ride an envelope meta like the password
flow). The login page picks it up once, clears it, and renders the
same challenge step; the login completes at /api/auth/mfa/verify with
identical continuation semantics. The cookie is deliberately not
httpOnly — the login page script must read the ticket, exactly the
exposure the password flow already has — and the ticket alone carries
no session authority (single-use, hashed at rest, 5-minute TTL,
attempt-capped).

Direct (factor-less) OIDC logins now also sync the admin-enforced-MFA
enrollment hint cookie, matching password login.
2026-07-14 11:46:40 +02:00
Marc-André Bombeck fa326c398a feat(auth): pin OIDC accounts to their (issuer, sub) identity
An OIDC login now resolves the account by the durable (issuer, sub)
pair instead of the email claim. First contact stamps the pair — by
auto-provision or a one-time link against an existing account matched
by verified email — and every later login matches on it alone, so an
IdP-side email change refreshes the display email but can never
re-point the login at a different account. An email that matches an
account already bound to a different identity is rejected and audited
(auth.oidc.link_conflict), never silently re-bound.

The email gate tightens with it: linking and provisioning now require
an explicit email_verified: true — an absent claim is a reject
(oidc_email_unverified) rather than a benefit of the doubt, since an
IdP that does not assert verification cannot anchor the account match.
Provisioned accounts get a lowercased email and the same server-default
timezone resolution the register route uses.

Migration 0240 adds the nullable users.oidc_issuer / users.oidc_sub
columns with a composite unique index.
2026-07-14 11:43:53 +02:00
Balaji Vijayakumar f815e4a83f feat(auth): add OIDC SSO login
Adds an OpenID Connect relying-party login path (PKCE authorization-code
flow) against a single env-configured identity provider — additive to
password/passkey login by default, with an operator opt-in (OIDC_ONLY,
enforced server-side on every auth route) to disable them entirely.
First-time sign-in auto-provisions or links an account by verified email.

Closes an open redirect in the post-login `next` parameter (both the new
OIDC path and the pre-existing password/passkey flow shared the same
backslash-normalization bypass) by resolving the target through a real
URL parse and comparing origins instead of a prefix check.
2026-07-13 16:33:13 +05:30
Marc-André Bombeck a4feabec81 chore(release): v1.28.30 — the daily briefing stops silently skipping days
The recurring no-briefing day had a chain of causes, closed end to end:

Every failure path in the nightly pregenerate + the comprehensive generator
now annotates a queryable cause (several classes — features-error,
payload-too-large, aborted, generator-failed — previously bumped a counter
with zero trace). A failed nightly warm enqueues one bounded retry ~45
minutes later (singleton per user, existing budgets apply), so a transient
provider hiccup heals before morning. A cached payload that carries NO
briefing no longer satisfies the 24h POST short-circuit nor the
unchanged-content hash gate — a once-stripped briefing could previously
block its own regeneration indefinitely. The card surfaces the omission
reason persistently (grounding strip, generation failure, not attempted)
with a forcing retry.

Documentation corrected against code truth: Withings credentials are
per-user (the documented env path never existed; the dead example entries
and manifest rows are gone, and WITHINGS_WEBHOOK_SECRET is now actually
whitelisted in compose — stock deploys could never deliver it); the classic
Fitbit portal no longer accepts new registrations; the backup-restore
example no longer mints a fresh encryption key and uses pnpm dlx tsx; the
TLS guide describes the real client model (two CA-level SPKI pins,
any-match, leaf renewals need no build); scaling/admin references fixed;
the operator-shared subscription provider is documented.
2026-07-12 11:08:59 +02:00
Marc-André Bombeck 13464b512e chore(release): v1.28.28 — OpenAI-compatible gateways work; a re-keyed night can no longer vanish
The Local provider speaks the standard JSON wire: response_format by default,
one self-healing fallback per endpoint when a server rejects it (dialect
cached per base URL) — LiteLLM, OpenRouter, vLLM, LM Studio and plain Ollama
all work from the same form, and the form + provider docs now say so. A
gateway that shims a Claude-family model behind a synthesized tool call is
parsed from the tool-call arguments instead of yielding silently empty
insights.

The insight token budget is configurable (INSIGHTS_MAX_TOKENS, default 2500,
clamped 500-8000) and unified across all six call sites; a reply cut off
mid-JSON returns a distinct truncation error instead of a generic parse
failure. A briefing withheld by the number-grounding check now says so on the
card instead of pretending nothing was generated. The provider test button
answers 4xx with a bad_request category instead of claiming the endpoint was
unreachable.

Sleep repair follow-up: measurements carries a second full unique index — the
natural key (type, measured_at, source, sleep_stage) — so a key-format
migration could tombstone the old rows while their re-keyed replacements
collided with that index and were silently dropped (live incident: a
full-history re-sync erased a user's sleep). Planned creates now run a
natural-key rescue probe: a match, live or tombstoned, is updated in place —
fresh value, the new externalId, deletedAt cleared. A rescue-probe failure
holds the watermark so nothing is lost past the overlap.
2026-07-11 10:36:02 +02:00
Marc-André Bombeck 5433fc8fbd chore(release): v1.28.26 — internal restructuring for maintainability
No user-facing change. Eight of the largest source files split along their
natural seams into focused modules, every move verbatim with stable import
paths:

- mood analytics calculators (mood-tag-influence / mood-crosstab /
  mood-better-days / mood-patterns; hub 2229 → 770 lines)
- insight status-invalidation machinery (status-invalidation.ts)
- feature extraction (feature-blocks / signals-of-day)
- doctor report types + pure helpers (doctor-report-types /
  doctor-report-helpers; collector 1756 → 1002)
- Google Health mapping layer (mappers.ts; client 2153 → 1022, acyclic
  client → mappers with type-only back-imports)
- coach chat bubbles + read-aloud controls (chat-bubble / read-aloud;
  message-thread 1713 → 464)
- Telegram webhook handlers to lib (route 1307 → 92 — handler thin, logic in
  lib; rate-limit-before-secret ordering byte-identical)
- coach snapshot cache, series helpers and the four largest per-metric blocks
  (snapshot-cache / snapshot-series / snapshot-blocks; 2691 → 1896)

The glucose-panel-window structural test follows the moved alias; two unused
imports left by the feature split are removed.
2026-07-11 08:43:41 +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 c400e2aa88 chore(release): v1.28.23 — sleep disagreement on the dashboard; Apple Health medication groundwork
The sleep source-discrepancy annotation now reaches the surfaces: SleepNight
carries the MAIN session's annotation (naps never cross-compare), the analytics
slim slice / dashboard snapshot / iOS summary thread it additively, and the
dashboard sleep tile renders the shared discreet marker (extracted from the
sleep panel — one implementation, two surfaces). Served totals unchanged,
pinned by tests.

Apple Health medications groundwork (server side of the medication-import
request): IntakeSource gains APPLE_HEALTH; medications accept an external
mirror identity (source-exclusive by policy) and dose imports carry the HK
dose-event UUID — both idempotent per stable key, with the concurrent-create
race resolved to the winner. Migration 0239, additive only. Entries without
the new fields behave byte-identically.

Hardening: doctor-report aggregation + PDF charts compute ranges with loops
instead of spread calls — a report over a sample-dense year cannot overflow
the call stack (the class fixed for the Google Health full sync).
2026-07-11 00:18:23 +02:00
Marc-André Bombeck 1212ed91d5 chore(release): v1.28.21 — sleep history heals itself; plateau context; sync diagnostics
A one-time background pass re-reads each connected account's Google Health
sleep history so nights stored twice before the sleep fix collapse to their
true total (new sleep_repaired_at marker, migration 0238; watermark-safe,
enqueued once at boot per un-repaired connection).

The sleep night read reports when two writers disagree about the same night —
the served total is unchanged, the disagreement rides along as an annotation
(sourceDiscrepancy on the night DTO) so clients can mark the number instead of
silently picking a side.

A GLP-1 medication whose weight has plateaued on the current dose says so next
to the estimated drug level (efficacy tab + insights) — a factual note from the
existing detector, association only, verb-guard covered.

Google Health diagnostics: the sync log now separates a daily-totals read that
returned nothing from one the importer could not parse
(googleHealth.rollup.emptyResponse vs droppedAll), and the connection test's
structure probe reports the raw response envelope key names when a rollup type
parses to zero — a per-account naming drift becomes visible from one report.
2026-07-10 21:23:00 +02:00
Marc-André Bombeck 6d02711e33 chore(release): v1.28.16 — documents-only shares stay documents-only
A share link created to carry only documents now records an explicit,
frozen document_only flag, and the shared view gates on that flag directly
instead of inferring documents-only from "are all report sections off?".
The inference would have re-opened an existing documents-only link the day
a new report section was added — the link's frozen settings never mention
the new section, so it would default on and begin serving that data. The
flag closes that path; the derived check stays only as the fallback for
links created before the column.

Migration 0237 adds the column and freezes it on for every existing link
whose sections are entirely off (checked at any nesting depth), leaving
full-record shares untouched.
2026-07-10 02:08:03 +02:00
Marc-André Bombeck eb102ac50c chore(release): v1.28.15 — document thumbnails; admin-shared AI access
Document vault shows a per-file thumbnail: a server-rendered preview,
downscaled and re-encoded so location and camera metadata are dropped,
served only to the file's owner over an authenticated request. Files that
cannot be previewed keep their type icon. Generation is bounded — a header
sniff refuses an over-large source before decode, so a crafted image cannot
exhaust the preview worker.

Also folds in admin-managed central AI access — an administrator connects
one access on the server and each user opts in or keeps their own provider,
off until an admin turns it on — and trims the redundant documents-search
caption and per-file searchable/read badges.
2026-07-10 01:32:28 +02:00
Marc-André Bombeck ee76672798 feat(documents): preview thumbnails in the vault overview
Render a small JPEG preview for each stored document and show it at the
card's leading edge, falling back to the kind icon when none exists.

- Generate previews in the background on upload (new document-thumbnail
  pg-boss queue) plus a boot-time corpus backfill for existing documents;
  upload never blocks on or fails because of it. Images are downscaled via
  @napi-rs/canvas, a PDF's first page is rasterised then downscaled, every
  other type is skipped. Generation never throws.
- Store the preview encrypted at rest in a 1:1 DocumentThumbnail side table
  (migration 0236) so the list/detail queries that omit the original blob
  never drag the preview either. Same AES-256-GCM string codec + fail-closed
  decrypt as the content index; wired into the key-rotation walk.
- Serve it through an owner-scoped GET /api/documents/inbound/[id]/thumbnail
  (auth + module gate + userId-scoped query + fail-closed decrypt), always
  image/jpeg + nosniff + private, no-store; 404 on miss. The card renders an
  authed lazy <img>, onError falls back to the icon.
- Add hasThumbnail to the document DTO so the card only fetches a preview
  that exists.

Also drop the "search looks inside your documents" caption from the overview
(the index-all prompt stays) and the per-card searchable marker; both were
noise. Remove the now-unused i18n keys across all six locales.
2026-07-10 01:15:32 +02:00
Marc-André Bombeck 0d25f3191a merge admin-central-oauth 2026-07-10 00:39:59 +02:00
Marc-André Bombeck d4965033c2 fix(clinician-share): a document share exposes only the document
Sharing a single document from the vault minted a full-record share: the
create form always defaulted `resourceTypes` to ["Patient","Observation"]
and never signalled a documents-only intent, so the served /c/<token> view
aggregated the whole doctor report — every vital, lab, medication and
wellness figure — alongside the attached file. An empty section blob was
indistinguishable from "defaults", so the report always resolved to the
full record.

Introduce an explicit documents-only share:

- add a `documentOnly` flag to the create payload; when set the server
  forces an empty report scope (all-off sections, no FHIR resource types,
  FHIR API off) and requires at least one document, so a document link can
  never widen into a record — whatever the body sends.
- gate the clinician-view aggregation on an enabled section: a share with
  no section on never calls the report aggregator, so no health metric
  leaves the database. The recipient sees only the attached document(s).
- the document detail Share action opens the create form in documents-only
  mode: hide the FHIR toggle, the FHIR resource-type checkboxes and the
  history-range selector, keep the picker/label/expiry/passphrase, and
  always carry the launched document into the created link.
- Settings sharing keeps the full record-share form unchanged.

Extract the create-payload builder as a pure function and cover the scope
contract, the empty-scope data load, and the served-view metric-free
guarantee with tests; refresh the share e2e and regenerate the OpenAPI
contract.
2026-07-10 00:18:57 +02:00
Marc-André Bombeck d7e616eaa8 feat(ai): add operator-shared central Codex connection with per-user opt-in
Introduce an operator-level ChatGPT-subscription (Codex) credential that the
operator connects once and shares server-wide, plus a per-user switch to route
AI egress through it.

- Schema (migration 0235): three encrypted columns on AppSettings for the
  operator's central Codex access/refresh tokens and the ChatGPT-Account-ID
  claim, plus expiry/connected-at/status; a per-user useCentralCodex flag
  (default false). Register the new encrypted columns in the rotation registry
  and script.
- Resolution: add the admin-codex chain type; append it to a user's resolved
  chain only when they opted in AND the operator connected it. Tokens refresh
  server-side against the singleton row (CAS-style), same primitives as the
  per-user Codex path.
- Budget: bill an admin-codex primary against the operator cost cap, not the
  user plan — it drains the operator's account.
- Consent: admin-codex is server-managed external egress, gated by the existing
  consent machinery (self-snapshot and document surfaces alike). No existing
  consent path is weakened.
- Document order: rank admin-codex with the subscription tier (last), so the
  document class stays local-first.
- UI: admin device-code connect/disconnect card (cookie-only admin routes) with
  honest copy on account pooling, shared rate limits, and training-by-default;
  a per-user opt-in switch shown only once the operator has connected it, off by
  default, behind a one-time honesty confirm.
- i18n across all six locales; unit tests for the chain type, budget cap,
  consent gate, vision capability, and document order; a mocked e2e for both
  the admin connect surface and the per-user switch.

The central credential's tokens are encrypted at rest and never returned to a
non-admin API; the opt-in only takes effect once the operator has connected the
shared account.
2026-07-10 00:10:56 +02:00
Marc-André Bombeck 46f8c71ee5 feat(labs-ocr): read PDFs on the configured provider, retire the separate scan provider
Lab-report OCR now rasterizes a PDF to page images for any non-Anthropic
vision provider, exactly as the document vault already does — Anthropic keeps
its native PDF block, every other vision provider (codex/OAuth, OpenAI, local)
reads the rendered pages. The capability probe reports `pdfSupported` whenever
the picked provider reads PDFs natively or the server-side rasterizer is
available, so the "scan a PDF" affordance shows for a codex provider too.

Drop the dedicated document-scan (Lab-OCR) provider: labs OCR always uses the
main configured provider chain. Removes the settings card, the `aiOcr*`
resolution branch, the GET/PATCH handler fields, the OpenAPI schema fields, the
i18n keys across all locales, and the five backing `User` columns (migration
0234). The on-device tesseract fallback is untouched.

Dashboard hero: stack the overdue-dose action directly beneath the verdict
text (tight gap, left-aligned) on every viewport instead of pushing it to the
far container edge on desktop.
2026-07-09 23:29:06 +02:00
Marc-André Bombeck 75eb0637e6 feat(strava): add per-user BYO-OAuth workout source + Garmin indirect-path docs
Add Strava as a per-user BYO-OAuth workout source, mirroring the shipped
WHOOP/Oura/Polar pattern. Strava caps every new app at single-player mode, so
each self-hoster registers their own app and pastes the client id/secret into
Settings (falling back to a shared env app). Strava is a workout source only —
it exposes no daily metrics and no training-depth analytics are derived.

- Schema (migration 0233): per-user encrypted client id/secret + access/refresh
  tokens, plaintext athlete id, incremental cursor, and backfill marker on
  `User`; `STRAVA` added to `MeasurementSource`.
- Token model follows Oura's reactive-refresh-on-401 with a compare-and-set on
  the stored refresh ciphertext, because Strava rotates its refresh token on
  every refresh.
- Client over safeFetch: authorization-code + refresh, activity list + budget-
  aware per-activity detail (for calories/HR), honouring the 200/15min cap; each
  activity maps to one `Workout` row. Activity name/description stay in metadata
  as data, never surfaced or interpolated.
- Dedup reuses the existing source-agnostic two-layer picker: `STRAVA` slots
  into the workout ladder below the device-native wearables and above
  MANUAL/IMPORT, so an Apple-Watch + Strava twin collapses to one canonical row.
- Poll-cohort hourly sync (:17) + self-converging boot backfill, wired into the
  integration-sync registrar. Webhook deferred.
- OAuth routes (connect/callback/credentials/disconnect/status/test) with signed
  state, cross-account re-auth rejection, and a surgical `www.strava.com`
  connect-src gated to the Strava surfaces.
- Settings card + consolidated status envelope entry; env + compose whitelist;
  i18n across all six locales.
- Garmin: no direct connector is possible (business-partner-only). Add
  docs/integrations/garmin.md documenting the Apple Health / Google Health
  Connect path and Garmin's withheld proprietary scores, cross-linked from both
  platform docs, plus a non-connector info note in the Connections panel.

Regenerate docs/api/openapi.yaml for the new `STRAVA` MeasurementSource value.
2026-07-09 21:25:25 +02:00
Marc-André Bombeck 783255d833 chore(release): v1.28.4 — medication efficacy
Per-medication effect view: plots the target metric/lab with start, dose
changes, and pauses marked, a before/after-start comparison, and adherence
overlaid — descriptive only, never a verdict or dose advice. Compact summary on
the medication insights area. Target resolved from ATC class or name, with a
per-user override. Migration 0232.
2026-07-09 13:14:07 +02:00
Marc-André Bombeck 725f69097f feat(medications): server-authoritative medication-efficacy view + API
Add the migration (0232), Prisma model MedicationEfficacyTarget (persists ONLY
the user's explicit target override; everything else derives each read), and
the server builder buildMedicationEfficacy that composes the target's series,
a before/after-start comparison, the cadence-aware adherence lane (never
recomputed), an optional conservative level-shift note, honest data-floor
states and the retarget options into one strictly-descriptive DTO — no
verdict/score field by construction. Expose GET /api/medications/[id]/efficacy
and PUT /api/medications/[id]/efficacy/target, regenerate the OpenAPI contract.
2026-07-09 12:45:50 +02:00
Marc-André Bombeck d2451021bc feat(documents): rasterize PDFs so image-only-wire providers can read them
Only Anthropic accepts a native PDF block; codex and other image-only-wire
providers dropped it. Add a bounded, best-effort server-side rasterizer that
renders a PDF's pages to JPEG images those providers read as ordinary image
input, so a scanned/image-only PDF is legible on the auto-read path.

- @napi-rs/canvas promoted to a direct dependency; its musl prebuilts + the
  pdfjs loader pinned into the standalone trace (next.config.ts)
- rasterize-pdf.ts: lazy pdfjs import (keeps DOMMatrix out of the eager chunk),
  first 10 pages, 1600px long edge, JPEG q80, never throws
- prepareVisionInput rasterizes a PDF for a non-Anthropic vision provider and
  keeps the native PDF block for Anthropic; the document capability reports
  PDFs readable via raster so the UI offers the read
2026-07-09 10:49:17 +02:00
Marc-André Bombeck 813dc2547b feat(documents): add per-user automatic AI reading opt-in
Add the documentsAutoAiRead per-user Boolean (migration 0231, default
false) that authorises ambient AI reading of uploaded vault documents.
OFF by default keeps the vault local-first for every self-hoster.

- schema column + migration 0231_documents_auto_ai_read
- documentAutoReadEnabled(userId) read helper
- GET/PATCH /api/auth/me/documents-auto-ai-read toggle route; the ON
  write mints an ai_full consent receipt for the audit trail
- the document consent gate short-circuits an external pick when the
  toggle is ON; a local pick stays ungated and OFF still requires a
  receipt for any external egress
2026-07-09 10:32:59 +02:00
Marc-André Bombeck 09bc94a1f0 chore(crypto): register + rotate verbatim index column; format 2026-07-09 03:00:24 +02:00
Marc-André Bombeck 718388ee04 test(documents): document-chat prompt, verbatim capture, and route security-model tests 2026-07-09 02:50:43 +02:00
Marc-André Bombeck 6e84048217 feat(documents): document-chat SSE route + history GET + OpenAPI (no-tools, no-snapshot, fenced, consent/budget/rate) 2026-07-09 02:44:55 +02:00
Marc-André Bombeck f5e4dc2b94 feat(documents): verbatim capture, chat prompt builder, budget, persistence documentId threading 2026-07-09 02:37:38 +02:00
Marc-André Bombeck 57c7972755 Merge branch 'fix/coldstart-perf' into release/v1.27.32 2026-07-09 02:08:46 +02:00
Marc-André Bombeck 7b1f077888 Merge branch 'fix/docai-governance-rebased' into release/v1.27.31 2026-07-09 01:28:07 +02:00
Marc-André Bombeck 4d6aab2816 Merge branch 'fix/docai-provider-governance' into fix/docai-governance-rebased
# Conflicts:
#	src/components/documents/__tests__/document-ai-section.test.tsx
#	src/components/documents/document-ai-section.tsx
2026-07-09 01:05:35 +02:00
Marc-André Bombeck 20275d19e0 Merge branch 'fix/resilience' into release/v1.27.30 2026-07-09 01:00:04 +02:00
Marc-André Bombeck 00140e509e docs(documents): correct extract-route gauntlet comment for new gate 2026-07-09 00:10:20 +02:00
Marc-André BombeckandMarc-André Bombeck c825d4b501 feat(documents): per-egress notice + document-AI capability probe
Add GET /api/documents/inbound/capability (document provider order + vendor-blind
egress class), repoint the vault capability hook to it, and show a per-egress
notice in the detail sheet before a document read leaves the machine. Cover the
document provider order, the egress consent gate, and the notice with tests.
i18n x6 + OpenAPI regenerated.

Co-Authored-By: Marc-André Bombeck
2026-07-08 23:59:56 +02:00