mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 20:32:02 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
635d7e2618 | ||
|
|
de8368cf01 | ||
|
|
1a642f1173 | ||
|
|
8dd04e0170 | ||
|
|
e9e186dca5 | ||
|
|
44ab74e125 | ||
|
|
71d7ca78cb | ||
|
|
5169be5887 | ||
|
|
a2cb4fb4a9 | ||
|
|
513e5309ac | ||
|
|
ccfda544f8 | ||
|
|
2c08201695 | ||
|
|
f223ca8979 | ||
|
|
3a45fcc3bd | ||
|
|
a2f926576e | ||
|
|
b00450aec8 | ||
|
|
c1c3aa9754 | ||
|
|
7ac9e7833e | ||
|
|
8c385b9ac2 | ||
|
|
9531bbdcec | ||
|
|
3c468b5e00 | ||
|
|
a66a346d0d | ||
|
|
20f0d13eba | ||
|
|
07f161bf25 | ||
|
|
5532a42ff6 | ||
|
|
66fe69b493 | ||
|
|
d2ad8e03e0 | ||
|
|
a0bde126dd | ||
|
|
749fb8eef1 | ||
|
|
efdcafb08d | ||
|
|
3dd5dbeb42 | ||
|
|
837c44e4c2 | ||
|
|
3dc6c7436f | ||
|
|
55186ccd3e | ||
|
|
a7c82eb582 | ||
|
|
d978c2b662 | ||
|
|
e7802701ba | ||
|
|
4c436d7dfc | ||
|
|
247bc4f33c | ||
|
|
1ff179178f | ||
|
|
7f44e2fa37 | ||
|
|
e8327109b1 | ||
|
|
e3dfe9f400 | ||
|
|
2c6699f682 | ||
|
|
2bc706a445 | ||
|
|
2b89259cce | ||
|
|
95ab0514b9 | ||
|
|
1417137162 | ||
|
|
2b3ff0e58f | ||
|
|
844b24c7ba | ||
|
|
bf1c052797 | ||
|
|
5f253f7d09 | ||
|
|
d124a3239d | ||
|
|
fb73c97125 | ||
|
|
4f7420905a | ||
|
|
8902d3985c | ||
|
|
30a0528ad0 | ||
|
|
cffebd78db | ||
|
|
8c0b200a5b | ||
|
|
0518c39050 | ||
|
|
a08e4378a0 | ||
|
|
0a08b166cf | ||
|
|
ba362f1d10 | ||
|
|
5cff7473c9 | ||
|
|
85a05efb4d | ||
|
|
d8e48a6304 | ||
|
|
60658be776 | ||
|
|
b11ecca8d2 | ||
|
|
43eddb63b0 | ||
|
|
351cf7dd85 | ||
|
|
05f80ad378 | ||
|
|
546c5c7d8f | ||
|
|
c527cd5aa4 | ||
|
|
16d142762a | ||
|
|
b396b51d35 | ||
|
|
da2de015fe | ||
|
|
bef3b9dd3f | ||
|
|
2b5fb617ba | ||
|
|
7445925d58 | ||
|
|
270f33edb2 | ||
|
|
4f198ec25f | ||
|
|
3aa11b0531 | ||
|
|
b3d586066a | ||
|
|
86e92f38ca | ||
|
|
43884569d3 | ||
|
|
63cdd709af | ||
|
|
99d80f8512 | ||
|
|
2da6a192a7 | ||
|
|
f6e6593bcb | ||
|
|
65f90160c7 | ||
|
|
f8c0ea5120 | ||
|
|
af4cad46fe | ||
|
|
ba5a17a54e | ||
|
|
2d9237fbad | ||
|
|
cb883e1470 | ||
|
|
8ac38926c4 | ||
|
|
2f1423fa09 | ||
|
|
d82862b384 | ||
|
|
a33527da57 | ||
|
|
8b7bb7914e | ||
|
|
1427125b7e | ||
|
|
b87f7b50d1 | ||
|
|
d470d406a7 | ||
|
|
73de8f6a52 | ||
|
|
46d25361da | ||
|
|
e272453327 | ||
|
|
502783a3ea | ||
|
|
04cb80da6a | ||
|
|
b6ded4d1f9 | ||
|
|
d72f078764 | ||
|
|
b7a6b4c898 | ||
|
|
7c96abc8ec | ||
|
|
cd3dfb81bc | ||
|
|
1d592bef78 | ||
|
|
d6ad76b1e1 | ||
|
|
08a3ae2ef1 | ||
|
|
0be26473b4 | ||
|
|
25d720e6e7 | ||
|
|
168c1c85d7 | ||
|
|
ba6c0990bf | ||
|
|
ecc34880ba | ||
|
|
7c45c876a0 | ||
|
|
e883b64b1f | ||
|
|
5440ac9070 | ||
|
|
5b24ca0fa1 | ||
|
|
57539283c8 | ||
|
|
49ed0f41df | ||
|
|
7a0a271c70 | ||
|
|
0f4e0be1a6 | ||
|
|
a523d2cc79 | ||
|
|
a9a318d4a4 | ||
|
|
f3a3b8f3a7 | ||
|
|
4ec6ae661e | ||
|
|
5b136b91e6 | ||
|
|
656e96f72b | ||
|
|
a59a5d75e1 | ||
|
|
0796d9fe14 | ||
|
|
15a09d77a0 | ||
|
|
cd0fbddd1d | ||
|
|
8b6024f0d7 | ||
|
|
6d83115d16 | ||
|
|
3a9ed8dd0e | ||
|
|
d4be568625 | ||
|
|
9569d1c528 | ||
|
|
45fb909c16 | ||
|
|
f05354796a | ||
|
|
461c1cfe09 | ||
|
|
273ead0304 | ||
|
|
7386630962 | ||
|
|
51d42f9422 | ||
|
|
3e917969c7 | ||
|
|
4852421f05 | ||
|
|
ea71ba2f37 | ||
|
|
0ba5334b57 | ||
|
|
8b1c5deac7 | ||
|
|
7a6598f7c3 | ||
|
|
82aa66b400 | ||
|
|
a614542cc3 | ||
|
|
132c113305 | ||
|
|
9bbd8a89dc | ||
|
|
136653e3d7 | ||
|
|
1e8d796c57 | ||
|
|
5445fc67ea | ||
|
|
a074286951 | ||
|
|
14a9a08a6e | ||
|
|
0b7d07dce4 | ||
|
|
bdae462948 | ||
|
|
bb1008f1a1 | ||
|
|
ac1105751d | ||
|
|
6d8fe06148 | ||
|
|
b57c517929 | ||
|
|
7592c73947 | ||
|
|
9b8b31c864 | ||
|
|
fa326c398a | ||
|
|
5b7ea48c46 | ||
|
|
f815e4a83f | ||
|
|
6712619495 | ||
|
|
a70139c16a | ||
|
|
c41ca511cb | ||
|
|
9ba34befce | ||
|
|
a21dc50cab | ||
|
|
4dfb207a6d | ||
|
|
14b61e1084 | ||
|
|
1375d20a9d | ||
|
|
85267f15b3 | ||
|
|
a4feabec81 | ||
|
|
14a517387e | ||
|
|
ae57025728 | ||
|
|
16f7869ffa | ||
|
|
8ee9c53b83 | ||
|
|
13464b512e | ||
|
|
132ede8833 | ||
|
|
45da6c3c7d | ||
|
|
da0e509095 | ||
|
|
5433fc8fbd | ||
|
|
4303ddaf37 | ||
|
|
9c63d3ce20 | ||
|
|
1085a1e056 | ||
|
|
2869d0ab27 | ||
|
|
1c0f91adc1 | ||
|
|
c400e2aa88 | ||
|
|
755da41019 | ||
|
|
3da6c781ed | ||
|
|
869f5e561b | ||
|
|
94ad48527e | ||
|
|
1212ed91d5 | ||
|
|
49be6851ec | ||
|
|
349cfa4d80 | ||
|
|
5f558abc33 | ||
|
|
993e693ad6 | ||
|
|
c2c4d56b44 | ||
|
|
719ba84673 | ||
|
|
ef1b5f5de9 | ||
|
|
3f4a845aae | ||
|
|
9b0d5dc0b7 | ||
|
|
3cdcb9c97c | ||
|
|
6d02711e33 | ||
|
|
fcff168112 | ||
|
|
eb102ac50c | ||
|
|
ee76672798 | ||
|
|
e484efb097 | ||
|
|
0d25f3191a | ||
|
|
54c78b527e | ||
|
|
2706a97838 | ||
|
|
33bcf18ae6 | ||
|
|
d4965033c2 | ||
|
|
d7e616eaa8 | ||
|
|
fa42c8bc0b | ||
|
|
82b4c09139 | ||
|
|
254de2794c | ||
|
|
46f8c71ee5 | ||
|
|
c8e67042a8 | ||
|
|
bf6231d79e | ||
|
|
edf7edc0d0 | ||
|
|
75eb0637e6 | ||
|
|
a3f118cef9 | ||
|
|
b9c0fd5b57 | ||
|
|
596e2f12cb | ||
|
|
0adba584a9 | ||
|
|
89634e20ee | ||
|
|
a53c84bc3f | ||
|
|
25ac037004 | ||
|
|
dc75ae69ae | ||
|
|
c07ffe78c0 | ||
|
|
0c72a5c8c6 | ||
|
|
9e5d84ef66 | ||
|
|
e5de72cae2 | ||
|
|
6f439b1572 | ||
|
|
73c694e308 | ||
|
|
a89e511359 | ||
|
|
8246d9251c | ||
|
|
0e7514f7b0 | ||
|
|
9672b7d8f4 | ||
|
|
d5f279a248 | ||
|
|
177029a80d | ||
|
|
97f8cb4303 | ||
|
|
1e2b1d2c6a | ||
|
|
9d37792ca4 | ||
|
|
783255d833 | ||
|
|
8617fa7129 | ||
|
|
725f69097f | ||
|
|
f3762cf0f9 | ||
|
|
45b7eb53ff | ||
|
|
f1c468f90f | ||
|
|
aa415a9e32 | ||
|
|
9f21d9f20d | ||
|
|
de16da43fe | ||
|
|
df78b29cfd | ||
|
|
79a78162e2 | ||
|
|
472a4904cd |
No files matched your search
+61
-3
@@ -29,10 +29,11 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Withings OAuth -- optional integration
|
||||
# Withings -- optional integration
|
||||
# -----------------------------------------------------------------------------
|
||||
# WITHINGS_CLIENT_ID=""
|
||||
# WITHINGS_CLIENT_SECRET=""
|
||||
# The Withings OAuth client id/secret are per-user: each user registers their
|
||||
# own Withings developer app and pastes the pair into Settings (stored
|
||||
# encrypted in the DB). There is therefore no WITHINGS_CLIENT_ID/SECRET.
|
||||
# WITHINGS_WEBHOOK_SECRET=""
|
||||
|
||||
|
||||
@@ -86,6 +87,49 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# OURA_CLIENT_SECRET=""
|
||||
# OURA_REDIRECT_URI=""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Strava -- optional integration (full OAuth, shared app from env)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Register an app at https://www.strava.com/settings/api and set its
|
||||
# "Authorization Callback Domain" to your instance host. Both vars together
|
||||
# enable the Strava connect button; set neither to leave the integration
|
||||
# disabled. Per-user BYO client id/secret in Settings take precedence over these
|
||||
# shared env values. Strava caps every new app at single-player mode (athlete
|
||||
# capacity 1) until you request a capacity increase — fine for a personal
|
||||
# self-host. Strava rotates its refresh token on every refresh; the sync
|
||||
# refreshes reactively on a 401. STRAVA_REDIRECT_URI overrides the derived
|
||||
# `${NEXT_PUBLIC_APP_URL}/api/strava/callback`.
|
||||
# STRAVA_CLIENT_ID=""
|
||||
# STRAVA_CLIENT_SECRET=""
|
||||
# STRAVA_REDIRECT_URI=""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# OIDC SSO login -- optional (self-hosted identity provider)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Register a confidential OIDC client for your IdP (Authentik, Keycloak,
|
||||
# Authelia, Google Workspace, etc.) with redirect URI
|
||||
# `${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback`. All three of ISSUER_URL/
|
||||
# CLIENT_ID/CLIENT_SECRET together enable the "Sign in with SSO" button on
|
||||
# the login page; set none to leave it disabled. First-time sign-in
|
||||
# auto-provisions (or links, by verified email) a HealthLog account. OIDC_ONLY
|
||||
# hides password/passkey login entirely once set to "true" -- but only takes
|
||||
# effect when the three vars above are also fully set, so a typo can't lock
|
||||
# every user out. Enforced server-side, not just a hidden button. The native
|
||||
# iOS client authenticates via password/passkey only -- there is no OIDC
|
||||
# flow for it yet -- so OIDC_ONLY="true" locks the iOS app out of sign-in
|
||||
# entirely until a native SSO flow ships. Do not enable it on a deployment
|
||||
# with active iOS users.
|
||||
# Serve the issuer over HTTPS wherever possible: sign-in material (the
|
||||
# authorization code, the token-endpoint response carrying the ID token)
|
||||
# crosses this connection. Plain HTTP is defensible only on a fully trusted
|
||||
# private network (IdP and app on the same box, a Tailscale mesh, a VPN).
|
||||
# OIDC_ISSUER_URL=""
|
||||
# OIDC_CLIENT_ID=""
|
||||
# OIDC_CLIENT_SECRET=""
|
||||
# OIDC_SCOPES="openid email profile"
|
||||
# OIDC_BUTTON_LABEL="Single Sign-On"
|
||||
# OIDC_ONLY="false"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Web Push -- VAPID keys (optional; the admin panel is the easier path)
|
||||
@@ -168,6 +212,11 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# self-hosted Ollama / LM Studio endpoint. See docs/integrations/ai-providers.md.
|
||||
# ALLOW_LOCAL_AI_PRIVATE_HOSTS=""
|
||||
|
||||
# Daily-briefing output-token ceiling (optional). Default 2500, clamped to
|
||||
# 500-8000. Raise it when a verbose model's briefing JSON gets cut off
|
||||
# ("AI response was cut off"). See docs/integrations/ai-providers.md.
|
||||
# INSIGHTS_MAX_TOKENS=""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Off-host backups -- all-or-none
|
||||
@@ -254,3 +303,12 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# force the public-host guard, so an internal instance is not blocked).
|
||||
# OPENMETEO_BASE_URL="https://archive-api.open-meteo.com"
|
||||
# OPENMETEO_GEOCODING_URL="https://geocoding-api.open-meteo.com"
|
||||
|
||||
# ── Native canvas renderer (document thumbnails, scanned-PDF rasterization) ──
|
||||
# The bundled renderer's x64 build uses AVX2 CPU instructions. On x86-64 hosts
|
||||
# WITHOUT AVX2 (Celeron/Atom-class NAS CPUs) it is disabled automatically and
|
||||
# those features degrade cleanly (no thumbnails; scanned PDFs read as text
|
||||
# only). Override only if auto-detection misfires:
|
||||
# off — force-disable the renderer
|
||||
# on — force-enable (crashes the container if the CPU truly lacks AVX2)
|
||||
# NATIVE_CANVAS="off"
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Targets the `develop` branch (not `main` — see [CONTRIBUTING.md](../CONTRIBUTING.md))
|
||||
- [ ] Targets the `main` branch (trunk-based — see [CONTRIBUTING.md](../CONTRIBUTING.md))
|
||||
- [ ] `pnpm typecheck` passes locally
|
||||
- [ ] `pnpm lint` passes locally
|
||||
- [ ] `pnpm test` passes locally
|
||||
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
# Image is built but not pushed in that case.
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -293,7 +293,7 @@ jobs:
|
||||
echo "IMAGE_REF=${REGISTRY}/${IMAGE_NAME,,}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
+948
@@ -2,6 +2,954 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.30.2] — 2026-07-18
|
||||
|
||||
More audit-backlog fixes.
|
||||
|
||||
- **Coach history is fully reachable.** The conversation list pages through your whole history and search runs on the server (by title), instead of only the first ~20 loaded chats.
|
||||
- **Navigation and flows.** A document links to the lab values it produced; the two "Recovery" pages are named distinctly and cross-linked; mood, mental wellbeing, and mood insights link to each other; the nutrients card moved out of "Source priority"; the onboarding checklist and tour no longer point at dead ends; asking the coach from a metric or a plan carries that context in.
|
||||
- **Correctness.** Correlations, the intraday chart, achievements, and the nutrients overview now bucket days by your own timezone and lead with the most recent data; a multi-source day is no longer counted twice.
|
||||
- **Performance.** The workouts list caches its de-duplication per filter, so paging through a long history is quick.
|
||||
|
||||
No migration. No breaking changes.
|
||||
|
||||
## [1.30.1] — 2026-07-18
|
||||
|
||||
Polish and small fixes.
|
||||
|
||||
- **Mobile.** Coach and document text wraps instead of overflowing; the intake history is a card list on small screens instead of a sideways-scrolling table; tap targets meet the minimum size; two dialogs became bottom sheets; the workout detail header stacks cleanly.
|
||||
- **Feedback.** Loading skeletons match the content that replaces them (no more layout jump), and the Today priority-card actions show a pending state and can’t fire twice on a double-tap.
|
||||
- **Remembered choices.** A chart’s time range and the measurement type you last logged are remembered; a coach chat deletes with an Undo; a backdated dose can be undone; lab panels get “Save & add another”; pull-to-refresh works on more pages (and a scroll-position bug that made it fire anywhere is fixed).
|
||||
- **Consistency, accessibility, wording.** Spacing and contrast tidied, more empty states use the shared component, headings and focus rings improved, a few German phrasings corrected, the daily line reads more calmly, and several explanations gained their guideline citation.
|
||||
|
||||
No migration. No breaking changes.
|
||||
|
||||
## [1.30.0] — 2026-07-18 — Quality pass
|
||||
|
||||
A broad correctness, performance, and polish release, plus two new features. Nothing new is required of you; existing data is untouched.
|
||||
|
||||
### Added
|
||||
|
||||
- **Document summaries.** An uploaded document can get a short plain-language summary, generated once at upload and stored encrypted. It runs only when the external-AI document reading you already opted into is on, and never touches older documents.
|
||||
- **Efficacy chart, readable.** The medication efficacy chart scales its axis to the data (a real trend no longer looks flat), gains a “% since start” view, and the history tab reaches your full intake history, not just the last 90 days.
|
||||
- **A metric catalog.** A quiet “All metrics” view lists everything HealthLog can track and which device supplies each — so a metric you don’t have data for stays out of your daily views but is still discoverable in one place.
|
||||
|
||||
### Fixed — correctness
|
||||
|
||||
- **The number shown matches reality.** The workouts and measurements lists now lead with the most recent entries (a long history no longer hid recent days), and a metric recorded by two devices on the same day is counted once, matching the dashboard — instead of being summed twice.
|
||||
- **Strava workouts carry their real sport** (cycling, running, …) — like WHOOP already did — with a history backfill, and back-to-back rides no longer collapse into one.
|
||||
- **Personal records and correlations** no longer double-count multi-source days or mix per-reading and daily-total values.
|
||||
|
||||
### Fixed — medical safety
|
||||
|
||||
- The hypoglycemia alert now uses your own glucose unit; the doctor-report PDF states your own timezone; the crisis card offers Austrian and Swiss lines, not only German ones; and resting-pulse labels drop clinical-verdict wording for calmer, neutral framing.
|
||||
|
||||
### Fixed — the daily view
|
||||
|
||||
- The Today view is faster (its heaviest reads are now cached instead of recomputed on every refresh) and updates immediately after you log a measurement, mood, or water — not only medication.
|
||||
- The intraday heart-rate chart no longer draws a continuous shape across gaps in sparse data, and shows how many readings it’s based on. A failed load shows a retry instead of an empty state.
|
||||
|
||||
### Fixed — onboarding, dates, consistency, accessibility
|
||||
|
||||
- Connecting a device no longer dead-ends on a fresh account, and the setup steps (including the callback URL) are shown up front.
|
||||
- A date from a previous year now shows the year, everywhere it’s displayed.
|
||||
- Contrast, chart colours in the light theme, and a handful of duplicated components were tidied; keyboard access and headings improved on several surfaces; the insights and settings menus are grouped more sensibly.
|
||||
|
||||
### Changed
|
||||
|
||||
- **A fuller backup.** The full backup now also includes lab results, illness episodes, allergies, family history, workouts, and a documents list. What isn’t included (raw document files, workout GPS/heart-rate detail) is stated plainly.
|
||||
|
||||
### Performance
|
||||
|
||||
- The Today digest and the insights cards read through the pre-aggregated tier instead of scanning raw rows; batch imports write in one pass.
|
||||
|
||||
Two migrations (0250, 0251). No breaking changes.
|
||||
|
||||
## [1.29.5] — 2026-07-17
|
||||
|
||||
### Security
|
||||
|
||||
- **Hardened the same-origin redirect guard.** A crafted relative
|
||||
post-login `next` path (e.g. `/..//evil.com`) passed the same-origin
|
||||
check but left a protocol-relative pathname that could re-resolve to a
|
||||
foreign origin when re-used in a redirect or client navigation. The
|
||||
guard now re-verifies the reconstructed path resolves on-origin and
|
||||
rejects it otherwise. Covers the OIDC callback and the password/passkey
|
||||
login redirect.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **OIDC login redirects use the operator app URL, not the request URL.**
|
||||
Behind a reverse proxy that does not forward the original host, the
|
||||
login and callback routes built their browser redirects from the
|
||||
address the app was reached at inside the container, sending users to
|
||||
an unreachable internal URL after login. Every OIDC redirect is now
|
||||
built from the operator-set `NEXT_PUBLIC_APP_URL`, matching the
|
||||
convention the other integrations already use.
|
||||
|
||||
## [1.29.4] — 2026-07-17
|
||||
|
||||
- **The workouts list shows the most recent first.** The list was ordered
|
||||
oldest-first, so on a longer history the newest sessions never made the first
|
||||
page. It now leads with the latest workout.
|
||||
- **Past-year dates show the year.** A workout from a previous year now carries
|
||||
its year in the list, so a December date is never mistaken for this year's.
|
||||
|
||||
## [1.29.3] — 2026-07-17
|
||||
|
||||
- **Hydration and micronutrients now have a home.** Your fluid intake and the
|
||||
vitamins and minerals your device records get a surface. A water tile on the
|
||||
dashboard shows the day's total with quick-add amounts; a new **Nutrients**
|
||||
page under Insights shows hydration over the last month, caffeine when you
|
||||
record it, and the vitamins and minerals you actually have data for — each
|
||||
against its reference daily intake where that applies, shown as context, never
|
||||
as a verdict. Manual water entries and device-synced totals now coexist
|
||||
instead of overwriting each other. Sparse logging is never read as a
|
||||
deficiency.
|
||||
|
||||
The nutrients tracking stays opt-in — enable it in Settings, or with the
|
||||
one-tap prompt on the page. Device-synced micronutrients flow once your client
|
||||
sends them.
|
||||
|
||||
One migration (0249). No breaking changes.
|
||||
|
||||
## [1.29.2] — 2026-07-17
|
||||
|
||||
- **WHOOP workouts carry their real sport.** WHOOP was the only source that
|
||||
didn't translate its activity into the app's own sport names, so
|
||||
WHOOP-tracked rides and runs landed unlabelled instead of as cycling or
|
||||
running. New and existing WHOOP workouts are now mapped, so your bike rides
|
||||
show up as cycling — history included. When the same session comes in from two
|
||||
sources, the canonical row now keeps the richest reading (heart rate, energy,
|
||||
distance) instead of dropping it.
|
||||
- **Scroll back through the day's heart rate.** The intraday heart-rate view
|
||||
gains a day navigator to step through previous days. The fine-grained history
|
||||
now stays for 90 days (was 14); older days show the coarser hourly shape.
|
||||
- **Clear a card from Today.** A milestone, a new ECG, or a tension note can be
|
||||
dismissed from the Today rail once you've seen it; the section tightens when
|
||||
the last one is gone. The cards that need an action stay until you act.
|
||||
- **Vitals context moves into an info tap.** The vitals tiles carry an (i) with
|
||||
the context — your reading against your personal range, and against your age
|
||||
where that applies — instead of a permanent caption. The VO₂ max heading no
|
||||
longer gets cut off.
|
||||
- **Oura cycle phases feed the cycle tracker.** Where your Oura connection
|
||||
provides cycle-phase data, it now fills the cycle tracker as a background
|
||||
source; your own entries always win.
|
||||
|
||||
Two migrations (0247, 0248). No breaking changes.
|
||||
|
||||
## [1.29.1] — 2026-07-17
|
||||
|
||||
A round of fixes and small refinements from living with the daily view.
|
||||
|
||||
- **Today refreshes right after a dose.** Logging or undoing a medication now
|
||||
updates the day's read immediately — the medication line no longer lingers on
|
||||
a stale count until the next reload.
|
||||
- **A calmer Today hero.** The extra score rings are gone; the hero keeps the
|
||||
one health-score ring and a tighter layout.
|
||||
- **The Coach names the document you attached.** Asking about a document now
|
||||
shows its title in the chat instead of a plain "1 document" count, and the
|
||||
attach menu stays within its popover on long labels.
|
||||
- **A fresh install starts with tracking on.** New installs now enable the
|
||||
tracking modules by default — you can still switch any of them off. The
|
||||
external-AI options and the daily push stay off until you turn them on.
|
||||
- **Insights vitals lose the explainer line.** The "your personal normal range
|
||||
is the median…" caption is removed from the baseline tiles.
|
||||
- **Illness journal: set and edit the end.** You can mark an episode's end date,
|
||||
clear it back to ongoing, and backdate the start. When nothing strayed from
|
||||
your personal range, the course section now reads a short settled line instead
|
||||
of appearing empty.
|
||||
- **The morning refresh covers every sleep source.** The refresh that folds last
|
||||
night's sleep into the day now triggers from Google Health, Fitbit, Oura, and
|
||||
Polar as well, not only Withings, WHOOP, and Apple Health. Each skipped refresh
|
||||
is now recorded so a stale morning read can be traced to its cause.
|
||||
|
||||
No new data is collected and there is no migration.
|
||||
|
||||
## [1.29.0] — 2026-07-17 — A daily health companion
|
||||
|
||||
This release draws a line under a run of work that turns HealthLog from a place
|
||||
you record your health into one that meets you each day — and it completes that
|
||||
picture with the pieces that make it feel whole.
|
||||
|
||||
Over the last releases the dashboard gained a **Today** view that leads with the
|
||||
day's read, a health score that refreshes the moment last night's sleep syncs, a
|
||||
coach check-in when a plan is due for a look-back, and an optional morning note.
|
||||
Documents came closer to hand — the Coach opens beside them, you can bring
|
||||
several into a conversation, and lab values move between a document and your
|
||||
labs. This release adds the layer that ties it together:
|
||||
|
||||
- **Your heart's rhythm, in context.** ECG recordings are no longer a page on
|
||||
their own — they're referenced where your resting heart rate and rhythm are
|
||||
discussed, always as the reading your device produced, never a reading of the
|
||||
app's own. A new recording surfaces gently in Today.
|
||||
- **The shape of your day.** Heart rate is now drawn across the day, not just as
|
||||
a daily number — so you can see it settle and climb. When it stays elevated
|
||||
while you're at rest and not moving, Today notes it plainly as possible
|
||||
tension, carefully and never as a verdict.
|
||||
- **Quiet milestones.** Reaching a steady stretch in your target range, or a new
|
||||
personal best, is marked with a calm moment — states you've reached, not
|
||||
streaks to keep, so there's nothing to break and nothing to nag.
|
||||
- **One piece throughout.** The Today view brings back the health rings you
|
||||
choose, each surface carries its metric's own calm colour, and the whole thing
|
||||
reads as one design rather than a stack of features.
|
||||
|
||||
Nothing new generates when you open the app — every daily surface reads the
|
||||
insight prepared overnight. No new data is collected; this makes more of what's
|
||||
already yours.
|
||||
|
||||
Four changes bring documents and your record closer together:
|
||||
|
||||
- **Ask the Coach opens beside your document.** Asking about a document now
|
||||
opens the Coach in a side panel rather than jumping to a full page — expand it
|
||||
to full screen whenever you like, and it keeps its place.
|
||||
- **Bring documents into a Coach chat.** From the composer's "+", attach one or
|
||||
more documents — pick from your vault or upload a new one, at the start or
|
||||
mid-conversation — and the Coach answers grounded in them. A conversation with
|
||||
a document runs in its fenced mode: grounded in those documents only, with no
|
||||
access to your health data or tools, so nothing in an uploaded file can reach
|
||||
anything it shouldn't.
|
||||
- **Lab values from a document, proposed for your labs.** When a document you've
|
||||
had read looks like lab work, its values are detected and proposed — one tap to
|
||||
add them to your labs; they're never added without your say-so.
|
||||
- **A lab scan is also kept.** Scanning a lab report into your labs now also
|
||||
files the document in your vault (encrypted, like every upload) and links the
|
||||
two, so the reading and its source stay together.
|
||||
|
||||
Migration 0245 (attaching multiple documents to a conversation) accompanies this
|
||||
release; existing document chats carry over unchanged.
|
||||
|
||||
Three pieces turn the Today view into a daily loop:
|
||||
|
||||
- **A coach check-in.** When a coach plan is due for a look-back, it surfaces as
|
||||
a calm card in Today with keep, adjust, or let-go — one tap. Letting go retires
|
||||
the plan quietly; silence never changes it.
|
||||
- **A morning that reflects last night.** The score and read are prepared
|
||||
overnight, but last night's sleep usually hasn't synced by then. Now, the
|
||||
moment it arrives, the sleep-dependent parts refresh on their own — so by the
|
||||
time you look, the day is up to date. Until sleep is in, Today says so plainly
|
||||
instead of showing a stale score.
|
||||
- **An optional daily nudge.** A once-a-day morning notification with the day's
|
||||
read, off by default and opt-in per channel under Notifications. It carries the
|
||||
refreshed digest when sleep is in, never repeats within a day, and is a calm
|
||||
note — never an alert.
|
||||
|
||||
All three read the digest prepared overnight; nothing is generated when you open
|
||||
the app.
|
||||
|
||||
With the Today view now leading the dashboard, the older opt-in hero card it
|
||||
replaced is retired, so the two never stack. The greeting stays, the tiles and
|
||||
charts are unchanged, and a layout stored with the old hero switched on simply
|
||||
ignores that setting — nothing to redo.
|
||||
|
||||
## [1.28.53] — 2026-07-16 — A "Today" overview at the top of your dashboard
|
||||
|
||||
The dashboard opens with a Today view: your health score, the day's lead read,
|
||||
and a short "worth a look" list — a dose window that's open, an integration to
|
||||
reconnect, a check-up coming due — each a single tap to the right place. The
|
||||
familiar tiles and charts sit just below, unchanged.
|
||||
|
||||
It reads the insight that's already prepared overnight, so it loads instantly
|
||||
and never generates anything on open. When last night's sleep hasn't synced yet,
|
||||
it says so plainly and fills in the sleep-dependent parts once the data arrives,
|
||||
rather than showing a stale or empty score. This is the first piece of a single
|
||||
daily view that later surfaces — a morning refresh, an optional daily note —
|
||||
will all build on.
|
||||
|
||||
The dashboard gains tiles for readings that were synced and charted but never
|
||||
had a place on the home strip: heart-rate variability, blood oxygen, breathing
|
||||
rate, wrist temperature, and — for anyone with a body-composition scale — muscle
|
||||
mass, body water, and bone mass.
|
||||
|
||||
Each tile appears only once you have that reading, so nothing new shows up on an
|
||||
account that doesn't track it, and every tile can be toggled or reordered under
|
||||
Settings → Dashboard like the rest. Tapping one opens its detail view. Nothing
|
||||
changed in how the readings are stored or synced — this only surfaces what was
|
||||
already there.
|
||||
|
||||
## [1.28.51] — 2026-07-16 — Chatting about a document lives in the Coach now
|
||||
|
||||
Asking the Coach about a document used to open a separate, half-finished chat
|
||||
panel that never showed up in your Coach history. It now opens the real Coach:
|
||||
the conversation appears in your Coach list like any other, marked with a small
|
||||
document badge and a "Chatting about: <title>" note so it's clear which
|
||||
document it's scoped to.
|
||||
|
||||
The safeguards that make document chat safe are unchanged. A conversation about
|
||||
a document still runs in its fenced mode — grounded in that document's text
|
||||
only, with no access to your health data or any tools — so text inside an
|
||||
uploaded file can't reach anything it shouldn't. Only the surface moved into the
|
||||
Coach; the boundary did not.
|
||||
|
||||
ECG recordings synced from a compatible watch were stored but never shown. They
|
||||
now have a place in Insights: a list of your recordings, each opening the full
|
||||
waveform on a familiar ECG grid, with the date, duration, average heart rate,
|
||||
and the result your device recorded.
|
||||
|
||||
The result shown is the one your recording device produced — HealthLog does not
|
||||
read or interpret the trace and does not provide a diagnosis. A recording is
|
||||
single-lead and for personal awareness only; if a result concerns you, a note
|
||||
alongside it points you to discuss the recording with a clinician.
|
||||
|
||||
## [1.28.49] — 2026-07-16 — Apple Health re-import no longer stops on a duplicate reading
|
||||
|
||||
Re-importing a cumulative export.zip could run for several minutes and then fail
|
||||
outright on a unique-constraint error when two readings shared the same time,
|
||||
type, and source but carried different internal ids — a single such reading
|
||||
aborted the whole import. The importer now recognises that case: it updates the
|
||||
existing reading in place (and brings back one that had been removed) instead of
|
||||
failing, and if it still can't place a single reading it skips just that one and
|
||||
carries on. A re-import completes cleanly.
|
||||
|
||||
## [1.28.48] — 2026-07-16 — Security follow-ups
|
||||
|
||||
Two security fixes:
|
||||
|
||||
- On a brand-new instance with single-sign-on configured, two people signing in
|
||||
for the very first time at the same moment could both be made admin. The
|
||||
first-admin selection now takes the same lock as password registration, so
|
||||
exactly one admin is created no matter which sign-in path or how concurrent.
|
||||
- A background nutrient-import error could echo a raw database message back to
|
||||
the client; it now returns a fixed status and logs the detail server-side only.
|
||||
|
||||
## [1.28.47] — 2026-07-16 — Job queue engine update (pg-boss 12.26)
|
||||
|
||||
Updates the background-job engine (pg-boss) from 12.20 to 12.26, which adds
|
||||
schema-drift detection, self-healing for background index builds, and a set of
|
||||
correctness fixes. Some of those fixes surface errors that earlier versions
|
||||
silently swallowed; the app already attaches an error listener to the queue and
|
||||
does not use the direct calls affected by the change, so the upgrade is
|
||||
transparent. No configuration change is needed.
|
||||
|
||||
## [1.28.46] — 2026-07-16 — Server performance on large instances
|
||||
|
||||
Performance work for accounts with a lot of history, none of it changing what
|
||||
any number means:
|
||||
|
||||
- Insight generation computed its 30-day comparison averages by pulling every
|
||||
matching row of the last 400 days into memory and averaging in code. It now
|
||||
asks the database for the averages directly — the same figures, without
|
||||
materialising the rows. (Sleep keeps its per-night reconstruction path.)
|
||||
- The background scans that run at worker startup used an in-memory
|
||||
de-duplication over full-table reads. They now de-duplicate in the database
|
||||
and are backed by partial indexes (migration 0243, additive), so startup on a
|
||||
large instance no longer does full scans.
|
||||
- The coach chat re-rendered every message on every streamed token; now only
|
||||
the message being streamed re-renders.
|
||||
- The doctor-report mood read no longer pulls fields it doesn't use.
|
||||
|
||||
## [1.28.45] — 2026-07-16 — Doctor report + privacy fixes
|
||||
|
||||
- Sleep now actually appears in the doctor-report PDF. The sleep section was
|
||||
enabled by default and its data was prepared, but the PDF's vitals table left
|
||||
it out, so the section rendered empty — it now shows time asleep in hours.
|
||||
- Glucose can now be included or withheld from a shared report like every other
|
||||
clinical section. The toggle existed in settings but did nothing; it now
|
||||
gates the glucose data end to end.
|
||||
- Turning the environment module off now also keeps its data (audio exposure,
|
||||
daylight, skin temperature) out of the coach, matching how the other modules
|
||||
behave.
|
||||
|
||||
## [1.28.44] — 2026-07-16 — Loose ends: a broken link, the API contract, module guards
|
||||
|
||||
- The "connect a device" link on the sleep insights empty state pointed at a
|
||||
settings page that doesn't exist and returned a 404; it now opens the
|
||||
integrations settings.
|
||||
- Six endpoints the iOS app already uses were missing from the published API
|
||||
contract (`docs/api/openapi.yaml`); they are now documented, so the contract
|
||||
is complete again.
|
||||
- A few module pages didn't redirect away when their module was turned off, the
|
||||
way the others do; they now behave consistently.
|
||||
|
||||
## [1.28.43] — 2026-07-16 — Visual consistency polish
|
||||
|
||||
A pass over small visual inconsistencies the design audit surfaced:
|
||||
|
||||
- The insights header now uses the standard heading weight and spacing scale.
|
||||
- Two dashboard tiles that hand-rolled a dimmer header now use the same tile
|
||||
header as their neighbours, so the cards read as one family.
|
||||
- A few tiles sat a step denser than their siblings; they now match.
|
||||
- Two header icons that used the accent colour are back to the standard.
|
||||
- Several edit dialogs on phone-reachable surfaces now open as the same bottom
|
||||
sheet the rest of the app uses.
|
||||
|
||||
A new lint rule keeps card spacing on the standard scale so this drift can't
|
||||
quietly return.
|
||||
|
||||
## [1.28.42] — 2026-07-16 — Snappier lists, correct mood refresh, safer first sign-up
|
||||
|
||||
Performance and correctness fixes:
|
||||
|
||||
- The shared date/number formatters rebuilt a fresh locale formatter on every
|
||||
single call, which stalled the main thread across long history lists. They
|
||||
now reuse cached formatters — the same output, without the per-row cost.
|
||||
- Long measurement lists (a year of dense readings) rendered every row twice —
|
||||
a desktop and a mobile copy, one hidden with CSS. They now render one layout
|
||||
for the current viewport.
|
||||
- Logging a mood entry now refreshes the dashboard immediately; the mood tile
|
||||
and score could previously stay stale for up to two minutes after a change.
|
||||
- On a brand-new instance, two people signing up at the exact same moment could
|
||||
both be made admin. First-admin selection is now atomic, so exactly one does.
|
||||
|
||||
## [1.28.41] — 2026-07-16 — Complete the translations, and guard the gap
|
||||
|
||||
One reminder-adjacent label — the recovery-driver "functional impact" track on
|
||||
the illness insights card — was showing its internal name instead of a
|
||||
translation in every language; it is now translated in all six. The class of
|
||||
bug behind it (a label resolved through a computed key the static check can't
|
||||
see, so a missing translation ships silently — the same seam as the well-being
|
||||
labels fixed a release ago) is now closed structurally: a new test walks each
|
||||
such key space against every language bundle, so a missing translation fails
|
||||
the build instead of reaching a user.
|
||||
|
||||
## [1.28.40] — 2026-07-16 — Insight assessments lead with meaning, not a number
|
||||
|
||||
The per-metric insight assessments read like a data readout — they opened on
|
||||
the current value. The overview texts already lead with the day's verdict in
|
||||
plain words and land the warm, motivating tone; the per-metric cards were told
|
||||
the opposite (name the level with a number first) and never received the
|
||||
opener-variation the other insight surfaces use. Now every metric assessment
|
||||
opens with what the reading MEANS and brings the number in right after as
|
||||
support, in the same warm register as the overview. A shared opening-shape
|
||||
contract keeps the surfaces from drifting apart again, and the deterministic
|
||||
first-paint fallbacks lead with the verdict too. No change to what the numbers
|
||||
say — only how the sentence is built.
|
||||
|
||||
## [1.28.39] — 2026-07-16 — Sync data-loss hardening
|
||||
|
||||
Four fixes to sync paths that could lose or corrupt data in edge cases:
|
||||
|
||||
- The nightly pass that folds raw cumulative samples (steps, energy, distance)
|
||||
into daily totals no longer re-counts a sample you had deleted — it was the
|
||||
one such pass missing the deleted-row filter its siblings all carry.
|
||||
- That same pass is now safe when two runs overlap (a deploy-timing overlap, or
|
||||
a manual admin trigger racing the nightly job): an advisory lock plus an
|
||||
in-transaction re-read make the fold idempotent, so an overlap can no longer
|
||||
permanently double a day's total.
|
||||
- A re-scored Withings sleep segment (its end time shifts) now updates the
|
||||
existing row in place instead of colliding with it and wedging that night —
|
||||
the same re-key rescue the other providers already carry.
|
||||
- Withings measurement sync now holds its position when a reading fails to
|
||||
write, instead of advancing past it and stranding the reading; the next sync
|
||||
retries it.
|
||||
|
||||
## [1.28.38] — 2026-07-16 — Documents polish, waist reminders, bulk sharing
|
||||
|
||||
The document vault gets a round of overdue polish. The list cards drop a
|
||||
stray glyph — the "read by AI" mark it carried moves into the document's
|
||||
detail view — and give the real filename its own line so it stops being cut
|
||||
off; the detail view's close button is aligned correctly (a shared sheet
|
||||
primitive was reserving space for a button it wasn't showing). The action
|
||||
that opens a document in the coach now reads "Coach fragen".
|
||||
|
||||
From a multi-selection in the overview you can now share several documents
|
||||
at once — one link holding the selection, with the same documents-only
|
||||
privacy as a single share (up to 50 per link).
|
||||
|
||||
Reminders can now be linked to waist circumference. And the well-being
|
||||
reminder types (WHO-5, sleep-condition) that were showing their internal
|
||||
names instead of a label are now translated in every language.
|
||||
|
||||
## [1.28.37] — 2026-07-15 — Provider step totals survive a restart
|
||||
|
||||
A boot-time maintenance pass that folds genuinely old raw step samples into
|
||||
daily totals had too broad a reach: it also matched the daily step totals that
|
||||
Google Health, Fitbit, and Withings write, and soft-deleted them on every
|
||||
worker start. Recent days came back on the next sync, but older days stayed
|
||||
gone until a full re-sync — which the next restart swept again. The pass now
|
||||
skips any row that is already a daily total and only ever touches the ingest
|
||||
paths that produced real legacy raw samples, so a provider's step history is
|
||||
left alone. A one-time repair restores the totals removed by earlier restarts
|
||||
(within the retention window; a single full sync recovers anything older) and
|
||||
cleans up the placeholder totals the bug minted in their place.
|
||||
|
||||
## [1.28.36] — 2026-07-15 — Re-importing after a failed Apple Health import actually retries
|
||||
|
||||
The v1.28.33 rollup and staging fixes were correct but never reached the
|
||||
reporter's case: the upload deduplicates by content hash, and the lookup
|
||||
matched a previously FAILED job too — so re-uploading the same export.zip
|
||||
returned the old failed job and replayed its stale error without ever running
|
||||
the import again. The dedup now ignores failed jobs; the same file retries
|
||||
cleanly. A redundant staged upload is also cleaned up on a dedup hit instead
|
||||
of lingering in the staging directory.
|
||||
|
||||
Two related hardenings: the boot-time reconcile that marks interrupted imports
|
||||
failed now performs a real liveness check (queue state plus a progress
|
||||
heartbeat) instead of unconditionally failing every in-flight job — so a split
|
||||
or multi-worker deployment no longer kills an import running in another worker.
|
||||
And the "staging file not found" message now names the separate-container cause
|
||||
explicitly, with a matching note in the scaling guide.
|
||||
|
||||
## [1.28.35] — 2026-07-14 — Timestamps honor the profile timezone
|
||||
|
||||
Client-rendered times previously fell back to a hardcoded display zone
|
||||
(Europe/Berlin) no matter what the profile timezone said; a user in Manila saw
|
||||
Berlin clock times on every card, chart, and table. Display now follows the
|
||||
profile timezone everywhere, with a strict fallback to the legacy display zone
|
||||
— never the browser zone, so screen, PDF, and export can no longer disagree.
|
||||
An invalid stored timezone can never break rendering; it falls back safely.
|
||||
|
||||
The same pass closed the latent inconsistencies the audit surfaced: the
|
||||
"Updated today" cards no longer mix one zone for the day boundary and another
|
||||
for the clock; calendar-date fields no longer shift a day for profiles west of
|
||||
UTC; the dose-history day headings and weekday labels render in the display
|
||||
zone; chart day/month labels stay correct for every zone (bucket math is
|
||||
untouched); and the clinician share view renders in the patient's timezone
|
||||
instead of the server container's. Day groupings, schedules, and aggregates
|
||||
were already timezone-correct server-side and are byte-identical.
|
||||
|
||||
## [1.28.34] — 2026-07-14 — 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 to HealthLog as daily totals. This is
|
||||
deliberately not a nutrition feature: no food diary, no calorie tracking, no
|
||||
dashboard tile. The data lands in a dedicated store (26 nutrients with EFSA
|
||||
reference values), is visible on a read-only card under Settings → Sources,
|
||||
and will feed the Coach as context in a later release.
|
||||
|
||||
The module is opt-in and off by default; with it off, the server refuses the
|
||||
data entirely. Re-posting a day replaces it, units are guarded against
|
||||
µg/mg mix-ups, and implausible values are skipped per entry. This release is
|
||||
inert until the iOS companion app ships its reading side (coordination ticket
|
||||
filed); the wire contract is in the OpenAPI spec.
|
||||
|
||||
## [1.28.33] — 2026-07-14 — Re-importing a cumulative Apple Health export works again
|
||||
|
||||
Re-uploading a fresh export.zip over existing data could fail with a duplicate-key
|
||||
error in the rollup tier: a concurrent recompute (a live sync, the nightly drain)
|
||||
racing the import's full fold could collide on the same aggregation bucket — and
|
||||
the losing side silently dropped its freshly computed aggregate, leaving a stale
|
||||
bucket. Rollup writes are now conflict-safe upserts with deterministic lock
|
||||
ordering: a race can neither fail the import nor lose an aggregate, and buckets
|
||||
left stale by earlier races self-heal on their next recompute.
|
||||
|
||||
The misleading error is gone too. A long import could outlive its queue timeout;
|
||||
the redelivery re-opened the already-consumed staging file and overwrote the real
|
||||
failure — or even a successful completion — with a raw ENOENT. Import jobs now
|
||||
finish terminally (no redelivery after the staging file is consumed, six-hour
|
||||
timeout), a delivery for an already-finished import is a no-op, and a genuinely
|
||||
missing staging file reports an honest "upload the export again" message.
|
||||
|
||||
## [1.28.32] — 2026-07-12 — Fitbit: re-keyed re-imports can no longer be silently dropped
|
||||
|
||||
Single-fix release for the Fitbit integration, mirroring the Google Health
|
||||
fix from v1.28.28. The measurements table carries a second unique index on
|
||||
the natural key (type, time, source, sleep stage) that also covers
|
||||
soft-deleted rows; when an import carries new external ids for rows that
|
||||
were previously removed — the re-keyed re-import case — the insert collided
|
||||
with the old row and was dropped without a trace. Planned inserts now get a
|
||||
second probe by natural key: a match, live or removed, is updated in place
|
||||
with the fresh value and the new id instead. If the probe itself fails, the
|
||||
sync reports the failure and holds its watermark so the next run retries —
|
||||
nothing is lost either way. No other code path is touched in this release.
|
||||
|
||||
## [1.28.31] — 2026-07-12 — Heart-rate history keeps its daily shape
|
||||
|
||||
Long-term retention for the dense intra-day signals (heart rate, HRV, blood
|
||||
oxygen) now folds to hourly means instead of a single daily mean. The last
|
||||
14 days keep every raw sample as before; older days keep up to 24 points —
|
||||
enough to see how a day actually went, at roughly nine thousand rows per
|
||||
metric per year instead of an unbounded raw stream.
|
||||
|
||||
Because the old fold soft-deleted rather than erased, a one-time rebuild
|
||||
reconstructs the hourly shape for every already-folded day from the retained
|
||||
raw samples, then retires the old daily row in the same transaction — no
|
||||
reader can ever double-count a day. The rebuild runs automatically once per
|
||||
installation after the update; days whose raw samples are no longer
|
||||
available keep their daily mean. Daily min/max/mean statistics captured at
|
||||
fold time stay untouched throughout.
|
||||
|
||||
## [1.28.30] — 2026-07-12 — The daily briefing stops silently skipping days
|
||||
|
||||
The recurring "no briefing today" had a chain of causes, now closed end to end.
|
||||
Every failure path in the nightly generation names its cause in the logs
|
||||
(several classes previously failed without a trace). A failed night retries
|
||||
itself about forty-five minutes later instead of waiting for the next night.
|
||||
A cached insight payload that carries no briefing no longer counts as fresh —
|
||||
previously it could satisfy the daily window and even the unchanged-data check,
|
||||
so a once-stripped briefing could block its own regeneration indefinitely. And
|
||||
the briefing card now reports persistently why a briefing is absent (withheld
|
||||
by the number check, generation failed, or not yet attempted) with a retry
|
||||
button that always generates.
|
||||
|
||||
Documentation corrections, verified against the code: Withings credentials are
|
||||
per-user in the app (the documented environment-variable path never existed —
|
||||
and the webhook secret is now actually passed through in the compose file);
|
||||
the classic Fitbit portal no longer accepts new registrations (new users:
|
||||
Google Health); the backup-restore example no longer generates a fresh
|
||||
encryption key (which could never decrypt an existing backup) and uses the
|
||||
invocation that works in the production image; the certificate-pinning guide
|
||||
now describes the real model (two CA-level pins, leaf renewals need no app
|
||||
build); scaling and admin-endpoint references corrected.
|
||||
|
||||
## [1.28.29] — 2026-07-11 — Dashboard edits show up on the way back; one scrollbar again
|
||||
|
||||
Changing the dashboard tile selection now shows up immediately when you
|
||||
navigate back — the third and final layer of this bug. Saving marked the
|
||||
dashboard's cached data stale but, with the dashboard page unmounted, nothing
|
||||
re-read it until the next poll or a window-focus flick. The save now refreshes
|
||||
that cache directly, mounted or not.
|
||||
|
||||
The settings pages with sortable lists (dashboard layout, medication order,
|
||||
modules, mood tags) showed a second page scrollbar: an invisible
|
||||
screen-reader hint below each list escaped its container and silently
|
||||
lengthened the document. All five editors are fixed and the overscroll guard
|
||||
now covers these routes.
|
||||
|
||||
## [1.28.28] — 2026-07-11 — OpenAI-compatible gateways work; a re-keyed night can no longer vanish
|
||||
|
||||
The user-level "Local (OpenAI-compatible)" provider now speaks the standard
|
||||
JSON wire: it sends `response_format` and, when an endpoint rejects it, falls
|
||||
back once and remembers that endpoint's dialect — so LiteLLM, OpenRouter,
|
||||
vLLM, LM Studio and plain Ollama all work from the same settings form. The
|
||||
form and the provider docs now say so explicitly. Gateways that wrap a
|
||||
Claude-family model behind a synthesized tool call are parsed correctly
|
||||
instead of yielding silently empty insights.
|
||||
|
||||
The briefing token budget is configurable (`INSIGHTS_MAX_TOKENS`, default
|
||||
raised so full briefings stop truncating), and a reply that was cut off
|
||||
mid-JSON now says exactly that instead of a generic parse error. When the
|
||||
number-grounding check withholds a briefing, the card now explains why
|
||||
instead of pretending nothing was generated. The provider test button
|
||||
distinguishes "the endpoint rejected the request" from "could not reach the
|
||||
endpoint".
|
||||
|
||||
Sleep repair follow-up: re-syncing a history whose sleep rows predate the
|
||||
stable segment keys could erase those nights — the old rows were swept while
|
||||
their replacements collided with a second uniqueness rule and were silently
|
||||
dropped. Re-imports now recognise such rows by their natural identity
|
||||
(type, instant, stage) and migrate them in place: fresh value, new key,
|
||||
restored if deleted. A full sync after updating heals any history affected.
|
||||
|
||||
## [1.28.27] — 2026-07-11 — Runs on CPUs without AVX2
|
||||
|
||||
Self-hosts on older x86-64 CPUs — Celeron/Atom-class NAS boxes, pre-2013
|
||||
Xeons, and VMs that mask newer CPU flags — crashed in a restart loop since the
|
||||
document renderer arrived: the bundled rasterizer uses AVX2 instructions, and
|
||||
a CPU without them kills the whole process the first time a thumbnail or a
|
||||
scanned-PDF render runs. The renderer is now gated on a one-time CPU-feature
|
||||
check: on unsupported hardware, thumbnails and scanned-PDF rasterization
|
||||
simply switch off (the vault shows type icons; PDFs are read as text) while
|
||||
everything else runs normally. `NATIVE_CANVAS=off|on` overrides the detection
|
||||
if ever needed. Reported by a self-hoster with kernel traps in hand — thanks,
|
||||
that made it a same-day fix.
|
||||
|
||||
## [1.28.26] — 2026-07-11 — Internal restructuring for maintainability
|
||||
|
||||
No user-facing change. Eight of the largest source files were split along
|
||||
their natural seams into focused modules — mood analytics calculators, the
|
||||
insight status-invalidation machinery, feature extraction blocks, the doctor
|
||||
report's types and helpers, the Google Health mapping layer, the coach chat
|
||||
bubbles and read-aloud controls, the Telegram webhook handlers (the route now
|
||||
holds only auth and dispatch), and the coach snapshot's cache, series helpers
|
||||
and largest per-metric blocks. Every move is verbatim with stable import
|
||||
paths; behavior is pinned by the full test suite.
|
||||
|
||||
## [1.28.25] — 2026-07-11 — Every integration held to the same standard
|
||||
|
||||
A platform-wide hardening pass: the failure classes found live this week were
|
||||
hunted down across every integration, not just where they first surfaced.
|
||||
|
||||
Fitbit carried the same silent wedge fixed for Google Health — a soft-deleted
|
||||
reading permanently blocked its own re-import, hidden behind a comment
|
||||
describing a database index that never existed. Fixed the same way: a re-import
|
||||
revives the deleted row in place.
|
||||
|
||||
Sleep segments now keep stable identities across re-scoring for Withings,
|
||||
WHOOP, Polar and Oura — previously a source refining a night could duplicate or
|
||||
orphan its stage rows, quietly inflating the night's total (the class fixed for
|
||||
Google earlier this week). Each sync now also sweeps rows a re-score left
|
||||
behind, so already-affected nights heal themselves as they are re-read.
|
||||
|
||||
One platform rule for deleted rows: a reading owned by a connected source comes
|
||||
back when the source reports it again — across Withings, WHOOP, Oura, Polar,
|
||||
Nightscout, the mood webhook, CSV re-imports and Apple Health day totals.
|
||||
Deleting an Apple Health sample in the Health app still sticks, as the paired
|
||||
client expects. Import counts and per-entry statuses now tell the truth in
|
||||
every one of these paths.
|
||||
|
||||
Google Health lifecycle: an expired connection no longer lets the hourly sync
|
||||
stamp success while importing nothing (it parks cleanly for reconnect instead
|
||||
of retrying against a dead token forever); history backfills and the sleep
|
||||
repair only mark themselves done after a clean pass, so a transient error now
|
||||
retries instead of silently leaving a gap; and a failed database write holds
|
||||
the sync watermark so the affected window is re-fetched.
|
||||
|
||||
Scale: the doctor report reads only the columns and sections it renders;
|
||||
exports and backups read the measurement table in pages instead of one giant
|
||||
query; long-window charts for sensor-dense metrics (glucose, pulse) aggregate
|
||||
per day in the database beyond 90 days instead of shipping every raw sample;
|
||||
blood-pressure pairing, achievement tallies and several "which types exist"
|
||||
scans moved from in-memory loops to the database. A year of continuous sensor
|
||||
data can no longer stall or crash a request.
|
||||
|
||||
## [1.28.24] — 2026-07-11 — A deleted Google reading no longer blocks its re-import
|
||||
|
||||
A Google Health reading that had been soft-deleted could never be imported
|
||||
again: the sync treated the deleted row as absent and planned a fresh insert,
|
||||
but the database's uniqueness rule still saw the deleted row and silently
|
||||
dropped the insert — every sync, forever. A self-hoster's step days were stuck
|
||||
exactly this way. The sync now recognises the deleted row and revives it in
|
||||
place with the freshly fetched value: Google remains the source of truth for
|
||||
its own readings, so a re-import deliberately brings a deleted one back. To
|
||||
remove Google data permanently, disconnect the integration.
|
||||
|
||||
## [1.28.23] — 2026-07-11 — Sleep disagreement visible on the dashboard; Apple Health medication groundwork
|
||||
|
||||
When two sources disagree about last night's sleep — a sleep mat's time in bed
|
||||
against a watch's time asleep — the dashboard sleep tile now carries a small
|
||||
marker next to the value. The tooltip names each source and its total, so a
|
||||
surprising number explains itself instead of silently picking a side. The same
|
||||
marker backs the sleep panel; the shown total is unchanged either way.
|
||||
|
||||
Groundwork for importing medications and their taken doses from Apple Health
|
||||
(iOS 26+): the server now accepts medications mirrored from an external source
|
||||
and dose events carrying a stable external identity, imported idempotently —
|
||||
re-syncs never duplicate. A mirrored medication is source-exclusive, so an
|
||||
imported dose can never double-count against a manually logged one. The
|
||||
matching client support arrives with a future app update.
|
||||
|
||||
Hardening: the doctor-report aggregation and PDF charts now compute their
|
||||
ranges with loops instead of spread calls, so a report over a sample-dense year
|
||||
(per-beat heart rate, sensor glucose) cannot overflow the call stack — the same
|
||||
failure class fixed for the Google Health full sync.
|
||||
|
||||
## [1.28.22] — 2026-07-10 — Full sync survives dense histories; quieter document chrome
|
||||
|
||||
A full Google Health sync no longer fails on sample-dense accounts. Collecting
|
||||
the rollup bookkeeping for a multi-year heart-rate history overflowed the call
|
||||
stack, which aborted the metrics pass mid-cycle — exactly on the accounts with
|
||||
the most data. Reported by a self-hoster whose sync log made the diagnosis
|
||||
immediate; the new sync diagnostics from the previous release confirmed every
|
||||
other read on that account healthy.
|
||||
|
||||
The documents vault gets quieter chrome: the "read by AI" tag no longer takes
|
||||
its own line on each card — a small glyph rides inline on the date-and-size row
|
||||
instead. The document view drops the status bar under the preview entirely;
|
||||
when reading is automatic and nothing needs review, the content starts straight
|
||||
with the document's fields.
|
||||
|
||||
## [1.28.21] — 2026-07-10 — Sleep history heals itself; plateau context; sync diagnostics
|
||||
|
||||
Three follow-ups to the recent sleep fix, plus better Google Health diagnostics.
|
||||
|
||||
Nights that were stored twice before the sleep fix now repair themselves: a
|
||||
one-time background pass re-reads each connected account's Google Health sleep
|
||||
history and collapses every re-scored night to its true total. No operator
|
||||
action needed — it runs once after the update and marks itself done.
|
||||
|
||||
The sleep night read now reports when two sources disagree about the same night
|
||||
(say, a sleep mat's ten hours in bed against a watch's seven and a half asleep).
|
||||
The served total is unchanged — the disagreement rides along as an annotation so
|
||||
clients can mark the number instead of silently picking a side.
|
||||
|
||||
A GLP-1 medication whose weight has plateaued on the current dose now says so
|
||||
where the estimated drug level is shown — a short, factual note with the
|
||||
observed change over the window, next to the curve in the efficacy view and in
|
||||
Insights. It states the association and nothing more.
|
||||
|
||||
Google Health sync diagnostics: a daily-totals read that returns rows but
|
||||
imports nothing now says so in the sync log, distinguishing an empty answer
|
||||
from one the importer could not read — and the connection test's structure
|
||||
probe reports the raw response field names when a type parses to zero, so a
|
||||
per-account naming drift is visible from a single report.
|
||||
|
||||
The estimated active-substance curve for a GLP-1 medication — the modelled drug
|
||||
level from your logged doses — now also appears in the efficacy view and in the
|
||||
Insights medication overview, not only under the injection tab. It is the same
|
||||
estimate shown there, surfaced next to the efficacy readouts so the drug level
|
||||
and its effect sit side by side. Non-GLP-1 medications are unaffected.
|
||||
|
||||
## [1.28.19] — 2026-07-10 — Document and card polish
|
||||
|
||||
Small refinements across a few surfaces.
|
||||
|
||||
In the documents vault, each card now carries the date, size and an "read by AI"
|
||||
marker directly under the document's title, instead of the meta line sitting off
|
||||
under the thumbnail.
|
||||
|
||||
The document view gets a larger, easier close button, drops a redundant top-right
|
||||
shortcut and the inline "read with AI" row under the preview, and gains a button
|
||||
to ask about the document right next to share and download.
|
||||
|
||||
The mental-wellbeing cards no longer wash the whole tile on hover — only the
|
||||
title is the tap target now, matching how the medication cards behave.
|
||||
|
||||
The medication API settings render inline instead of behind an expander that
|
||||
never had anything else beside it.
|
||||
|
||||
Sleep imported from Google Health could read far too long — a night of about
|
||||
seven and a half hours showing as ten. Google re-scores a night after the fact,
|
||||
and the sync re-reads recent nights to pick that up. The re-read was landing as a
|
||||
second, parallel copy of the night rather than replacing the first, so the
|
||||
night-total added both together.
|
||||
|
||||
Each sleep segment now keys on a stable identity (the session's own id plus the
|
||||
segment's start) instead of a value that shifted every time Google refined the
|
||||
night, so a re-read overwrites in place. And before writing a re-read night, the
|
||||
sync clears any superseded rows left in that night's window — so a re-scored
|
||||
night reads its true total, and a mere re-classification of a block (light to
|
||||
deep) updates in place instead of duplicating.
|
||||
|
||||
Nights already stored with duplicates heal as they are re-read; operators can
|
||||
repair the full history at once with a one-shot re-sync.
|
||||
|
||||
Fixes two bugs and hardens two edges.
|
||||
|
||||
Dashboard layout changes now take effect immediately. Reordering or toggling
|
||||
tiles and pressing save could appear to revert to the previous selection for a
|
||||
few minutes: the saved layout was written correctly, but the home screen kept
|
||||
serving a cached snapshot that the save never evicted. The save now clears that
|
||||
snapshot, so the dashboard reflects the new layout on the next paint.
|
||||
|
||||
A share link created with a narrowed set of report sections now serves exactly
|
||||
that set. Previously a link built with a reduced selection could fall back to the
|
||||
full default set of sections when viewed — the opposite of what the owner chose.
|
||||
The shared view now reads the frozen selection faithfully, so a section switched
|
||||
off stays off for the recipient. Documents-only shares were never affected.
|
||||
|
||||
Hardening: the shared-document download path and the owner download path now use
|
||||
identical filename sanitising, and thumbnail generation enforces its size ceiling
|
||||
a second time after decoding, so a malformed image that slips past the up-front
|
||||
check still cannot exhaust memory.
|
||||
|
||||
## [1.28.16] — 2026-07-10 — Documents-only shares stay documents-only
|
||||
|
||||
Hardens the "share a document, not the record" guarantee. A link created as
|
||||
documents-only now carries an explicit frozen flag, and the shared view reads
|
||||
that flag directly instead of inferring it from "are all report sections off?".
|
||||
The old 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 have defaulted on. The flag closes that path: a
|
||||
documents-only link can never begin serving health data later, whatever the
|
||||
report grows to include. Existing documents-only links are migrated to the flag.
|
||||
|
||||
## [1.28.15] — 2026-07-10 — Document thumbnails; admin-shared AI access
|
||||
|
||||
The documents library now shows a thumbnail for each file — a small preview
|
||||
rendered on the server, downscaled and re-encoded so location and camera
|
||||
metadata are dropped, and served only to the file's owner over an authenticated
|
||||
request. Files that cannot be previewed keep their type icon, and a file that
|
||||
fails to render simply shows the icon rather than an error.
|
||||
|
||||
An administrator can connect one central AI access on the server and offer it to
|
||||
the people who use it: each user decides in their own settings whether to use the
|
||||
shared access or keep their own provider. It is set up in the admin area and
|
||||
stays off until an admin turns it on, so anyone already using their own key is
|
||||
unaffected.
|
||||
|
||||
Also trims two now-redundant pieces of the documents view: the caption noting
|
||||
that search covers file contents, and the per-file searchable/read badges. The
|
||||
same actions remain available without the extra chrome.
|
||||
|
||||
## [1.28.13] — 2026-07-10 — Sharing a document shares only the document
|
||||
|
||||
Fixes a scope bug: a link created from a document carried the full health record
|
||||
(vital signs, labs, medications) because an empty report scope was read as "all
|
||||
sections". A document share is now document-only — it serves the attached
|
||||
document and no health data — while sharing from Settings keeps the full record
|
||||
form. NOTE: revoke and re-create any document link made before this release; an
|
||||
earlier link cannot be narrowed retroactively.
|
||||
|
||||
## [1.28.12] — 2026-07-09 — Lab scans use your AI provider; tidier dashboard
|
||||
|
||||
Scanning a lab report now uses the AI provider you already configured — including
|
||||
a scanned PDF, which is rendered to images the same way documents are, so a
|
||||
subscription or local vision provider reads it instead of only a Claude one. The
|
||||
separate lab-scan provider setting is gone; there is one place to configure AI.
|
||||
On the dashboard, the "log dose" action for an overdue medication now sits
|
||||
directly under its line instead of far to the right.
|
||||
|
||||
## [1.28.11] — 2026-07-09 — Strava workouts + Garmin guidance
|
||||
|
||||
Connect Strava to bring your activities in as workouts. Like the other wearable
|
||||
connections, you register your own Strava application and paste its keys, so no
|
||||
shared account is involved. A run that also arrives through Apple Health is not
|
||||
double-counted — the existing source-priority picker collapses the duplicate.
|
||||
Strava is a workout source only (no sleep, recovery, or body metrics). Also adds
|
||||
a Garmin page and an in-app note: Garmin has no direct connection, but its data
|
||||
flows in through Apple Health or Google Health Connect.
|
||||
|
||||
## [1.28.10] — 2026-07-09 — Onboarding shows the AI value early
|
||||
|
||||
New accounts now meet the AI value up front instead of discovering it as a cold
|
||||
"no provider connected" error. The finish screen shows a clearly-labelled sample
|
||||
briefing — a static example, nothing is sent anywhere — and lays out the setup
|
||||
choices local-first (a local model, your own API key, or a signed-in account),
|
||||
with a plain reminder that the app is fully useful without AI. The getting-started
|
||||
checklist gains a matching step that satisfies itself once a provider is
|
||||
connected (or when the operator has configured a shared one).
|
||||
|
||||
## [1.28.9] — 2026-07-09 — Document chat in the coach dialog
|
||||
|
||||
Chatting about a document now opens the coach chat dialog, scoped to that
|
||||
document ("Chatting about: …"), instead of a separate inline panel. The entry
|
||||
is a neutral icon in the top-right of the document, matching the insights
|
||||
surface. The document chat keeps its stricter footing unchanged — it reads only
|
||||
the one document, carries no health-record context, uses no tools, and renders
|
||||
as plain text.
|
||||
|
||||
## [1.28.8] — 2026-07-09 — Documents: automatic when set, cleaner when not
|
||||
|
||||
With automatic AI reading turned on, a document is read on upload — so the
|
||||
per-document AI action buttons no longer appear (nothing to press). They return
|
||||
when automatic reading is off, for manual per-document control. The AI-suggestion
|
||||
review also drops its heading and grey sub-caption — just the fields to review
|
||||
and apply.
|
||||
|
||||
## [1.28.7] — 2026-07-09 — Scanned-PDF reading actually works
|
||||
|
||||
The prior fix made the PDF image library loadable, but the page renderer was
|
||||
still a bundled copy that failed on a real document with a bare error, so reading
|
||||
a scanned PDF on a non-Anthropic provider still fell back to "PDF scanning needs
|
||||
a Claude vision provider". The renderer now runs the real, un-bundled module —
|
||||
the same one that renders these documents correctly in isolation — so scanned
|
||||
PDFs are read on a subscription or local vision provider. Also drops a redundant
|
||||
per-view note from the document summary panel.
|
||||
|
||||
## [1.28.6] — 2026-07-09 — Fix scanned-PDF reading in the container
|
||||
|
||||
Reading a scanned PDF with a non-Anthropic AI provider (a signed-in subscription
|
||||
model or a local vision model) failed with "PDF scanning needs a Claude vision
|
||||
provider" because the PDF page renderer could not load its image library inside
|
||||
the standalone container image. The library is now resolvable where the renderer
|
||||
looks for it, so a scanned PDF is rendered to images and read as intended.
|
||||
|
||||
## [1.28.5] — 2026-07-09 — Read documents with a subscription AI, cleaner surfaces
|
||||
|
||||
Documents can now be read by a signed-in (subscription) AI provider — the
|
||||
capability check no longer wrongly treats those models as unable to see images,
|
||||
so "read with AI" works on that path instead of erroring. Scanned PDFs and
|
||||
photos are read the same way. Two now-redundant blurbs are gone from the document
|
||||
panel, and on the recovery insights page each metric shows its explanation inside
|
||||
its own card instead of repeating the heading above it.
|
||||
|
||||
## [1.28.4] — 2026-07-09 — Medication efficacy
|
||||
|
||||
A per-medication "effect" view: see whether a medication is moving the outcome
|
||||
it targets, against your own data. The detail page gains a tab that plots the
|
||||
target metric (or lab) with the start date, dose changes, and pauses marked, a
|
||||
before/after-start comparison, and your adherence overlaid — strictly
|
||||
descriptive, never a verdict or dose advice. A compact version surfaces on the
|
||||
medication insights area. The target is resolved from the drug's class or its
|
||||
name, and you can override which metric a medication is tracked against.
|
||||
|
||||
## [1.28.3] — 2026-07-09 — Consistent spacing and alignment
|
||||
|
||||
A consistency pass across the app, driven by a layout audit. Insight cards now
|
||||
share one left edge — headings and body text line up instead of drifting from
|
||||
card to card (worst on mobile). Settings and admin pages no longer scroll past
|
||||
their content into empty space. On mobile, Settings and Notifications live only
|
||||
in the account menu instead of being duplicated in the overflow menu. The
|
||||
dashboard greeting keeps its action beside the text rather than pushing it onto
|
||||
its own line. Preventive-care, cycle, and onboarding cards adopt the shared card
|
||||
anatomy. The spacing and alignment rules behind these are written down so they
|
||||
hold going forward.
|
||||
|
||||
Also folds in the document PDF rasterization runtime: the image renderer now
|
||||
loads in the container, so a scanned PDF is read on an image-only AI provider
|
||||
(previously it quietly fell back to text-only).
|
||||
|
||||
## [1.28.2] — 2026-07-09 — Automatic AI document reading (opt-in)
|
||||
|
||||
One opt-in in AI settings: read every uploaded document automatically with your
|
||||
|
||||
@@ -95,7 +95,7 @@ The architecture map in `.planning/codebase/arch.md` walks each layer with file:
|
||||
- **`annotate()` on every interesting code path.** Wide-event observability. Action names follow `<surface>.<noun>.<verb>` (`coach.budget.exceeded`, `measurement.batch.ingest`). Don't free-text in `meta` — pin the shape so dashboards stay stable.
|
||||
- **i18n keys exist for every `t()` call.** `i18n-call-site-coverage.test.ts` walks every `.ts(x)` under `src/`, extracts every `t("ns.key")` literal, and asserts each key resolves in `messages/en.json`. `i18n-locale-integrity.test.ts` propagates the EN guarantee across `de / en / es / fr / it / pl`. If a guard fails, fix the bundle, don't suppress the test.
|
||||
- **OpenAPI registry stays in sync.** Zod schemas carry `.meta()` annotations under `src/lib/openapi/`; `pnpm openapi:generate` emits `docs/api/openapi.yaml`. CI fails on drift. Re-run the generator after touching a request / response schema and commit the YAML alongside the Zod change.
|
||||
- **File naming.** Components and lib files: kebab-case (`daily-briefing.tsx`, `secure-cookie.ts`). Hooks: `use-` prefix kebab-case (`use-coach-prefs.ts`). React component exports stay PascalCase regardless of filename. The 18 PascalCase outliers under `src/components/medications/` + `src/components/onboarding/` are pre-existing drift and worth cleaning up when the surrounding files come up for edit.
|
||||
- **File naming.** Components and lib files: kebab-case (`daily-briefing.tsx`, `secure-cookie.ts`). Hooks: `use-` prefix kebab-case (`use-coach-prefs.ts`). React component exports stay PascalCase regardless of filename.
|
||||
|
||||
## Tests and commands
|
||||
|
||||
@@ -165,6 +165,7 @@ The native SwiftUI client lives in a separate repository and rides public beta v
|
||||
- The `clientManaged` opt-in on `PATCH /api/auth/me/notification-prefs` suppresses server-side `MEDICATION_REMINDER` APNs for clients that manage their own local reminders. The cron skips dose-due APNs when the flag is `true` and emits a `medication_reminder.suppressed_client_managed` wide-event annotation per skip.
|
||||
- `push_attempts` table holds the last 90 days of APNS / Web-Push / Telegram / NTFY delivery attempts (channel + eventType + result + reason + createdAt). The admin diagnostic endpoint at `/api/admin/notifications/diagnostic` surfaces masked device tokens + channel state + last 20 attempts for the calling user.
|
||||
- Any request / response schema change must regen `docs/api/openapi.yaml` and commit the result. CI fails on drift.
|
||||
- The iOS app authenticates via password/passkey only (`src/lib/auth/native-client.ts`) — there is no OIDC flow for native clients yet. `OIDC_ONLY=true` is enforced server-side on those same routes, so enabling it locks every iOS user out of sign-in until a native SSO flow ships. Don't recommend it to an operator running the iOS app.
|
||||
|
||||
## DO-NOTs
|
||||
|
||||
|
||||
+37
@@ -81,6 +81,43 @@ COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
# @napi-rs/canvas (document PDF rasterization): Next's file tracer copies the
|
||||
# native binary's package into the standalone tree but NOT the pnpm symlinks that
|
||||
# resolve it, so at runtime nothing can `require('@napi-rs/canvas')`. TWO
|
||||
# resolutions must work: (1) pdfjs-dist does a bare `require('@napi-rs/canvas')`
|
||||
# from its OWN dir — Node walks up to /app/node_modules, so the package must be
|
||||
# HOISTED to the top level there (verified: without it pdfjs logs "Cannot load
|
||||
# @napi-rs/canvas" → "Cannot polyfill DOMMatrix" → rasterize ReferenceError);
|
||||
# (2) @napi-rs/canvas's own loader then resolves its platform binary. Hoist BOTH
|
||||
# the canvas package and the musl binary package to /app/node_modules/@napi-rs
|
||||
# via symlink into the .pnpm store, and also drop the prebuilt .node into the
|
||||
# canvas dir so the loader's local-file fallback (`require('./skia.<triple>.node')`)
|
||||
# is belt-and-suspenders. Per-arch buildx installs only the matching musl triple.
|
||||
RUN set -e; \
|
||||
CANVAS_DIR="$(find /app/node_modules/.pnpm -maxdepth 4 -type d -path '*@napi-rs+canvas@*/node_modules/@napi-rs/canvas' 2>/dev/null | head -1)"; \
|
||||
MUSL_DIR="$(find /app/node_modules/.pnpm -maxdepth 4 -type d -path '*@napi-rs+canvas-linux-*-musl@*/node_modules/@napi-rs/canvas-linux-*-musl' 2>/dev/null | head -1)"; \
|
||||
NODE_BIN="$(find /app/node_modules/.pnpm -maxdepth 5 -name 'skia.linux-*-musl.node' 2>/dev/null | head -1)"; \
|
||||
if [ -n "$CANVAS_DIR" ]; then \
|
||||
mkdir -p /app/node_modules/@napi-rs; \
|
||||
ln -sfn "$CANVAS_DIR" /app/node_modules/@napi-rs/canvas; \
|
||||
[ -n "$MUSL_DIR" ] && ln -sfn "$MUSL_DIR" "/app/node_modules/@napi-rs/$(basename "$MUSL_DIR")"; \
|
||||
[ -n "$NODE_BIN" ] && cp "$NODE_BIN" "$CANVAS_DIR/"; \
|
||||
chown -R nextjs:nodejs /app/node_modules/@napi-rs; \
|
||||
echo "canvas hoisted for pdfjs: $CANVAS_DIR (musl=$MUSL_DIR)"; \
|
||||
else echo "WARN: @napi-rs/canvas not found; PDF rasterization will degrade to local text"; fi
|
||||
|
||||
# pdfjs-dist is `serverExternalPackages` (NOT bundled — the bundled copy's render
|
||||
# path breaks in the standalone image), so the server does a runtime bare
|
||||
# `import('pdfjs-dist/legacy/build/pdf.mjs')`. Node resolves that up to
|
||||
# /app/node_modules, so hoist the real pnpm-store copy to the top level too.
|
||||
RUN set -e; \
|
||||
PDFJS_DIR="$(find /app/node_modules/.pnpm -maxdepth 4 -type d -path '*pdfjs-dist@*/node_modules/pdfjs-dist' 2>/dev/null | head -1)"; \
|
||||
if [ -n "$PDFJS_DIR" ]; then \
|
||||
ln -sfn "$PDFJS_DIR" /app/node_modules/pdfjs-dist; \
|
||||
chown -h nextjs:nodejs /app/node_modules/pdfjs-dist; \
|
||||
echo "pdfjs-dist hoisted: $PDFJS_DIR"; \
|
||||
else echo "WARN: pdfjs-dist not found; PDF rasterization will degrade to local text"; fi
|
||||
|
||||
# Copy Prisma for migrations (schema, migration SQL, config, engines)
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
|
||||
|
||||
@@ -80,6 +80,8 @@ services:
|
||||
# whitelist — vars not listed here don't propagate even though
|
||||
# docker-compose reads .env for `${VAR}` substitution at compose-up time).
|
||||
SESSION_COOKIE_SECURE: "${SESSION_COOKIE_SECURE:-}"
|
||||
NATIVE_CANVAS: "${NATIVE_CANVAS:-}"
|
||||
WITHINGS_WEBHOOK_SECRET: "${WITHINGS_WEBHOOK_SECRET:-}"
|
||||
# v1.18.11 (W3) — client-IP resolution + geo lookup. Behind Cloudflare
|
||||
# the real visitor IP is in `cf-connecting-ip`; set
|
||||
# TRUST_CF_CONNECTING_IP=1 ONLY when Cloudflare is in front (off by
|
||||
@@ -131,6 +133,30 @@ services:
|
||||
OURA_CLIENT_ID: "${OURA_CLIENT_ID:-}"
|
||||
OURA_CLIENT_SECRET: "${OURA_CLIENT_SECRET:-}"
|
||||
OURA_REDIRECT_URI: "${OURA_REDIRECT_URI:-}"
|
||||
# v1.28.x — Strava OAuth (shared app from env; per-user BYO keys override).
|
||||
# Both together enable the connect button; an unset pair leaves it
|
||||
# disabled. STRAVA_REDIRECT_URI overrides the derived
|
||||
# `${NEXT_PUBLIC_APP_URL}/api/strava/callback`. Listed here so operator
|
||||
# values in .env reach the container (the `environment:` block is a
|
||||
# whitelist).
|
||||
STRAVA_CLIENT_ID: "${STRAVA_CLIENT_ID:-}"
|
||||
STRAVA_CLIENT_SECRET: "${STRAVA_CLIENT_SECRET:-}"
|
||||
STRAVA_REDIRECT_URI: "${STRAVA_REDIRECT_URI:-}"
|
||||
# OIDC SSO login (self-hosted IdP: Authentik/Keycloak/Authelia/etc).
|
||||
# All three of ISSUER_URL/CLIENT_ID/CLIENT_SECRET together enable the
|
||||
# "Sign in with SSO" button; set none to leave it disabled. OIDC_ONLY
|
||||
# hides password/passkey login entirely, but only takes effect when
|
||||
# the three above are also fully set — a half-configured group never
|
||||
# locks every user out. It IS enforced server-side, and there is no
|
||||
# OIDC flow for the native iOS client yet — enabling it locks iOS
|
||||
# users out of sign-in entirely. Listed here so operator values in
|
||||
# .env reach the container (the `environment:` block is a whitelist).
|
||||
OIDC_ISSUER_URL: "${OIDC_ISSUER_URL:-}"
|
||||
OIDC_CLIENT_ID: "${OIDC_CLIENT_ID:-}"
|
||||
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET:-}"
|
||||
OIDC_SCOPES: "${OIDC_SCOPES:-}"
|
||||
OIDC_BUTTON_LABEL: "${OIDC_BUTTON_LABEL:-}"
|
||||
OIDC_ONLY: "${OIDC_ONLY:-}"
|
||||
# Deploy-result webhook (optional). DEPLOY_WEBHOOK_SECRET authenticates
|
||||
# POST /api/internal/deploy-webhook; DEPLOY_LOGS_URL is the
|
||||
# deployment-dashboard URL (e.g. the Coolify UI) linked in
|
||||
@@ -156,6 +182,11 @@ services:
|
||||
# in .env reaches the container (the `environment:` block is a whitelist).
|
||||
# See docs/integrations/ai-providers.md.
|
||||
ALLOW_LOCAL_AI_PRIVATE_HOSTS: "${ALLOW_LOCAL_AI_PRIVATE_HOSTS:-}"
|
||||
# Daily-briefing output-token ceiling (optional; default 2500, clamped
|
||||
# 500-8000). Raise when a verbose model's briefing JSON is cut off.
|
||||
# Listed here so an operator value in .env reaches the container (the
|
||||
# `environment:` block is a whitelist). See docs/integrations/ai-providers.md.
|
||||
INSIGHTS_MAX_TOKENS: "${INSIGHTS_MAX_TOKENS:-}"
|
||||
# v1.17.1 — SMTP transport for the email notification channel (optional,
|
||||
# all-or-none: HOST + PORT + FROM enable it; USER/PASS optional; SECURE
|
||||
# toggles implicit TLS). Listed here so operator values in .env reach the
|
||||
|
||||
+3
-1
@@ -21,7 +21,9 @@ notes, and machine-readable specs that ship alongside the source:
|
||||
recipe. Routine install steps stay on `docs.healthlog.dev`. Includes
|
||||
[`self-hosting/mcp.md`](./self-hosting/mcp.md) — enabling the
|
||||
off-by-default MCP connector and pointing Claude.ai / ChatGPT /
|
||||
Claude Desktop at it.
|
||||
Claude Desktop at it, and [`self-hosting/sso.md`](./self-hosting/sso.md)
|
||||
— OIDC SSO login: IdP setup, the identity-pinning security model,
|
||||
`OIDC_ONLY` consequences, and the break-glass runbook.
|
||||
- [`migration/`](./migration/) — release-by-release migration notes.
|
||||
Read the entry for the version you're upgrading from.
|
||||
- [`audit/`](./audit/) — per-release audit summaries archived for
|
||||
|
||||
+3044
-59
File diff suppressed because it is too large.
Load diff
@@ -33,7 +33,9 @@ HealthLog supports two key-provisioning modes that coexist:
|
||||
`AppSettings.adminAiKeyEncrypted`. Users who have not configured
|
||||
their own key fall back to the admin key. This is the last entry
|
||||
in the default provider chain so it acts as a safety net rather
|
||||
than the default route.
|
||||
than the default route. Since v1.28.14 the operator can also share
|
||||
a subscription-based account that users opt into — see
|
||||
[Admin-shared subscription access](#admin-shared-subscription-access).
|
||||
|
||||
Single-user instances usually pick one mode and stop there.
|
||||
Multi-user instances tend to run BYOK with the admin-shared key as a
|
||||
@@ -148,6 +150,35 @@ internal endpoint.
|
||||
16-24 GB GPU / M-series Pro/Max (sweet spot for BYOK-style usage),
|
||||
70B+ for 48 GB+ / multi-GPU (comparable to mid-tier hosted models).
|
||||
|
||||
### OpenAI-compatible gateways (LiteLLM, OpenRouter, …)
|
||||
|
||||
The **Local** provider is also the gateway path: any service that
|
||||
speaks the OpenAI `/v1/chat/completions` wire plugs in the same way,
|
||||
whether it runs on your host or not. The OPENAI provider deliberately
|
||||
pins `api.openai.com` (an OpenAI key is never forwarded to a custom
|
||||
host), so a gateway always goes through **Local**:
|
||||
|
||||
1. In `/settings/ai`, set provider to **Local (OpenAI-compatible)**.
|
||||
2. Base URL is the gateway's OpenAI-compatible root and **ends with
|
||||
`/v1`** — e.g. `https://litellm.example.com/v1` or
|
||||
`https://openrouter.ai/api/v1`.
|
||||
3. API key is **optional** — set it when the gateway requires a
|
||||
Bearer token (OpenRouter key, LiteLLM master key), leave it blank
|
||||
otherwise. It is encrypted at rest like every other credential.
|
||||
4. Model is whatever the gateway routes — e.g.
|
||||
`anthropic/claude-sonnet-4-6` on OpenRouter or a LiteLLM alias.
|
||||
|
||||
JSON surfaces send the standard
|
||||
`response_format: { type: "json_object" }`; an endpoint that rejects
|
||||
the field is detected on the first refusal and retried without it,
|
||||
so strict gateways and older local servers both work unmodified.
|
||||
|
||||
The **admin** server-key path has a separate guard: an admin-set
|
||||
custom base URL must additionally be allowed via
|
||||
`ADMIN_AI_BASE_URL_ALLOWLIST` in the container environment. The
|
||||
user-level Local provider does not read that variable — it is
|
||||
governed by the SSRF guard above.
|
||||
|
||||
## ChatGPT OAuth (Codex)
|
||||
|
||||
The `CHATGPT_OAUTH` provider routes generations via the
|
||||
@@ -180,6 +211,41 @@ documented in `docs/codex-protocol-spec.md` for anyone who wants to
|
||||
audit the request shape; users who just want the integration to
|
||||
work do not need to read it.
|
||||
|
||||
## Admin-shared subscription access
|
||||
|
||||
Besides the shared API key, the operator can connect a single
|
||||
subscription-based AI account for the whole instance — an
|
||||
operator-shared subscription provider that users without any
|
||||
personal AI setup can opt into.
|
||||
|
||||
- **Admin side.** Under **Admin → AI** the operator runs the same
|
||||
device-code OAuth flow as the per-user connection
|
||||
(`POST /api/admin/central-codex/device-start` +
|
||||
`device-poll`). The tokens land AES-256-GCM-encrypted in the
|
||||
`AppSettings` singleton and are never returned by any endpoint —
|
||||
status and disconnect go through `GET` / `DELETE
|
||||
/api/admin/central-codex`, which sit behind the cookie-only
|
||||
`requireAdmin()` boundary.
|
||||
- **User side.** Each user opts in individually in `/settings/ai`
|
||||
(`PATCH /api/auth/me/use-central-codex`, body
|
||||
`{ "useCentralCodex": boolean }`). The opt-in is off by default.
|
||||
- **Chain semantics.** The shared connection is the internal
|
||||
`admin-codex` chain entry. It is never part of the persisted chain
|
||||
and never offered in the chain editor — it is appended **last** to
|
||||
the resolved chain, and only when the user opted in AND the
|
||||
operator has connected the account
|
||||
(`src/lib/ai/provider-chain.ts`). A hand-crafted persisted entry
|
||||
resolves to nothing.
|
||||
- **Cost.** Generations through the shared connection run under the
|
||||
operator's account, so usage and rate limits land on the operator,
|
||||
not the user.
|
||||
|
||||
The settings UI states this plainly: the shared access is a single
|
||||
signed-in AI account the operator connected for everyone on the
|
||||
server — not the user's own key. Users who want their data to reach
|
||||
only a provider under their own contract should configure a personal
|
||||
provider instead; their own entries always resolve first.
|
||||
|
||||
## Privacy stance
|
||||
|
||||
- **OpenAI** and **Anthropic** see the prompt + structured snapshot
|
||||
@@ -283,6 +349,12 @@ Lower it when running on a tight budget — the 24h cache already
|
||||
short-circuits read traffic; only force-regens and cache-misses cost
|
||||
tokens.
|
||||
|
||||
`INSIGHTS_MAX_TOKENS` (default `2500`, clamped to 500–8000) sets the
|
||||
output-token ceiling for the daily-briefing generation. Raise it if a
|
||||
verbose model gets its briefing cut off mid-JSON — the API reports
|
||||
that case as "AI response was cut off" rather than the generic
|
||||
invalid-JSON error.
|
||||
|
||||
## Connection test
|
||||
|
||||
`POST /api/ai/test` runs a one-shot probe against the resolved
|
||||
|
||||
@@ -199,3 +199,10 @@ The wide-event log line `import.apple-health.kickoff` (or
|
||||
`import.apple-health.kickoff.denied` on failure) captures every
|
||||
attempt with the upload size, SHA-256, and the resolved `ImportJob`
|
||||
id for debugging.
|
||||
|
||||
## Garmin owners
|
||||
|
||||
Garmin has no direct connector for a self-hosted instance. Garmin Connect writes
|
||||
to Apple Health automatically, so the import path on this page is how Garmin data
|
||||
reaches HealthLog on iOS. See [garmin.md](./garmin.md) for what comes through and
|
||||
what Garmin withholds.
|
||||
+20
-17
@@ -5,10 +5,10 @@
|
||||
|
||||
HealthLog reads Fitbit and Pixel Watch data through the **classic Fitbit Web
|
||||
API** (`api.fitbit.com`). The OAuth app is a **Fitbit developer app registered
|
||||
at [dev.fitbit.com](https://dev.fitbit.com/apps/new)** — **not** a Google Cloud
|
||||
at [dev.fitbit.com](https://dev.fitbit.com/)** — **not** a Google Cloud
|
||||
OAuth client. Like WHOOP, credentials are brought per user: each user (or the
|
||||
operator on their behalf) registers their own Fitbit app and pastes the client
|
||||
id/secret into Settings. The internal integration key is `fitbit`.
|
||||
operator on their behalf) supplies their own Fitbit app's client id/secret in
|
||||
Settings. The internal integration key is `fitbit`.
|
||||
|
||||
> **Common mistake:** the field wants a **Fitbit** Client ID (a short numeric
|
||||
> id from dev.fitbit.com), **not** a Google Cloud client id ending in
|
||||
@@ -16,30 +16,33 @@ id/secret into Settings. The internal integration key is `fitbit`.
|
||||
> consent screen with `unauthorized_client — Invalid client_id`, because Fitbit
|
||||
> and Google Cloud are separate client registries.
|
||||
|
||||
> **Heads-up — September 2026 sunset.** Google is retiring the classic Fitbit
|
||||
> Web API in **September 2026** and moving to the Google Health API behind Google
|
||||
> sign-in. That replacement currently requires Google Restricted-scope brand
|
||||
> verification plus an annual third-party CASA security assessment, which does
|
||||
> not fit a self-hosted bring-your-own-credentials model, so the path beyond the
|
||||
> sunset is still being worked out. Until then the setup below is the supported
|
||||
> way to connect Fitbit.
|
||||
> **Heads-up — new registrations are closed.** Fitbit has **discontinued
|
||||
> self-serve app registration**: the form at `dev.fitbit.com/apps/new` is
|
||||
> disabled for new applications, and the classic Fitbit Web API sunsets in
|
||||
> **September 2026**. If you already hold a registered Fitbit app, it keeps
|
||||
> working until the sunset and the setup below still applies. If you are
|
||||
> connecting Fitbit data for the first time, use the
|
||||
> [Google Health integration](google-health.md) instead — Fitbit devices sync
|
||||
> into Google's health stack, which HealthLog reads directly.
|
||||
|
||||
## 1. Register a Fitbit developer app
|
||||
## 1. Use an existing Fitbit developer app
|
||||
|
||||
1. Sign in at <https://dev.fitbit.com/apps/new> and register a new application.
|
||||
2. **OAuth 2.0 Application Type:**
|
||||
Registration of new apps is no longer possible (see the note above). For an
|
||||
app you registered before the shutdown, these are the settings that matter:
|
||||
|
||||
1. **OAuth 2.0 Application Type:**
|
||||
- **Personal** — the app only ever sees the **owner's own** Fitbit account,
|
||||
and gets intraday (per-minute) heart-rate/steps automatically. This is the
|
||||
right choice for a single-person self-host.
|
||||
- **Server** (or **Client**) — needed when the instance connects **other
|
||||
people's** accounts. Intraday/heart-rate access is then granted case by
|
||||
case via Fitbit's Intraday Data Access request form.
|
||||
3. **Callback URL:** `https://your-instance.example.com/api/fitbit/callback`
|
||||
2. **Callback URL:** `https://your-instance.example.com/api/fitbit/callback`
|
||||
— absolute, **HTTPS**, and matching the `redirect_uri` HealthLog sends (see
|
||||
the redirect-URI note below). One entry per environment you connect from.
|
||||
4. **Scopes:** tick the ones HealthLog requests (next section).
|
||||
5. Save. Fitbit issues a **Client ID** (short, numeric) and a **Client Secret**
|
||||
— keep the secret out of any chat log.
|
||||
3. **Scopes:** the ones HealthLog requests (next section).
|
||||
4. The app's settings page shows the **Client ID** (short, numeric) and the
|
||||
**Client Secret** — keep the secret out of any chat log.
|
||||
|
||||
## 2. Scopes
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Garmin (via Apple Health or Google Health Connect)
|
||||
|
||||
**There is no direct Garmin connector for a self-hosted HealthLog instance, and
|
||||
there cannot be one.** Garmin's data-sharing program is business-partner-only:
|
||||
it is approval-gated, requires a public callback endpoint, and is not the
|
||||
"register your own app, paste a key" model the wearable integrations here rely
|
||||
on. So instead of a broken half-integration, HealthLog reads Garmin data the
|
||||
honest way — through the phone health platform your Garmin already feeds.
|
||||
|
||||
## The path that works
|
||||
|
||||
```
|
||||
Garmin device → Garmin Connect → Apple Health (iOS) ─┐
|
||||
→ Google Health Connect ├─→ HealthLog
|
||||
(Android) ─┘
|
||||
```
|
||||
|
||||
- **iOS:** Garmin Connect writes to Apple Health automatically. Import it with
|
||||
the existing [Apple Health](./apple-health.md) path (the `export.zip` upload or
|
||||
the native client's background sync).
|
||||
- **Android:** since Garmin Connect 5.14.1 (July 2025) the app writes to **Google
|
||||
Health Connect**. Connect HealthLog through the existing
|
||||
[Google Health](./google-health.md) integration.
|
||||
|
||||
No extra code, no Garmin developer account, no approval queue — if your Garmin
|
||||
already syncs to your phone's health platform, HealthLog already reads it.
|
||||
|
||||
## What comes through
|
||||
|
||||
Everything Garmin shares with Apple Health / Health Connect flows in: steps,
|
||||
heart rate (including beat-to-beat variability), sleep stages (REM / deep /
|
||||
light), calories (active and total), distance, weight and body composition,
|
||||
cadence, and — partially — blood-oxygen saturation.
|
||||
|
||||
## What Garmin withholds
|
||||
|
||||
These are Garmin's **proprietary** scores. Garmin does **not** share them with
|
||||
Apple Health or Health Connect, so no self-hosted integration — and no amount of
|
||||
configuration — can obtain them. They are only available inside Garmin Connect
|
||||
itself:
|
||||
|
||||
- Body Battery
|
||||
- HRV Status
|
||||
- VO₂ max
|
||||
- Training Load and Training Effect
|
||||
- Recovery Time
|
||||
- Intensity Minutes
|
||||
- Stress score
|
||||
- Running dynamics
|
||||
|
||||
If you rely on those specific numbers, keep the Garmin Connect app for them;
|
||||
everything else lands in HealthLog through the platform path above.
|
||||
@@ -141,3 +141,10 @@ per metric.
|
||||
|
||||
The Settings **Disconnect** button removes the connection and its stored tokens.
|
||||
Previously synced measurements stay in the database.
|
||||
|
||||
## Garmin owners
|
||||
|
||||
Garmin has no direct connector for a self-hosted instance. Since Garmin Connect
|
||||
5.14.1 (July 2025) the app writes to Google Health Connect, so this integration
|
||||
is how Garmin data reaches HealthLog on Android. See [garmin.md](./garmin.md) for
|
||||
what comes through and what Garmin withholds.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Strava integration
|
||||
|
||||
HealthLog reads your Strava activities and folds them into the same workout
|
||||
history as every other source. Strava is a **workout source only** — its API is
|
||||
activity-centric and exposes no sleep, recovery, body-composition or glucose
|
||||
data, so connecting it adds runs, rides, swims and the rest to your workout
|
||||
surface and nothing else.
|
||||
|
||||
The OAuth app is brought **per user**: each self-hoster registers their own
|
||||
Strava API application and pastes the client id/secret into Settings. Strava
|
||||
caps every newly-created app at "single-player mode" (an athlete capacity of 1)
|
||||
until you request a capacity increase — perfectly fine for a personal
|
||||
self-host, and the reason a single shared app cannot serve many operators.
|
||||
|
||||
## What you get — and what you don't
|
||||
|
||||
**Covered** (one `Workout` row per activity): sport type, start and end time,
|
||||
moving duration, distance, elevation gain, average and maximum heart rate (when
|
||||
the activity recorded it), and calories. Power, cadence, the "trainer"/"commute"
|
||||
flags and the activity name are kept on the row's metadata for provenance but
|
||||
are **not** surfaced as training analytics — HealthLog treats Strava as another
|
||||
workout source, not a training-load platform.
|
||||
|
||||
**Not covered**: Strava exposes no daily metrics (steps, sleep, resting heart
|
||||
rate, weight, …). Those come from your wearable's own integration or from Apple
|
||||
Health / Google Health Connect.
|
||||
|
||||
## Duplicate activities are collapsed automatically
|
||||
|
||||
If you record a run on an Apple Watch **and** it also lands in Strava, the same
|
||||
run arrives twice — once via Apple Health, once via Strava. HealthLog keeps one
|
||||
canonical row per logical workout using your **source-priority ladder**: by
|
||||
default the device-native capture (Apple Watch, WHOOP, Withings) wins over the
|
||||
Strava copy, because a Strava activity is often a re-upload with lower-fidelity
|
||||
or missing heart-rate data. A run you record **only** on Strava (phone GPS)
|
||||
survives untouched. You can promote Strava above the wearables in
|
||||
**Settings → Sources** if you prefer the Strava copy.
|
||||
|
||||
## 1. Register a Strava API application
|
||||
|
||||
1. Sign in at <https://www.strava.com/settings/api>.
|
||||
2. Create an application. For **Authorization Callback Domain** enter the host
|
||||
of your HealthLog instance (the domain only, no scheme or path).
|
||||
3. Copy the **Client ID** and **Client Secret**.
|
||||
|
||||
## 2. Paste the credentials into HealthLog
|
||||
|
||||
1. Open **Settings → Integrations** and find the **Strava** card.
|
||||
2. Paste the Client ID and Client Secret into the **API Credentials** fields and
|
||||
save. (An operator can instead configure a shared app via the
|
||||
`STRAVA_CLIENT_ID` / `STRAVA_CLIENT_SECRET` environment variables; per-user
|
||||
credentials always take precedence.)
|
||||
3. Click **Connect Strava** and approve the access request. HealthLog requests
|
||||
the `activity:read_all` scope so it can read every activity, including those
|
||||
you marked "only me".
|
||||
|
||||
## 3. First sync
|
||||
|
||||
The first history import runs in the background shortly after you connect;
|
||||
afterwards an hourly poll pulls new activities. Strava rotates its refresh token
|
||||
on every refresh, and HealthLog persists the rotated token each time, so the
|
||||
connection stays healthy without you re-authorising.
|
||||
|
||||
## Disconnecting
|
||||
|
||||
Disconnecting revokes the grant at Strava and clears the stored tokens.
|
||||
Activities already imported stay in your history; reconnecting resumes the sync.
|
||||
|
||||
## Related
|
||||
|
||||
- Wearable that syncs to Strava but not directly to HealthLog? See the device's
|
||||
own page, or ingest via [Apple Health](./apple-health.md) /
|
||||
[Google Health Connect](./google-health.md).
|
||||
- **Garmin owners:** Garmin has no direct connector — see [garmin.md](./garmin.md).
|
||||
@@ -50,8 +50,10 @@ Click **Connect WHOOP**. The flow:
|
||||
2. The user signs in on WHOOP and grants the requested scopes.
|
||||
3. WHOOP redirects back to `/api/whoop/callback` with an auth code.
|
||||
4. HealthLog exchanges the code for an access + refresh token, stores
|
||||
them encrypted, registers the webhook, and triggers an initial
|
||||
sync of recent history.
|
||||
them encrypted, and triggers an initial sync of recent history.
|
||||
(The webhook is **not** registered per user — it is configured once
|
||||
at app level in the WHOOP developer console; see the env-var
|
||||
section below.)
|
||||
5. Subsequent syncs run on every webhook delivery plus a safety-net
|
||||
cron pull.
|
||||
|
||||
|
||||
@@ -38,20 +38,25 @@ arrive via the sleep series endpoint.
|
||||
needed — `user.metrics` covers weight / BP / heart-list / sleep /
|
||||
SpO₂ / body comp / temperature / VO₂ max; `user.activity` is the
|
||||
workout-equivalent stream for steps / energy / distance / floors.
|
||||
See `src/lib/withings/client.ts:42` (`WITHINGS_OAUTH_SCOPE`).
|
||||
See `WITHINGS_OAUTH_SCOPE` in `src/lib/withings/client.ts`.
|
||||
4. Save the application. Withings will issue a **Client ID** and a
|
||||
**Client Secret** — keep the secret out of any chat log; it grants
|
||||
read access to every linked account's full health history.
|
||||
|
||||
## 2. Wire the credentials into HealthLog
|
||||
## 2. Wire the credentials in Settings → Integrations
|
||||
|
||||
Paste the client ID and secret into `.env`:
|
||||
Withings credentials are stored per user, not in `.env`. Sign in as
|
||||
the target user, open **Settings → Integrations → Withings**, and
|
||||
paste the client ID and secret. They are encrypted AES-256-GCM at
|
||||
rest (`src/lib/withings/credentials.ts`). On a shared instance every
|
||||
user brings their own Withings app credentials; there is no
|
||||
server-level client-id/secret fallback.
|
||||
|
||||
Two server-level env vars remain:
|
||||
|
||||
```env
|
||||
WITHINGS_CLIENT_ID="your-client-id"
|
||||
WITHINGS_CLIENT_SECRET="your-client-secret"
|
||||
WITHINGS_REDIRECT_URI="https://your-instance.example.com/api/withings/callback"
|
||||
WITHINGS_WEBHOOK_SECRET="$(openssl rand -hex 32)"
|
||||
WITHINGS_REDIRECT_URI="https://your-instance.example.com/api/withings/callback"
|
||||
```
|
||||
|
||||
`WITHINGS_WEBHOOK_SECRET` is the path-segment token the webhook
|
||||
@@ -60,10 +65,9 @@ that fits in a URL path segment works, but a 64-character hex string
|
||||
keeps it indistinguishable from random. Restart the `app` container
|
||||
after saving.
|
||||
|
||||
Per-user override is also possible: the Settings UI accepts a
|
||||
client-ID/secret pair scoped to one user, useful if you want a
|
||||
shared instance where every user brings their own Withings app
|
||||
credentials. Server-level env vars cover the default case.
|
||||
`WITHINGS_REDIRECT_URI` is optional — when unset the callback URL
|
||||
defaults to `${NEXT_PUBLIC_APP_URL}/api/withings/callback`. Set it
|
||||
only when the OAuth callback must differ from that default.
|
||||
|
||||
## 3. Set the webhook URL
|
||||
|
||||
@@ -90,9 +94,8 @@ callback. The portal only owns the OAuth callback URL.
|
||||
|
||||
## 4. Link from the Settings page
|
||||
|
||||
With env vars in place and the app restarted, sign in as the target
|
||||
user and open `/settings/integrations/withings`. Click **Connect
|
||||
Withings**. The flow:
|
||||
With the credentials saved in Settings, open
|
||||
`/settings/integrations/withings` and click **Connect Withings**. The flow:
|
||||
|
||||
1. The Connect button redirects to Withings' `oauth2/authorize`.
|
||||
2. The user signs in on Withings and grants the requested scopes.
|
||||
@@ -133,10 +136,10 @@ Override per-user via the Sources section of `/settings/thresholds`.
|
||||
|
||||
### The Connect button errors out
|
||||
|
||||
- **`Withings credentials not configured`** — `WITHINGS_CLIENT_ID`
|
||||
or `WITHINGS_CLIENT_SECRET` is missing from `.env`. Confirm both
|
||||
are set in the container's runtime environment (`docker compose
|
||||
exec app env | grep WITHINGS`).
|
||||
- **`Withings credentials not configured`** — the signed-in user has
|
||||
not saved a client ID + secret under **Settings → Integrations →
|
||||
Withings**. Credentials live per user in the database, not in
|
||||
`.env` — paste the pair from the developer portal and retry.
|
||||
- **`Callback URI mismatch`** — the URL HealthLog sends to Withings
|
||||
must match an entry in the developer-portal app's callback list.
|
||||
Add the public URL exactly, including the `https://` scheme and
|
||||
|
||||
@@ -68,20 +68,26 @@ The credentials are never returned.
|
||||
## Restore
|
||||
|
||||
Pick a key (e.g. `2026-05-08/user-clx123.json.enc`) from the bucket
|
||||
and run:
|
||||
and run the restore script with the same backup credentials and
|
||||
encryption key the backup was written under — a freshly generated
|
||||
`BACKUP_ENCRYPTION_KEY` cannot decrypt any existing object:
|
||||
|
||||
```bash
|
||||
BACKUP_S3_ENDPOINT=https://...r2.cloudflarestorage.com \
|
||||
BACKUP_S3_BUCKET=healthlog-backups \
|
||||
BACKUP_S3_ACCESS_KEY=... \
|
||||
BACKUP_S3_SECRET_KEY=... \
|
||||
BACKUP_S3_REGION=auto \
|
||||
BACKUP_ENCRYPTION_KEY=$(openssl rand -hex 32) \
|
||||
pnpm tsx scripts/restore-backup.ts \
|
||||
BACKUP_S3_ENDPOINT=https://...r2.cloudflarestorage.com \
|
||||
BACKUP_S3_BUCKET=healthlog-backups \
|
||||
BACKUP_S3_ACCESS_KEY=... \
|
||||
BACKUP_S3_SECRET_KEY=... \
|
||||
BACKUP_S3_REGION=auto \
|
||||
BACKUP_ENCRYPTION_KEY=<the key the backup was written under> \
|
||||
pnpm dlx tsx scripts/restore-backup.ts \
|
||||
2026-05-08/user-clx123.json.enc \
|
||||
/tmp/restored.json
|
||||
```
|
||||
|
||||
The production standalone image strips `tsx`, so a bare
|
||||
`pnpm tsx scripts/...` fails inside the container — always invoke
|
||||
one-shot scripts via `pnpm dlx tsx`.
|
||||
|
||||
The script downloads the object, decrypts it, and writes the JSON dump
|
||||
to disk. Importing the JSON back into a HealthLog instance is left to
|
||||
the operator (use `prisma db seed` or a custom script).
|
||||
|
||||
@@ -44,7 +44,7 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
|
||||
3. **Run the rotation script** to re-encrypt every existing encrypted column
|
||||
under the new active key:
|
||||
```
|
||||
pnpm tsx scripts/rotate-encryption-key.ts v2
|
||||
pnpm dlx tsx scripts/rotate-encryption-key.ts v2
|
||||
```
|
||||
The script is idempotent — running it again is a no-op for rows already
|
||||
prefixed with `v2.`. It rotates User, WithingsConnection, AppSettings,
|
||||
@@ -69,7 +69,7 @@ script reports zero `v2.`-prefixed rows remaining.
|
||||
```
|
||||
ENCRYPTION_KEYS='{"v2":"<old>","v3":"<new>"}'
|
||||
ENCRYPTION_ACTIVE_KEY_ID="v3"
|
||||
pnpm tsx scripts/rotate-encryption-key.ts v3
|
||||
pnpm dlx tsx scripts/rotate-encryption-key.ts v3
|
||||
ENCRYPTION_KEYS='{"v3":"<new>"}'
|
||||
```
|
||||
|
||||
|
||||
+65
-48
@@ -1,26 +1,34 @@
|
||||
# TLS leaf SPKI pin — coupling, alarm, and re-pin runbook
|
||||
# TLS SPKI pinning — coupling, alarm, and re-pin runbook
|
||||
|
||||
The native client SPKI-pins the server's TLS **leaf** certificate. This is a
|
||||
deliberate hardening choice on the client side, but it couples the client to
|
||||
the exact leaf keypair the server serves: when that leaf rotates, a client
|
||||
that was only ever shipped the old pin refuses to connect. This page explains
|
||||
the coupling, the server-side alarm that watches for a rotation, and the
|
||||
operator / release-owner steps when it fires.
|
||||
The native client SPKI-pins the server's TLS chain at the **CA level**. It
|
||||
ships a small pin set — currently the issuing intermediate plus the root —
|
||||
and accepts a connection only when the served chain passes baseline X.509
|
||||
evaluation AND at least one certificate anywhere in the chain matches a pin
|
||||
(any-match). The leaf is deliberately **not** pinned, so routine leaf
|
||||
renewals are a client no-op. The outage risk is a **chain change**: the
|
||||
served chain stops terminating in the pinned CA keys — the server moves to a
|
||||
different CA, or the CA rotates its intermediate / root keys. This page
|
||||
explains the coupling, the server-side alarm that watches the served leaf,
|
||||
and the operator / release-owner steps when it fires.
|
||||
|
||||
## The coupling
|
||||
|
||||
A SPKI pin is `base64(sha256(DER subjectPublicKeyInfo))` — a hash of the
|
||||
A SPKI pin is `base64(sha256(DER subjectPublicKeyInfo))` — a hash of a
|
||||
certificate's public key, not of the whole certificate. The native client
|
||||
carries a set of these pins and rejects any TLS connection whose leaf public
|
||||
key is not in the set.
|
||||
carries a set of these pins (build-time config, ≥ 2 well-formed pins
|
||||
enforced for release builds) and rejects any TLS connection whose validated
|
||||
chain contains no pinned key. Hosts outside the client's pinned-host list
|
||||
fall back to plain system-trust validation, which is the documented model
|
||||
for self-hosted instances that ship their own chain.
|
||||
|
||||
The app host's certificate is issued by Google Trust Services (GTS) and
|
||||
auto-renews roughly every 90 days. Each renewal mints a **new leaf keypair**,
|
||||
so the SPKI pin changes on every renewal. Intermediate and root rolls have
|
||||
the same effect on the leaf chain. Because the client pins the leaf, every
|
||||
such change is a hard outage for any client that hasn't been shipped the new
|
||||
pin — and there is otherwise no server-side signal that a client is pinning
|
||||
the served leaf at all.
|
||||
The app host's certificate auto-renews roughly every 90 days and each
|
||||
renewal mints a new leaf keypair. Because the client pins the CA keys rather
|
||||
than the leaf, those renewals pass without a client build. What DOES break
|
||||
every pinned client is a chain that no longer terminates in a pinned key —
|
||||
and there is no server-side signal that clients are pinning the served chain
|
||||
at all. The alarm below exists to catch exactly that early: it watches the
|
||||
served **leaf**, the most frequently rotating element, as the canary for any
|
||||
chain movement.
|
||||
|
||||
## The alarm
|
||||
|
||||
@@ -46,13 +54,15 @@ successful probe returning a pin genuinely outside the known set.
|
||||
|
||||
### Baseline source: an env var, not auto-learning
|
||||
|
||||
`TLS_LEAF_SPKI_PINS` (comma-separated) is the known-good set. The baseline is
|
||||
an env var on purpose, **not** a persisted "last-seen" row:
|
||||
`TLS_LEAF_SPKI_PINS` (comma-separated) is the known-good set of **served
|
||||
leaf** pins. It is the operator's explicit record of what the server is
|
||||
expected to serve — distinct from the client's CA-level pin set, which lives
|
||||
in the client build config. The baseline is an env var on purpose, **not** a
|
||||
persisted "last-seen" row:
|
||||
|
||||
- The pinned client only trusts the pins it was shipped, so the operator must
|
||||
derive the client's pin set from a single explicit source of truth. The env
|
||||
var **is** that source — the same value goes into the client's pin set and
|
||||
into `TLS_LEAF_SPKI_PINS`.
|
||||
- An explicit baseline forces the operator to acknowledge every leaf
|
||||
rotation and check the new chain against the client's pinned CA keys
|
||||
before the alarm goes quiet.
|
||||
- A persisted last-seen baseline would silently adopt the first rotated pin
|
||||
and suppress the very alarm the pinned client needs.
|
||||
- When the env var is unset, the monitor fails **loud, not open**: it logs
|
||||
@@ -84,36 +94,43 @@ The variable is on the compose `environment:` whitelist and the
|
||||
whether it is set. It is optional: without it the monitor still runs and logs
|
||||
the served pin, but cannot alarm on a change.
|
||||
|
||||
## When the alarm fires — re-pin procedure
|
||||
## When the alarm fires
|
||||
|
||||
When you see a `tls.pin.leaf_changed` alert / `system.tls.pin_changed` audit
|
||||
row, the served leaf has rotated. The pinned client will stop connecting once
|
||||
its shipped pin no longer matches a served leaf. Act inside the certificate's
|
||||
remaining validity, with margin for app review and client roll-out — target
|
||||
**≥ 11 days before the old pin's certificate expires** (the alert body and
|
||||
audit row both carry the new leaf's `validTo`).
|
||||
row, the served leaf has rotated. First determine which of two cases you are
|
||||
in:
|
||||
|
||||
1. **Re-extract the new leaf pin** with the `openssl` pipeline above. It
|
||||
should equal the `servedPin` in the alert.
|
||||
2. **Dual-pin.** Add the new pin to the client's pin set **alongside** the old
|
||||
one (do not replace it yet), and update `TLS_LEAF_SPKI_PINS` to the
|
||||
comma-separated pair, e.g.
|
||||
`TLS_LEAF_SPKI_PINS="<old-pin>,<new-pin>"`. Holding both pins means clients
|
||||
on the old build keep working through the cutover and the server-side
|
||||
alarm goes quiet because the served pin is now in the known set. Re-deploy
|
||||
the server so the new env value takes effect.
|
||||
1. **Routine renewal, same chain.** Inspect the new chain (the `openssl`
|
||||
pipeline above, or the alert payload). If the issuing intermediate and
|
||||
root are unchanged, the client's CA-level pins still match and no client
|
||||
is at risk. Update `TLS_LEAF_SPKI_PINS` to the new served-leaf pin and
|
||||
re-deploy so the alarm goes quiet. No client build.
|
||||
2. **Chain change.** The new leaf chains through a different intermediate /
|
||||
root (CA switch, or the CA rotated its keys). Every pinned client will
|
||||
stop connecting once the old chain is no longer served — run the re-pin
|
||||
procedure below inside the old chain's remaining validity, with margin
|
||||
for app review and client roll-out (the alert body and audit row carry
|
||||
the new leaf's `validTo`).
|
||||
|
||||
### Re-pin procedure (chain change)
|
||||
|
||||
1. **Extract the new chain's CA pins** (intermediate + root) with the
|
||||
`openssl` pipeline / the client repo's SPKI-extraction script.
|
||||
2. **Dual-pin.** Add the new CA pins to the client's pin set **alongside**
|
||||
the old ones (do not replace them yet). Clients on the old build keep
|
||||
working as long as the old chain is still served; the dual-pinned build
|
||||
works against both chains. Update `TLS_LEAF_SPKI_PINS` to the currently
|
||||
served leaf pin(s) so the server-side alarm reflects reality.
|
||||
3. **Ship the client build** carrying the dual-pin set to TestFlight (and
|
||||
onward to release). Give it long enough to roll out to the installed base
|
||||
before the next renewal.
|
||||
4. **Retire the old pin** once the old leaf is no longer served and the
|
||||
dual-pinned client build has reached the installed base: drop the old pin
|
||||
from both the client pin set and `TLS_LEAF_SPKI_PINS`, leaving only the
|
||||
current leaf pin.
|
||||
onward to release). Give it long enough to roll out to the installed
|
||||
base before the old chain disappears.
|
||||
4. **Retire the old pins** once the old chain is no longer served and the
|
||||
dual-pinned client build has reached the installed base.
|
||||
|
||||
Treat the dual-pin window as standing practice: always ship the **next** pin
|
||||
before the current leaf rotates, so a renewal never catches a single-pinned
|
||||
client. If GTS renews on a fixed cadence, pre-extracting and dual-pinning the
|
||||
upcoming leaf ahead of the renewal turns every rotation into a no-op.
|
||||
CA-level pins move rarely — intermediates and roots carry multi-year
|
||||
lifetimes and rotations are announced well ahead. Track the pinned
|
||||
certificates' expiry dates and pre-stage the successor pins before the
|
||||
cutover, so a chain change never catches a single-chain client.
|
||||
|
||||
## Environment assumption
|
||||
|
||||
|
||||
@@ -24,15 +24,44 @@ deployed in three modes:
|
||||
3. `docker compose up -d --build`.
|
||||
|
||||
Both containers connect to the same Postgres. pg-boss claims jobs
|
||||
atomically via row-level locking, so it is safe to run multiple worker
|
||||
replicas — they will share the load.
|
||||
atomically via row-level locking (`FOR UPDATE SKIP LOCKED`), so
|
||||
running multiple worker replicas is **safe** — no job runs twice —
|
||||
but it doubles the DB load and muddies telemetry without adding
|
||||
throughput a personal instance needs (`src/lib/process-type.ts`).
|
||||
Run **one** worker container and scale the `web` containers instead.
|
||||
|
||||
## Apple Health import staging (shared volume required for a split)
|
||||
|
||||
The Apple Health `export.zip` import streams the upload to a staging
|
||||
file on local disk (under the container's temp dir) and hands the
|
||||
worker only the file **path**. In single-container mode
|
||||
(`HEALTHLOG_PROCESS_TYPE=all`, the default) the web handler and the
|
||||
worker share the same filesystem, so the handoff just works.
|
||||
|
||||
In a web/worker split — or any topology where the container that
|
||||
accepts the upload is not the one that runs the job (a `web` +
|
||||
`worker` split, extra `all` replicas, or old+new containers coexisting
|
||||
during a rolling deploy) — the worker cannot see the web container's
|
||||
staging file. The import then fails with a staging-file-missing error
|
||||
("the import staging file is not visible to the worker"). Two things to
|
||||
know:
|
||||
|
||||
- A pure `web` container has no pg-boss handle, so an import kicked off
|
||||
there currently returns `503 Background worker is not running`. Run
|
||||
imports from a container that also runs the worker until send-only
|
||||
boss support lands.
|
||||
- If you run web and worker as separate containers, they **must share
|
||||
the import staging directory** — mount the same named volume at the
|
||||
temp path on both — or run imports in single-container (`all`) mode.
|
||||
Without a shared staging volume, imports in split mode cannot
|
||||
complete.
|
||||
|
||||
## Healthchecks
|
||||
|
||||
- `app` (web) healthcheck: `wget /api/version` every 30s.
|
||||
- `app-worker` does not expose a port; its liveness is reported into
|
||||
the `worker_status` table on every reminder pass and surfaced through
|
||||
`/api/admin/worker-status`.
|
||||
`/api/admin/status`.
|
||||
|
||||
The web container does NOT depend on the worker, and vice versa, so
|
||||
neither container's startup can deadlock the other. The shared
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# SSO login (OIDC)
|
||||
|
||||
HealthLog can delegate sign-in to a self-hosted OpenID Connect identity
|
||||
provider — Authentik, Keycloak, Authelia, Pocket-ID, or anything else
|
||||
that serves a standard discovery document. The flow is the OIDC
|
||||
authorization-code flow with PKCE against a single, env-configured
|
||||
provider; by default it is **additive** (the SSO button appears next to
|
||||
password and passkey login), with an explicit opt-in to make it the only
|
||||
way in.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Register a **confidential** OIDC client at your IdP with the redirect
|
||||
URI
|
||||
|
||||
```
|
||||
${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback
|
||||
```
|
||||
|
||||
The comparison at the IdP is byte-exact: scheme, host, port, and path
|
||||
all matter. `https://healthlog.example.com/api/auth/oidc/callback` and
|
||||
`http://healthlog.example.com/api/auth/oidc/callback` are different
|
||||
URIs, and a trailing slash breaks the match.
|
||||
|
||||
2. Set the three provider vars (all three together enable the login-page
|
||||
button; anything less keeps SSO fully disabled):
|
||||
|
||||
```env
|
||||
OIDC_ISSUER_URL="https://idp.example.com"
|
||||
OIDC_CLIENT_ID="healthlog"
|
||||
OIDC_CLIENT_SECRET="..."
|
||||
```
|
||||
|
||||
The issuer must serve
|
||||
`<issuer>/.well-known/openid-configuration`. Prefer HTTPS — the
|
||||
authorization code and the token response carrying the ID token cross
|
||||
this connection. Plain HTTP is defensible only on a fully trusted
|
||||
private network (IdP and app on the same box, a Tailscale mesh, a
|
||||
VPN); a LAN-only issuer is explicitly supported.
|
||||
|
||||
3. Optional tuning:
|
||||
|
||||
```env
|
||||
OIDC_SCOPES="openid email profile" # default shown
|
||||
OIDC_BUTTON_LABEL="Single Sign-On" # default shown
|
||||
OIDC_ONLY="false" # see below before changing
|
||||
```
|
||||
|
||||
4. Remember the compose env whitelist: every `OIDC_*` var must appear
|
||||
under `environment:` in `docker-compose.yml` (the shipped file lists
|
||||
them) — vars not on the whitelist never reach the container even when
|
||||
`.env` has them.
|
||||
|
||||
`pnpm check-env` knows all six vars and flags a partially configured
|
||||
provider group.
|
||||
|
||||
## Per-IdP notes
|
||||
|
||||
Client registration essentials only — the IdP's own docs cover the rest.
|
||||
|
||||
- **Authentik.** Create a _Provider_ of type OAuth2/OpenID (confidential
|
||||
client), bind it to an _Application_, and use the provider's issuer URL
|
||||
(shaped like `https://authentik.example.com/application/o/<app-slug>/`
|
||||
— the trailing slash is part of Authentik's issuer; the discovery
|
||||
check tolerates the difference). Signing key: any RSA/EC key; the
|
||||
default RS256 works.
|
||||
- **Keycloak.** Create a client with _Client authentication_ ON and the
|
||||
_Standard flow_ enabled. The issuer is
|
||||
`https://keycloak.example.com/realms/<realm>`. Keep the default RS256
|
||||
signature algorithm; do not switch the client to `HS256` — HealthLog
|
||||
rejects HMAC-signed ID tokens.
|
||||
- **Authelia.** Define a client under `identity_providers.oidc.clients`
|
||||
with `token_endpoint_auth_method: client_secret_post` (HealthLog sends
|
||||
the secret in the POST body) and the redirect URI above. The issuer is
|
||||
Authelia's root URL.
|
||||
- **Pocket-ID.** Add an OIDC client, copy the generated ID and secret,
|
||||
and use the Pocket-ID base URL as the issuer. Pocket-ID authenticates
|
||||
users by passkey at the IdP; to HealthLog it is a provider like any
|
||||
other.
|
||||
|
||||
## Security model
|
||||
|
||||
- **Identity is pinned, not guessed.** The first OIDC sign-in either
|
||||
provisions a fresh account or links to an existing account matched by
|
||||
**verified** email (`email_verified: true` — an IdP that does not
|
||||
assert verification is rejected). Either way the account is stamped
|
||||
with the provider's `(issuer, sub)` pair, and every later login
|
||||
matches on that pair alone. An email change at the IdP updates the
|
||||
displayed email; it can never re-point the login at a different
|
||||
account. An email that matches an account already pinned to a
|
||||
_different_ identity is rejected and audited.
|
||||
- **SSO sessions never satisfy step-up.** Destructive actions (disabling
|
||||
MFA, rotating encryption keys, encrypted export, account deletion)
|
||||
require a _fresh_ second factor confirmed by HealthLog itself. An OIDC
|
||||
login is a delegated factor — whatever the IdP checked, this app did
|
||||
not see it — so an SSO session always has to confirm a native factor
|
||||
(or the password path) when it hits one of those gates.
|
||||
- **Accounts with native 2FA keep it.** If the account has a confirmed
|
||||
TOTP secret or a registered security key, an OIDC login does not
|
||||
complete on its own: after the IdP round-trip the user lands in the
|
||||
same second-factor step password login uses. SSO never downgrades an
|
||||
account's own MFA.
|
||||
- **MFA belongs at the IdP.** For SSO-provisioned accounts, enforce
|
||||
MFA in the IdP's policy engine — that is the point of delegating
|
||||
authentication. HealthLog-side 2FA remains available on top for the
|
||||
step-up gates above.
|
||||
- **Linking is once.** There is no unlink/relink flow in this cut; the
|
||||
pin is deliberately sticky. Moving an account to a new IdP identity is
|
||||
an operator decision (a manual `users.oidc_issuer` / `users.oidc_sub`
|
||||
update), not something a login can do.
|
||||
|
||||
## OIDC_ONLY
|
||||
|
||||
`OIDC_ONLY="true"` disables password and passkey login **server-side**
|
||||
(the routes return 403; hiding the buttons is not the boundary).
|
||||
Consequences to accept before turning it on:
|
||||
|
||||
- **The native iOS app cannot sign in.** It authenticates via
|
||||
password/passkey only; there is no native SSO flow yet. Do not enable
|
||||
`OIDC_ONLY` on a deployment with active iOS users.
|
||||
- **Existing sessions survive.** The flag gates new sign-ins, not live
|
||||
sessions — nobody is thrown out at flip time, including
|
||||
password-authenticated sessions.
|
||||
- **Self-disabling on half-config.** The flag only takes effect while
|
||||
the three provider vars are fully set, so a typo in the provider group
|
||||
can never lock every user out.
|
||||
|
||||
### Break-glass (IdP down or misconfigured)
|
||||
|
||||
1. Unset `OIDC_ONLY` (or set it to `"false"`) in `.env`, then
|
||||
`docker compose up -d`. Password and passkey login are live again
|
||||
immediately.
|
||||
2. Accounts that were **provisioned** through SSO have no password. Set
|
||||
one from the host, per [docs/ops/password-reset.md](../ops/password-reset.md):
|
||||
|
||||
```sh
|
||||
docker compose exec app node scripts/reset-password.mjs <username-or-email>
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause / fix |
|
||||
| --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Login page shows "Single sign-on failed", log says `discovery issuer mismatch` | `OIDC_ISSUER_URL` must equal the `issuer` inside the discovery document. A single trailing slash either way is tolerated; anything else (scheme, host, port, path, `//`) is a hard reject. |
|
||||
| `discovery request failed: 3xx` | Discovery redirects are not followed (egress policy pins `redirect: "manual"`). Point `OIDC_ISSUER_URL` at the final URL — e.g. include the realm/application path, match `http` vs `https`, add/remove `www`. |
|
||||
| IdP shows its own "invalid redirect URI" page | The mismatch happens **at the IdP**, before HealthLog is involved. Compare the registered URI against `${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback` byte for byte — scheme and port included. |
|
||||
| Sign-in loops back with "failed" and the log shows an `exp`/`nbf` claim error | Clock skew beyond the 60 s tolerance. Fix NTP on the IdP or app box. |
|
||||
| "Your identity provider did not mark your email address as verified" | The IdP omits `email_verified` or sends `false`. Mark the address verified at the IdP (Keycloak: user → Email verified; Authentik: verify the email stage) — HealthLog will not link or provision without it. |
|
||||
| "That email address belongs to an account that is already linked to a different SSO identity" | Deliberate: identities never re-bind silently. See "Linking is once" above. |
|
||||
| SSO button missing | One of the three provider vars is empty or not whitelisted in compose. `pnpm check-env` shows which. |
|
||||
@@ -0,0 +1,197 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* Operator-shared central Codex (ChatGPT subscription) — two mocked flows:
|
||||
*
|
||||
* A. The per-user opt-in switch on /settings/ai, shown only when the operator
|
||||
* has connected the shared account (`centralCodexAvailable: true`). Toggle
|
||||
* on → honesty confirm → PATCH /api/auth/me/use-central-codex.
|
||||
* B. The admin connect surface on /admin/coach: device-code panel →
|
||||
* connected state, driven by the three admin endpoints.
|
||||
*
|
||||
* The real OAuth handshake bounces through chatgpt.com, which CI never hits;
|
||||
* every relevant endpoint is mocked so the assertions focus on the UI state.
|
||||
*/
|
||||
test.describe("central codex opt-in switch (mocked)", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test("switch shows when available and writes the opt-in on confirm", async ({
|
||||
page,
|
||||
}) => {
|
||||
let useCentralCodex = false;
|
||||
|
||||
await page.route("**/api/insights/settings", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
codexStatus: "disconnected",
|
||||
codexConnectedAt: null,
|
||||
hasAdminKey: false,
|
||||
codexOauthConfigured: true,
|
||||
centralCodexAvailable: true,
|
||||
useCentralCodex,
|
||||
privacyMode: "aggregated",
|
||||
lastInsightAt: null,
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route("**/api/user/ai-provider", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
provider: null,
|
||||
model: null,
|
||||
baseUrl: null,
|
||||
hasAnthropicKey: false,
|
||||
anthropicKeyPreview: null,
|
||||
hasLocalKey: false,
|
||||
hasOpenaiKey: false,
|
||||
openaiKeyPreview: null,
|
||||
responseTimeoutSeconds: null,
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route("**/api/insights/provider-chain", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
activeProvider: "codex",
|
||||
cachedActiveProvider: null,
|
||||
configuredChain: [{ providerType: "codex", available: true }],
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route("**/api/auth/me/documents-auto-ai-read", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { documentsAutoAiRead: false },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route("**/api/consent/ai/web", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: { ok: true }, error: null }),
|
||||
}),
|
||||
);
|
||||
|
||||
// The opt-in write — flips the mocked state so a re-read reflects it.
|
||||
await page.route("**/api/auth/me/use-central-codex", (route) => {
|
||||
useCentralCodex = true;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { useCentralCodex: true },
|
||||
error: null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/settings/ai", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const toggle = page.getByTestId("use-central-codex-enable");
|
||||
await expect(toggle).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Off → on reveals the honesty confirm; the write only happens on confirm.
|
||||
await toggle.click();
|
||||
const confirm = page.locator('[data-slot="central-codex-confirm-cta"]');
|
||||
await expect(confirm).toBeVisible();
|
||||
|
||||
const wrote = page.waitForRequest(
|
||||
(req) =>
|
||||
req.url().includes("/api/auth/me/use-central-codex") &&
|
||||
req.method() === "PATCH",
|
||||
);
|
||||
await confirm.click();
|
||||
await wrote;
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("admin central codex connect (mocked)", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test("connect button → device panel → connected badge", async ({ page }) => {
|
||||
let connected = false;
|
||||
|
||||
await page.route("**/api/admin/central-codex", (route) => {
|
||||
if (route.request().method() !== "GET") return route.continue();
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
status: connected ? "connected" : "disconnected",
|
||||
connectedAt: connected ? new Date().toISOString() : null,
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/admin/central-codex/device-start", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
userCode: "WXYZ-7890",
|
||||
verificationUrl: "https://chatgpt.com/codex/device",
|
||||
intervalSeconds: 1,
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
let pollCount = 0;
|
||||
await page.route("**/api/admin/central-codex/device-poll", (route) => {
|
||||
pollCount += 1;
|
||||
if (pollCount >= 2) connected = true;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { status: pollCount >= 2 ? "connected" : "pending" },
|
||||
error: null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/admin/coach", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const connect = page.locator('[data-slot="admin-central-codex-connect"]');
|
||||
await expect(connect).toBeVisible({ timeout: 10_000 });
|
||||
await connect.click();
|
||||
|
||||
// Device-code panel surfaces the user code.
|
||||
await expect(page.getByText("WXYZ-7890")).toBeVisible();
|
||||
|
||||
// After the mocked poll flips to connected, the disconnect action shows.
|
||||
await expect(
|
||||
page.locator('[data-slot="admin-central-codex-disconnect"]'),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
@@ -98,6 +98,70 @@ test.describe("authenticated dashboard render", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
// S2 — the Today hero reads the unified daily digest from
|
||||
// `/api/daily/digest`. Mock it with a deterministic digest (a score +
|
||||
// one worth-a-look item) so the hero paints without reaching the real
|
||||
// route, and so a slow/absent digest can never hang the render or
|
||||
// surface a console error under the significant-error gate below.
|
||||
await page.route(/\/api\/daily\/digest(\?|$)/, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
generatedAt: new Date().toISOString(),
|
||||
phase: "final",
|
||||
sleepPending: false,
|
||||
score: { value: 82, band: "green", delta: 2 },
|
||||
topSignal: null,
|
||||
briefingLead: "Your week is trending steady.",
|
||||
line: "Your week is trending steady.",
|
||||
worthALook: [
|
||||
{
|
||||
kind: "sync_issue",
|
||||
title: "Sync needs attention",
|
||||
body: "Withings isn't syncing.",
|
||||
status: "warning",
|
||||
actions: [
|
||||
{
|
||||
labelKey: "daily.action.reconnect",
|
||||
intent: "sync.reconnect",
|
||||
href: "/settings/integrations",
|
||||
},
|
||||
],
|
||||
},
|
||||
// S3 — a coach check-in card (keep / adjust / let-go). Rendered
|
||||
// only; the mutating taps PATCH the plan route, which this spec
|
||||
// never clicks, so it stays green without a plan-route stub.
|
||||
{
|
||||
kind: "coach_checkin",
|
||||
title: "Time to check in on a plan",
|
||||
body: "About a week ago you set: “every morning → weigh in”.",
|
||||
status: "info",
|
||||
moduleKey: "coach",
|
||||
actions: [
|
||||
{
|
||||
labelKey: "daily.action.checkinKeep",
|
||||
intent: "coach.checkin.keep:p1",
|
||||
},
|
||||
{
|
||||
labelKey: "daily.action.checkinAdjust",
|
||||
intent: "coach.checkin.adjust",
|
||||
href: "/coach",
|
||||
},
|
||||
{
|
||||
labelKey: "daily.action.checkinLetGo",
|
||||
intent: "coach.checkin.letGo:p1",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// v1.18.6 — the dashboard now batches every visible chart's daily
|
||||
// series into ONE `/api/measurements/series-batch` round-trip and
|
||||
// hands each chart its slice via `preloadedSeries`. The route is a
|
||||
@@ -209,6 +273,17 @@ test.describe("authenticated dashboard render", () => {
|
||||
const tileCount = Number(await strip.getAttribute("data-tile-count"));
|
||||
expect(tileCount).toBeGreaterThan(0);
|
||||
|
||||
// S2 — the Today hero paints above the tile strip from the mocked
|
||||
// digest: its score ring and its worth-a-look rail render.
|
||||
const todayHero = page.locator('[data-slot="today-hero"]');
|
||||
await expect(todayHero).toBeVisible({ timeout: 10_000 });
|
||||
await expect(
|
||||
todayHero.locator('[data-slot="today-hero-score"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
todayHero.locator('[data-slot="today-hero-rail"]'),
|
||||
).toBeVisible();
|
||||
|
||||
// At least one chart must render. Recharts mounts a `<svg>` with
|
||||
// `class="recharts-surface"` (or a `<div class="recharts-wrapper">`
|
||||
// around it). Use the recharts class so we don't accidentally match
|
||||
|
||||
+17
-46
@@ -110,6 +110,13 @@ async function mockAiEnabled(
|
||||
error: null,
|
||||
}),
|
||||
);
|
||||
// The detail sheet reads the auto-AI-read opt-in to decide whether to show
|
||||
// the manual per-document action row. Default it OFF here so the manual
|
||||
// Read / Suggest / Summarise actions render for these assertions; an unmocked
|
||||
// fetch would leave the sheet reading a live value.
|
||||
await page.route("**/api/auth/me/documents-auto-ai-read", (route) =>
|
||||
fulfilJson(route, { data: { documentsAutoAiRead: false }, error: null }),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("document vault — AI assist + content search", () => {
|
||||
@@ -172,9 +179,6 @@ test.describe("document vault — AI assist + content search", () => {
|
||||
|
||||
const review = sheet.locator('[data-slot="assist-suggestion-review"]');
|
||||
await expect(review).toBeVisible();
|
||||
await expect(
|
||||
review.getByText("AI suggestion — review before saving"),
|
||||
).toBeVisible();
|
||||
|
||||
// Applying the title seeds the EDITABLE field only — no write yet.
|
||||
await review.getByRole("button", { name: "Use title" }).click();
|
||||
@@ -196,9 +200,7 @@ test.describe("document vault — AI assist + content search", () => {
|
||||
|
||||
// ── (b) Session-only summary: transient, note present, closing discards ──
|
||||
|
||||
test("summary panel is transient and labelled not-saved / not-a-diagnosis", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("summary panel is transient and discards on close", async ({ page }) => {
|
||||
await mockAiEnabled(page, { mode: "vision" });
|
||||
const summaryText = "A chest imaging report issued by a radiology clinic.";
|
||||
let summaryCalls = 0;
|
||||
@@ -221,11 +223,6 @@ test.describe("document vault — AI assist + content search", () => {
|
||||
const panel = sheet.locator('[data-slot="document-summary-panel"]');
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(panel.getByText(summaryText)).toBeVisible();
|
||||
await expect(
|
||||
panel.getByText(
|
||||
"Not saved · not a diagnosis. Shown once for this view only.",
|
||||
),
|
||||
).toBeVisible();
|
||||
expect(summaryCalls).toBe(1);
|
||||
|
||||
// Closing discards — the panel is gone and nothing lingers.
|
||||
@@ -253,12 +250,6 @@ test.describe("document vault — AI assist + content search", () => {
|
||||
await expect(card).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page).toHaveURL(new RegExp(`q=${CONTENT_BODY_WORD}`));
|
||||
|
||||
// The matched card advertises its searchable body.
|
||||
const searchable = page
|
||||
.locator('[data-slot="document-card"]', { hasText: "Radiology note" })
|
||||
.locator('[data-slot="document-searchable"]');
|
||||
await expect(searchable).toBeVisible();
|
||||
|
||||
// Sanity: the word really is absent from the short fields (server-side
|
||||
// confirmation the union — not an ILIKE — did the work).
|
||||
const viaApi = await page.request.get(
|
||||
@@ -354,10 +345,6 @@ test.describe("document vault — AI assist + content search", () => {
|
||||
await expect(read).toBeVisible();
|
||||
await expect(read).toHaveText(/Read with AI/);
|
||||
|
||||
// The status pill starts at the calm "not searchable yet".
|
||||
const status = sheet.locator('[data-slot="content-search-status"]');
|
||||
await expect(status).toHaveAttribute("data-state", "none");
|
||||
|
||||
await read.click();
|
||||
await expect(page.getByText("The AI read your document.")).toBeVisible();
|
||||
expect(indexCalls).toBe(1);
|
||||
@@ -368,36 +355,20 @@ test.describe("document vault — AI assist + content search", () => {
|
||||
|
||||
test("a vision-indexed document surfaces the AI-read provenance", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
}) => {
|
||||
// CONTENT_DOC_ID is seeded with source "vision" (an AI provider read it).
|
||||
// No mocks: the real list/detail GET threads the provenance through.
|
||||
// No mocks: the real detail GET threads the provenance through. v1.28.38 —
|
||||
// the vault card no longer carries the AI-read glyph (it looked wrong on
|
||||
// the card face); the provenance now lives as a calm muted meta line in the
|
||||
// document DETAIL view. Deep-link straight to the document so the sheet
|
||||
// opens deterministically (the vault grid is virtualized).
|
||||
await page.goto(`/documents?doc=${CONTENT_DOC_ID}`);
|
||||
const sheet = page.getByRole("dialog");
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
// The detail status pill reflects the AI-read source — asserted on every
|
||||
// project (this is the provenance contract; it holds on desktop + mobile).
|
||||
const status = sheet.locator('[data-slot="content-search-status"]');
|
||||
await expect(status).toHaveAttribute("data-state", "ai-read");
|
||||
await expect(status).toHaveText(/Read by AI/);
|
||||
|
||||
// The timeline card carries the same AI-read marker — desktop only. The
|
||||
// vault timeline is virtualized; on the narrow mobile viewport the seeded
|
||||
// card renders outside the initial virtual window (not in the DOM), so a
|
||||
// card-level attribute read there tests the virtualizer, not the marker.
|
||||
// The marker's render logic is viewport-independent and covered by the SSR
|
||||
// card test + this desktop assertion; mobile provenance is already proven
|
||||
// by the status pill above.
|
||||
if (testInfo.project.name !== "chromium-mobile") {
|
||||
await page.keyboard.press("Escape");
|
||||
const marker = page
|
||||
.locator('[data-slot="document-card"]', { hasText: "Radiology note" })
|
||||
.locator('[data-slot="document-searchable"]')
|
||||
.first();
|
||||
await expect(marker).toHaveAttribute("data-source", "ai-read", {
|
||||
timeout: 20_000,
|
||||
});
|
||||
}
|
||||
const marker = sheet.locator('[data-slot="document-detail-ai-read"]');
|
||||
await expect(marker).toBeVisible();
|
||||
await expect(marker).toHaveText("Read by AI");
|
||||
});
|
||||
|
||||
// ── (f) Text-mode refuses a non-image before any OCR/upload ──────────────
|
||||
|
||||
@@ -44,9 +44,21 @@ async function fulfilJson(
|
||||
*/
|
||||
async function mockDocumentCapability(
|
||||
page: Page,
|
||||
opts: { egress: "local" | "external"; pdfSupported?: boolean },
|
||||
opts: {
|
||||
egress: "local" | "external";
|
||||
pdfSupported?: boolean;
|
||||
/** The per-user auto-AI-read opt-in. OFF keeps the manual action row. */
|
||||
autoRead?: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
const pdfSupported = opts.pdfSupported ?? true;
|
||||
const autoRead = opts.autoRead ?? false;
|
||||
await page.route("**/api/auth/me/documents-auto-ai-read", (route) =>
|
||||
fulfilJson(route, {
|
||||
data: { documentsAutoAiRead: autoRead },
|
||||
error: null,
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/documents/inbound/usage", (route) =>
|
||||
fulfilJson(route, {
|
||||
data: {
|
||||
@@ -171,22 +183,22 @@ test.describe("automatic AI reading — vault per-document contract", () => {
|
||||
|
||||
test.beforeEach(() => test.slow());
|
||||
|
||||
test("the vault shows the per-egress notice once and the explicit read action", async ({
|
||||
test("the vault shows the explicit read action for an external provider", async ({
|
||||
page,
|
||||
}) => {
|
||||
// External egress (the codex/OAuth or any non-local provider case).
|
||||
// External egress (the codex/OAuth or any non-local provider case). The
|
||||
// per-document egress notice was retired — the settings-toggle honesty
|
||||
// confirm already covers the egress trade — so no per-document notice
|
||||
// renders here regardless of egress mode.
|
||||
await mockDocumentCapability(page, { egress: "external" });
|
||||
|
||||
await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`);
|
||||
const sheet = page.getByRole("dialog");
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
// The vendor-blind "leaves your machine" notice is shown exactly once.
|
||||
await expect(
|
||||
sheet.locator('[data-slot="document-ai-egress-notice"]'),
|
||||
).toHaveCount(1);
|
||||
// The per-document read is the explicit path — the action is present and the
|
||||
// user must tap it (auto-read never removes the affordance, only the tap).
|
||||
// With auto-read OFF the per-document read is the explicit path — the action
|
||||
// is present and the user taps it. (When auto-read is ON the row collapses;
|
||||
// that is covered by its own test below.)
|
||||
await expect(sheet.locator('[data-slot="document-read-ai"]')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -218,8 +230,12 @@ test.describe("automatic AI reading — vault per-document contract", () => {
|
||||
expect(indexCalls).toBe(1);
|
||||
});
|
||||
|
||||
test("a local-egress read shows no external notice", async ({ page }) => {
|
||||
// A self-hosted local model never leaves the machine — no egress notice.
|
||||
test("a local-egress provider shows the explicit read action", async ({
|
||||
page,
|
||||
}) => {
|
||||
// A self-hosted local model never leaves the machine; the read action is
|
||||
// offered the same way as for an external provider (no per-document notice
|
||||
// in either mode after the notice was retired).
|
||||
await mockDocumentCapability(page, {
|
||||
egress: "local",
|
||||
pdfSupported: false,
|
||||
@@ -229,8 +245,36 @@ test.describe("automatic AI reading — vault per-document contract", () => {
|
||||
const sheet = page.getByRole("dialog");
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
await expect(sheet.locator('[data-slot="document-read-ai"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("with auto-read ON the sheet drops the manual AI action row", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Auto-read reads every upload with no per-document tap, so the manual
|
||||
// Read / Suggest actions are redundant and the WHOLE block collapses
|
||||
// (v1.28.22 — no status chrome; provenance lives on the vault card).
|
||||
await mockDocumentCapability(page, { egress: "external", autoRead: true });
|
||||
|
||||
await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`);
|
||||
const sheet = page.getByRole("dialog");
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
// v1.28.22 — with auto-read ON and nothing pending the WHOLE block
|
||||
// collapses (no status chrome; provenance lives on the vault card), so the
|
||||
// sheet content starts straight with the document fields. The footer's
|
||||
// download action is the loaded-state anchor (it renders only once the
|
||||
// document GET resolved), so the absence checks below cannot pass
|
||||
// vacuously against the loading skeleton.
|
||||
await expect(
|
||||
sheet.locator('[data-slot="document-ai-egress-notice"]'),
|
||||
sheet.locator('[data-slot="document-download"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
sheet.locator('[data-slot="document-ai-section"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(sheet.locator('[data-slot="document-read-ai"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(sheet.locator('[data-slot="assist-suggest"]')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
+47
-318
@@ -1,20 +1,26 @@
|
||||
/**
|
||||
* Document vault — the P4 "chat about this document" acceptance suite.
|
||||
* Document vault — the "chat about a document" entry point (v1.28.52, R3).
|
||||
*
|
||||
* Pins the scoped chat that lives in the detail sheet's AI area: the entry is
|
||||
* offered only when a provider is available AND the document is content-indexed;
|
||||
* a non-indexed document shows a calm read-it-first hint (never an error); a
|
||||
* conversation streams the assistant reply and settles it as plain text with the
|
||||
* always-on "not medical advice" safety note; an injection-laced document is
|
||||
* described, never obeyed, and any HTML the model emits renders inert (the XSS
|
||||
* posture); and with NO provider the AI area collapses to the settings pointer
|
||||
* with no chat surface at all.
|
||||
* The bespoke in-sheet chat drawer is gone (Documents R3). Asking the Coach
|
||||
* about a document now opens the REAL Coach conversation, scoped to that
|
||||
* document, in the shared SIDE DRAWER: the detail sheet's Coach affordance
|
||||
* closes the sheet and opens the coach drawer pre-scoped to the document (its
|
||||
* maximize control expands to the full `/coach` page). The conversation runs on
|
||||
* the same hardened fenced endpoint — only WHERE it renders changed.
|
||||
*
|
||||
* v1.27.31 lesson: the panel depends on new endpoints (the capability probe, the
|
||||
* chat history GET, the streaming chat POST), so every one is mocked here —
|
||||
* otherwise the panel never renders and the specs time out waiting for elements.
|
||||
* The provider round-trips are mocked, so the SSE reply is deterministic. The
|
||||
* seeded demo user has no AI provider, so the no-provider state is the default.
|
||||
* This suite pins the documents-side contract: the affordance is offered only
|
||||
* when a provider is available, and it OPENS THE DOC-SCOPED DRAWER in place (no
|
||||
* full-page nav); with NO provider the AI area collapses to the settings
|
||||
* pointer and no Coach entry renders at all. The Coach-side rendering (the
|
||||
* "Chatting about" scope banner + the not-indexed hint) is covered by the
|
||||
* coach-conversation component test; the fenced-send guarantee (a document turn
|
||||
* never reaches the tool route) by the coach-send-target + persistence-scope
|
||||
* unit tests.
|
||||
*
|
||||
* v1.27.31 lesson kept: the sheet's AI area depends on the capability probe +
|
||||
* usage endpoints, so both are mocked — otherwise the AI area never renders and
|
||||
* the specs time out. The seeded demo user has no AI provider, so the
|
||||
* no-provider state is the default.
|
||||
*/
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
@@ -22,7 +28,6 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import {
|
||||
ensureVaultFixture,
|
||||
ensureVaultAiFixture,
|
||||
AI_PROBE_DOC_ID,
|
||||
CONTENT_DOC_ID,
|
||||
} from "./setup/vault-fixture";
|
||||
|
||||
@@ -54,20 +59,11 @@ function usageBody() {
|
||||
};
|
||||
}
|
||||
|
||||
/** Serialise SSE frames the way the chat route emits them. */
|
||||
function sse(frames: Array<Record<string, unknown>>): string {
|
||||
return frames.map((f) => `data: ${JSON.stringify(f)}\n\n`).join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock the AI-availability surface (usage + the document-scoped capability
|
||||
* probe) so the sheet offers the AI area and the chat entry. `egress` drives the
|
||||
* vendor-blind notice; default external.
|
||||
* probe) so the sheet offers the AI area and the Coach entry.
|
||||
*/
|
||||
async function mockAiEnabled(
|
||||
page: Page,
|
||||
opts: { egress?: "local" | "external" } = {},
|
||||
): Promise<void> {
|
||||
async function mockAiEnabled(page: Page): Promise<void> {
|
||||
await page.route("**/api/documents/inbound/usage", (route) =>
|
||||
fulfilJson(route, usageBody()),
|
||||
);
|
||||
@@ -78,11 +74,14 @@ async function mockAiEnabled(
|
||||
mode: "vision",
|
||||
reason: null,
|
||||
pdfSupported: true,
|
||||
egress: opts.egress ?? "external",
|
||||
egress: "external",
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/auth/me/documents-auto-ai-read", (route) =>
|
||||
fulfilJson(route, { data: { documentsAutoAiRead: false }, error: null }),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("document vault — chat about a document", () => {
|
||||
@@ -93,319 +92,47 @@ test.describe("document vault — chat about a document", () => {
|
||||
await ensureVaultAiFixture();
|
||||
});
|
||||
|
||||
// The chat drives a mocked provider round-trip; on a loaded CI runner it races
|
||||
// the default 30s, so the whole group earns the tripled timeout.
|
||||
test.beforeEach(() => {
|
||||
test.slow();
|
||||
});
|
||||
|
||||
// ── (a) Indexed + available: the entry opens an empty, safe chat surface ──
|
||||
// ── (a) Provider available: the Coach entry opens the doc-scoped drawer ──
|
||||
|
||||
test("offers the chat entry and opens an empty, safety-noted surface", async ({
|
||||
test("offers the Coach entry and opens the document-scoped Coach drawer in place", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockAiEnabled(page);
|
||||
// The document has no thread yet → the history list is empty.
|
||||
await page.route(
|
||||
`**/api/documents/inbound/${CONTENT_DOC_ID}/chat**`,
|
||||
(route) => {
|
||||
if (route.request().method() !== "GET") return route.fallback();
|
||||
return fulfilJson(route, {
|
||||
data: { conversations: [], nextCursor: null },
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto(`/documents?doc=${CONTENT_DOC_ID}`);
|
||||
const sheet = page.getByRole("dialog");
|
||||
const sheet = page.locator('[data-slot="responsive-sheet-content"]');
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
// The neutral Coach affordance lives in the detail-sheet AI area; tapping it
|
||||
// closes the sheet and opens the REAL coach drawer scoped to this document.
|
||||
const open = sheet.locator('[data-slot="document-chat-open"]');
|
||||
await expect(open).toBeVisible();
|
||||
await open.click();
|
||||
|
||||
await expect(
|
||||
sheet.locator('[data-slot="document-chat-log"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
sheet.locator('[data-slot="document-chat-empty"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
sheet.locator('[data-slot="document-chat-input"]'),
|
||||
).toBeVisible();
|
||||
// The always-on safety note is present.
|
||||
await expect(
|
||||
sheet.locator('[data-slot="document-chat-safety"]'),
|
||||
).toContainText("not medical advice");
|
||||
// The documents-side contract: the coach drawer opens IN PLACE (no
|
||||
// full-page nav) scoped to this document — its doc-scope banner renders.
|
||||
const drawer = page.locator('[data-slot="coach-drawer"]');
|
||||
await expect(drawer).toBeVisible({ timeout: 10_000 });
|
||||
await expect(drawer.locator('[data-slot="coach-doc-scope"]')).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
// Still on /documents — the drawer expands to the page only via maximize.
|
||||
expect(new URL(page.url()).pathname).not.toContain("/coach");
|
||||
});
|
||||
|
||||
// ── (b) A turn streams a grounded reply that settles as plain text ──
|
||||
// ── (b) No provider: the calm settings pointer, no Coach entry at all ──
|
||||
|
||||
test("streams the assistant reply and settles the conversation", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockAiEnabled(page);
|
||||
|
||||
const CONV_ID = "e2edocchatconv0000000001";
|
||||
const QUESTION = "What does the Impression say?";
|
||||
const REPLY = "Per the report's Impression, the findings are unremarkable.";
|
||||
let posted = false;
|
||||
|
||||
await page.route(
|
||||
`**/api/documents/inbound/${CONTENT_DOC_ID}/chat**`,
|
||||
async (route) => {
|
||||
const req = route.request();
|
||||
if (req.method() === "POST") {
|
||||
posted = true;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream; charset=utf-8",
|
||||
body: sse([
|
||||
{ type: "token", token: REPLY },
|
||||
{
|
||||
type: "done",
|
||||
conversationId: CONV_ID,
|
||||
messageId: "e2edocchatmsg0000000001",
|
||||
},
|
||||
]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const url = new URL(req.url());
|
||||
if (url.searchParams.get("conversationId")) {
|
||||
// The persisted thread the post-`done` refetch reads back.
|
||||
await fulfilJson(route, {
|
||||
data: {
|
||||
id: CONV_ID,
|
||||
title: QUESTION,
|
||||
createdAt: "2026-07-07T10:00:00.000Z",
|
||||
updatedAt: "2026-07-07T10:00:02.000Z",
|
||||
messageCount: 2,
|
||||
messages: [
|
||||
{
|
||||
id: "e2edocchatu0000000001",
|
||||
role: "user",
|
||||
content: QUESTION,
|
||||
createdAt: "2026-07-07T10:00:00.000Z",
|
||||
metricSource: null,
|
||||
providerType: null,
|
||||
promptVersion: null,
|
||||
tokensUsed: null,
|
||||
model: null,
|
||||
},
|
||||
{
|
||||
id: "e2edocchatmsg0000000001",
|
||||
role: "assistant",
|
||||
content: REPLY,
|
||||
createdAt: "2026-07-07T10:00:02.000Z",
|
||||
metricSource: null,
|
||||
providerType: "mock",
|
||||
promptVersion: null,
|
||||
tokensUsed: 42,
|
||||
model: "mock-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// The thread list: empty until the turn is posted, then the new thread.
|
||||
await fulfilJson(route, {
|
||||
data: {
|
||||
conversations: posted
|
||||
? [
|
||||
{
|
||||
id: CONV_ID,
|
||||
title: QUESTION,
|
||||
createdAt: "2026-07-07T10:00:00.000Z",
|
||||
updatedAt: "2026-07-07T10:00:02.000Z",
|
||||
messageCount: 2,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
nextCursor: null,
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto(`/documents?doc=${CONTENT_DOC_ID}`);
|
||||
const sheet = page.getByRole("dialog");
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
await sheet.locator('[data-slot="document-chat-open"]').click();
|
||||
const input = sheet.locator('[data-slot="document-chat-input"]');
|
||||
await input.fill(QUESTION);
|
||||
await sheet.locator('[data-slot="document-chat-send"]').click();
|
||||
|
||||
// The user's question and the streamed/settled reply both land in the log.
|
||||
const log = sheet.locator('[data-slot="document-chat-log"]');
|
||||
await expect(log.getByText(QUESTION)).toBeVisible();
|
||||
await expect(log.getByText(/findings are unremarkable/)).toBeVisible();
|
||||
// The reply settled to a single assistant bubble (no duplicate).
|
||||
await expect(
|
||||
log.locator('[data-slot="document-chat-message"][data-role="assistant"]'),
|
||||
).toHaveCount(1);
|
||||
});
|
||||
|
||||
// ── (b2) An injection-laced document: the reply describes it, never obeys,
|
||||
// and any HTML the model emits renders inert (no markdown, no
|
||||
// dangerouslySetInnerHTML — the project's XSS posture). ──
|
||||
|
||||
test("answers about an injection-laced document without obeying it, and renders HTML inert", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockAiEnabled(page);
|
||||
|
||||
const CONV_ID = "e2edocchatconv0000000002";
|
||||
const QUESTION = "What does this document say?";
|
||||
// The (mocked) server reply the panel receives: a calm describe-only answer
|
||||
// — NOT obedience to the document's embedded "ignore instructions … reveal
|
||||
// the system prompt … say HACKED" — plus a literal <script> the model was
|
||||
// coerced into emitting. The panel must show it as text and never execute it.
|
||||
const REPLY =
|
||||
"The document's body contains text formatted like an instruction, but I treat it as document content, not a command. <script>window.__docChatXss = 1</script> It appears to be a lab report.";
|
||||
let posted = false;
|
||||
|
||||
await page.route(
|
||||
`**/api/documents/inbound/${CONTENT_DOC_ID}/chat**`,
|
||||
async (route) => {
|
||||
const req = route.request();
|
||||
if (req.method() === "POST") {
|
||||
posted = true;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream; charset=utf-8",
|
||||
body: sse([
|
||||
{ type: "token", token: REPLY },
|
||||
{
|
||||
type: "done",
|
||||
conversationId: CONV_ID,
|
||||
messageId: "e2edocchatmsg0000000002",
|
||||
},
|
||||
]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const url = new URL(req.url());
|
||||
if (url.searchParams.get("conversationId")) {
|
||||
await fulfilJson(route, {
|
||||
data: {
|
||||
id: CONV_ID,
|
||||
title: QUESTION,
|
||||
createdAt: "2026-07-07T10:00:00.000Z",
|
||||
updatedAt: "2026-07-07T10:00:02.000Z",
|
||||
messageCount: 2,
|
||||
messages: [
|
||||
{
|
||||
id: "e2edocchatu0000000002",
|
||||
role: "user",
|
||||
content: QUESTION,
|
||||
createdAt: "2026-07-07T10:00:00.000Z",
|
||||
metricSource: null,
|
||||
providerType: null,
|
||||
promptVersion: null,
|
||||
tokensUsed: null,
|
||||
model: null,
|
||||
},
|
||||
{
|
||||
id: "e2edocchatmsg0000000002",
|
||||
role: "assistant",
|
||||
content: REPLY,
|
||||
createdAt: "2026-07-07T10:00:02.000Z",
|
||||
metricSource: null,
|
||||
providerType: "mock",
|
||||
promptVersion: null,
|
||||
tokensUsed: 30,
|
||||
model: "mock-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await fulfilJson(route, {
|
||||
data: {
|
||||
conversations: posted
|
||||
? [
|
||||
{
|
||||
id: CONV_ID,
|
||||
title: QUESTION,
|
||||
createdAt: "2026-07-07T10:00:00.000Z",
|
||||
updatedAt: "2026-07-07T10:00:02.000Z",
|
||||
messageCount: 2,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
nextCursor: null,
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto(`/documents?doc=${CONTENT_DOC_ID}`);
|
||||
const sheet = page.getByRole("dialog");
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
await sheet.locator('[data-slot="document-chat-open"]').click();
|
||||
const input = sheet.locator('[data-slot="document-chat-input"]');
|
||||
await input.fill(QUESTION);
|
||||
await sheet.locator('[data-slot="document-chat-send"]').click();
|
||||
|
||||
const log = sheet.locator('[data-slot="document-chat-log"]');
|
||||
// The describe-only answer lands; the panel renders the model's <script>
|
||||
// payload as literal text (proof it is a React text child, not HTML).
|
||||
await expect(log.getByText(/treat it as document content/)).toBeVisible();
|
||||
await expect(log.getByText(/window\.__docChatXss/)).toBeVisible();
|
||||
|
||||
// The injected script never executed — no markdown lib, no innerHTML sink.
|
||||
const executed = await page.evaluate(
|
||||
() =>
|
||||
(window as unknown as { __docChatXss?: number }).__docChatXss ?? null,
|
||||
);
|
||||
expect(executed).toBeNull();
|
||||
// And no real <script> element was injected into the document from the reply.
|
||||
const scriptCount = await page.locator("script#doc-chat-xss").count();
|
||||
expect(scriptCount).toBe(0);
|
||||
});
|
||||
|
||||
// ── (c) Not indexed: a calm read-it-first hint, never an error ──
|
||||
|
||||
test("shows a calm hint when the document is not indexed", async ({
|
||||
page,
|
||||
}) => {
|
||||
// AI_PROBE_DOC_ID carries no content index (hasContentIndex === false).
|
||||
await mockAiEnabled(page);
|
||||
await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`);
|
||||
const sheet = page.getByRole("dialog");
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
await expect(
|
||||
sheet.locator('[data-slot="document-chat-not-indexed"]'),
|
||||
).toBeVisible();
|
||||
// No chat entry, and no error styling.
|
||||
await expect(sheet.locator('[data-slot="document-chat-open"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(
|
||||
sheet.locator('[data-slot="document-chat"] [role="alert"]'),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
// ── (d) No provider: the calm settings pointer, no chat surface at all ──
|
||||
|
||||
test("with no provider there is no chat surface, only the settings pointer", async ({
|
||||
test("with no provider there is no Coach entry, only the settings pointer", async ({
|
||||
page,
|
||||
}) => {
|
||||
// No mocks — the seeded demo user has no AI provider configured, so the AI
|
||||
// area collapses to the calm pointer and the chat slot never renders.
|
||||
// area collapses to the calm pointer and the Coach entry never renders.
|
||||
await page.goto(`/documents?doc=${CONTENT_DOC_ID}`);
|
||||
const sheet = page.getByRole("dialog");
|
||||
const sheet = page.locator('[data-slot="responsive-sheet-content"]');
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
await expect(
|
||||
@@ -414,6 +141,8 @@ test.describe("document vault — chat about a document", () => {
|
||||
await expect(
|
||||
sheet.getByRole("link", { name: "Open AI settings" }),
|
||||
).toBeVisible();
|
||||
await expect(sheet.locator('[data-slot="document-chat"]')).toHaveCount(0);
|
||||
await expect(sheet.locator('[data-slot="document-chat-open"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
});
|
||||
});
|
||||
+49
-15
@@ -13,21 +13,21 @@ import { mockMoodInsights } from "./utils/mock-mood-insights";
|
||||
* Phone-width density guard (390×844 iPhone class, 360×780 small
|
||||
* Android class).
|
||||
*
|
||||
* Two invariants, per the report against the dashboard hero (a long
|
||||
* briefing-signal headline squeezed into a one-word-per-line column
|
||||
* beside a wide delta, the delta spilling past the tile edge at 360 px):
|
||||
* Two invariants, per the original overflow report (long headline text
|
||||
* squeezed into a one-word-per-line column beside a wide delta, the
|
||||
* delta spilling past the tile edge at 360 px):
|
||||
*
|
||||
* 1. No horizontal page overflow — `scrollWidth` never exceeds the
|
||||
* viewport width (1 px sub-pixel tolerance).
|
||||
* 2. Nothing escapes its tile — for every card / hero band / list
|
||||
* 2. Nothing escapes its tile — for every card / Today hero / list
|
||||
* row, no descendant's visible box crosses the tile's left or
|
||||
* right edge (clip-aware: content genuinely hidden by an inner
|
||||
* `overflow-hidden` wrapper below the tile is fine; content cut
|
||||
* off at the tile's own edge is the bug).
|
||||
*
|
||||
* The snapshot mock pins the worst case: hero band on, three score
|
||||
* rings, and a fresh briefing whose signal rows pair wrapping German
|
||||
* headlines with wide delta strings.
|
||||
* The mocks pin the worst case: the Today hero (its score face + a
|
||||
* worth-a-look rail) plus a fresh briefing whose rows pair wrapping
|
||||
* German headlines with wide delta strings.
|
||||
*
|
||||
* Desktop project only — the assertions are viewport-driven via
|
||||
* `setViewportSize` (the `ipad-viewport.spec.ts` pattern).
|
||||
@@ -51,7 +51,7 @@ interface TileEscape {
|
||||
function collectTileEscapes(): TileEscape[] {
|
||||
const escapes: TileEscape[] = [];
|
||||
const tiles = document.querySelectorAll(
|
||||
'[data-slot="card"], [data-slot="dashboard-hero"], [data-slot="list-row"]',
|
||||
'[data-slot="card"], [data-slot="today-hero"], [data-slot="list-row"]',
|
||||
);
|
||||
const TOLERANCE = 1;
|
||||
for (const tile of tiles) {
|
||||
@@ -113,10 +113,46 @@ test.describe("phone-width density guard", () => {
|
||||
await page.setViewportSize(viewport);
|
||||
await mockDashboardSnapshot(page, {
|
||||
summaries: POPULATED_SUMMARIES,
|
||||
heroVisible: true,
|
||||
briefing: LONG_HEADLINE_BRIEFING,
|
||||
scoreRings: MOCK_SCORE_RINGS,
|
||||
});
|
||||
// The Today hero reads the unified daily digest from
|
||||
// `/api/daily/digest`. Mock it with a deterministic digest (a
|
||||
// score + one worth-a-look item) so the hero paints without
|
||||
// reaching the real route — mirror of the dashboard.spec mock.
|
||||
await page.route(/\/api\/daily\/digest(\?|$)/, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
generatedAt: new Date().toISOString(),
|
||||
phase: "final",
|
||||
sleepPending: false,
|
||||
score: { value: 82, band: "green", delta: 2 },
|
||||
topSignal: null,
|
||||
briefingLead: "Your week is trending steady.",
|
||||
line: "Your week is trending steady.",
|
||||
worthALook: [
|
||||
{
|
||||
kind: "sync_issue",
|
||||
title: "Sync needs attention",
|
||||
body: "Withings isn't syncing.",
|
||||
status: "warning",
|
||||
actions: [
|
||||
{
|
||||
labelKey: "daily.action.reconnect",
|
||||
intent: "sync.reconnect",
|
||||
href: "/settings/integrations",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
if (route === "/insights/mood") {
|
||||
await mockMoodInsights(page);
|
||||
}
|
||||
@@ -124,14 +160,12 @@ test.describe("phone-width density guard", () => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
if (route === "/") {
|
||||
// The guard must not pass vacuously — the hero band and its
|
||||
// long-headline spotlight rows have to actually render.
|
||||
// The guard must not pass vacuously — the Today hero and its
|
||||
// score face have to actually render.
|
||||
await expect(page.locator('[data-slot="today-hero"]')).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-slot="dashboard-hero"]'),
|
||||
page.locator('[data-slot="today-hero-score"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-slot="dashboard-briefing-spotlight-row"]'),
|
||||
).toHaveCount(3);
|
||||
}
|
||||
|
||||
if (route === "/insights/mood") {
|
||||
|
||||
@@ -242,6 +242,20 @@ test.describe("dashboard onboarding card flicker guard", () => {
|
||||
body: JSON.stringify({ data: { channels: [] }, error: null }),
|
||||
}),
|
||||
);
|
||||
// v1.28 — the checklist's "Turn on insights" row self-satisfies from
|
||||
// `/api/user/ai-provider` (`aiAvailable` presence-only). Stub it so the
|
||||
// row renders deterministically as not-done and no real request fires
|
||||
// while the card mounts.
|
||||
await page.route("**/api/user/ai-provider", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { aiAvailable: false, managedBy: null },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
// v1.4.16 Wave-C — the dashboard added `/api/dashboard/widgets`
|
||||
// (A5 tile-strip persistence) since this spec was written. Without
|
||||
// a stub the unauthenticated layout query 500s in CI and React
|
||||
|
||||
@@ -95,6 +95,20 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
|
||||
webhookSecret: "ml_xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
entryCount: 42,
|
||||
},
|
||||
// v1.28.x — Strava OAuth card reads off this consolidated
|
||||
// envelope; without a `strava` entry the new card's query key
|
||||
// never resolves and the integrations page hangs.
|
||||
{
|
||||
integration: "strava",
|
||||
state: "disconnected",
|
||||
lastSuccessAt: null,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
connected: false,
|
||||
configured: false,
|
||||
available: false,
|
||||
hasOwnCredentials: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
error: null,
|
||||
@@ -143,9 +157,10 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
|
||||
|
||||
// Wait for the pills to mount (queries resolve after first paint).
|
||||
// One pill per card: Withings, WHOOP, Fitbit, Google Health, Polar,
|
||||
// Oura, Nightscout.
|
||||
// Oura, Strava, Nightscout. (The Garmin info note is not an OAuth card and
|
||||
// carries no pill.)
|
||||
const pills = page.locator('[data-testid="integration-status-pill"]');
|
||||
await expect(pills).toHaveCount(7, { timeout: 10_000 });
|
||||
await expect(pills).toHaveCount(8, { timeout: 10_000 });
|
||||
|
||||
// Withings is connected in this fixture; WHOOP is a BYO-keys card that
|
||||
// stays dormant until an operator adds credentials, so assert the one
|
||||
|
||||
@@ -13,18 +13,34 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
* the shell's own budget and reopens the dark-band-below-the-last-card
|
||||
* bug.
|
||||
*
|
||||
* The assertion: `main.scrollHeight` ≤ bottom edge of the lowest content
|
||||
* element + the shell-owned padding + tolerance. Runs across the
|
||||
* settings routes (short hub, long sortable-list subpages, long form
|
||||
* section) at desktop AND phone-shaped viewports, because the two shell
|
||||
* The assertion: `main.scrollHeight` ≤ bottom edge of the lowest real
|
||||
* content element + the shell-owned padding + tolerance. "Real content"
|
||||
* is measured against each element's CONTENT-box bottom, not its padding-
|
||||
* box: a sub-shell column that re-declares its own bottom gutter (`pb-*`)
|
||||
* no longer folds that gutter into `contentBottom`, so a redundant reserve
|
||||
* FAILS the assertion instead of being absorbed by it. Runs across both
|
||||
* two-column sub-shells — Settings AND Admin (they share the grid-floor
|
||||
* source) — at desktop AND phone-shaped viewports, because the two shell
|
||||
* paddings differ per breakpoint.
|
||||
*/
|
||||
const ROUTES = [
|
||||
// Settings sub-shell — short hub, long sortable-list subpages, long form.
|
||||
"/settings/layout",
|
||||
"/settings/layout/dashboard",
|
||||
// The other sortable-list subpages + the Modules hub: each hosts an
|
||||
// order editor with the sr-only drag-hint paragraph (see the
|
||||
// one-scroll-floor assertion below).
|
||||
"/settings/layout/insights",
|
||||
"/settings/layout/medications",
|
||||
"/settings/layout/mood",
|
||||
"/settings/modules",
|
||||
"/settings/account",
|
||||
"/settings/notifications",
|
||||
// Admin sub-shell — short overview + a longer list page. Guards that the
|
||||
// pre-#154 column reserve stays retired on every admin breakpoint.
|
||||
"/admin",
|
||||
"/admin/login-overview",
|
||||
"/admin/system-status",
|
||||
] as const;
|
||||
|
||||
const VIEWPORTS = [
|
||||
@@ -32,7 +48,7 @@ const VIEWPORTS = [
|
||||
{ name: "phone", width: 390, height: 844, expectedPad: 144 }, // pb-20 + main pb-16
|
||||
] as const;
|
||||
|
||||
test.describe("settings vertical over-scroll guard", () => {
|
||||
test.describe("settings + admin vertical over-scroll guard", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(({}, testInfo) => {
|
||||
@@ -56,11 +72,17 @@ test.describe("settings vertical over-scroll guard", () => {
|
||||
if (!main) return null;
|
||||
const wrapper = main.firstElementChild as HTMLElement | null;
|
||||
if (!wrapper) return null;
|
||||
// Lowest content edge: max bottom over the wrapper's visible,
|
||||
// non-fixed descendants, in the scroll container's coordinate
|
||||
// space. Elements inside nested scroll containers are clipped
|
||||
// by their own overflow and never add page scroll height, so
|
||||
// skip anything whose scrollable ancestor is not `main`.
|
||||
// Lowest content edge: max CONTENT-box bottom over the wrapper's
|
||||
// visible, non-fixed descendants, in the scroll container's
|
||||
// coordinate space. Using the content box (padding-box bottom
|
||||
// minus the element's own bottom padding + border) means a
|
||||
// layout column's own `pb-*` gutter is NOT counted — its last
|
||||
// real child (a card) is still measured through the column's
|
||||
// content box, so a redundant sub-shell bottom gutter shows up
|
||||
// as pure over-scroll instead of inflating the allowed budget.
|
||||
// Elements inside nested scroll containers are clipped by their
|
||||
// own overflow and never add page scroll height, so skip
|
||||
// anything whose scrollable ancestor is not `main`.
|
||||
let maxBottom = 0;
|
||||
const mainTop = main.getBoundingClientRect().top;
|
||||
for (const el of wrapper.querySelectorAll<HTMLElement>("*")) {
|
||||
@@ -84,7 +106,10 @@ test.describe("settings vertical over-scroll guard", () => {
|
||||
p = p.parentElement;
|
||||
}
|
||||
if (inner) continue;
|
||||
const bottom = rect.bottom + main.scrollTop - mainTop;
|
||||
const padBottom = parseFloat(cs.paddingBottom) || 0;
|
||||
const borderBottom = parseFloat(cs.borderBottomWidth) || 0;
|
||||
const bottom =
|
||||
rect.bottom - padBottom - borderBottom + main.scrollTop - mainTop;
|
||||
if (bottom > maxBottom) maxBottom = bottom;
|
||||
}
|
||||
return {
|
||||
@@ -97,6 +122,27 @@ test.describe("settings vertical over-scroll guard", () => {
|
||||
expect(dims, "main-content / wrapper present").not.toBeNull();
|
||||
if (!dims) return;
|
||||
|
||||
// One-scroll-floor (UI-STANDARDS §9): `<main>` is the ONLY vertical
|
||||
// scroll surface — the document itself must never become scrollable
|
||||
// next to it (a second painted scrollbar + a dead dark band under
|
||||
// the shell). The historic offender class: an absolutely-positioned
|
||||
// sr-only element (the dnd drag-hint paragraph) whose containing
|
||||
// block resolves to the initial containing block because no ancestor
|
||||
// between it and the root is positioned — its static position below
|
||||
// a long list then extends the DOCUMENT's scrollable overflow while
|
||||
// `<main>` clips everything in normal flow.
|
||||
const doc = await page.evaluate(() => ({
|
||||
scrollHeight: document.documentElement.scrollHeight,
|
||||
clientHeight: document.documentElement.clientHeight,
|
||||
}));
|
||||
expect(
|
||||
doc.scrollHeight,
|
||||
`document scrollHeight=${doc.scrollHeight} > viewport ` +
|
||||
`${doc.clientHeight} — a second vertical scroll surface exists ` +
|
||||
`beside <main> (likely an absolutely-positioned element escaping ` +
|
||||
`to the initial containing block)`,
|
||||
).toBeLessThanOrEqual(doc.clientHeight + 1);
|
||||
|
||||
// A page shorter than the viewport cannot over-scroll at all.
|
||||
if (dims.scrollHeight <= dims.clientHeight) return;
|
||||
|
||||
|
||||
@@ -234,12 +234,14 @@ test.describe("clinician document sharing", () => {
|
||||
await clinician.context().close();
|
||||
});
|
||||
|
||||
test("the document detail Share action opens the create flow with that document pre-attached", async ({
|
||||
test("the document Share action mints a DOCUMENT-ONLY link — the document, never the record", async ({
|
||||
page,
|
||||
browser,
|
||||
}) => {
|
||||
// Opens two stacked sheets (detail → share) and drives a real create with
|
||||
// the one-time QR render — heavier than the default 30s budget on a loaded
|
||||
// runner. Runs on the desktop and 390px mobile projects.
|
||||
// Opens two stacked sheets (detail → share), drives a real create with the
|
||||
// one-time QR render, then unlocks the link as a recipient to prove the
|
||||
// served view carries the document and ZERO health metrics. Heavier than
|
||||
// the default 30s budget. Runs on the desktop and 390px mobile projects.
|
||||
test.slow();
|
||||
|
||||
// Deep-link straight to the document detail sheet.
|
||||
@@ -263,6 +265,13 @@ test.describe("clinician document sharing", () => {
|
||||
await expect(chips).toHaveCount(1);
|
||||
await expect(chips.first()).toContainText("MRT Knie");
|
||||
|
||||
// Document-only mode hides every record-scope control: no FHIR API toggle,
|
||||
// no FHIR resource-type checkboxes, no history-range selector. Only the
|
||||
// document, label, and expiry remain.
|
||||
await expect(shareSheet.locator("#share-fhir")).toHaveCount(0);
|
||||
await expect(shareSheet.locator("#share-range")).toHaveCount(0);
|
||||
await expect(shareSheet.locator("#share-expiry")).toHaveCount(1);
|
||||
|
||||
// Create the link — the one-time reveal (with the scannable QR) lands in
|
||||
// the same sheet, carrying the one attached document.
|
||||
await shareSheet.getByRole("button", { name: "Create link" }).click();
|
||||
@@ -276,6 +285,46 @@ test.describe("clinician document sharing", () => {
|
||||
const box = await qrImg.boundingBox();
|
||||
expect(box, "QR image has a rendered box").not.toBeNull();
|
||||
expect(box!.width).toBeGreaterThanOrEqual(160);
|
||||
|
||||
// Read the one-time secrets off the reveal and unlock the link as a
|
||||
// recipient (no owner session).
|
||||
const shareUrl = (await reveal.locator("code").first().innerText()).trim();
|
||||
const token = shareUrl.split("/c/")[1];
|
||||
expect(token, "token parsed from share URL").toMatch(/^hls_[0-9a-f]{48}$/);
|
||||
const passphrase = (
|
||||
await shareSheet.getByTestId("share-passphrase").innerText()
|
||||
).trim();
|
||||
const clinician = await openUnlockedClinician(browser, {
|
||||
token,
|
||||
passphrase,
|
||||
label: "MRT Knie",
|
||||
});
|
||||
|
||||
// The served view lists the attached document…
|
||||
await expect(
|
||||
clinician.getByRole("heading", { name: "Documents" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
clinician.locator(
|
||||
`iframe[src*="/d/${MRT_DOC_ID}"], img[src*="/d/${MRT_DOC_ID}"]`,
|
||||
),
|
||||
).toHaveCount(1);
|
||||
|
||||
// …and NOTHING from the health record: no vitals, no labs, no medications,
|
||||
// no reporting-period line. This is the privacy guarantee — sharing a
|
||||
// document must never expose a single metric.
|
||||
await expect(
|
||||
clinician.getByRole("heading", { name: "Vital signs" }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
clinician.getByRole("heading", { name: "Lab values" }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
clinician.getByRole("heading", { name: "Medications & adherence" }),
|
||||
).toHaveCount(0);
|
||||
await expect(clinician.getByText("Reporting period:")).toHaveCount(0);
|
||||
|
||||
await clinician.context().close();
|
||||
});
|
||||
|
||||
test("picker enforces the document cap at the limit", async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* Issue #490 — client-rendered timestamps follow the PROFILE timezone.
|
||||
*
|
||||
* `/api/auth/me` is mocked with an `Asia/Manila` profile; the sessions
|
||||
* card on /settings/privacy renders `lastActiveAt` through
|
||||
* `useFormatters()`, which reads the localStorage timezone mirror that
|
||||
* `fetchMe` fills from the mocked payload. A fixed UTC instant must
|
||||
* therefore paint the MANILA wall clock (14:30), never the Berlin
|
||||
* fallback (08:30) and never the browser zone (the Playwright context is
|
||||
* pinned to Europe/Berlin precisely so this assertion is meaningful).
|
||||
*
|
||||
* Asserts against the stable `data-slot="security-session-row"` hook,
|
||||
* not viewport-dependent text.
|
||||
*/
|
||||
|
||||
// 06:30 UTC = 14:30 Asia/Manila (+8, no DST) = 08:30 Europe/Berlin (CEST).
|
||||
const LAST_ACTIVE_UTC = "2026-07-14T06:30:00.000Z";
|
||||
|
||||
test.describe("profile-timezone display (#490)", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test("sessions card renders lastActiveAt in the Manila profile zone", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/api/auth/me", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
id: "user_e2e",
|
||||
username: "e2e-tester",
|
||||
email: "e2e@healthlog.test",
|
||||
role: "USER",
|
||||
heightCm: 180,
|
||||
dateOfBirth: "1990-01-01",
|
||||
gender: "MALE",
|
||||
timezone: "Asia/Manila",
|
||||
timeFormat: "H24",
|
||||
dateFormat: "AUTO",
|
||||
onboardingCompletedAt: "2025-01-01T00:00:00.000Z",
|
||||
onboardingTourCompleted: true,
|
||||
gravatarUrl: null,
|
||||
glucoseUnit: "mg/dL",
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route("**/api/auth/me/sessions", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
sessions: [
|
||||
{
|
||||
id: "sess_e2e_current",
|
||||
device: "Chrome on macOS",
|
||||
ipMasked: "192.168.1.xxx",
|
||||
location: null,
|
||||
lastActiveAt: LAST_ACTIVE_UTC,
|
||||
createdAt: LAST_ACTIVE_UTC,
|
||||
isCurrent: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/settings/privacy", { waitUntil: "domcontentloaded" });
|
||||
|
||||
// The sessions card is collapsed by default — expand it first. The
|
||||
// toggle's onClick can be dropped if Playwright clicks before React has
|
||||
// hydrated the handler, leaving the region `hidden` for the whole wait.
|
||||
// Retry the click until `aria-expanded` actually flips.
|
||||
const toggle = page.locator(
|
||||
'[data-slot="settings-security-sessions-toggle"]',
|
||||
);
|
||||
await expect(toggle).toBeVisible({ timeout: 10_000 });
|
||||
await expect(async () => {
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true", {
|
||||
timeout: 2_000,
|
||||
});
|
||||
}).toPass({ timeout: 15_000 });
|
||||
|
||||
const row = page.locator('[data-slot="security-session-row"]').first();
|
||||
await expect(row).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Manila wall clock (H24). The mirror write from the mocked /me may
|
||||
// land after the first paint of the sessions row — the poll absorbs
|
||||
// the re-render.
|
||||
await expect(row).toContainText("14:30", { timeout: 10_000 });
|
||||
// Never the Berlin fallback for this instant…
|
||||
await expect(row).not.toContainText("08:30");
|
||||
// …and never a 12-hour rendering (the H24 preference rides the same
|
||||
// mirror pipeline).
|
||||
await expect(row).not.toContainText("PM");
|
||||
});
|
||||
});
|
||||
@@ -231,11 +231,9 @@ export interface MockSnapshotOptions {
|
||||
summaries?: Record<string, DataSummary>;
|
||||
/** Artificial latency in ms before the route fulfils (race specs). */
|
||||
delayMs?: number;
|
||||
/** Render the dashboard hero band (`layout.heroVisible` opt-in). */
|
||||
heroVisible?: boolean;
|
||||
/** Fresh (`ready`, non-stale) briefing for the hero spotlight rows. */
|
||||
/** Fresh (`ready`, non-stale) briefing for the dashboard briefing card. */
|
||||
briefing?: DailyBriefing;
|
||||
/** Selected hero score rings (renders beside the health-score ring). */
|
||||
/** Selected score rings carried on the snapshot (server-computed). */
|
||||
scoreRings?: DashboardScoreRing[];
|
||||
}
|
||||
|
||||
@@ -263,9 +261,7 @@ export function buildMockSnapshot(
|
||||
onboardingTourCompleted: true,
|
||||
greetingHour: 9,
|
||||
},
|
||||
layout: options.heroVisible
|
||||
? { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true }
|
||||
: DEFAULT_DASHBOARD_LAYOUT,
|
||||
layout: DEFAULT_DASHBOARD_LAYOUT,
|
||||
layoutCatalogue: buildLayoutCatalogue(),
|
||||
metricStates: {},
|
||||
targetBands: mockTargetBands(),
|
||||
@@ -283,6 +279,7 @@ export function buildMockSnapshot(
|
||||
nextDueAt: null,
|
||||
nextDueOverdue: false,
|
||||
nextDueMedicationName: null,
|
||||
nextDueMedicationId: null,
|
||||
},
|
||||
healthScore: options.briefing
|
||||
? { score: 84, band: "green", delta: -2, restMode: null }
|
||||
|
||||
@@ -60,6 +60,32 @@ ruleTester.run("spacing-scale", rule, {
|
||||
code: '<CardHeader className="pb-2" />',
|
||||
filename: "/repo/src/components/__tests__/example.test.tsx",
|
||||
},
|
||||
// The card-shell `5` check is scoped to the bg-card+border pair, so the
|
||||
// justified list-marker inset never trips it (no shell).
|
||||
{
|
||||
code: '<ul className="list-disc pl-5 space-y-1" />',
|
||||
filename: APP_FILE,
|
||||
},
|
||||
// A directional inset ON a shell is out of scope (pl-5/pr-5 not banned).
|
||||
{
|
||||
code: '<div className="bg-card border-border rounded-xl border pl-5" />',
|
||||
filename: APP_FILE,
|
||||
},
|
||||
// Form-body rhythm without a shell surface is fine.
|
||||
{
|
||||
code: '<form className="space-y-5" />',
|
||||
filename: APP_FILE,
|
||||
},
|
||||
// A shell already on-scale is fine.
|
||||
{
|
||||
code: '<div className="bg-card border-border rounded-xl border p-4 md:p-6" />',
|
||||
filename: APP_FILE,
|
||||
},
|
||||
// bg-card without a border is not treated as a shell.
|
||||
{
|
||||
code: '<div className="bg-card p-5" />',
|
||||
filename: APP_FILE,
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
@@ -108,5 +134,34 @@ ruleTester.run("spacing-scale", rule, {
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "slotPadding" }],
|
||||
},
|
||||
// Card-shell `5` step: bg-card + border + p-5 on one element.
|
||||
{
|
||||
code: '<div className="bg-card border-border rounded-xl border p-5" />',
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "cardShellStep5" }],
|
||||
},
|
||||
// …py-5, …space-y-5, …gap-5 on a shell all trip it.
|
||||
{
|
||||
code: '<section className="bg-card rounded-lg border py-5" />',
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "cardShellStep5" }],
|
||||
},
|
||||
{
|
||||
code: '<div className="bg-card border-border rounded-xl border gap-5 flex" />',
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "cardShellStep5" }],
|
||||
},
|
||||
// Split across cn() args — the join still sees the shell + the step-5.
|
||||
{
|
||||
code: '<div className={cn("bg-card border rounded-xl", "p-5")} />',
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "cardShellStep5" }],
|
||||
},
|
||||
// Modifier-prefixed step on a shell.
|
||||
{
|
||||
code: '<div className="bg-card border-border rounded-xl border sm:p-5" />',
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "cardShellStep5" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -6,7 +6,8 @@
|
||||
* - `safe-fetch-required` — outbound fetch must route through safeFetch.
|
||||
* - `api-fetch-required` — client /api/ calls must route through apiFetch.
|
||||
* - `no-raw-palette-color` — ban raw Tailwind palette utilities in app UI.
|
||||
* - `spacing-scale` — no pt-/pb- overrides on gap-based Card slots.
|
||||
* - `spacing-scale` — no pt-/pb- overrides on gap-based Card slots,
|
||||
* and no off-scale `5` step on a bg-card shell.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
@@ -1,34 +1,47 @@
|
||||
/**
|
||||
* @fileoverview ESLint rule — no `pt-*` / `pb-*` overrides on the
|
||||
* gap-based Card slots.
|
||||
* @fileoverview ESLint rule — spacing-scale discipline. Two checks:
|
||||
*
|
||||
* The Card primitive is gap-based, not padding-based
|
||||
* (`src/components/ui/card.tsx`): the header→body distance comes from
|
||||
* the PARENT flex gap, never from slot padding. A `pb-*` on
|
||||
* `<CardHeader>` or a `pt-*` on `<CardContent>` therefore adds on top
|
||||
* of the gap (or is a no-op) and inverts its own intent — the drift
|
||||
* class the sl-001 sweep cleaned (~35 stale overrides). Density is
|
||||
* expressed on the Card itself (`<Card className="gap-2 py-3 md:py-4">`,
|
||||
* the sanctioned compact tier), never on a slot.
|
||||
* 1. `slotPadding` — no `pt-*` / `pb-*` overrides on the gap-based Card
|
||||
* slots. The Card primitive is gap-based, not padding-based
|
||||
* (`src/components/ui/card.tsx`): the header→body distance comes from
|
||||
* the PARENT flex gap, never from slot padding. A `pb-*` on
|
||||
* `<CardHeader>` or a `pt-*` on `<CardContent>` therefore adds on top
|
||||
* of the gap (or is a no-op) and inverts its own intent — the drift
|
||||
* class the sl-001 sweep cleaned (~35 stale overrides). Density is
|
||||
* expressed on the Card itself (`<Card className="gap-2 py-3 md:py-4">`,
|
||||
* the sanctioned compact tier), never on a slot.
|
||||
*
|
||||
* Detection: JSX elements named `CardHeader` / `CardContent` (any
|
||||
* member-expression tail, so `UI.CardHeader` matches too) whose
|
||||
* `className` attribute carries a static `pt-<n>` / `pb-<n>` fragment —
|
||||
* including modifier-prefixed (`md:pb-2`) and arbitrary-value
|
||||
* (`pb-[10px]`) forms. Strings are collected from the whole attribute
|
||||
* subtree, so `cn("pb-2", …)`, ternaries, and template literals are all
|
||||
* seen. Non-numeric suffixes (`pb-safe`) never match.
|
||||
* Detection: JSX elements named `CardHeader` / `CardContent` (any
|
||||
* member-expression tail, so `UI.CardHeader` matches too) whose
|
||||
* `className` carries a static `pt-<n>` / `pb-<n>` fragment — including
|
||||
* modifier-prefixed (`md:pb-2`) and arbitrary-value (`pb-[10px]`) forms.
|
||||
* Non-numeric suffixes (`pb-safe`) never match.
|
||||
*
|
||||
* 2. `cardShellStep5` — no off-scale `5` step (20 px, banned by §2) on a
|
||||
* hand-rolled card SHELL. The shell heuristic (§7.2) keys off the
|
||||
* `bg-card` + `border` pair on ONE element: a `p-5` / `px-5` / `py-5` /
|
||||
* `space-y-5` / `gap-5` there sits denser than every sibling `<Card>`
|
||||
* (which is `p-4 md:p-6`), and the 4 px step reads next to a conformant
|
||||
* card on the same viewport. Deliberately NARROW: it fires only when the
|
||||
* same element paints a card surface, so justified `pl-5`/`pl-7`
|
||||
* list-marker insets (no `bg-card`) and form-body `space-y-5` rhythm (no
|
||||
* shell) never trip it. Hand-rolled shells sweep to `p-4 md:p-6`
|
||||
* (or `p-3` for a dense inner tile); new surfaces compose `<Card>`.
|
||||
*
|
||||
* Both checks collect strings from the whole `className` subtree, so
|
||||
* `cn("…", cond && "…")`, ternaries, and template literals are all seen.
|
||||
*
|
||||
* Scope: `src/components/**` + `src/app/**`; test files exempt — same
|
||||
* gating as the sibling rules. `src/components/ui/card.tsx` itself
|
||||
* composes raw `<div>`s and is untouched by construction.
|
||||
* gating as the sibling rules. `src/components/ui/*` primitives compose the
|
||||
* shells themselves and are untouched by construction (they carry no
|
||||
* `bg-card` + `border` + step-5 combination).
|
||||
*
|
||||
* There is deliberately NO allowlist: no current site has a documented
|
||||
* load-bearing reason for a slot padding override. If one ever appears,
|
||||
* it carries an inline `eslint-disable-next-line` with the reason —
|
||||
* visible in review — rather than a silent registry entry here.
|
||||
* load-bearing reason. If one ever appears it carries an inline
|
||||
* `eslint-disable-next-line` with the reason — visible in review — rather
|
||||
* than a silent registry entry here.
|
||||
*
|
||||
* @see .planning/audits/2026-07-05-fable/UI-STANDARDS.md §1 + §2.
|
||||
* @see .planning/audits/2026-07-05-fable/UI-STANDARDS.md §1 + §2 + §7.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
@@ -44,6 +57,19 @@ const SLOT_PADDING_RE =
|
||||
|
||||
const SLOT_NAMES = new Set(["CardHeader", "CardContent"]);
|
||||
|
||||
// Card-shell heuristic: the element paints a card surface (`bg-card`) AND a
|
||||
// border. `\bborder\b` matches `border`, `border-border`, `border-b`,
|
||||
// `border-dashed` — any border utility qualifies as a shell.
|
||||
const SHELL_BG_RE = /\bbg-card\b/;
|
||||
const SHELL_BORDER_RE = /\bborder(?:-|\b)/;
|
||||
|
||||
// The banned `5` step (20 px) on a shell — the subset §7.2 scopes to an
|
||||
// error-clean rule: p-5 / px-5 / py-5 / space-y-5 / gap-5, with optional
|
||||
// modifier prefixes (`sm:p-5`). Directional insets (`pl-5`/`pr-5`) and
|
||||
// half-steps are intentionally OUT of scope (see the header).
|
||||
const SHELL_STEP5_RE =
|
||||
/(?:^|\s)(?:[^\s"']*:)*((?:p|px|py|space-y|gap)-5)(?=\s|$)/;
|
||||
|
||||
function toPosix(filename) {
|
||||
return filename.replace(/\\/g, "/");
|
||||
}
|
||||
@@ -122,12 +148,14 @@ const spacingScaleRule = {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Ban pt-*/pb-* overrides on CardHeader/CardContent — the Card is gap-based; density is expressed via the Card's own gap/py, never via slot padding.",
|
||||
"Spacing-scale discipline — no pt-*/pb-* overrides on gap-based Card slots, and no off-scale `5` step on a bg-card+border shell.",
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
slotPadding:
|
||||
'"{{match}}" on <{{element}}> fights the gap-based Card contract — the header→body distance comes from the parent flex gap, so slot padding stacks on top of it (or is a no-op). Tune density on the Card itself instead: <Card className="gap-2 py-3 md:py-4">.',
|
||||
cardShellStep5:
|
||||
'Off-scale "{{match}}" (20 px) on a bg-card+border shell — the `5` step is banned (UI-STANDARDS §2) and reads denser than every sibling <Card> (p-4 md:p-6). Sweep the shell to `p-4 md:p-6` (or `p-3` for a dense inner tile); new surfaces should compose <Card>.',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
@@ -138,9 +166,6 @@ const spacingScaleRule = {
|
||||
|
||||
return {
|
||||
JSXOpeningElement(node) {
|
||||
const name = elementName(node);
|
||||
if (!name || !SLOT_NAMES.has(name)) return;
|
||||
|
||||
const classNameAttr = node.attributes.find(
|
||||
(attr) =>
|
||||
attr.type === "JSXAttribute" &&
|
||||
@@ -151,13 +176,33 @@ const spacingScaleRule = {
|
||||
|
||||
const strings = [];
|
||||
collectStrings(classNameAttr.value, strings);
|
||||
for (const { node: strNode, text } of strings) {
|
||||
const m = SLOT_PADDING_RE.exec(text);
|
||||
|
||||
// Check 1 — pt-/pb- slot padding on the gap-based Card slots.
|
||||
const name = elementName(node);
|
||||
if (name && SLOT_NAMES.has(name)) {
|
||||
for (const { node: strNode, text } of strings) {
|
||||
const m = SLOT_PADDING_RE.exec(text);
|
||||
if (m) {
|
||||
context.report({
|
||||
node: strNode,
|
||||
messageId: "slotPadding",
|
||||
data: { match: m[1], element: name },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check 2 — off-scale `5` step on a card shell. Join every collected
|
||||
// string so the `bg-card` + `border` pair and the step-5 utility are
|
||||
// seen together even when split across cn() args, then report once.
|
||||
const joined = strings.map((s) => s.text).join(" ");
|
||||
if (SHELL_BG_RE.test(joined) && SHELL_BORDER_RE.test(joined)) {
|
||||
const m = SHELL_STEP5_RE.exec(joined);
|
||||
if (m) {
|
||||
context.report({
|
||||
node: strNode,
|
||||
messageId: "slotPadding",
|
||||
data: { match: m[1], element: name },
|
||||
node: classNameAttr,
|
||||
messageId: "cardShellStep5",
|
||||
data: { match: m[1] },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+56
-3
@@ -66,12 +66,65 @@ const eslintConfig = defineConfig([
|
||||
// token references, not utilities, and stay legal — the light-theme
|
||||
// overrides in globals.css keep them AA on both themes.
|
||||
"healthlog/no-dracula-utility": ["error", { checks: ["dracula"] }],
|
||||
// The Card primitive is gap-based; pt-/pb- on CardHeader/CardContent
|
||||
// fights the gap and re-opens the sl-001 drift class. Density lives
|
||||
// on the Card itself (`gap-2 py-3 md:py-4`). See the rule header.
|
||||
// Spacing-scale discipline. Two error-clean checks: pt-/pb- on
|
||||
// CardHeader/CardContent fights the gap-based Card contract (re-opens
|
||||
// the sl-001 drift class), and an off-scale `5` step (p-5/py-5/space-y-5/
|
||||
// gap-5) on a bg-card+border shell reads denser than every sibling
|
||||
// <Card>. The shell check is scoped to the bg-card+border pair so
|
||||
// justified list-marker insets (pl-5/pl-7) and form-body rhythm never
|
||||
// trip it. Density lives on the Card itself (`gap-2 py-3 md:py-4`) or on
|
||||
// a swept `p-4 md:p-6` shell. See the rule header.
|
||||
"healthlog/spacing-scale": "error",
|
||||
},
|
||||
},
|
||||
// v1.28.17 — every recharts-rendering component funnels through the
|
||||
// shared `chart-runtime.ts` barrel (see its file header): pointing N
|
||||
// `next/dynamic` boundaries at N different modules mints a separate
|
||||
// ~312 KB recharts chunk per boundary, a regression the project already
|
||||
// paid for once. Direct `from "recharts"` imports outside the barrel's
|
||||
// own static import graph previously only tripped the bundle-budget
|
||||
// gate at PR-CI; this rule catches the same mistake at local lint,
|
||||
// mirroring how `healthlog/safe-fetch-required` scopes its own
|
||||
// wrapper-internals exemption by file list.
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}"],
|
||||
ignores: [
|
||||
"src/components/charts/chart-runtime.ts",
|
||||
"src/components/admin/host-metrics-chart.tsx",
|
||||
"src/components/charts/health-chart.tsx",
|
||||
"src/components/charts/medication-compliance-chart.tsx",
|
||||
"src/components/charts/mood-chart.tsx",
|
||||
"src/components/charts/nutrient-daily-bar-chart.tsx",
|
||||
"src/components/charts/scatter-correlation-chart.tsx",
|
||||
"src/components/custom-metrics/custom-metric-chart.tsx",
|
||||
"src/components/cycle/bbt-chart.tsx",
|
||||
"src/components/insights/derived/delta-sparkline.tsx",
|
||||
"src/components/insights/intraday-pulse-chart.tsx",
|
||||
"src/components/insights/mood/mood-distribution-chart.tsx",
|
||||
"src/components/insights/mood/mood-time-of-day-chart.tsx",
|
||||
"src/components/insights/mood/mood-weekday-chart.tsx",
|
||||
"src/components/insights/sleep-stage-stacked-bar.tsx",
|
||||
"src/components/labs/lab-biomarker-chart.tsx",
|
||||
"src/components/medications/detail/efficacy/efficacy-chart.tsx",
|
||||
"src/components/medications/dose-strength-curve.tsx",
|
||||
"src/components/medications/drug-level-chart.tsx",
|
||||
"src/components/mental-health/assessment-history-chart.tsx",
|
||||
],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: "recharts",
|
||||
message:
|
||||
"Import chart components through @/components/charts/chart-runtime (the shared dynamic-import barrel), not recharts directly — a second static import mints a duplicate recharts chunk. Add the component to chart-runtime.ts's export list if it's a new chart.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -47,7 +47,6 @@
|
||||
".next/**",
|
||||
"**/__mocks__/**",
|
||||
"e2e/setup/test-helpers.ts",
|
||||
"src/components/charts/compliance-line-chart.tsx",
|
||||
"src/lib/logging/index.ts"
|
||||
],
|
||||
"ignoreDependencies": [
|
||||
|
||||
+523
-122
File diff suppressed because it is too large.
Load diff
+524
-123
File diff suppressed because it is too large.
Load diff
+520
-119
File diff suppressed because it is too large.
Load diff
+520
-119
File diff suppressed because it is too large.
Load diff
+520
-119
File diff suppressed because it is too large.
Load diff
+520
-119
File diff suppressed because it is too large.
Load diff
@@ -233,6 +233,18 @@ const nextConfig: NextConfig = {
|
||||
"pg-boss",
|
||||
"@prisma/adapter-pg",
|
||||
"pg",
|
||||
// Document PDF handling MUST run the real, un-bundled modules. When
|
||||
// Turbopack bundles pdfjs-dist into the server chunks, its runtime
|
||||
// `require('@napi-rs/canvas')` and its NodeCanvasFactory render path break
|
||||
// in the standalone image (a scanned PDF fails to rasterize with a bare
|
||||
// `Error`, so the read falls back to "PDF scanning needs a Claude vision
|
||||
// provider"), while the identical un-bundled module rasterizes the same
|
||||
// document fine. Externalise both so the server loads the real files from
|
||||
// node_modules; the Dockerfile hoists them to the top level so the runtime
|
||||
// `import()` resolves in the standalone tree, and outputFileTracingIncludes
|
||||
// still ships pdfjs's wasm decoders.
|
||||
"pdfjs-dist",
|
||||
"@napi-rs/canvas",
|
||||
],
|
||||
// v1.4.25 Fix-G — `src/lib/ai/prompts/safety-contracts.ts` reads its
|
||||
// sibling YAML files at runtime via `__dirname + readFileSync`. The
|
||||
|
||||
+7
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.28.2",
|
||||
"version": "1.30.2",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
@@ -65,7 +65,7 @@
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.1",
|
||||
"@simplewebauthn/server": "^13.3.2",
|
||||
"@tanstack/react-query": "5.101.2",
|
||||
"@tanstack/react-virtual": "^3.14.5",
|
||||
"@types/sax": "^1.2.7",
|
||||
@@ -74,6 +74,7 @@
|
||||
"date-fns": "^4.4.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"fflate": "^0.8.3",
|
||||
"jose": "^6.2.3",
|
||||
"jspdf": "^4.2.1",
|
||||
"jspdf-autotable": "^5.0.8",
|
||||
"lucide-react": "^1.18.0",
|
||||
@@ -83,7 +84,7 @@
|
||||
"nodemailer": "9.0.3",
|
||||
"otpauth": "^9.5.1",
|
||||
"p-limit": "^7.3.0",
|
||||
"pg-boss": "^12.20.0",
|
||||
"pg-boss": "^12.26.0",
|
||||
"prisma": "^7.8.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"radix-ui": "^1.6.0",
|
||||
@@ -110,7 +111,7 @@
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testcontainers/postgresql": "^12.0.3",
|
||||
"@types/node": "26.1.0",
|
||||
"@types/node": "26.1.1",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
@@ -122,13 +123,13 @@
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"knip": "^6.16.1",
|
||||
"lefthook": "^2.1.9",
|
||||
"lefthook": "^2.1.10",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pdfjs-dist": "5.4.296",
|
||||
"pg": "^8.22.0",
|
||||
"prettier": "^3.9.1",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"shadcn": "^4.9.0",
|
||||
"shadcn": "^4.13.0",
|
||||
"tailwindcss": "^4",
|
||||
"testcontainers": "^12.0.3",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
|
||||
@@ -59,6 +59,12 @@ export default defineConfig({
|
||||
// matches what real users render on first paint and is the only honest
|
||||
// a11y baseline for this app.
|
||||
colorScheme: "dark",
|
||||
// Issue #490 — pin the browser timezone. Without this the context
|
||||
// inherits the HOST zone (UTC on CI, Europe/Berlin on a local Mac),
|
||||
// so any surface that renders a clock or a day boundary could pass
|
||||
// locally and skew two hours / a calendar day on CI. Berlin matches
|
||||
// the app's own display fallback, so CI and local render identically.
|
||||
timezoneId: "Europe/Berlin",
|
||||
},
|
||||
|
||||
projects: [
|
||||
|
||||
Generated
+562
-417
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,49 @@
|
||||
-- Medication efficacy tracking ("Wirkung"): persist ONLY the user's explicit
|
||||
-- target override.
|
||||
--
|
||||
-- `medication_efficacy_targets` — a lean child of `medications`. The
|
||||
-- drug-class → outcome resolution (WHO ATC-prefix → whole-word name
|
||||
-- inference) is computed each read from the closed in-repo lookup table, so
|
||||
-- nothing about a class→outcome association is denormalised (it cannot drift
|
||||
-- as the map improves). The ONE thing that must survive is the user's own
|
||||
-- pick when they retarget a medication or choose a target for a med the
|
||||
-- resolver could not classify — that is this table.
|
||||
--
|
||||
-- Exactly one of `measurement_type` (a first-class metric series) /
|
||||
-- `biomarker_id` (a lab analyte) is set per row; `primary` marks the
|
||||
-- storyline-leading target when several are pinned. `medication_id`
|
||||
-- cascades (the override dies with the medication); `biomarker_id` sets
|
||||
-- null (a deleted biomarker leaves an orphan row the resolver treats as no
|
||||
-- override). `user_id` is never a column here — ownership is narrowed
|
||||
-- through the parent medication, never a body field.
|
||||
--
|
||||
-- Additive; applies clean on a 0231 database and on a fresh database as part
|
||||
-- of the ordered migration chain.
|
||||
|
||||
CREATE TABLE "medication_efficacy_targets" (
|
||||
"id" TEXT NOT NULL,
|
||||
"medication_id" TEXT NOT NULL,
|
||||
"measurement_type" "measurement_type",
|
||||
"biomarker_id" TEXT,
|
||||
"primary" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "medication_efficacy_targets_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "medication_efficacy_targets_medication_id_idx"
|
||||
ON "medication_efficacy_targets" ("medication_id");
|
||||
|
||||
CREATE INDEX "medication_efficacy_targets_biomarker_id_idx"
|
||||
ON "medication_efficacy_targets" ("biomarker_id");
|
||||
|
||||
ALTER TABLE "medication_efficacy_targets"
|
||||
ADD CONSTRAINT "medication_efficacy_targets_medication_id_fkey"
|
||||
FOREIGN KEY ("medication_id") REFERENCES "medications" ("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "medication_efficacy_targets"
|
||||
ADD CONSTRAINT "medication_efficacy_targets_biomarker_id_fkey"
|
||||
FOREIGN KEY ("biomarker_id") REFERENCES "biomarkers" ("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,33 @@
|
||||
-- v1.28.x — Strava OAuth integration (schema + data layer).
|
||||
--
|
||||
-- Strava is a per-user BYO-OAuth workout source, mirroring WHOOP/Oura/Polar:
|
||||
-- Strava caps every newly-created API app at single-player mode (athlete
|
||||
-- capacity 1), so a single shared app cannot serve many self-hosters — each
|
||||
-- registers their own Strava app and pastes the client id/secret into Settings.
|
||||
-- Strava feeds `Workout` rows only (it exposes no sleep / recovery / body
|
||||
-- metrics) and rotates its refresh token on every refresh, so it follows Oura's
|
||||
-- reactive-refresh + compare-and-set token model (tokens live on `users`, no
|
||||
-- token-expiry column).
|
||||
--
|
||||
-- 1. `measurement_source` += `STRAVA` for the server-side native ingest.
|
||||
-- 2. `users` += the encrypted BYO-app credentials + granted tokens, the
|
||||
-- plaintext athlete id, the incremental-sync cursor, and the backfill
|
||||
-- marker. Purely-additive: nullable columns, no backfill, no row touched.
|
||||
--
|
||||
-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only.
|
||||
--
|
||||
-- Reversibility: Postgres cannot remove an enum value, so `STRAVA` stays; with
|
||||
-- no rows carrying it it is inert. The columns drop with `DROP COLUMN IF EXISTS`.
|
||||
|
||||
-- ── 1. measurement_source — append the Strava server-owned source ──────
|
||||
ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'STRAVA';
|
||||
|
||||
-- ── 2. users — per-user Strava credentials, tokens, cursor, backfill marker ──
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN IF NOT EXISTS "strava_client_id_encrypted" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "strava_client_secret_encrypted" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "strava_access_token_encrypted" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "strava_refresh_token_encrypted" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "strava_athlete_id" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "strava_last_activity_at" TIMESTAMP(3),
|
||||
ADD COLUMN IF NOT EXISTS "strava_backfill_completed_at" TIMESTAMP(3);
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Retire the dedicated document-scan (Lab-OCR) provider.
|
||||
--
|
||||
-- The separate `ai_ocr_*` provider entry is gone: lab-report OCR now always
|
||||
-- uses the user's main configured provider chain (the same providers, incl.
|
||||
-- codex / OAuth, that the Coach and Insights already use), and a PDF is read
|
||||
-- natively on Anthropic or rasterized to page images for any other vision
|
||||
-- provider. The five per-user columns that backed the standalone provider are
|
||||
-- no longer read or written anywhere, so drop them.
|
||||
--
|
||||
-- `ai_ocr_key_encrypted` was an AES-256-GCM column; it is removed from the
|
||||
-- encrypted-column registry + the rotation script in the same change. Dropping
|
||||
-- it here means those rows are gone before any future key rotation runs.
|
||||
--
|
||||
-- Idempotent (IF EXISTS); applies clean on a 0233 database and on a fresh
|
||||
-- database as part of the ordered replay.
|
||||
ALTER TABLE "users"
|
||||
DROP COLUMN IF EXISTS "ai_ocr_enabled",
|
||||
DROP COLUMN IF EXISTS "ai_ocr_provider",
|
||||
DROP COLUMN IF EXISTS "ai_ocr_model",
|
||||
DROP COLUMN IF EXISTS "ai_ocr_base_url",
|
||||
DROP COLUMN IF EXISTS "ai_ocr_key_encrypted";
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Operator-shared central Codex (ChatGPT subscription) OAuth + per-user opt-in.
|
||||
--
|
||||
-- The only operator-level AI credential so far has been an API key
|
||||
-- (`app_settings.admin_ai_key_encrypted`). This adds an operator-level Codex
|
||||
-- OAuth credential: the operator connects ONE signed-in ChatGPT account (the
|
||||
-- same device-code flow the per-user `codex_*_encrypted` columns use) and shares
|
||||
-- it server-wide. Each user opts in individually via `users.use_central_codex`.
|
||||
--
|
||||
-- All columns are additive and defaulted, so existing rows keep the current
|
||||
-- behaviour (no central Codex, no user opted in) with no backfill. The three
|
||||
-- token pieces are AES-256-GCM encrypted at rest, same crypto as every other
|
||||
-- `*_encrypted` column, so the key-rotation script covers them.
|
||||
--
|
||||
-- Routing through the central Codex is external egress on a shared,
|
||||
-- train-by-default consumer account bound by the operator's own rate limits.
|
||||
-- It is consent-gated (server-managed provider) and billed against the operator
|
||||
-- cost cap, never the user plan. OFF until the operator connects it.
|
||||
|
||||
ALTER TABLE "app_settings"
|
||||
ADD COLUMN "admin_codex_access_token_encrypted" TEXT,
|
||||
ADD COLUMN "admin_codex_refresh_token_encrypted" TEXT,
|
||||
ADD COLUMN "admin_codex_account_id_encrypted" TEXT,
|
||||
ADD COLUMN "admin_codex_token_expires_at" TIMESTAMP(3),
|
||||
ADD COLUMN "admin_codex_connected_at" TIMESTAMP(3),
|
||||
ADD COLUMN "admin_codex_connection_status" TEXT NOT NULL DEFAULT 'disconnected';
|
||||
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN "use_central_codex" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,50 @@
|
||||
-- Document vault: encrypted preview thumbnails.
|
||||
--
|
||||
-- `document_thumbnails` — a 1:1 sibling of `inbound_documents` holding a small
|
||||
-- JPEG preview (~320px long edge, q70) rendered in the background from the
|
||||
-- original: images are decoded + downscaled, a PDF's first page is rasterised.
|
||||
-- The blob rides `thumbnail_encrypted` in the `encrypt()`-string-as-UTF-8
|
||||
-- BYTEA shape (base64 of the JPEG) `document_content_index.text_encrypted`
|
||||
-- uses — a scanned medical preview is PHI, never stored in the clear or
|
||||
-- logged. The canvas re-encode strips EXIF/GPS from the source, so the
|
||||
-- preview is metadata-free by construction.
|
||||
--
|
||||
-- A SIDE TABLE, not a column on `inbound_documents`, so the list/detail
|
||||
-- queries that `omit` the original blob never drag this blob into the SELECT.
|
||||
--
|
||||
-- Both foreign keys cascade: a deleted document (or user) takes its thumbnail
|
||||
-- with it. Additive; applies clean on a 0234 database and on a fresh database.
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "document_thumbnails" (
|
||||
"id" TEXT NOT NULL,
|
||||
"document_id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"thumbnail_encrypted" BYTEA NOT NULL,
|
||||
"width" INTEGER NOT NULL,
|
||||
"height" INTEGER NOT NULL,
|
||||
"byte_size" INTEGER NOT NULL,
|
||||
"source_mime" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "document_thumbnails_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- 1:1 with the document.
|
||||
CREATE UNIQUE INDEX "document_thumbnails_document_id_key"
|
||||
ON "document_thumbnails" ("document_id");
|
||||
|
||||
-- Owner-scoped queries (the backfill discovery + list-page existence probe).
|
||||
CREATE INDEX "document_thumbnails_user_id_idx"
|
||||
ON "document_thumbnails" ("user_id");
|
||||
|
||||
ALTER TABLE "document_thumbnails"
|
||||
ADD CONSTRAINT "document_thumbnails_document_id_fkey"
|
||||
FOREIGN KEY ("document_id") REFERENCES "inbound_documents" ("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "document_thumbnails"
|
||||
ADD CONSTRAINT "document_thumbnails_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,29 @@
|
||||
-- v1.28.16 — explicit `document_only` flag on a clinician share link.
|
||||
--
|
||||
-- Until now "this link serves only documents, no health record" was DERIVED at
|
||||
-- read time as "are all report sections off?" (`hasAnyReportSection`). That
|
||||
-- coupling is fragile: the day a new report section is added to the prefs shape,
|
||||
-- an OLD documents-only link — whose frozen `sections_json` never mentions the
|
||||
-- new key — would resolve the new key from the defaults (ON), the derived check
|
||||
-- would flip to "has a section", and the link would start serving that section's
|
||||
-- data. This column makes the guarantee authoritative and frozen at creation, so
|
||||
-- no future section can silently re-open an existing documents-only link.
|
||||
ALTER TABLE "clinician_share_links"
|
||||
ADD COLUMN "document_only" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- Backfill: freeze the flag ON for every existing link whose sections are
|
||||
-- EXPLICITLY all-off (a non-empty object with NO `true` value at ANY depth — the
|
||||
-- shape the documents-only create flow persisted as `EMPTY_DOCTOR_REPORT_PREFS`).
|
||||
-- The recursive `$.** ? (@ == true)` JSONPath is deliberate: a record share can
|
||||
-- store a GROUPED shape (e.g. `{"vitals":{"bp":true}}`) whose only `true` sits in
|
||||
-- a nested object, so a top-level-only scan would misread it as empty and
|
||||
-- wrongly pin it documents-only. Checking every depth marks ONLY links that truly
|
||||
-- enable no section — so this can never remove report access a viewer legitimately
|
||||
-- had, only pin an already-empty report against a future schema growth. The `{}`
|
||||
-- case is excluded (empty object = "full-record defaults", NOT documents-only).
|
||||
-- Safe on a populated table (non-volatile default, no rewrite) and idempotent.
|
||||
UPDATE "clinician_share_links"
|
||||
SET "document_only" = true
|
||||
WHERE jsonb_typeof("sections_json") = 'object'
|
||||
AND "sections_json" <> '{}'::jsonb
|
||||
AND NOT jsonb_path_exists("sections_json", '$.** ? (@ == true)');
|
||||
@@ -0,0 +1,4 @@
|
||||
-- One-shot Google Health sleep duplicate repair (post-v1.28.18 fix).
|
||||
-- Null = repair pending; stamped once the boot-time full sleep re-read
|
||||
-- completes for the connection.
|
||||
ALTER TABLE "google_health_connections" ADD COLUMN "sleep_repaired_at" TIMESTAMP(3);
|
||||
@@ -0,0 +1,62 @@
|
||||
-- v1.28 — Apple Health medications + intake (server contract, #423).
|
||||
--
|
||||
-- iOS 26+ HealthKit exposes the user's medication list
|
||||
-- (`HKUserAnnotatedMedication`, stable `medicationConceptIdentifier`) and
|
||||
-- dose events (`HKMedicationDoseEvent`, stable UUID, taken/skipped),
|
||||
-- read-only. The server side is purely additive: a provenance pair on
|
||||
-- `medications` (a med with `external_source` set is MIRRORED from that
|
||||
-- source — source-exclusive, no cross-source slot collapse) and an
|
||||
-- external dose-event id on `medication_intake_events` that makes the
|
||||
-- bulk re-sync idempotent.
|
||||
|
||||
-- ── 1. intake_source — append the Apple Health source ──────────────────
|
||||
--
|
||||
-- Purely-additive enum extension; no row touched. `ADD VALUE IF NOT
|
||||
-- EXISTS` makes the rerun safe. The new value is NOT used elsewhere in
|
||||
-- this migration (the columns below are typed `intake_source` but no row
|
||||
-- is inserted or updated), so it is safe to extend the enum in the same
|
||||
-- step — Postgres only forbids USING a freshly-added enum value inside
|
||||
-- the transaction that added it.
|
||||
ALTER TYPE "intake_source" ADD VALUE IF NOT EXISTS 'APPLE_HEALTH';
|
||||
|
||||
-- ── 2. medications — mirrored-medication provenance ────────────────────
|
||||
--
|
||||
-- `external_source` non-NULL marks the row a read-only mirror of an
|
||||
-- external medication list; `external_id` carries the source's stable
|
||||
-- concept identifier (HealthKit `medicationConceptIdentifier`). Both
|
||||
-- nullable, no default, no backfill: every existing row stays a native
|
||||
-- HealthLog medication.
|
||||
ALTER TABLE "medications"
|
||||
ADD COLUMN "external_source" "intake_source",
|
||||
ADD COLUMN "external_id" TEXT;
|
||||
|
||||
-- One mirror row per external concept per user. A plain (non-partial)
|
||||
-- unique matching the schema's `@@unique([userId, externalSource,
|
||||
-- externalId])`: Postgres treats NULLs as distinct in unique indexes, so
|
||||
-- the existing rows (both columns NULL) are unaffected — only genuinely
|
||||
-- mirrored medications occupy a slot. The create route pre-queries the
|
||||
-- triple and returns the existing medication; this index is the race
|
||||
-- backstop (concurrent create surfaces P2002, which the route resolves
|
||||
-- to the winning row).
|
||||
CREATE UNIQUE INDEX "medications_user_id_external_source_external_id_key"
|
||||
ON "medications" ("user_id", "external_source", "external_id");
|
||||
|
||||
-- ── 3. medication_intake_events — external dose-event id ───────────────
|
||||
--
|
||||
-- Carries the HealthKit `HKMedicationDoseEvent` UUID. Nullable, no
|
||||
-- backfill: NULL for every non-mirrored row.
|
||||
ALTER TABLE "medication_intake_events"
|
||||
ADD COLUMN "external_id" TEXT;
|
||||
|
||||
-- Idempotent re-import: one LIVE row per `(user_id, external_id)`. A
|
||||
-- partial unique like the live-row slot unique from migration 0121 —
|
||||
-- restricted to non-NULL externalIds (rows without one, i.e. everything
|
||||
-- pre-Apple, stay unconstrained and out of the index) and to live rows
|
||||
-- (`deleted_at IS NULL`, so a tombstone never blocks the sync ledger).
|
||||
-- The bulk intake route pre-checks the batch's externalIds and reports
|
||||
-- replays as duplicates; this index is the race backstop. Prisma can't
|
||||
-- express a partial-predicate unique, so this is hand-written and the
|
||||
-- schema keeps the full `@@unique` for the compound where-key.
|
||||
CREATE UNIQUE INDEX "medication_intake_events_user_external_id_live_key"
|
||||
ON "medication_intake_events" ("user_id", "external_id")
|
||||
WHERE "external_id" IS NOT NULL AND "deleted_at" IS NULL;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- v1.28 — OIDC SSO identity pinning.
|
||||
--
|
||||
-- The (oidc_issuer, oidc_sub) pair pins an account to exactly one IdP
|
||||
-- identity: stamped on first OIDC login (auto-provision or one-time link by
|
||||
-- verified email), matched on every later OIDC login. Email stays a display
|
||||
-- field after the stamp — an IdP-side email change can never re-point the
|
||||
-- login at a different account. Both columns are NULL on accounts that never
|
||||
-- signed in via SSO; Postgres unique indexes ignore NULL pairs, so the
|
||||
-- constraint only bites on actual duplicate identities.
|
||||
ALTER TABLE "users" ADD COLUMN "oidc_issuer" TEXT;
|
||||
ALTER TABLE "users" ADD COLUMN "oidc_sub" TEXT;
|
||||
|
||||
CREATE UNIQUE INDEX "users_oidc_issuer_oidc_sub_key" ON "users"("oidc_issuer", "oidc_sub");
|
||||
@@ -0,0 +1,37 @@
|
||||
-- v1.28 — micronutrient intake daily totals (opt-in `nutrients` module).
|
||||
--
|
||||
-- One row per (user, local day, nutrient): vitamins, minerals, water and
|
||||
-- caffeine as supplement-style day totals synced from Apple Health. The
|
||||
-- composite PK is the idempotency key — an iOS re-post of a grown day
|
||||
-- total replaces the row in place (last-writer-wins day-total contract),
|
||||
-- so there is no externalId column and no soft delete. `day` is a
|
||||
-- YYYY-MM-DD TEXT key anchored to the user's IANA timezone (the
|
||||
-- EnvironmentContext / MedicationComplianceRollup day-key precedent);
|
||||
-- `nutrient` is a bare TEXT code enforced in code against the closed
|
||||
-- catalog (`src/lib/nutrients/catalog.ts`) so adding a code never needs
|
||||
-- a migration. `unit` is denormalised from the catalog on purpose: rows
|
||||
-- stay self-describing in exports even if the catalog ever drifts.
|
||||
CREATE TABLE "nutrient_intake_days" (
|
||||
"user_id" TEXT NOT NULL,
|
||||
"day" TEXT NOT NULL,
|
||||
"nutrient" TEXT NOT NULL,
|
||||
"amount" DOUBLE PRECISION NOT NULL,
|
||||
"unit" TEXT NOT NULL,
|
||||
"source" TEXT NOT NULL DEFAULT 'APPLE_HEALTH',
|
||||
"external_source_version" TEXT,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "nutrient_intake_days_pkey" PRIMARY KEY ("user_id", "day", "nutrient")
|
||||
);
|
||||
|
||||
-- Read path: the settings-card / read-endpoint window scan per user, and
|
||||
-- the later per-nutrient series fetch (day DESC for "latest first").
|
||||
CREATE INDEX "nutrient_intake_days_user_id_nutrient_day_idx"
|
||||
ON "nutrient_intake_days" ("user_id", "nutrient", "day" DESC);
|
||||
|
||||
-- Account deletion cascades; rows are worthless without their owner.
|
||||
ALTER TABLE "nutrient_intake_days"
|
||||
ADD CONSTRAINT "nutrient_intake_days_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Import-job liveness heartbeat (issue #486 follow-up).
|
||||
--
|
||||
-- `reconcileOrphanImportJobs` used to flip EVERY non-terminal ImportJob
|
||||
-- row to `failed` on any worker boot — killing an import that was still
|
||||
-- actively parsing in another worker (multi-replica / rolling deploy).
|
||||
-- The reconcile now gates on this heartbeat: a non-terminal row is only
|
||||
-- treated as orphaned when its pg-boss job is gone/terminal OR its
|
||||
-- heartbeat has gone stale. `@updatedAt` bumps this column on every
|
||||
-- write, including each mid-run progress tick, so a live import keeps it
|
||||
-- fresh. Existing rows backfill to the ALTER time via the DB default.
|
||||
ALTER TABLE "import_jobs"
|
||||
ADD COLUMN "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||
@@ -0,0 +1,54 @@
|
||||
-- v1.28.46 perf (H4) — partial indexes for the boot-time converging-backfill
|
||||
-- discovery scans. Each discovery now runs a DB-level `SELECT DISTINCT user_id`
|
||||
-- over a growth table filtered by a "still-un-migrated" predicate; without an
|
||||
-- index that is a full filtered-partition scan at worker boot (the boot-storm
|
||||
-- class on a heavy tenant). Each index below is PARTIAL on exactly the
|
||||
-- discovery predicate, so the scan is index-only and shrinks to nothing as the
|
||||
-- one-time backfill converges (a migrated row drops out of the predicate).
|
||||
--
|
||||
-- Additive-only: every statement is `CREATE INDEX IF NOT EXISTS`, no table
|
||||
-- rewrite, no data change. NON-CONCURRENT on purpose — Prisma Migrate wraps a
|
||||
-- migration file in a transaction and `CREATE INDEX CONCURRENTLY` cannot run in
|
||||
-- one; these build at boot (docker-entrypoint runs `migrate deploy` BEFORE the
|
||||
-- app serves traffic), so the brief build lock lands in the pre-traffic window.
|
||||
-- Partial predicates match the write-side note convention (partial indexes are
|
||||
-- raw-SQL-only here; Prisma cannot express a partial predicate — see the 0087 /
|
||||
-- 0108 / 0183 precedents documented in schema.prisma).
|
||||
|
||||
-- note-encryption-backfill: measurements (densest table) + mood_entries.
|
||||
CREATE INDEX IF NOT EXISTS "measurements_note_backfill_idx"
|
||||
ON "measurements" ("user_id")
|
||||
WHERE "notes" IS NOT NULL AND "notes_encrypted" IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "mood_entries_note_backfill_idx"
|
||||
ON "mood_entries" ("user_id")
|
||||
WHERE "note" IS NOT NULL AND "note_encrypted" IS NULL;
|
||||
|
||||
-- med-notes-encryption-backfill: side-effects, inventory items, dose-changes.
|
||||
-- Dose-changes carry no user_id; the discovery joins to medications on
|
||||
-- medication_id, so the partial index leads with that join key.
|
||||
CREATE INDEX IF NOT EXISTS "medication_side_effects_note_backfill_idx"
|
||||
ON "medication_side_effects" ("user_id")
|
||||
WHERE "notes" IS NOT NULL AND "notes_encrypted" IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "medication_inventory_items_note_backfill_idx"
|
||||
ON "medication_inventory_items" ("user_id")
|
||||
WHERE "notes" IS NOT NULL AND "notes_encrypted" IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "medication_dose_changes_note_backfill_idx"
|
||||
ON "medication_dose_changes" ("medication_id")
|
||||
WHERE "note" IS NOT NULL AND "note_encrypted" IS NULL;
|
||||
|
||||
-- lab-biomarker-backfill: unlinked live lab results.
|
||||
CREATE INDEX IF NOT EXISTS "lab_results_biomarker_backfill_idx"
|
||||
ON "lab_results" ("user_id")
|
||||
WHERE "deleted_at" IS NULL AND "biomarker_id" IS NULL;
|
||||
|
||||
-- document-thumbnail-backfill: live thumbnailable documents. The thumbnail
|
||||
-- absence lives on the `document_thumbnails` side table (a cross-table
|
||||
-- anti-join, not indexable here); this partial index bounds the document-side
|
||||
-- scan on `(user_id, mime_type)` for live rows, and the anti-join rides
|
||||
-- `document_thumbnails.document_id` (already UNIQUE via the 1:1 relation).
|
||||
CREATE INDEX IF NOT EXISTS "inbound_documents_thumbnail_backfill_idx"
|
||||
ON "inbound_documents" ("user_id", "mime_type")
|
||||
WHERE "deleted_at" IS NULL;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Morning-freshness marker (S4 of the unified daily-value system).
|
||||
--
|
||||
-- The nightly insight pre-pass runs ~04:30, before last night's sleep has
|
||||
-- synced, so the morning digest/health-score reference the day WITHOUT the
|
||||
-- current sleep. This column records the user-local YYYY-MM-DD (their profile
|
||||
-- timezone) for which the event-driven morning refresh has completed: last
|
||||
-- night's sleep arrived AND the sleep-dependent insight/score cache was
|
||||
-- regenerated with it. The daily digest reports `final` once this equals the
|
||||
-- user's current local date, `provisional` otherwise (honest degradation when
|
||||
-- sleep is late or never arrives). Stamped by the `morning-digest-refresh`
|
||||
-- pg-boss job the sleep-arrival trigger enqueues, debounced once per local
|
||||
-- morning. NULL for every existing row = provisional until the next sleep
|
||||
-- landing, which is the correct pre-feature reading.
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN "morning_digest_refreshed_on" TEXT;
|
||||
@@ -0,0 +1,71 @@
|
||||
-- S7: attach multiple documents to a coach conversation.
|
||||
--
|
||||
-- Replaces the scalar `coach_conversations.document_id` discriminator (which
|
||||
-- could express only ONE document and would UNFENCE a conversation when that
|
||||
-- document was deleted) with a join table + a sticky `document_scoped` flag.
|
||||
--
|
||||
-- The flag is the fail-closed half of the fenced-only discriminator: it is set
|
||||
-- true at fenced creation / first attach and NEVER cleared. The join table
|
||||
-- holds the LIVE attachment set. A count-only predicate would let a deleted
|
||||
-- attachment flip a conversation (whose history may contain document-derived
|
||||
-- text) back onto the tool loop; the sticky flag makes "fenced" a one-way latch
|
||||
-- independent of attachment liveness.
|
||||
--
|
||||
-- Backfill runs in the SAME transaction as the DDL (Postgres allows it) BEFORE
|
||||
-- the scalar column is dropped, so there is no dual-source-of-truth window:
|
||||
-- every legacy single-doc chat becomes a one-attachment fenced conversation.
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "coach_conversation_documents" (
|
||||
"conversation_id" TEXT NOT NULL,
|
||||
"document_id" TEXT NOT NULL,
|
||||
"added_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "coach_conversation_documents_pkey" PRIMARY KEY ("conversation_id", "document_id")
|
||||
);
|
||||
|
||||
-- CreateIndex — the document-sheet "chats about this doc" scan + the path-id
|
||||
-- join filter the single-doc route uses.
|
||||
CREATE INDEX "coach_conversation_documents_document_id_idx"
|
||||
ON "coach_conversation_documents" ("document_id");
|
||||
|
||||
-- AddForeignKey — deleting a conversation cascades its attachment rows away.
|
||||
ALTER TABLE "coach_conversation_documents"
|
||||
ADD CONSTRAINT "coach_conversation_documents_conversation_id_fkey"
|
||||
FOREIGN KEY ("conversation_id") REFERENCES "coach_conversations" ("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey — deleting a document cascades its join rows away (its text
|
||||
-- leaves every conversation's context) but NEVER the conversation itself.
|
||||
ALTER TABLE "coach_conversation_documents"
|
||||
ADD CONSTRAINT "coach_conversation_documents_document_id_fkey"
|
||||
FOREIGN KEY ("document_id") REFERENCES "inbound_documents" ("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AlterTable — the sticky fence flag.
|
||||
ALTER TABLE "coach_conversations"
|
||||
ADD COLUMN "document_scoped" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- Backfill: every legacy single-doc chat becomes a one-attachment fenced
|
||||
-- conversation. Runs BEFORE the DROP COLUMN below.
|
||||
INSERT INTO "coach_conversation_documents" ("conversation_id", "document_id", "added_at")
|
||||
SELECT "id", "document_id", "created_at"
|
||||
FROM "coach_conversations"
|
||||
WHERE "document_id" IS NOT NULL;
|
||||
|
||||
UPDATE "coach_conversations" SET "document_scoped" = true
|
||||
WHERE "document_id" IS NOT NULL;
|
||||
|
||||
-- The scalar discriminator goes away in the SAME migration — no dual-source-of-
|
||||
-- truth window, no "in case" shim. Every reader is repointed in the same
|
||||
-- release. Drop the FK + covering index first, then the column.
|
||||
ALTER TABLE "coach_conversations"
|
||||
DROP CONSTRAINT "coach_conversations_document_id_fkey";
|
||||
|
||||
DROP INDEX "coach_conversations_user_id_document_id_updated_at_idx";
|
||||
|
||||
ALTER TABLE "coach_conversations"
|
||||
DROP COLUMN "document_id";
|
||||
|
||||
-- CreateIndex — the rail + tool-route-send predicate on the sticky flag.
|
||||
CREATE INDEX "coach_conversations_user_id_document_scoped_updated_at_idx"
|
||||
ON "coach_conversations" ("user_id", "document_scoped", "updated_at");
|
||||
@@ -0,0 +1,289 @@
|
||||
-- v1.29.x — backfill non-canonical WHOOP `Workout.sport_type` values.
|
||||
--
|
||||
-- Root cause (confirmed from prod data): WHOOP was the only workout
|
||||
-- integration that never normalised its sport to HealthLog's canonical
|
||||
-- `WorkoutSportType` enum (see `workoutSportTypeEnum` in
|
||||
-- src/lib/validations/workout.ts). `upsertWhoopWorkout()` in
|
||||
-- src/lib/whoop/sync-workout.ts wrote WHOOP's raw `sport_name` verbatim, or
|
||||
-- a literal `whoop_sport_<sport_id>` placeholder when WHOOP sent only the
|
||||
-- (now-retired) numeric id — so existing WHOOP rows carry values the
|
||||
-- `/insights/workouts` list can't map to an icon/label (a WHOOP-tracked bike
|
||||
-- ride never rendered as "cycling"). `mapWhoopSportType()`
|
||||
-- (src/lib/whoop/sport-map.ts) fixes the write path going forward; this
|
||||
-- migration re-derives every EXISTING WHOOP `Workout` row from the same
|
||||
-- table so historical data and the live mapper never drift apart.
|
||||
--
|
||||
-- The WHEN arms below were generated FROM `WHOOP_SPORT_TABLE` in
|
||||
-- src/lib/whoop/sport-map.ts (one JS-side script run against that exact
|
||||
-- table, not hand-transcribed) and cover both shapes a pre-fix row could
|
||||
-- carry:
|
||||
-- - the literal `whoop_sport_<id>` placeholder (id present, no name), and
|
||||
-- - the raw `sport_name` WHOOP sent (name present), normalised the same
|
||||
-- way the SQL expression below normalises the stored column: lowercase,
|
||||
-- any run of characters outside `[a-z0-9_]` collapsed to a single `_`.
|
||||
-- (`normaliseSportKey()` in sport-map.ts mirrors this exact rule so a
|
||||
-- future re-generation of this list stays consistent with the SQL.)
|
||||
--
|
||||
-- Anything neither arm recognises falls to `ELSE 'other'` — same fallback
|
||||
-- `mapWhoopSportType()` uses, never leaves a row on a `whoop_sport_<n>`
|
||||
-- placeholder.
|
||||
--
|
||||
-- Idempotent: the WHERE clause excludes rows whose `sport_type` is ALREADY
|
||||
-- one of the 20 canonical values, so a re-run touches zero rows once the
|
||||
-- first run has completed. Safe to re-run after a partial failure.
|
||||
UPDATE "workouts"
|
||||
SET "sport_type" = CASE
|
||||
lower(regexp_replace(trim("sport_type"), '[^a-zA-Z0-9_]+', '_', 'g'))
|
||||
WHEN 'whoop_sport__1' THEN 'other'
|
||||
WHEN 'whoop_sport_0' THEN 'running'
|
||||
WHEN 'whoop_sport_1' THEN 'cycling'
|
||||
WHEN 'whoop_sport_16' THEN 'other'
|
||||
WHEN 'whoop_sport_17' THEN 'basketball'
|
||||
WHEN 'whoop_sport_18' THEN 'rowing'
|
||||
WHEN 'whoop_sport_19' THEN 'other'
|
||||
WHEN 'whoop_sport_20' THEN 'other'
|
||||
WHEN 'whoop_sport_21' THEN 'other'
|
||||
WHEN 'whoop_sport_22' THEN 'golf'
|
||||
WHEN 'whoop_sport_24' THEN 'other'
|
||||
WHEN 'whoop_sport_25' THEN 'other'
|
||||
WHEN 'whoop_sport_27' THEN 'other'
|
||||
WHEN 'whoop_sport_28' THEN 'other'
|
||||
WHEN 'whoop_sport_29' THEN 'other'
|
||||
WHEN 'whoop_sport_30' THEN 'soccer'
|
||||
WHEN 'whoop_sport_31' THEN 'other'
|
||||
WHEN 'whoop_sport_32' THEN 'tennis'
|
||||
WHEN 'whoop_sport_33' THEN 'swimming'
|
||||
WHEN 'whoop_sport_34' THEN 'tennis'
|
||||
WHEN 'whoop_sport_35' THEN 'running'
|
||||
WHEN 'whoop_sport_36' THEN 'other'
|
||||
WHEN 'whoop_sport_37' THEN 'swimming'
|
||||
WHEN 'whoop_sport_38' THEN 'strength'
|
||||
WHEN 'whoop_sport_39' THEN 'hiit'
|
||||
WHEN 'whoop_sport_42' THEN 'dance'
|
||||
WHEN 'whoop_sport_43' THEN 'mindAndBody'
|
||||
WHEN 'whoop_sport_44' THEN 'yoga'
|
||||
WHEN 'whoop_sport_45' THEN 'strength'
|
||||
WHEN 'whoop_sport_47' THEN 'other'
|
||||
WHEN 'whoop_sport_48' THEN 'crossTraining'
|
||||
WHEN 'whoop_sport_49' THEN 'mixedCardio'
|
||||
WHEN 'whoop_sport_51' THEN 'crossTraining'
|
||||
WHEN 'whoop_sport_52' THEN 'hiking'
|
||||
WHEN 'whoop_sport_53' THEN 'other'
|
||||
WHEN 'whoop_sport_55' THEN 'rowing'
|
||||
WHEN 'whoop_sport_56' THEN 'hiit'
|
||||
WHEN 'whoop_sport_57' THEN 'cycling'
|
||||
WHEN 'whoop_sport_59' THEN 'strength'
|
||||
WHEN 'whoop_sport_60' THEN 'crossTraining'
|
||||
WHEN 'whoop_sport_61' THEN 'rowing'
|
||||
WHEN 'whoop_sport_62' THEN 'mixedCardio'
|
||||
WHEN 'whoop_sport_63' THEN 'walking'
|
||||
WHEN 'whoop_sport_64' THEN 'other'
|
||||
WHEN 'whoop_sport_65' THEN 'elliptical'
|
||||
WHEN 'whoop_sport_66' THEN 'stairClimber'
|
||||
WHEN 'whoop_sport_70' THEN 'mindAndBody'
|
||||
WHEN 'whoop_sport_71' THEN 'other'
|
||||
WHEN 'whoop_sport_73' THEN 'other'
|
||||
WHEN 'whoop_sport_74' THEN 'other'
|
||||
WHEN 'whoop_sport_75' THEN 'other'
|
||||
WHEN 'whoop_sport_76' THEN 'other'
|
||||
WHEN 'whoop_sport_77' THEN 'other'
|
||||
WHEN 'whoop_sport_82' THEN 'other'
|
||||
WHEN 'whoop_sport_83' THEN 'stairClimber'
|
||||
WHEN 'whoop_sport_84' THEN 'hiit'
|
||||
WHEN 'whoop_sport_85' THEN 'other'
|
||||
WHEN 'whoop_sport_86' THEN 'other'
|
||||
WHEN 'whoop_sport_87' THEN 'other'
|
||||
WHEN 'whoop_sport_88' THEN 'other'
|
||||
WHEN 'whoop_sport_89' THEN 'other'
|
||||
WHEN 'whoop_sport_90' THEN 'other'
|
||||
WHEN 'whoop_sport_91' THEN 'other'
|
||||
WHEN 'whoop_sport_92' THEN 'other'
|
||||
WHEN 'whoop_sport_93' THEN 'other'
|
||||
WHEN 'whoop_sport_94' THEN 'crossTraining'
|
||||
WHEN 'whoop_sport_95' THEN 'other'
|
||||
WHEN 'whoop_sport_96' THEN 'hiit'
|
||||
WHEN 'whoop_sport_97' THEN 'cycling'
|
||||
WHEN 'whoop_sport_98' THEN 'hiit'
|
||||
WHEN 'whoop_sport_99' THEN 'other'
|
||||
WHEN 'whoop_sport_100' THEN 'other'
|
||||
WHEN 'whoop_sport_101' THEN 'tennis'
|
||||
WHEN 'whoop_sport_102' THEN 'other'
|
||||
WHEN 'whoop_sport_103' THEN 'hiit'
|
||||
WHEN 'whoop_sport_104' THEN 'other'
|
||||
WHEN 'whoop_sport_105' THEN 'other'
|
||||
WHEN 'whoop_sport_106' THEN 'tennis'
|
||||
WHEN 'whoop_sport_107' THEN 'mindAndBody'
|
||||
WHEN 'whoop_sport_108' THEN 'other'
|
||||
WHEN 'whoop_sport_109' THEN 'other'
|
||||
WHEN 'whoop_sport_110' THEN 'crossTraining'
|
||||
WHEN 'whoop_sport_111' THEN 'other'
|
||||
WHEN 'whoop_sport_112' THEN 'other'
|
||||
WHEN 'whoop_sport_113' THEN 'other'
|
||||
WHEN 'whoop_sport_121' THEN 'other'
|
||||
WHEN 'whoop_sport_123' THEN 'strength'
|
||||
WHEN 'whoop_sport_125' THEN 'other'
|
||||
WHEN 'whoop_sport_126' THEN 'cycling'
|
||||
WHEN 'whoop_sport_127' THEN 'hiit'
|
||||
WHEN 'whoop_sport_128' THEN 'mindAndBody'
|
||||
WHEN 'whoop_sport_230' THEN 'tennis'
|
||||
WHEN 'whoop_sport_231' THEN 'tennis'
|
||||
WHEN 'whoop_sport_232' THEN 'basketball'
|
||||
WHEN 'whoop_sport_233' THEN 'other'
|
||||
WHEN 'whoop_sport_234' THEN 'golf'
|
||||
WHEN 'whoop_sport_235' THEN 'other'
|
||||
WHEN 'whoop_sport_236' THEN 'other'
|
||||
WHEN 'whoop_sport_237' THEN 'other'
|
||||
WHEN 'whoop_sport_238' THEN 'other'
|
||||
WHEN 'whoop_sport_239' THEN 'other'
|
||||
WHEN 'whoop_sport_240' THEN 'other'
|
||||
WHEN 'whoop_sport_248' THEN 'crossTraining'
|
||||
WHEN 'whoop_sport_249' THEN 'tennis'
|
||||
WHEN 'whoop_sport_250' THEN 'crossTraining'
|
||||
WHEN 'whoop_sport_251' THEN 'other'
|
||||
WHEN 'whoop_sport_252' THEN 'walking'
|
||||
WHEN 'whoop_sport_253' THEN 'running'
|
||||
WHEN 'whoop_sport_254' THEN 'other'
|
||||
WHEN 'whoop_sport_255' THEN 'other'
|
||||
WHEN 'whoop_sport_258' THEN 'mindAndBody'
|
||||
WHEN 'whoop_sport_259' THEN 'yoga'
|
||||
WHEN 'whoop_sport_261' THEN 'stairClimber'
|
||||
WHEN 'whoop_sport_262' THEN 'other'
|
||||
WHEN 'whoop_sport_263' THEN 'other'
|
||||
WHEN 'whoop_sport_264' THEN 'other'
|
||||
WHEN 'whoop_sport_266' THEN 'walking'
|
||||
WHEN 'whoop_sport_267' THEN 'other'
|
||||
WHEN 'whoop_sport_268' THEN 'other'
|
||||
WHEN 'whoop_sport_269' THEN 'other'
|
||||
WHEN 'whoop_sport_270' THEN 'other'
|
||||
WHEN 'whoop_sport_272' THEN 'other'
|
||||
WHEN 'activity' THEN 'other'
|
||||
WHEN 'running' THEN 'running'
|
||||
WHEN 'cycling' THEN 'cycling'
|
||||
WHEN 'baseball' THEN 'other'
|
||||
WHEN 'basketball' THEN 'basketball'
|
||||
WHEN 'rowing' THEN 'rowing'
|
||||
WHEN 'fencing' THEN 'other'
|
||||
WHEN 'field_hockey' THEN 'other'
|
||||
WHEN 'football' THEN 'other'
|
||||
WHEN 'golf' THEN 'golf'
|
||||
WHEN 'ice_hockey' THEN 'other'
|
||||
WHEN 'lacrosse' THEN 'other'
|
||||
WHEN 'rugby' THEN 'other'
|
||||
WHEN 'sailing' THEN 'other'
|
||||
WHEN 'skiing' THEN 'other'
|
||||
WHEN 'soccer' THEN 'soccer'
|
||||
WHEN 'softball' THEN 'other'
|
||||
WHEN 'squash' THEN 'tennis'
|
||||
WHEN 'swimming' THEN 'swimming'
|
||||
WHEN 'tennis' THEN 'tennis'
|
||||
WHEN 'track_field' THEN 'running'
|
||||
WHEN 'volleyball' THEN 'other'
|
||||
WHEN 'water_polo' THEN 'swimming'
|
||||
WHEN 'wrestling' THEN 'strength'
|
||||
WHEN 'boxing' THEN 'hiit'
|
||||
WHEN 'dance' THEN 'dance'
|
||||
WHEN 'pilates' THEN 'mindAndBody'
|
||||
WHEN 'yoga' THEN 'yoga'
|
||||
WHEN 'weightlifting' THEN 'strength'
|
||||
WHEN 'cross_country_skiing' THEN 'other'
|
||||
WHEN 'functional_fitness' THEN 'crossTraining'
|
||||
WHEN 'duathlon' THEN 'mixedCardio'
|
||||
WHEN 'gymnastics' THEN 'crossTraining'
|
||||
WHEN 'hiking_rucking' THEN 'hiking'
|
||||
WHEN 'horseback_riding' THEN 'other'
|
||||
WHEN 'kayaking' THEN 'rowing'
|
||||
WHEN 'martial_arts' THEN 'hiit'
|
||||
WHEN 'mountain_biking' THEN 'cycling'
|
||||
WHEN 'powerlifting' THEN 'strength'
|
||||
WHEN 'rock_climbing' THEN 'crossTraining'
|
||||
WHEN 'paddleboarding' THEN 'rowing'
|
||||
WHEN 'triathlon' THEN 'mixedCardio'
|
||||
WHEN 'walking' THEN 'walking'
|
||||
WHEN 'surfing' THEN 'other'
|
||||
WHEN 'elliptical' THEN 'elliptical'
|
||||
WHEN 'stairmaster' THEN 'stairClimber'
|
||||
WHEN 'meditation' THEN 'mindAndBody'
|
||||
WHEN 'other' THEN 'other'
|
||||
WHEN 'diving' THEN 'other'
|
||||
WHEN 'operations_tactical' THEN 'other'
|
||||
WHEN 'operations_medical' THEN 'other'
|
||||
WHEN 'operations_flying' THEN 'other'
|
||||
WHEN 'operations_water' THEN 'other'
|
||||
WHEN 'ultimate' THEN 'other'
|
||||
WHEN 'climber' THEN 'stairClimber'
|
||||
WHEN 'jumping_rope' THEN 'hiit'
|
||||
WHEN 'australian_football' THEN 'other'
|
||||
WHEN 'skateboarding' THEN 'other'
|
||||
WHEN 'coaching' THEN 'other'
|
||||
WHEN 'ice_bath' THEN 'other'
|
||||
WHEN 'commuting' THEN 'other'
|
||||
WHEN 'gaming' THEN 'other'
|
||||
WHEN 'snowboarding' THEN 'other'
|
||||
WHEN 'motocross' THEN 'other'
|
||||
WHEN 'caddying' THEN 'other'
|
||||
WHEN 'obstacle_course_racing' THEN 'crossTraining'
|
||||
WHEN 'motor_racing' THEN 'other'
|
||||
WHEN 'hiit' THEN 'hiit'
|
||||
WHEN 'spin' THEN 'cycling'
|
||||
WHEN 'jiu_jitsu' THEN 'hiit'
|
||||
WHEN 'manual_labor' THEN 'other'
|
||||
WHEN 'cricket' THEN 'other'
|
||||
WHEN 'pickleball' THEN 'tennis'
|
||||
WHEN 'inline_skating' THEN 'other'
|
||||
WHEN 'box_fitness' THEN 'hiit'
|
||||
WHEN 'spikeball' THEN 'other'
|
||||
WHEN 'wheelchair_pushing' THEN 'other'
|
||||
WHEN 'paddle_tennis' THEN 'tennis'
|
||||
WHEN 'barre' THEN 'mindAndBody'
|
||||
WHEN 'stage_performance' THEN 'other'
|
||||
WHEN 'high_stress_work' THEN 'other'
|
||||
WHEN 'parkour' THEN 'crossTraining'
|
||||
WHEN 'gaelic_football' THEN 'other'
|
||||
WHEN 'hurling_camogie' THEN 'other'
|
||||
WHEN 'circus_arts' THEN 'other'
|
||||
WHEN 'massage_therapy' THEN 'other'
|
||||
WHEN 'strength_trainer' THEN 'strength'
|
||||
WHEN 'watching_sports' THEN 'other'
|
||||
WHEN 'assault_bike' THEN 'cycling'
|
||||
WHEN 'kickboxing' THEN 'hiit'
|
||||
WHEN 'stretching' THEN 'mindAndBody'
|
||||
WHEN 'table_tennis' THEN 'tennis'
|
||||
WHEN 'badminton' THEN 'tennis'
|
||||
WHEN 'netball' THEN 'basketball'
|
||||
WHEN 'sauna' THEN 'other'
|
||||
WHEN 'disc_golf' THEN 'golf'
|
||||
WHEN 'yard_work' THEN 'other'
|
||||
WHEN 'air_compression' THEN 'other'
|
||||
WHEN 'percussive_massage' THEN 'other'
|
||||
WHEN 'paintball' THEN 'other'
|
||||
WHEN 'ice_skating' THEN 'other'
|
||||
WHEN 'handball' THEN 'other'
|
||||
WHEN 'f45_training' THEN 'crossTraining'
|
||||
WHEN 'padel' THEN 'tennis'
|
||||
WHEN 'barry_s' THEN 'crossTraining'
|
||||
WHEN 'dedicated_parenting' THEN 'other'
|
||||
WHEN 'stroller_walking' THEN 'walking'
|
||||
WHEN 'stroller_jogging' THEN 'running'
|
||||
WHEN 'toddlerwearing' THEN 'other'
|
||||
WHEN 'babywearing' THEN 'other'
|
||||
WHEN 'barre3' THEN 'mindAndBody'
|
||||
WHEN 'hot_yoga' THEN 'yoga'
|
||||
WHEN 'stadium_steps' THEN 'stairClimber'
|
||||
WHEN 'polo' THEN 'other'
|
||||
WHEN 'musical_performance' THEN 'other'
|
||||
WHEN 'kite_boarding' THEN 'other'
|
||||
WHEN 'dog_walking' THEN 'walking'
|
||||
WHEN 'water_skiing' THEN 'other'
|
||||
WHEN 'wakeboarding' THEN 'other'
|
||||
WHEN 'cooking' THEN 'other'
|
||||
WHEN 'cleaning' THEN 'other'
|
||||
WHEN 'public_speaking' THEN 'other'
|
||||
ELSE 'other'
|
||||
END
|
||||
WHERE "source" = 'WHOOP'
|
||||
AND "sport_type" NOT IN (
|
||||
'walking', 'running', 'cycling', 'hiking', 'swimming', 'rowing',
|
||||
'elliptical', 'stairClimber', 'yoga', 'mindAndBody', 'strength', 'hiit',
|
||||
'dance', 'golf', 'tennis', 'basketball', 'soccer', 'crossTraining',
|
||||
'mixedCardio', 'other'
|
||||
);
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Server-side "dismiss / mark seen" ledger for the Today rail's OBSERVATIONAL
|
||||
-- PriorityItem kinds (milestone, ecg_new_recording, tension_window). The
|
||||
-- ACTIONABLE kinds (dose_window, sync_issue, preventive_care, coach_checkin)
|
||||
-- never write a row here — they clear on their own once the user acts.
|
||||
--
|
||||
-- `item_key` is a deterministic identity for the candidate INSTANCE the
|
||||
-- digest builder already computes (kind + metric + reach day for a
|
||||
-- milestone, the recording's own timestamp for an ECG, the local day for a
|
||||
-- tension window) — not the bare kind — so dismissing today's milestone never
|
||||
-- suppresses tomorrow's.
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dismissed_priority_items" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"item_key" TEXT NOT NULL,
|
||||
"dismissed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "dismissed_priority_items_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex — the dismiss upsert's natural key + the digest read's
|
||||
-- `userId + itemKey IN (...)` lookup.
|
||||
CREATE UNIQUE INDEX "dismissed_priority_items_user_id_item_key_key"
|
||||
ON "dismissed_priority_items" ("user_id", "item_key");
|
||||
|
||||
-- AddForeignKey — deleting a user cascades its dismissal rows away.
|
||||
ALTER TABLE "dismissed_priority_items"
|
||||
ADD CONSTRAINT "dismissed_priority_items_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- v1.29 — `source` joins the `nutrient_intake_days` primary key so a
|
||||
-- manual water entry and the Apple-synced day total coexist as two rows
|
||||
-- instead of the manual write being silently clobbered by the next
|
||||
-- Apple last-writer-wins sync (the batch route always upserts the
|
||||
-- (userId, day, nutrient) row with the CURRENT running total, so a
|
||||
-- manual increment folded into that same row would vanish on the next
|
||||
-- sync — a data-loss bug by construction, not a rare race).
|
||||
--
|
||||
-- No data rewrite: every existing row already carries
|
||||
-- `source = 'APPLE_HEALTH'` (the column default since migration 0241),
|
||||
-- so dropping and recreating the PRIMARY KEY over the four columns is a
|
||||
-- pure constraint change — same rows, same values, wider key. The
|
||||
-- existing `(user_id, nutrient, day DESC)` index is untouched; it does
|
||||
-- not reference `source` and continues to serve the window-scan reads.
|
||||
ALTER TABLE "nutrient_intake_days" DROP CONSTRAINT "nutrient_intake_days_pkey";
|
||||
|
||||
ALTER TABLE "nutrient_intake_days"
|
||||
ADD CONSTRAINT "nutrient_intake_days_pkey"
|
||||
PRIMARY KEY ("user_id", "day", "nutrient", "source");
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Persist a short plain-language summary of an uploaded document.
|
||||
--
|
||||
-- Adds two nullable columns to `inbound_documents`:
|
||||
-- - `summary_encrypted` — AES-256-GCM ciphertext of a 3-4 sentence
|
||||
-- plain-language description of WHAT the document is, generated ONCE in
|
||||
-- the background right after upload when the user's `documents_auto_ai_read`
|
||||
-- opt-in is ON (that flag is also the standing AI-egress consent). NULL
|
||||
-- when auto-read is OFF, no provider is configured, or generation has not
|
||||
-- run yet.
|
||||
-- - `summary_generated_at` — when that background summary was generated
|
||||
-- (NULL until it runs).
|
||||
--
|
||||
-- STORAGE CONVENTION: Bytes (`bytea`), matching the dominant free-text
|
||||
-- encrypted-note convention already in the schema (the `encrypt()` ciphertext
|
||||
-- string stored UTF-8 as `bytea`, the shape the rotation script's
|
||||
-- `rotateBytesColumn` already covers). The column is registered in
|
||||
-- ENCRYPTED_COLUMNS + the rotation script in the same change.
|
||||
--
|
||||
-- ADDITIVE ONLY: existing rows keep NULL summaries; the on-demand transient
|
||||
-- summary route is unchanged.
|
||||
ALTER TABLE "inbound_documents"
|
||||
ADD COLUMN IF NOT EXISTS "summary_encrypted" BYTEA,
|
||||
ADD COLUMN IF NOT EXISTS "summary_generated_at" TIMESTAMP(3);
|
||||
@@ -0,0 +1,103 @@
|
||||
-- v1.29.x — backfill non-canonical Strava `Workout.sport_type` values.
|
||||
--
|
||||
-- Root cause (the same class the 0247 WHOOP migration fixed): Strava was the
|
||||
-- second workout integration that never normalised its sport to HealthLog's
|
||||
-- canonical `WorkoutSportType` enum (see `workoutSportTypeEnum` in
|
||||
-- src/lib/validations/workout.ts). `mapActivity()` in
|
||||
-- src/lib/strava/client.ts wrote Strava's raw PascalCase `sport_type` (or
|
||||
-- the legacy `type`, e.g. "Run", "Ride", "VirtualRide", "TrailRun") — or the
|
||||
-- literal fallback "workout" when neither field was present — straight into
|
||||
-- `Workout.sport_type`. Existing Strava rows therefore carry values the
|
||||
-- `/insights/workouts` list can't map to an icon/label, AND the read-time
|
||||
-- canonical picker in src/lib/sources/pick-canonical-workout.ts treats any
|
||||
-- two non-canonical ("generic") rows as sport-compatible — a same-source
|
||||
-- brick session (a Strava "Ride" ending 15:00 immediately followed by a
|
||||
-- Strava "Run" starting 15:03) could cluster into one and silently drop a
|
||||
-- leg. `mapStravaSportType()` (src/lib/strava/sport-map.ts) fixes the write
|
||||
-- path going forward; this migration re-derives every EXISTING Strava
|
||||
-- `Workout` row from the same table so historical data and the live mapper
|
||||
-- never drift apart.
|
||||
--
|
||||
-- The WHEN arms below were generated FROM `STRAVA_SPORT_TABLE` in
|
||||
-- src/lib/strava/sport-map.ts (one JS-side script run against that exact
|
||||
-- table, not hand-transcribed). The match expression lower-cases the stored
|
||||
-- column and strips every character outside `[a-zA-Z0-9]`, mirroring
|
||||
-- `normaliseStravaSportKey()` in sport-map.ts exactly, so "TrailRun",
|
||||
-- "trailrun", and any stray-separator variant all resolve to the same key —
|
||||
-- and so the pre-fix literal fallback "workout" resolves to the same row as
|
||||
-- Strava's own "Workout" catch-all.
|
||||
--
|
||||
-- Anything neither arm recognises falls to `ELSE 'other'` — same fallback
|
||||
-- `mapStravaSportType()` uses, never leaves a row on a raw Strava label.
|
||||
--
|
||||
-- Idempotent: the WHERE clause excludes rows whose `sport_type` is ALREADY
|
||||
-- one of the 20 canonical values, so a re-run touches zero rows once the
|
||||
-- first run has completed. Safe to re-run after a partial failure.
|
||||
UPDATE "workouts"
|
||||
SET "sport_type" = CASE
|
||||
lower(regexp_replace(trim("sport_type"), '[^a-zA-Z0-9]+', '', 'g'))
|
||||
WHEN 'alpineski' THEN 'other'
|
||||
WHEN 'backcountryski' THEN 'other'
|
||||
WHEN 'badminton' THEN 'tennis'
|
||||
WHEN 'basketball' THEN 'basketball'
|
||||
WHEN 'canoeing' THEN 'rowing'
|
||||
WHEN 'cricket' THEN 'other'
|
||||
WHEN 'crossfit' THEN 'crossTraining'
|
||||
WHEN 'dance' THEN 'dance'
|
||||
WHEN 'ebikeride' THEN 'cycling'
|
||||
WHEN 'elliptical' THEN 'elliptical'
|
||||
WHEN 'emountainbikeride' THEN 'cycling'
|
||||
WHEN 'golf' THEN 'golf'
|
||||
WHEN 'gravelride' THEN 'cycling'
|
||||
WHEN 'handcycle' THEN 'cycling'
|
||||
WHEN 'highintensityintervaltraining' THEN 'hiit'
|
||||
WHEN 'hike' THEN 'hiking'
|
||||
WHEN 'iceskate' THEN 'other'
|
||||
WHEN 'inlineskate' THEN 'other'
|
||||
WHEN 'kayaking' THEN 'rowing'
|
||||
WHEN 'kitesurf' THEN 'other'
|
||||
WHEN 'mountainbikeride' THEN 'cycling'
|
||||
WHEN 'nordicski' THEN 'other'
|
||||
WHEN 'padel' THEN 'tennis'
|
||||
WHEN 'physicaltherapy' THEN 'mindAndBody'
|
||||
WHEN 'pickleball' THEN 'tennis'
|
||||
WHEN 'pilates' THEN 'mindAndBody'
|
||||
WHEN 'racquetball' THEN 'tennis'
|
||||
WHEN 'ride' THEN 'cycling'
|
||||
WHEN 'rockclimbing' THEN 'crossTraining'
|
||||
WHEN 'rollerski' THEN 'other'
|
||||
WHEN 'rowing' THEN 'rowing'
|
||||
WHEN 'run' THEN 'running'
|
||||
WHEN 'sail' THEN 'other'
|
||||
WHEN 'skateboard' THEN 'other'
|
||||
WHEN 'snowboard' THEN 'other'
|
||||
WHEN 'snowshoe' THEN 'hiking'
|
||||
WHEN 'soccer' THEN 'soccer'
|
||||
WHEN 'squash' THEN 'tennis'
|
||||
WHEN 'stairstepper' THEN 'stairClimber'
|
||||
WHEN 'standuppaddling' THEN 'rowing'
|
||||
WHEN 'surfing' THEN 'other'
|
||||
WHEN 'swim' THEN 'swimming'
|
||||
WHEN 'tabletennis' THEN 'tennis'
|
||||
WHEN 'tennis' THEN 'tennis'
|
||||
WHEN 'trailrun' THEN 'running'
|
||||
WHEN 'velomobile' THEN 'cycling'
|
||||
WHEN 'virtualride' THEN 'cycling'
|
||||
WHEN 'virtualrow' THEN 'rowing'
|
||||
WHEN 'virtualrun' THEN 'running'
|
||||
WHEN 'volleyball' THEN 'other'
|
||||
WHEN 'walk' THEN 'walking'
|
||||
WHEN 'weighttraining' THEN 'strength'
|
||||
WHEN 'wheelchair' THEN 'other'
|
||||
WHEN 'windsurf' THEN 'other'
|
||||
WHEN 'workout' THEN 'other'
|
||||
WHEN 'yoga' THEN 'yoga'
|
||||
ELSE 'other'
|
||||
END
|
||||
WHERE "source" = 'STRAVA'
|
||||
AND "sport_type" NOT IN (
|
||||
'walking', 'running', 'cycling', 'hiking', 'swimming', 'rowing',
|
||||
'elliptical', 'stairClimber', 'yoga', 'mindAndBody', 'strength', 'hiit',
|
||||
'dance', 'golf', 'tennis', 'basketball', 'soccer', 'crossTraining',
|
||||
'mixedCardio', 'other'
|
||||
);
|
||||
+406
-53
@@ -42,6 +42,13 @@ model User {
|
||||
email String? @unique
|
||||
passwordHash String? @map("password_hash") // argon2id, optional fallback
|
||||
role String @default("USER") // "ADMIN" | "USER"
|
||||
// OIDC SSO — the (issuer, sub) pair pins the account to exactly one IdP
|
||||
// identity. Stamped on first OIDC login (auto-provision or one-time link
|
||||
// by verified email); every later OIDC login matches on this pair, never
|
||||
// on the email, so an IdP-side email change can update the display email
|
||||
// but can never re-point the login at a different account.
|
||||
oidcIssuer String? @map("oidc_issuer")
|
||||
oidcSub String? @map("oidc_sub")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@ -76,6 +83,13 @@ model User {
|
||||
codexTokenExpiresAt DateTime? @map("codex_token_expires_at")
|
||||
codexConnectedAt DateTime? @map("codex_connected_at")
|
||||
codexConnectionStatus String @default("disconnected") @map("codex_connection_status")
|
||||
// Per-user opt-in to the operator's shared central Codex (ChatGPT
|
||||
// subscription) connection stored on `AppSettings.adminCodex*`. OFF by
|
||||
// default; only takes effect when the operator has actually connected the
|
||||
// central Codex. Routing through it is external egress on a shared,
|
||||
// train-by-default account bound by the operator's rate limits — consent-gated
|
||||
// exactly like any other server-managed egress.
|
||||
useCentralCodex Boolean @default(false) @map("use_central_codex")
|
||||
insightsPrivacyMode String @default("aggregated") @map("insights_privacy_mode") // "aggregated" | "raw"
|
||||
insightsCachedAt DateTime? @map("insights_cached_at")
|
||||
insightsCachedText String? @map("insights_cached_text") // cached JSON output
|
||||
@@ -97,6 +111,15 @@ model User {
|
||||
// without re-paying for the findings. This stamps the day a re-roll ran so
|
||||
// the gate stays at most one extra call per day.
|
||||
insightsBriefingRerollDate String? @map("insights_briefing_reroll_date")
|
||||
// Morning-freshness marker: the user-local YYYY-MM-DD (their profile
|
||||
// timezone) for which the event-driven morning refresh has run — i.e. last
|
||||
// night's sleep has arrived AND the sleep-dependent insight/score cache was
|
||||
// regenerated with it. The daily digest reads `final` when this equals the
|
||||
// user's current local date, `provisional` otherwise. Stamped by the
|
||||
// `morning-digest-refresh` job the sleep-arrival trigger enqueues; the
|
||||
// string form makes the "same local morning" comparison timezone-honest
|
||||
// without a second timestamp read.
|
||||
morningDigestRefreshedOn String? @map("morning_digest_refreshed_on")
|
||||
// v1.4.36 W3 T3 — per-user AI Insights exclude-metrics list, mirrors
|
||||
// `coachPrefsJson.excludeMetrics`. The /api/insights/generate route
|
||||
// filters matching keys off `features` before JSON.stringify so the
|
||||
@@ -187,6 +210,29 @@ model User {
|
||||
ouraClientIdEncrypted String? @map("oura_client_id_encrypted")
|
||||
ouraClientSecretEncrypted String? @map("oura_client_secret_encrypted")
|
||||
|
||||
// v1.28.x — Strava OAuth credentials (per-user BYO-app, encrypted at app
|
||||
// level). Strava caps every newly-created API app at single-player mode
|
||||
// (athlete capacity 1), so a single shared app cannot serve many
|
||||
// self-hosters — each registers their own Strava app and pastes the client
|
||||
// id/secret into Settings, mirroring the WHOOP/Oura/Polar posture. Strava
|
||||
// rotates its refresh token on EVERY refresh, so both tokens are persisted
|
||||
// (Oura's reactive-refresh + compare-and-set model, no token-expiry column —
|
||||
// the sync refreshes reactively on a 401).
|
||||
stravaClientIdEncrypted String? @map("strava_client_id_encrypted")
|
||||
stravaClientSecretEncrypted String? @map("strava_client_secret_encrypted")
|
||||
stravaAccessTokenEncrypted String? @map("strava_access_token_encrypted")
|
||||
stravaRefreshTokenEncrypted String? @map("strava_refresh_token_encrypted")
|
||||
// Strava's numeric athlete id (plaintext opaque id): pins the granted
|
||||
// account so a cross-account re-auth can be rejected, and is the fan-out key
|
||||
// if webhooks are added later. Mirrors polarUserId / fitbitUserId intent.
|
||||
stravaAthleteId String? @map("strava_athlete_id")
|
||||
// Incremental-sync cursor: the start instant of the newest activity already
|
||||
// imported. The poll walks `/athlete/activities?after=<epoch>` from here.
|
||||
stravaLastActivityAt DateTime? @map("strava_last_activity_at")
|
||||
// First-connect full-history backfill marker (self-converging discovery
|
||||
// predicate `strava_backfill_completed_at IS NULL`), mirroring WHOOP/Fitbit.
|
||||
stravaBackfillCompletedAt DateTime? @map("strava_backfill_completed_at")
|
||||
|
||||
// moodLog integration (per-user, encrypted)
|
||||
moodLogUrlEncrypted String? @map("mood_log_url_encrypted")
|
||||
moodLogApiKeyEncrypted String? @map("mood_log_api_key_encrypted")
|
||||
@@ -289,19 +335,6 @@ model User {
|
||||
// default timed the Coach out before the first token landed.
|
||||
aiResponseTimeoutSeconds Int? @map("ai_response_timeout_seconds")
|
||||
|
||||
// v1.22 (#90) — dedicated document-scan (Lab-OCR) provider. OFF by default:
|
||||
// the lab-report OCR path uses the main provider chain unchanged. When
|
||||
// `aiOcrEnabled` is true the OCR ingestion resolves THIS provider/model/key
|
||||
// instead, so a user can keep a cheap text Coach on one provider and route
|
||||
// expensive vision OCR to a vision-capable one. The key is AES-256-GCM at
|
||||
// rest like every other `*Encrypted` column (registered in
|
||||
// `src/lib/crypto/encrypted-columns.ts` + the rotation script).
|
||||
aiOcrEnabled Boolean @default(false) @map("ai_ocr_enabled")
|
||||
aiOcrProvider String? @map("ai_ocr_provider") // "OPENAI" | "ANTHROPIC" | "LOCAL"
|
||||
aiOcrModel String? @map("ai_ocr_model")
|
||||
aiOcrBaseUrl String? @map("ai_ocr_base_url") // LOCAL OCR provider only
|
||||
aiOcrKeyEncrypted String? @map("ai_ocr_key_encrypted")
|
||||
|
||||
// Automatic AI reading of uploaded vault documents. OFF by default: the vault
|
||||
// stays local-first — a newly stored document is only read externally when the
|
||||
// user explicitly taps a per-document AI action (which still needs an active
|
||||
@@ -686,6 +719,8 @@ model User {
|
||||
// ships with v1.4.25 so the iOS app can build its query path
|
||||
// against a stable contract.
|
||||
personalRecords PersonalRecord[]
|
||||
// Today rail dismiss ledger — observational PriorityItem kinds only.
|
||||
dismissedPriorityItems DismissedPriorityItem[]
|
||||
// v1.4.25 W19b — per-pen / per-vial inventory entities, the
|
||||
// companion to the running-sum MedicationInventoryEvent ledger.
|
||||
// Carries the EMA §6.3 30-day in-use clock per opened pen.
|
||||
@@ -788,6 +823,10 @@ model User {
|
||||
// that win over the home location for the days they cover.
|
||||
environmentContexts EnvironmentContext[]
|
||||
environmentTravelLocations EnvironmentTravelLocation[]
|
||||
// v1.28 — micronutrient intake daily totals synced from Apple Health
|
||||
// (vitamins, minerals, water, caffeine). Opt-in `nutrients` module, off
|
||||
// by default; the ingest route refuses while the module is off.
|
||||
nutrientIntakeDays NutrientIntakeDay[]
|
||||
// v1.25 (W-DOCS-IN) — inbound clinical documents (doctor reports /
|
||||
// discharge letters) the user uploads. The raw document is encrypted at
|
||||
// rest; extracted facts land in a review-then-confirm staging area
|
||||
@@ -797,6 +836,7 @@ model User {
|
||||
extractedFacts ExtractedFact[]
|
||||
documentConditionLinks DocumentConditionLink[]
|
||||
documentContentIndexes DocumentContentIndex[]
|
||||
documentThumbnails DocumentThumbnail[]
|
||||
// v1.19.0 — full Withings ECG waveform recordings (encrypted at rest).
|
||||
// The paired AFib verdict still lands as an IRREGULAR_RHYTHM_NOTIFICATION
|
||||
// Measurement EVENT row (v1.18.11); this holds the raw signal samples.
|
||||
@@ -849,6 +889,10 @@ model User {
|
||||
// v1.16.0 — per-signup invite redemption ledger (multi-use history).
|
||||
inviteRedemptions InviteRedemption[] @relation("InviteRedemptions")
|
||||
|
||||
// One account per IdP identity — the (issuer, sub) pair is the durable
|
||||
// OIDC key (both NULL on accounts that never signed in via SSO; Postgres
|
||||
// unique indexes ignore NULL pairs).
|
||||
@@unique([oidcIssuer, oidcSub])
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
@@ -1504,6 +1548,12 @@ enum MeasurementSource {
|
||||
// path); rejected on batch + single POST like WITHINGS/WHOOP/FITBIT. iOS DTO
|
||||
// mirrors this exact spelling so /api/sync/changes decodes the rows.
|
||||
GOOGLE_HEALTH
|
||||
// v1.28.x — Strava OAuth integration. Native Strava activities ingest
|
||||
// server-side as `source = STRAVA` Workout rows (no client write path);
|
||||
// rejected on the batch + single POST like WITHINGS/WHOOP/FITBIT. iOS DTO
|
||||
// mirrors this exact spelling so the `/api/sync/changes` delta feed decodes
|
||||
// the rows. Feeds Workout rows only — Strava exposes no daily metrics.
|
||||
STRAVA
|
||||
|
||||
@@map("measurement_source")
|
||||
}
|
||||
@@ -1668,6 +1718,11 @@ model Measurement {
|
||||
// and series reads for every type NOT covered by the two partials above
|
||||
// and removes the `deleted_at` heap re-check. Same Prisma-can't-express-a-
|
||||
// partial-predicate reason it is omitted here.
|
||||
// `measurements_note_backfill_idx` is a partial index over `(user_id)
|
||||
// WHERE notes IS NOT NULL AND notes_encrypted IS NULL` — see migration 0243
|
||||
// (v1.28.46 H4). It makes the note-encryption boot-discovery `SELECT DISTINCT
|
||||
// user_id` an index-only scan that converges to empty. Same partial-predicate
|
||||
// reason it is omitted here.
|
||||
@@map("measurements")
|
||||
}
|
||||
|
||||
@@ -2047,6 +2102,34 @@ model PersonalRecord {
|
||||
@@map("personal_records")
|
||||
}
|
||||
|
||||
/// The Today rail's server-side "dismiss / mark seen" ledger — OBSERVATIONAL
|
||||
/// `PriorityItem` kinds only (`milestone`, `ecg_new_recording`,
|
||||
/// `tension_window`). The ACTIONABLE kinds (dose_window, sync_issue,
|
||||
/// preventive_care, coach_checkin) never write a row here — they clear on
|
||||
/// their own once the user acts, so a dismiss affordance on them would just
|
||||
/// duplicate the existing resolution path.
|
||||
///
|
||||
/// `itemKey` is a deterministic identity for the candidate INSTANCE the
|
||||
/// digest builder already computes (kind + metric + reach day for a
|
||||
/// milestone, the recording's own timestamp for an ECG, the local day for a
|
||||
/// tension window) — not the bare `kind` — so dismissing today's milestone
|
||||
/// never suppresses tomorrow's; the underlying item's own freshness window
|
||||
/// (reached-once / 24h / one-per-day) is what actually retires the row's
|
||||
/// relevance, this table only fast-forwards that for the rest of today.
|
||||
model DismissedPriorityItem {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
itemKey String @map("item_key")
|
||||
dismissedAt DateTime @default(now()) @map("dismissed_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
/// The dismiss upsert's natural key + the digest read's
|
||||
/// `userId + itemKey IN (...)` lookup.
|
||||
@@unique([userId, itemKey])
|
||||
@@map("dismissed_priority_items")
|
||||
}
|
||||
|
||||
// ─── Medications ───────────────────────────────────────────
|
||||
|
||||
/// Treatment-class discriminator (v1.4.25 W4d). GLP1 turns on the
|
||||
@@ -2207,6 +2290,20 @@ model Medication {
|
||||
/// `notificationPrefs.medication.reorderLeadDays` default. Additive; no
|
||||
/// backfill.
|
||||
reorderLeadDays Int? @map("reorder_lead_days")
|
||||
/// v1.28 — provenance of a MIRRORED medication. Non-NULL marks this row a
|
||||
/// read-only mirror of an external medication list (today only
|
||||
/// APPLE_HEALTH — the iOS 26+ HealthKit Medications sync). Source-
|
||||
/// exclusive by policy: dose events from that source attach only to
|
||||
/// medications mirrored from it, and the app's own dose-logging UI is
|
||||
/// expected to stay off a mirrored medication client-side. NULL = a
|
||||
/// native HealthLog medication.
|
||||
externalSource IntakeSource? @map("external_source")
|
||||
/// v1.28 — stable external identifier of the mirrored medication (the
|
||||
/// HealthKit `medicationConceptIdentifier`). Paired with `externalSource`
|
||||
/// (both set or both NULL; the create route enforces the pairing). Drives
|
||||
/// the idempotent re-mirror on `POST /api/medications` via the
|
||||
/// `(userId, externalSource, externalId)` unique below.
|
||||
externalId String? @map("external_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@ -2244,14 +2341,57 @@ model Medication {
|
||||
/// single-pause marker the reminder/today surfaces read; the era table
|
||||
/// is the additive durable record the rate paths need.
|
||||
pauseEras MedicationPauseEra[]
|
||||
/// v1.28 — user-pinned efficacy target(s) for the "Wirkung" view. Rows
|
||||
/// exist ONLY when the user explicitly overrides the derived (ATC / name)
|
||||
/// target resolution; absence means the resolver derives the target each
|
||||
/// read. Nothing about a class→outcome association is denormalised here.
|
||||
efficacyTargets MedicationEfficacyTarget[]
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
/// v1.28 — one mirror row per external medication concept per user
|
||||
/// (migration 0239). Postgres treats NULLs as distinct in unique
|
||||
/// indexes, so the vast majority of rows (both columns NULL) are
|
||||
/// unaffected; only genuinely mirrored medications occupy a slot. A
|
||||
/// re-mirror of the same concept id returns the existing row (the
|
||||
/// create route pre-queries the triple and catches the P2002 race).
|
||||
@@unique([userId, externalSource, externalId])
|
||||
@@index([userId])
|
||||
@@index([treatmentClass])
|
||||
@@map("medications")
|
||||
}
|
||||
|
||||
/// v1.28 — the user's explicit override of a medication's efficacy target,
|
||||
/// the ONE thing the "Wirkung" view persists. Class→outcome resolution
|
||||
/// (ATC-prefix → name inference) is computed each read from the closed
|
||||
/// `med-target-map` table, so nothing drifts as that map improves; the only
|
||||
/// durable state is the user's own pick when they retarget a medication or
|
||||
/// choose a target for a med the resolver could not classify. Exactly one of
|
||||
/// `measurementType` (a first-class metric series) / `biomarkerId` (a lab
|
||||
/// analyte) is set per row. Cascade-deletes with the medication.
|
||||
model MedicationEfficacyTarget {
|
||||
id String @id @default(cuid())
|
||||
medicationId String @map("medication_id")
|
||||
/// Metric target — one of the closed `MeasurementType` series. NULL when
|
||||
/// this row targets a lab analyte instead.
|
||||
measurementType MeasurementType? @map("measurement_type")
|
||||
/// Lab target — a user-scoped Biomarker. NULL when this row targets a
|
||||
/// metric series instead. SetNull on biomarker delete so the row survives
|
||||
/// as an orphan the resolver then treats as "no override".
|
||||
biomarkerId String? @map("biomarker_id")
|
||||
/// The storyline-leading target when several are pinned. Default true.
|
||||
primary Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
|
||||
biomarker Biomarker? @relation(fields: [biomarkerId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([medicationId])
|
||||
@@index([biomarkerId])
|
||||
@@map("medication_efficacy_targets")
|
||||
}
|
||||
|
||||
model MedicationSchedule {
|
||||
id String @id @default(cuid())
|
||||
medicationId String @map("medication_id")
|
||||
@@ -2428,6 +2568,11 @@ enum IntakeSource {
|
||||
API
|
||||
REMINDER
|
||||
IMPORT
|
||||
/// v1.28 — Apple Health medication sync (iOS 26+ HealthKit Medications
|
||||
/// API). Spelled exactly like `MeasurementSource.APPLE_HEALTH`; the iOS
|
||||
/// DTO mirrors this wire spelling. Doubles as the provenance label on
|
||||
/// `Medication.externalSource` for mirrored medications.
|
||||
APPLE_HEALTH
|
||||
|
||||
@@map("intake_source")
|
||||
}
|
||||
@@ -2529,6 +2674,13 @@ model MedicationIntakeEvent {
|
||||
/// stamp freezes history: editing `Medication.unitsPerDose` later
|
||||
/// never changes what an already-taken dose consumed.
|
||||
inventoryConsumption Json? @map("inventory_consumption")
|
||||
/// v1.28 — external dose-event identifier (the HealthKit
|
||||
/// `HKMedicationDoseEvent` UUID). Drives idempotent re-import on the
|
||||
/// bulk intake route: a re-synced Apple dose dedups against the live
|
||||
/// `(userId, externalId)` unique below instead of inserting again
|
||||
/// (first-write-wins per external dose event). NULL for every
|
||||
/// non-mirrored row.
|
||||
externalId String? @map("external_id")
|
||||
// v1.7.0 sync — server-set mutation timestamp. The model had no
|
||||
// `updatedAt`; the offline-sync keyset feed orders on it, so it is
|
||||
// added here. `@updatedAt` makes Prisma maintain it on every write.
|
||||
@@ -2564,6 +2716,16 @@ model MedicationIntakeEvent {
|
||||
/// full `@@unique` for the `userId_medicationId_scheduledFor_source`
|
||||
/// compound where-key.
|
||||
@@unique([userId, medicationId, scheduledFor, source])
|
||||
/// v1.28 — `medication_intake_events_user_external_id_live_key` is a
|
||||
/// partial UNIQUE index over `(user_id, external_id) WHERE external_id
|
||||
/// IS NOT NULL AND deleted_at IS NULL` — see migration 0239. The bulk
|
||||
/// intake route pre-checks the batch's externalIds and reports replays
|
||||
/// as duplicates; the index is the race backstop (a concurrent insert
|
||||
/// surfaces P2002, which the route resolves to the winning row). Prisma
|
||||
/// can't express a partial-predicate unique (same as the live-row slot
|
||||
/// unique above, migration 0121), so the schema keeps the full
|
||||
/// `@@unique` for the `userId_externalId` compound where-key.
|
||||
@@unique([userId, externalId])
|
||||
@@index([userId, medicationId, scheduledFor])
|
||||
// v1.7.0 sync — keyset support for the `/api/sync/changes` intake feed
|
||||
// (`where userId` ordered by `(updatedAt asc, id asc)`).
|
||||
@@ -2601,6 +2763,11 @@ model MedicationDoseChange {
|
||||
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([medicationId, effectiveFrom])
|
||||
// `medication_dose_changes_note_backfill_idx` is a partial index over
|
||||
// `(medication_id) WHERE note IS NOT NULL AND note_encrypted IS NULL` — see
|
||||
// migration 0243 (v1.28.46 H4). The med-notes boot-discovery joins dose-
|
||||
// changes to medications on medication_id to reach the owner; the index
|
||||
// leads with that join key. Raw SQL only (partial predicate).
|
||||
@@map("medication_dose_changes")
|
||||
}
|
||||
|
||||
@@ -2699,6 +2866,10 @@ model MedicationInventoryItem {
|
||||
|
||||
@@index([userId, medicationId, state])
|
||||
@@index([userId, expiresAt])
|
||||
// `medication_inventory_items_note_backfill_idx` is a partial index over
|
||||
// `(user_id) WHERE notes IS NOT NULL AND notes_encrypted IS NULL` — see
|
||||
// migration 0243 (v1.28.46 H4) for the med-notes boot-discovery scan. Raw
|
||||
// SQL only (partial predicate not expressible in Prisma).
|
||||
@@map("medication_inventory_items")
|
||||
}
|
||||
|
||||
@@ -2758,6 +2929,10 @@ model MedicationSideEffect {
|
||||
|
||||
@@index([userId, medicationId, occurredAt])
|
||||
@@index([userId, occurredAt])
|
||||
// `medication_side_effects_note_backfill_idx` is a partial index over
|
||||
// `(user_id) WHERE notes IS NOT NULL AND notes_encrypted IS NULL` — see
|
||||
// migration 0243 (v1.28.46 H4) for the med-notes boot-discovery scan. Raw
|
||||
// SQL only (partial predicate not expressible in Prisma).
|
||||
@@map("medication_side_effects")
|
||||
}
|
||||
|
||||
@@ -3301,6 +3476,11 @@ model GoogleHealthConnection {
|
||||
/// Set once the self-converging history backfill walks every data type to
|
||||
/// completion. Null = backfill still in flight (or never started).
|
||||
backfillCompletedAt DateTime? @map("backfill_completed_at")
|
||||
/// Set once the one-shot sleep duplicate-repair re-read completed — the full
|
||||
/// sleep-history re-sync that collapses duplicate rows left by the volatile
|
||||
/// pre-fix externalId (a re-scored night minted parallel copies). Null =
|
||||
/// repair pending (or the connection predates the fix).
|
||||
sleepRepairedAt DateTime? @map("sleep_repaired_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@ -3416,6 +3596,10 @@ model MoodEntry {
|
||||
// v1.7.0 sync — keyset support for the `/api/sync/changes` mood feed
|
||||
// (`where userId` ordered by `(updatedAt asc, id asc)`).
|
||||
@@index([userId, updatedAt, id])
|
||||
// `mood_entries_note_backfill_idx` is a partial index over `(user_id)
|
||||
// WHERE note IS NOT NULL AND note_encrypted IS NULL` — see migration 0243
|
||||
// (v1.28.46 H4), serving the note-encryption boot-discovery scan. Raw SQL
|
||||
// only (partial predicate not expressible in Prisma).
|
||||
@@map("mood_entries")
|
||||
}
|
||||
|
||||
@@ -3730,6 +3914,21 @@ model AppSettings {
|
||||
adminAiModel String @default("gpt-4o-mini") @map("admin_ai_model")
|
||||
adminAiBaseUrl String @default("https://api.openai.com/v1") @map("admin_ai_base_url")
|
||||
|
||||
// Operator-shared central Codex (ChatGPT subscription) OAuth credential. The
|
||||
// operator connects ONE signed-in ChatGPT account here (device-code flow, same
|
||||
// mechanism as the per-user `codex*Encrypted` columns) and any user who flips
|
||||
// the per-user `useCentralCodex` opt-in routes their AI egress through it. The
|
||||
// three token pieces (access, refresh, and the mandatory `ChatGPT-Account-ID`
|
||||
// claim) are each AES-256-GCM encrypted at rest. OFF until the operator
|
||||
// connects it; the shared account's rate limits + training-by-default caveat
|
||||
// apply to every opted-in user (see the admin connect UI's honesty copy).
|
||||
adminCodexAccessTokenEncrypted String? @map("admin_codex_access_token_encrypted")
|
||||
adminCodexRefreshTokenEncrypted String? @map("admin_codex_refresh_token_encrypted")
|
||||
adminCodexAccountIdEncrypted String? @map("admin_codex_account_id_encrypted")
|
||||
adminCodexTokenExpiresAt DateTime? @map("admin_codex_token_expires_at")
|
||||
adminCodexConnectedAt DateTime? @map("admin_codex_connected_at")
|
||||
adminCodexConnectionStatus String @default("disconnected") @map("admin_codex_connection_status")
|
||||
|
||||
// v1.4.16 phase B5e — daily-aggregated rec-feedback summary written
|
||||
// by `feedback-aggregator.ts`. Shape:
|
||||
// {
|
||||
@@ -3951,6 +4150,12 @@ model ImportJob {
|
||||
exportedAt DateTime? @map("exported_at")
|
||||
startedAt DateTime @default(now()) @map("started_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
/// Bumped on every write, including each mid-run progress tick. Acts
|
||||
/// as a liveness heartbeat: `reconcileOrphanImportJobs` treats a
|
||||
/// non-terminal row whose heartbeat has gone stale as orphaned, so a
|
||||
/// job still actively parsing in another worker (fresh heartbeat) is
|
||||
/// never flipped to `failed` (issue #486 follow-up).
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
/// JSON envelope mirroring `ImportJobProgress` (`src/lib/measurements/
|
||||
/// import-apple-health-export.ts`). Updated mid-run.
|
||||
progress Json @default("{}")
|
||||
@@ -4229,6 +4434,10 @@ model LabResult {
|
||||
@@index([userId, analyte, takenAt])
|
||||
@@index([userId, deletedAt])
|
||||
@@index([biomarkerId])
|
||||
// `lab_results_biomarker_backfill_idx` is a partial index over `(user_id)
|
||||
// WHERE deleted_at IS NULL AND biomarker_id IS NULL` — see migration 0243
|
||||
// (v1.28.46 H4), serving the lab-biomarker boot-discovery `SELECT DISTINCT
|
||||
// user_id`. Raw SQL only (partial predicate not expressible in Prisma).
|
||||
@@map("lab_results")
|
||||
}
|
||||
|
||||
@@ -4268,8 +4477,11 @@ model Biomarker {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
results LabResult[]
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
results LabResult[]
|
||||
/// v1.28 — medications the user has pinned to this biomarker as their
|
||||
/// efficacy target. SetNull on delete keeps the override rows as orphans.
|
||||
efficacyTargets MedicationEfficacyTarget[]
|
||||
|
||||
@@unique([userId, name])
|
||||
@@index([userId])
|
||||
@@ -4475,27 +4687,53 @@ model CoachConversation {
|
||||
summaryUpdatedAt DateTime? @map("summary_updated_at")
|
||||
summaryTurnCount Int @default(0) @map("summary_turn_count")
|
||||
|
||||
// v1.27.33 (Document vault P4 — chat about a document) — the discriminator
|
||||
// that turns a Coach conversation into a DOCUMENT chat. NULL = a normal Coach
|
||||
// conversation (health-record surface, snapshot + tools). Non-NULL = a chat
|
||||
// scoped to exactly one stored document: its only context is that document's
|
||||
// text, no health snapshot, no tools. The two never mix — the Coach rail
|
||||
// filters `documentId: null` and the document sheet filters `documentId: <id>`.
|
||||
// Cascades on document delete so a discarded document takes its chats with it.
|
||||
documentId String? @map("document_id")
|
||||
// v1.29.x (S7 — attach multiple documents to a coach conversation) — PERMANENT
|
||||
// fence marker. Set true when the conversation is created via the fenced
|
||||
// endpoint or when the FIRST document is attached. NO code path ever sets it
|
||||
// back to false — detaching every document, or a document being deleted,
|
||||
// leaves it true. A conversation whose history may contain document-derived
|
||||
// text must never regain the tool loop. The LIVE attachment set lives in the
|
||||
// `CoachConversationDocument` join table; this sticky flag is the fail-closed
|
||||
// half of the fenced-only discriminator (see the dual predicate on the tool
|
||||
// route). Replaces the pre-S7 scalar `documentId` discriminator, which could
|
||||
// only express one document and would UNFENCE on that document's delete.
|
||||
documentScoped Boolean @default(false) @map("document_scoped")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
document InboundDocument? @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
messages CoachMessage[]
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
messages CoachMessage[]
|
||||
attachments CoachConversationDocument[]
|
||||
|
||||
@@index([userId, updatedAt])
|
||||
// v1.27.33 — the document sheet lists a document's chats newest-first; the
|
||||
// Coach rail lists `documentId IS NULL` newest-first. This composite covers
|
||||
// both scans without a separate index per surface.
|
||||
@@index([userId, documentId, updatedAt])
|
||||
// v1.29.x (S7) — the rail lists health threads + fenced threads newest-first
|
||||
// and the tool-route send path filters on the flag. Replaces the retired
|
||||
// [userId, documentId, updatedAt] composite.
|
||||
@@index([userId, documentScoped, updatedAt])
|
||||
@@map("coach_conversations")
|
||||
}
|
||||
|
||||
/// v1.29.x (S7) — a document attached to a coach conversation. The EXISTENCE of
|
||||
/// any row for a conversation is one half of the fenced-only discriminator (the
|
||||
/// other is `CoachConversation.documentScoped`). Composite PK so the SQL
|
||||
/// backfill needs no client-side id generation and duplicate attaches are
|
||||
/// impossible by construction. Two cascades:
|
||||
/// - from DOCUMENT: deleting a vault document removes its join rows (its text
|
||||
/// leaves every conversation's context) but NEVER the conversation — a
|
||||
/// fenced thread survives via the sticky flag.
|
||||
/// - from CONVERSATION: deleting a chat removes its attachment rows, never the
|
||||
/// documents.
|
||||
model CoachConversationDocument {
|
||||
conversationId String @map("conversation_id")
|
||||
documentId String @map("document_id")
|
||||
addedAt DateTime @default(now()) @map("added_at")
|
||||
|
||||
conversation CoachConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||
document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([conversationId, documentId])
|
||||
@@index([documentId]) // document-sheet "chats about this doc" scan + path-id join filter
|
||||
@@map("coach_conversation_documents")
|
||||
}
|
||||
|
||||
// One message in a Coach conversation. `role` is "user" or "assistant"
|
||||
// — same shape as the OpenAI/Anthropic chat-completion APIs.
|
||||
//
|
||||
@@ -4869,6 +5107,15 @@ model ClinicianShareLink {
|
||||
resourceTypes String[] @map("resource_types")
|
||||
/// Whether the FHIR API is reachable via this link at all (vs the view only).
|
||||
allowFhirApi Boolean @default(false) @map("allow_fhir_api")
|
||||
/// v1.28.16 — the authoritative "documents-only" flag, frozen write-once at
|
||||
/// create. When true the public view serves ONLY the attached documents and
|
||||
/// the aggregator is never called, INDEPENDENT of `sectionsJson`. Decoupling
|
||||
/// the guarantee from "are all section flags off?" means a future report
|
||||
/// section added to the prefs shape can never silently re-open an old
|
||||
/// documents-only link (the previous derived check would have leaked it once
|
||||
/// the new key defaulted on). Legacy links (pre-column) keep the derived
|
||||
/// all-off fallback in the view loader.
|
||||
documentOnly Boolean @default(false) @map("document_only")
|
||||
|
||||
/// REQUIRED — no never-expiring share (capped at `SHARE_LINK_MAX_DAYS`).
|
||||
expiresAt DateTime @map("expires_at")
|
||||
@@ -5814,9 +6061,9 @@ enum ExtractedFactStatus {
|
||||
}
|
||||
|
||||
model InboundDocument {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
kind InboundDocumentKind @default(OTHER)
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
kind InboundDocumentKind @default(OTHER)
|
||||
/// User-given label. Deliberately plaintext (like `filename`) so the library
|
||||
/// list can ILIKE-search + ORDER BY in SQL. The document BODY stays
|
||||
/// encrypted; only this short, user-authored title is clear-text. Tradeoff:
|
||||
@@ -5824,18 +6071,18 @@ model InboundDocument {
|
||||
/// cleartext at rest / in backups — the same exposure the pre-existing
|
||||
/// plaintext `filename` already carries. Accepted to keep server-side search
|
||||
/// and sort.
|
||||
title String?
|
||||
title String?
|
||||
/// Original filename as uploaded (sanitised, optional).
|
||||
filename String?
|
||||
filename String?
|
||||
/// Magic-byte-sniffed MIME type the document was stored as.
|
||||
mimeType String @map("mime_type")
|
||||
mimeType String @map("mime_type")
|
||||
/// Stored byte length of the original (pre-encryption) document.
|
||||
byteSize Int @map("byte_size")
|
||||
byteSize Int @map("byte_size")
|
||||
/// AES-256-GCM ciphertext of the raw document bytes (base64 of the binary
|
||||
/// → `encrypt()` string → UTF-8 BYTEA), the `*_encrypted` Bytes convention
|
||||
/// shared with the note columns. The original is sensitive and never
|
||||
/// stored in the clear or logged.
|
||||
contentEncrypted Bytes @map("content_encrypted")
|
||||
contentEncrypted Bytes @map("content_encrypted")
|
||||
/// Lowercase-hex SHA-256 of the PLAINTEXT document bytes, computed at
|
||||
/// upload for same-user duplicate detection. Nullable: rows stored before
|
||||
/// the vault release are not backfilled (only new uploads dedupe). A
|
||||
@@ -5844,42 +6091,59 @@ model InboundDocument {
|
||||
/// CREATE UNIQUE INDEX inbound_documents_user_sha_live
|
||||
/// ON inbound_documents (user_id, content_sha256)
|
||||
/// WHERE deleted_at IS NULL AND content_sha256 IS NOT NULL;
|
||||
contentSha256 String? @map("content_sha256") @db.VarChar(64)
|
||||
contentSha256 String? @map("content_sha256") @db.VarChar(64)
|
||||
/// Storage codec of `contentEncrypted`. "base64v1" = legacy string path
|
||||
/// (base64-of-binary → `encrypt()` string → UTF-8 bytes); "binary2" = the
|
||||
/// binary AES-256-GCM layout written by `encryptBytes()` (no base64
|
||||
/// detour). Explicit column, never header-sniffed; the default keeps every
|
||||
/// pre-existing row correct.
|
||||
contentCodec String @default("base64v1") @map("content_codec")
|
||||
status InboundDocumentStatus @default(STORED)
|
||||
contentCodec String @default("base64v1") @map("content_codec")
|
||||
status InboundDocumentStatus @default(STORED)
|
||||
/// Provider type that ran the extraction (e.g. "anthropic", "openai",
|
||||
/// "local"). Diagnostic only. Null on a store-only document.
|
||||
providerType String? @map("provider_type")
|
||||
providerType String? @map("provider_type")
|
||||
/// The document's stated report/collection date (YYYY-MM-DD at UTC), as
|
||||
/// transcribed by the model — never inferred, never user-set.
|
||||
reportDate DateTime? @map("report_date")
|
||||
reportDate DateTime? @map("report_date")
|
||||
/// Effective filing date the library sorts, groups, and range-filters on
|
||||
/// (editable). Distinct from `reportDate`, which is the model-transcribed
|
||||
/// value. On store it defaults to the upload day (now) when the user does
|
||||
/// not supply one, so a stored row's filing date is never NULL and display
|
||||
/// == sort == filter. It stays user-editable and MAY be cleared back to
|
||||
/// NULL via PATCH (the list then sorts such rows last).
|
||||
documentDate DateTime? @map("document_date")
|
||||
documentDate DateTime? @map("document_date")
|
||||
/// Failure reason when status = FAILED (a short code, never the document).
|
||||
errorReason String? @map("error_reason")
|
||||
errorReason String? @map("error_reason")
|
||||
/// AES-256-GCM ciphertext of a short (3-4 sentence) plain-language summary
|
||||
/// of WHAT the document is, generated ONCE in the background right after
|
||||
/// upload when the user's `documentsAutoAiRead` opt-in is ON (that flag is
|
||||
/// also the standing AI-egress consent). Absent when auto-read is OFF, no
|
||||
/// provider is configured, or generation has not run yet. Descriptive only —
|
||||
/// never a diagnosis. `encrypt()`-string-as-UTF-8 BYTEA, the shared Bytes
|
||||
/// codec every other note column uses; PHI, never logged.
|
||||
summaryEncrypted Bytes? @map("summary_encrypted")
|
||||
/// When the background summary was generated (null until it runs). Surfaced
|
||||
/// as meta beside the summary in the document detail view.
|
||||
summaryGeneratedAt DateTime? @map("summary_generated_at")
|
||||
|
||||
facts ExtractedFact[]
|
||||
conditionLinks DocumentConditionLink[]
|
||||
facts ExtractedFact[]
|
||||
conditionLinks DocumentConditionLink[]
|
||||
/// 1:1 blind content-search index (encrypted extracted text + HMAC token
|
||||
/// array). Absent until the document is indexed; cascades on delete.
|
||||
contentIndex DocumentContentIndex?
|
||||
/// v1.27.33 (Document vault P4) — "chat about a document" threads. Each is a
|
||||
/// `CoachConversation` with `documentId` set to this document; cascades on
|
||||
/// delete so a discarded document takes its chats with it.
|
||||
chats CoachConversation[]
|
||||
contentIndex DocumentContentIndex?
|
||||
/// 1:1 encrypted preview thumbnail (small JPEG, AES-256-GCM at rest).
|
||||
/// Absent until the background thumbnail job runs; a side table (not a
|
||||
/// column) so the list/detail `omit: { contentEncrypted }` queries never
|
||||
/// drag the blob into the SELECT. Cascades on delete.
|
||||
thumbnail DocumentThumbnail?
|
||||
/// v1.29.x (S7) — coach-conversation attachments (m:n via
|
||||
/// `CoachConversationDocument`). Deleting this document cascades its join rows
|
||||
/// away (its text leaves every conversation's context) but never the
|
||||
/// conversation itself — a fenced thread survives via its sticky flag.
|
||||
chatAttachments CoachConversationDocument[]
|
||||
/// share-link memberships (m:n via `ClinicianShareLinkDocument`).
|
||||
/// Cascades: deleting this document removes it from every share it was on.
|
||||
shareLinks ClinicianShareLinkDocument[]
|
||||
shareLinks ClinicianShareLinkDocument[]
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
@@ -5894,6 +6158,11 @@ model InboundDocument {
|
||||
// a superset of the plain `(userId, deletedAt)` index, which is therefore
|
||||
// redundant for read planning and dropped.
|
||||
@@index([userId, deletedAt, documentDate])
|
||||
// `inbound_documents_thumbnail_backfill_idx` is a partial index over
|
||||
// `(user_id, mime_type) WHERE deleted_at IS NULL` — see migration 0243
|
||||
// (v1.28.46 H4). It bounds the document-side scan of the thumbnail
|
||||
// boot-discovery (which anti-joins document_thumbnails for the missing
|
||||
// preview). Raw SQL only (partial predicate not expressible in Prisma).
|
||||
@@map("inbound_documents")
|
||||
}
|
||||
|
||||
@@ -6024,3 +6293,87 @@ model DocumentContentIndex {
|
||||
@@index([userId])
|
||||
@@map("document_content_index")
|
||||
}
|
||||
|
||||
/// 1:1 encrypted preview thumbnail for a stored document. A small JPEG
|
||||
/// (~320px long edge, q70) rendered in the background from the original —
|
||||
/// images are decoded + downscaled, a PDF's first page is rasterised — then
|
||||
/// stored encrypted at rest. Held in a side table (not a column on
|
||||
/// `InboundDocument`) so the list/detail queries that `omit` the original
|
||||
/// blob never drag this blob either. The re-encode strips EXIF/GPS from the
|
||||
/// source, so the preview is metadata-free by construction. Owner-scoped
|
||||
/// serve route only; cascades on delete.
|
||||
model DocumentThumbnail {
|
||||
id String @id @default(cuid())
|
||||
documentId String @unique @map("document_id")
|
||||
userId String @map("user_id")
|
||||
/// AES-256-GCM ciphertext of the JPEG preview, stored as the
|
||||
/// `encrypt()`-string-as-UTF-8 Bytes shape (base64 of the JPEG), the same
|
||||
/// codec `DocumentContentIndex.textEncrypted` uses so the standard
|
||||
/// key-rotation walk covers it. A scanned medical preview is PHI — never
|
||||
/// stored in the clear or logged. The list query never selects it.
|
||||
thumbnailEncrypted Bytes @map("thumbnail_encrypted")
|
||||
/// Decoded thumbnail dimensions (px) — diagnostic + lets a future consumer
|
||||
/// size the tile without decoding the blob.
|
||||
width Int
|
||||
height Int
|
||||
/// Plaintext byte length of the JPEG (pre-encryption), for `Content-Length`.
|
||||
byteSize Int @map("byte_size")
|
||||
/// Source MIME the preview was rendered from (diagnostic only).
|
||||
sourceMime String @map("source_mime")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@map("document_thumbnails")
|
||||
}
|
||||
|
||||
// ─── Nutrient intake (v1.28) ───────────────────────────────────────
|
||||
//
|
||||
// Micronutrient daily totals synced from Apple Health — vitamins,
|
||||
// minerals, water, caffeine as supplement-style "substances taken",
|
||||
// never a food diary (energy / macros are out of scope by directive).
|
||||
// One row per (user, local day, nutrient); the composite PK IS the
|
||||
// idempotency key, so an iOS re-post of a grown day total replaces the
|
||||
// row in place (last-writer-wins day-total contract — the `stats:`
|
||||
// overwrite semantics without externalId string parsing). `nutrient`
|
||||
// is a bare string enforced in code against the closed catalog in
|
||||
// `src/lib/nutrients/catalog.ts` (the IntegrationStatus precedent: no
|
||||
// migration to add a code). No soft delete: rows are source-owned day
|
||||
// totals; replace-on-repost is the correction mechanism and account
|
||||
// deletion cascades. Behind the opt-in `nutrients` module
|
||||
// (refuse-ingest-when-off).
|
||||
|
||||
model NutrientIntakeDay {
|
||||
userId String @map("user_id")
|
||||
/// YYYY-MM-DD anchored to the user's IANA timezone (iOS-computed —
|
||||
/// the same day-key contract as the `stats:` externalIds).
|
||||
day String
|
||||
/// Closed catalog code ("vitamin_d", "magnesium", "water", ...).
|
||||
nutrient String
|
||||
/// Daily total in the catalog's canonical unit.
|
||||
amount Float
|
||||
/// Canonical unit echoed from the catalog ("mg" | "ug" | "ml") —
|
||||
/// denormalised on purpose: rows stay self-describing in exports
|
||||
/// even if the catalog ever drifts.
|
||||
unit String
|
||||
/// "APPLE_HEALTH" | "MANUAL" (closed set enforced in app code, not a
|
||||
/// DB enum). v1.29 — joined the composite PK (migration 0249) so a
|
||||
/// manual water entry and the Apple-synced day total coexist as two
|
||||
/// rows instead of the manual write being silently clobbered by the
|
||||
/// next Apple last-writer-wins sync. Apple owns its row (LWW on
|
||||
/// re-post); manual owns its row (increment/set via
|
||||
/// `POST /api/nutrients/water`); reads SUM across sources per day.
|
||||
source String @default("APPLE_HEALTH")
|
||||
externalSourceVersion String? @map("external_source_version")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([userId, day, nutrient, source])
|
||||
@@index([userId, nutrient, day(sort: Desc)])
|
||||
@@map("nutrient_intake_days")
|
||||
}
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.28.2";
|
||||
/* @sw-version-fallback */ "v1.30.2";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
+52
-12
@@ -37,17 +37,9 @@
|
||||
},
|
||||
{
|
||||
"name": "Withings OAuth",
|
||||
"description": "Required to enable the Withings integration. App boots without these but Withings sync stays disabled.",
|
||||
"description": "Withings API credentials are entered per user in Settings → Integrations (no env-var path). Only the webhook shared secret is operator-level.",
|
||||
"required": false,
|
||||
"variables": [
|
||||
{
|
||||
"name": "WITHINGS_CLIENT_ID",
|
||||
"purpose": "Withings OAuth client_id from https://developer.withings.com."
|
||||
},
|
||||
{
|
||||
"name": "WITHINGS_CLIENT_SECRET",
|
||||
"purpose": "Withings OAuth client_secret."
|
||||
},
|
||||
{
|
||||
"name": "WITHINGS_WEBHOOK_SECRET",
|
||||
"purpose": "Random secret carried as a path segment in the Withings webhook callback URL. Generate with `openssl rand -hex 32`."
|
||||
@@ -86,6 +78,45 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "OIDC SSO login",
|
||||
"description": "Required to enable SSO login against a self-hosted identity provider (Authentik, Keycloak, Authelia, Google Workspace, etc.). App boots without these but the login page's SSO button stays hidden. Register a confidential client with redirect URI ${NEXT_PUBLIC_APP_URL}/api/auth/oidc/callback.",
|
||||
"required": false,
|
||||
"allOrNone": true,
|
||||
"variables": [
|
||||
{
|
||||
"name": "OIDC_ISSUER_URL",
|
||||
"purpose": "OIDC provider issuer URL (discovery doc served at <issuer>/.well-known/openid-configuration)."
|
||||
},
|
||||
{
|
||||
"name": "OIDC_CLIENT_ID",
|
||||
"purpose": "OIDC confidential client id."
|
||||
},
|
||||
{
|
||||
"name": "OIDC_CLIENT_SECRET",
|
||||
"purpose": "OIDC confidential client secret."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "OIDC SSO options",
|
||||
"description": "Optional tuning for an enabled OIDC provider. Each stands alone (deliberately NOT allOrNone — any subset is valid) and each has a safe default when unset.",
|
||||
"required": false,
|
||||
"variables": [
|
||||
{
|
||||
"name": "OIDC_SCOPES",
|
||||
"purpose": "Space-separated scopes requested at authorization. Default: \"openid email profile\"."
|
||||
},
|
||||
{
|
||||
"name": "OIDC_BUTTON_LABEL",
|
||||
"purpose": "Label on the login page's SSO button. Default: \"Single Sign-On\"."
|
||||
},
|
||||
{
|
||||
"name": "OIDC_ONLY",
|
||||
"purpose": "\"true\" disables password/passkey login server-side (only effective when the OIDC provider group is fully configured). Default: additive login. Locks the native iOS app out of sign-in."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "APNs (iOS push)",
|
||||
"description": "All four core APNS_* vars together. Missing any disables APNs silently — the v1.4.40 AP-2 gap that motivated this manifest. allOrNone trips the synthetic <all-or-none> required row whenever 1-3 of the 4 are set.",
|
||||
@@ -167,9 +198,18 @@
|
||||
"name": "BACKUP_S3_ENDPOINT",
|
||||
"purpose": "S3-compatible endpoint URL."
|
||||
},
|
||||
{ "name": "BACKUP_S3_BUCKET", "purpose": "Bucket name." },
|
||||
{ "name": "BACKUP_S3_ACCESS_KEY", "purpose": "S3 access key." },
|
||||
{ "name": "BACKUP_S3_SECRET_KEY", "purpose": "S3 secret key." },
|
||||
{
|
||||
"name": "BACKUP_S3_BUCKET",
|
||||
"purpose": "Bucket name."
|
||||
},
|
||||
{
|
||||
"name": "BACKUP_S3_ACCESS_KEY",
|
||||
"purpose": "S3 access key."
|
||||
},
|
||||
{
|
||||
"name": "BACKUP_S3_SECRET_KEY",
|
||||
"purpose": "S3 secret key."
|
||||
},
|
||||
{
|
||||
"name": "BACKUP_ENCRYPTION_KEY",
|
||||
"purpose": "Separate AES key for the backup body — MUST NOT match ENCRYPTION_KEY."
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* scripts/repair-google-health-sleep.ts — operator repair for the historic
|
||||
* Google Health sleep OVER-COUNT.
|
||||
*
|
||||
* THE DEFECT (fixed in code from v1.28.18): the per-segment sleep externalId was
|
||||
* keyed on a VOLATILE anchor (the session's `interval.endTime` plus a positional
|
||||
* segment index). When Google re-scored a night after the fact — which the sync
|
||||
* deliberately re-fetches on a 24 h overlap — the shifted anchor/index minted
|
||||
* FRESH externalIds, so the upsert created a SECOND parallel set of stage rows
|
||||
* for the same night instead of overwriting. The night-total then summed both
|
||||
* copies (a real 7h35 night could read as 10h+).
|
||||
*
|
||||
* THE CODE FIX anchors the externalId on Google's stable resource `name` plus
|
||||
* the segment's own start, and — via `replaceStaleGoogleHealthSleep` —
|
||||
* soft-deletes any stale row left in a re-fetched night's window before the
|
||||
* fresh set upserts. That makes every FUTURE sync idempotent and self-healing
|
||||
* for any night inside the incremental overlap.
|
||||
*
|
||||
* THIS SCRIPT heals HISTORY: nights older than the incremental window keep their
|
||||
* duplicate rows until re-fetched. It runs a FULL Google Health re-sync per
|
||||
* connected user, which re-imports every sleep session and lets the
|
||||
* replace-by-window cleanup collapse each night to a single canonical set (and
|
||||
* re-folds the rollup tier). It reuses the production sync path verbatim — no
|
||||
* bespoke SQL — so it can only do what an ordinary re-sync does.
|
||||
*
|
||||
* SAFETY: dry-run by default (lists the connections that WOULD be re-synced and
|
||||
* exits). Pass `--run` to execute. Runs users sequentially to avoid a burst
|
||||
* against Google's API. A per-user failure is reported and skipped, never fatal.
|
||||
*
|
||||
* RUN (never `pnpm tsx` — the standalone image strips tsx):
|
||||
* pnpm dlx tsx scripts/repair-google-health-sleep.ts # dry run
|
||||
* pnpm dlx tsx scripts/repair-google-health-sleep.ts --run # execute
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { syncUserGoogleHealth } from "@/lib/google-health/sync";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const execute = process.argv.includes("--run");
|
||||
|
||||
const connections = await prisma.googleHealthConnection.findMany({
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
if (connections.length === 0) {
|
||||
console.log("No Google Health connections found — nothing to repair.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${connections.length} Google Health connection(s) found.` +
|
||||
(execute
|
||||
? " Running a full re-sync per user (sequential)…"
|
||||
: " Dry run — pass --run to execute. Would re-sync:"),
|
||||
);
|
||||
|
||||
if (!execute) {
|
||||
for (const { userId } of connections) console.log(` • ${userId}`);
|
||||
console.log(
|
||||
"\nA full re-sync re-imports all Google Health data for each user and " +
|
||||
"collapses each re-scored night to one canonical set. Re-run with --run.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let ok = 0;
|
||||
let failed = 0;
|
||||
for (const { userId } of connections) {
|
||||
try {
|
||||
const imported = await syncUserGoogleHealth(userId, { fullSync: true });
|
||||
ok += 1;
|
||||
console.log(` ✓ ${userId} — re-synced (${imported} rows imported)`);
|
||||
} catch (err) {
|
||||
failed += 1;
|
||||
console.error(
|
||||
` ✗ ${userId} — re-sync failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nDone. ${ok} re-synced, ${failed} failed.`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -168,7 +168,9 @@ async function main() {
|
||||
// "polarUserIdEncrypted" "polarClientIdEncrypted"
|
||||
// "polarClientSecretEncrypted" "ouraAccessTokenEncrypted"
|
||||
// "ouraRefreshTokenEncrypted" "ouraClientIdEncrypted"
|
||||
// "ouraClientSecretEncrypted" "aiAnthropicKeyEncrypted"
|
||||
// "ouraClientSecretEncrypted" "stravaClientIdEncrypted"
|
||||
// "stravaClientSecretEncrypted" "stravaAccessTokenEncrypted"
|
||||
// "stravaRefreshTokenEncrypted" "aiAnthropicKeyEncrypted"
|
||||
// "aiLocalKeyEncrypted" "aiOpenaiKeyEncrypted" "insuranceNumberEncrypted"
|
||||
const userFields = [
|
||||
"codexAccessTokenEncrypted",
|
||||
@@ -195,10 +197,13 @@ async function main() {
|
||||
"ouraRefreshTokenEncrypted",
|
||||
"ouraClientIdEncrypted",
|
||||
"ouraClientSecretEncrypted",
|
||||
"stravaClientIdEncrypted",
|
||||
"stravaClientSecretEncrypted",
|
||||
"stravaAccessTokenEncrypted",
|
||||
"stravaRefreshTokenEncrypted",
|
||||
"aiAnthropicKeyEncrypted",
|
||||
"aiLocalKeyEncrypted",
|
||||
"aiOpenaiKeyEncrypted",
|
||||
"aiOcrKeyEncrypted",
|
||||
"insuranceNumberEncrypted",
|
||||
// v1.23 — TOTP shared secret (second factor).
|
||||
"totpSecretEncrypted",
|
||||
@@ -241,9 +246,14 @@ async function main() {
|
||||
|
||||
// ───── AppSettings — operator credentials (String) ─────
|
||||
// Columns: "adminAiKeyEncrypted" "webPushVapidPrivateKeyEncrypted"
|
||||
// "adminCodexAccessTokenEncrypted" "adminCodexRefreshTokenEncrypted"
|
||||
// "adminCodexAccountIdEncrypted"
|
||||
for (const field of [
|
||||
"adminAiKeyEncrypted",
|
||||
"webPushVapidPrivateKeyEncrypted",
|
||||
"adminCodexAccessTokenEncrypted",
|
||||
"adminCodexRefreshTokenEncrypted",
|
||||
"adminCodexAccountIdEncrypted",
|
||||
]) {
|
||||
results.push(
|
||||
await rotateStringColumn("AppSettings", field, prisma.appSettings),
|
||||
@@ -530,6 +540,18 @@ async function main() {
|
||||
prisma.extractedFact,
|
||||
),
|
||||
);
|
||||
// The short background document summary. Small `encrypt()`-string-as-UTF-8
|
||||
// Bytes payload (never the multi-megabyte original blob — the select is
|
||||
// scoped to id + this column), so the generic Bytes walk covers it. NULL on
|
||||
// rows uploaded with auto-read OFF / no provider — `rotateBytesColumn` skips
|
||||
// those.
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"InboundDocument",
|
||||
"summaryEncrypted",
|
||||
prisma.inboundDocument,
|
||||
),
|
||||
);
|
||||
|
||||
// ───── v1.27.22 document content-search index (Bytes text + re-tokenise) ─────
|
||||
// The blind content index carries TWO coupled artefacts under the index key
|
||||
@@ -609,6 +631,19 @@ async function main() {
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// ───── Document preview thumbnails (Bytes column) ─────
|
||||
// The small JPEG preview, stored as the `encrypt()`-string-as-UTF-8 Bytes
|
||||
// shape (base64 of the JPEG) — the same codec as the content index — so the
|
||||
// shared Bytes walk re-encrypts it under the active key. NOT NULL, so every
|
||||
// thumbnail row rotates.
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"DocumentThumbnail",
|
||||
"thumbnailEncrypted",
|
||||
prisma.documentThumbnail,
|
||||
),
|
||||
);
|
||||
|
||||
console.log("\n=== Rotation summary ===");
|
||||
let totalRotated = 0;
|
||||
let totalErrors = 0;
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { resolveKey } from "@/lib/i18n/resolve-key";
|
||||
import {
|
||||
ILLNESS_SCAN_TYPES,
|
||||
FUNCTIONAL_IMPACT_RETURN_KEY,
|
||||
} from "@/lib/illness/correlation";
|
||||
import {
|
||||
illnessTypeEnum,
|
||||
illnessLifecycleEnum,
|
||||
} from "@/lib/validations/illness";
|
||||
import {
|
||||
allergyCategoryEnum,
|
||||
allergyTypeEnum,
|
||||
allergySeverityEnum,
|
||||
allergyStatusEnum,
|
||||
} from "@/lib/validations/allergy";
|
||||
import { familyRelationshipEnum } from "@/lib/validations/family-history";
|
||||
import { INSTRUMENTS } from "@/lib/mental-health/instruments";
|
||||
|
||||
/**
|
||||
* Enum-driven exhaustiveness guard for DYNAMICALLY-built i18n keys.
|
||||
*
|
||||
* The three existing i18n guards each see one face of the problem and miss the
|
||||
* fourth:
|
||||
* - `i18n-call-site-coverage.test.ts` sees only literal `t("ns.key")` calls,
|
||||
* so a runtime-assembled `t(\`ns.${x}\`)` key is invisible to it.
|
||||
* - `i18n-reverse-coverage.test.ts` sees bundle orphans, but treats any
|
||||
* `ns.${x}` template as covering its whole subtree — so a MEMBER missing
|
||||
* from the subtree is invisible.
|
||||
* - `i18n-locale-integrity.test.ts` sees cross-locale parity, so it only
|
||||
* fires when a key is present in SOME locale but not all.
|
||||
*
|
||||
* None of them assert the class that actually ships broken: "for enum E, every
|
||||
* member's dynamically-built key resolves, non-empty, in every locale." Because
|
||||
* parity is perfect, a key missing from ONE locale is missing from ALL SIX at
|
||||
* once — exactly the seam that let `measurementReminders.types.{WHO5_SCORE,
|
||||
* SCI_SCORE,WAIST_CIRCUMFERENCE}` (v1.28.38) and `illness.vital.FUNCTIONAL_IMPACT`
|
||||
* (v1.28.41) ship absent from every bundle.
|
||||
*
|
||||
* This test derives the EXPECTED key set from the SOURCE enum/const and asserts
|
||||
* every `prefix + member` resolves. Adding an enum member without a bundle key
|
||||
* fails CI by construction — no hand-maintained floor to drift.
|
||||
*
|
||||
* ── Covered key spaces (source is a clean, finite, import-safe enum/const) ──
|
||||
* - illness.vital.* ILLNESS_SCAN_TYPES ∪ FUNCTIONAL_IMPACT_RETURN_KEY
|
||||
* - illness.type.* illnessTypeEnum
|
||||
* - illness.lifecycle.* illnessLifecycleEnum
|
||||
* - records.allergies.category.* allergyCategoryEnum
|
||||
* - records.allergies.type.* allergyTypeEnum
|
||||
* - records.allergies.severity.* allergySeverityEnum ∪ "NONE" sentinel
|
||||
* - records.allergies.status.* allergyStatusEnum
|
||||
* - records.family.relationship.* familyRelationshipEnum
|
||||
* - mentalHealth.instrument.* } INSTRUMENTS i18nKey slugs
|
||||
* - mentalHealth.instrumentSub.* } (PHQ-9 / GAD-7 / WHO-5 / SCI —
|
||||
* - mentalHealth.instrumentDescription.* } the WHO-5/SCI class that bit before)
|
||||
*
|
||||
* ── Deliberately EXCLUDED (would produce false positives) ──
|
||||
* - `insights.workouts.sport.*` — the Strava render path stores a raw
|
||||
* `sport_type` and the render site legitimately falls back to that raw
|
||||
* string, so a "missing" key is a graceful degradation, not a broken token
|
||||
* (audit LOW #2). Asserting it would fail on a path that is by-design lax.
|
||||
* - `settings.sections.*.description`, `settings.ai.providerChain.types.admin-codex`,
|
||||
* `insights.<metric>.emptyState.cta` — structurally unreachable behind a
|
||||
* tsc-exhaustive map / a `!= null` guard / a server-only filter (audit LOW).
|
||||
* Registering them would demand bundle keys for code paths users never hit.
|
||||
* - `measurementReminders.types.*` — already exhaustively covered by
|
||||
* `measurement-reminders/__tests__/type-labels-i18n.test.ts`; its source
|
||||
* const lives in a client component, not a lib-level export.
|
||||
* - Prisma cycle enums, medication/document/lab/nutrient catalogs — clean
|
||||
* enums the audit flags as candidates; left for a follow-up extension. Add
|
||||
* them here once their exact prefix→member mapping is confirmed 1:1 (the
|
||||
* cycle cervix* prefixes and catalog slug maps need per-space verification
|
||||
* to stay false-positive-free).
|
||||
*/
|
||||
|
||||
const MESSAGES_DIR = join(__dirname, "../../messages");
|
||||
|
||||
const LOCALES = readdirSync(MESSAGES_DIR)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => ({
|
||||
locale: f.replace(/\.json$/, ""),
|
||||
messages: JSON.parse(readFileSync(join(MESSAGES_DIR, f), "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
}));
|
||||
|
||||
/** PHQ-9 / GAD-7 / WHO-5 / SCI lowercase i18n slugs, straight from the source. */
|
||||
const INSTRUMENT_SLUGS = Object.values(INSTRUMENTS).map((i) => i.i18nKey);
|
||||
|
||||
interface KeySpace {
|
||||
/** The dotted prefix the key is built from (`t(\`prefix.${member}\`)`). */
|
||||
prefix: string;
|
||||
/** The finite member set, derived from the SOURCE enum/const. */
|
||||
members: readonly string[];
|
||||
}
|
||||
|
||||
const REGISTRY: readonly KeySpace[] = [
|
||||
// illness.* — the currently-broken seed (FUNCTIONAL_IMPACT wins recovery-gap ties).
|
||||
{
|
||||
prefix: "illness.vital",
|
||||
members: [...ILLNESS_SCAN_TYPES.map(String), FUNCTIONAL_IMPACT_RETURN_KEY],
|
||||
},
|
||||
{ prefix: "illness.type", members: illnessTypeEnum.options },
|
||||
{ prefix: "illness.lifecycle", members: illnessLifecycleEnum.options },
|
||||
|
||||
// records.allergies.* — Zod enums; severity carries a "NONE" (not-assessed) sentinel.
|
||||
{
|
||||
prefix: "records.allergies.category",
|
||||
members: allergyCategoryEnum.options,
|
||||
},
|
||||
{ prefix: "records.allergies.type", members: allergyTypeEnum.options },
|
||||
{
|
||||
prefix: "records.allergies.severity",
|
||||
members: [...allergySeverityEnum.options, "NONE"],
|
||||
},
|
||||
{ prefix: "records.allergies.status", members: allergyStatusEnum.options },
|
||||
|
||||
// records.family.relationship.*
|
||||
{
|
||||
prefix: "records.family.relationship",
|
||||
members: familyRelationshipEnum.options,
|
||||
},
|
||||
|
||||
// mentalHealth.* — the WHO-5/SCI class whose absence originally motivated this guard.
|
||||
{ prefix: "mentalHealth.instrument", members: INSTRUMENT_SLUGS },
|
||||
{ prefix: "mentalHealth.instrumentSub", members: INSTRUMENT_SLUGS },
|
||||
{ prefix: "mentalHealth.instrumentDescription", members: INSTRUMENT_SLUGS },
|
||||
];
|
||||
|
||||
describe("dynamic-key exhaustiveness (enum-derived)", () => {
|
||||
it("discovers all six shipped locales", () => {
|
||||
expect(LOCALES.map((l) => l.locale).sort()).toEqual([
|
||||
"de",
|
||||
"en",
|
||||
"es",
|
||||
"fr",
|
||||
"it",
|
||||
"pl",
|
||||
]);
|
||||
});
|
||||
|
||||
it("every registered key space has a non-empty member set", () => {
|
||||
for (const space of REGISTRY) {
|
||||
expect(
|
||||
space.members.length,
|
||||
`${space.prefix} resolved to an empty member set — source import broke`,
|
||||
).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
for (const { locale, messages } of LOCALES) {
|
||||
for (const space of REGISTRY) {
|
||||
for (const member of space.members) {
|
||||
const key = `${space.prefix}.${member}`;
|
||||
it(`resolves ${key} in ${locale}`, () => {
|
||||
const value = resolveKey(messages, key);
|
||||
expect(value, `${key} missing in ${locale}.json`).toBeTypeOf(
|
||||
"string",
|
||||
);
|
||||
expect((value ?? "").trim().length).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -104,7 +104,6 @@ const LEGIT_IDENTICAL = new Set<string>([
|
||||
"insights.healthScore.provenance.sources.appleHealth",
|
||||
"measurements.sourceAppleHealth",
|
||||
"onboarding.source.more.appleHealth",
|
||||
"settings.ai.ocrProviderOption.ANTHROPIC",
|
||||
"settings.ai.providerChain.types.anthropic",
|
||||
"settings.ai.providerChain.types.codex",
|
||||
"settings.ai.providerSelect.anthropic",
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* v1.28 — banned-verb guard for the `medications.efficacy.*` namespace.
|
||||
*
|
||||
* The medication-efficacy view ("Wirkung") is a medication-adjacent claim, so
|
||||
* its safety boundary is structural, not left to reviewer vigilance: the DTO
|
||||
* carries no verdict / score field, and the copy must stay strictly
|
||||
* descriptive — an association, never "the drug works / is effective", never a
|
||||
* dose change. This test walks every efficacy string in all six locales and
|
||||
* fails if any value contains a verdict or dose-advice verb, so a forbidden
|
||||
* word can never creep into the bundle. Mirrors the `i18n-english-leak-guard`
|
||||
* flatten + filter + `toEqual([])` pattern.
|
||||
*
|
||||
* If this fails: rewrite the offending string to descriptive phrasing (numbers
|
||||
* + neutral connectives). Do NOT weaken the denylist to pass.
|
||||
*/
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
const MESSAGES = join(__dirname, "..", "..", "messages");
|
||||
const LOCALES = readdirSync(MESSAGES).filter((f) => f.endsWith(".json"));
|
||||
|
||||
/** Verdict + efficacy-adjective + dose-advice verbs across the six locales. */
|
||||
const BANNED: RegExp[] = [
|
||||
// English
|
||||
/\bworks?\b/i,
|
||||
/\bworking\b/i,
|
||||
/\beffective(ly)?\b/i,
|
||||
/\bineffective\b/i,
|
||||
/\bcures?\b/i,
|
||||
/\bcured\b/i,
|
||||
/\bheals?\b/i,
|
||||
/\bhealed\b/i,
|
||||
/\bimprove(d|s|ment)?\b/i,
|
||||
/\bworsen(ed|s)?\b/i,
|
||||
/\bbetter\b/i,
|
||||
/\bworse\b/i,
|
||||
/\bincrease\b/i,
|
||||
/\bdecrease\b/i,
|
||||
/\breduce\b/i,
|
||||
/\braise\b/i,
|
||||
/\bsuccess(ful)?\b/i,
|
||||
// German
|
||||
/\bwirksam\b/i,
|
||||
/\bunwirksam\b/i,
|
||||
/\banschlägt\b/i,
|
||||
/\bheilt\b/i,
|
||||
/\bverbessert\b/i,
|
||||
/\bverschlechtert\b/i,
|
||||
/\berhöhen\b/i,
|
||||
/\bsenken\b/i,
|
||||
/\babsetzen\b/i,
|
||||
// Spanish
|
||||
/\bfunciona\b/i,
|
||||
/\beficaz\b/i,
|
||||
/\bineficaz\b/i,
|
||||
/\bmejora\b/i,
|
||||
/\bempeora\b/i,
|
||||
// French
|
||||
/\befficace\b/i,
|
||||
/\binefficace\b/i,
|
||||
/\baméliore\b/i,
|
||||
/\baggrave\b/i,
|
||||
// Italian
|
||||
/\bmigliora\b/i,
|
||||
/\bpeggiora\b/i,
|
||||
// Polish
|
||||
/\bskuteczn\w*\b/i,
|
||||
/\bpoprawia\b/i,
|
||||
/\bpogarsza\b/i,
|
||||
];
|
||||
|
||||
function flatten(obj: unknown, prefix: string, out: [string, string][]): void {
|
||||
if (obj === null || typeof obj !== "object") return;
|
||||
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
||||
const key = prefix ? `${prefix}.${k}` : k;
|
||||
if (typeof v === "string") out.push([key, v]);
|
||||
else flatten(v, key, out);
|
||||
}
|
||||
}
|
||||
|
||||
describe("medications.efficacy — banned verdict/advice verbs", () => {
|
||||
it.each(LOCALES)("%s carries no forbidden verb", (file) => {
|
||||
const bundle = JSON.parse(readFileSync(join(MESSAGES, file), "utf8")) as {
|
||||
medications?: { efficacy?: unknown };
|
||||
};
|
||||
const efficacy = bundle.medications?.efficacy;
|
||||
expect(efficacy, `${file} is missing medications.efficacy`).toBeDefined();
|
||||
|
||||
const flat: [string, string][] = [];
|
||||
flatten(efficacy, "medications.efficacy", flat);
|
||||
expect(flat.length).toBeGreaterThan(0);
|
||||
|
||||
const violations = flat
|
||||
.filter(([, value]) => BANNED.some((re) => re.test(value)))
|
||||
.map(([key, value]) => `${key} = ${JSON.stringify(value)}`);
|
||||
|
||||
expect(
|
||||
violations,
|
||||
`Forbidden verdict/advice verb in ${file} — the efficacy view is ` +
|
||||
`association-only. Rewrite to descriptive phrasing:\n` +
|
||||
violations.map((v) => ` ${v}`).join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -138,8 +138,10 @@ describe("TrendCardSkeleton", () => {
|
||||
// Same outer chrome + min-height floor as the real TrendCard slot.
|
||||
expect(html).toContain("rounded-xl");
|
||||
expect(html).toContain("min-h-[8rem]");
|
||||
// Reduced motion honoured via the Skeleton primitive.
|
||||
expect(html).toContain("motion-reduce:animate-none");
|
||||
// Reduced motion honoured via the Skeleton primitive's shimmer class
|
||||
// (its prefers-reduced-motion guard lives beside the keyframes in
|
||||
// globals.css).
|
||||
expect(html).toContain("skeleton-shimmer");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,19 +5,16 @@ import { join } from "node:path";
|
||||
/**
|
||||
* Dashboard hero — page + header wiring pins.
|
||||
*
|
||||
* The hero band mounts between the page header and the tile strip:
|
||||
* The legacy opt-in `DashboardHero` (gated on a layout `heroVisible`
|
||||
* flag) was retired: the digest-driven `TodayHero` is the single,
|
||||
* unambiguous hero, mounted above the tile strip. Pinned here:
|
||||
*
|
||||
* - it gates on the layout's `heroVisible` flag (only the literal
|
||||
* `true` renders — the hero is opt-in) AND on snapshot mode (the
|
||||
* verdict needs the snapshot payload);
|
||||
* - while `primaryLoading` is true (same `!mounted ||` pin the tile
|
||||
* silhouettes use) a footprint-identical skeleton holds the band,
|
||||
* so the hero and the tiles swap to content in the SAME render
|
||||
* pass;
|
||||
* - the greeting paragraph lives in the hero when that renders; when
|
||||
* the hero does NOT render (snapshot flag off, or hidden via the
|
||||
* layout toggle) the header takes it back behind `showGreeting`,
|
||||
* fed from the page's hero gate — the greeting never disappears.
|
||||
* - the page mounts `TodayHero` and no longer references the legacy
|
||||
* hero, its skeleton, or the `heroVisible` gate;
|
||||
* - the greeting paragraph lives in the header on every mount (the
|
||||
* header owns it unconditionally now that the legacy hero — which
|
||||
* once carried it — is gone), while the page itself derives no
|
||||
* welcome text.
|
||||
*/
|
||||
const PAGE_PATH = join(process.cwd(), "src/app/page-client.tsx");
|
||||
const HEADER_PATH = join(
|
||||
@@ -28,60 +25,44 @@ const HEADER_PATH = join(
|
||||
describe("dashboard hero — page wiring", () => {
|
||||
const src = readFileSync(PAGE_PATH, "utf8");
|
||||
|
||||
it("gates the hero on snapshot mode + the layout's heroVisible flag", () => {
|
||||
expect(src).toMatch(
|
||||
/const\s+heroVisible\s*=\s*snapshotEnabled\s*&&\s*layout\.heroVisible\s*===\s*true;/,
|
||||
);
|
||||
});
|
||||
|
||||
it("holds the skeleton while primaryLoading and swaps the hero in the same pass", () => {
|
||||
const block = src.match(
|
||||
/\{heroVisible\s*&&[\s\S]*?<DashboardHero[\s\S]*?\)\}/,
|
||||
);
|
||||
expect(block).not.toBeNull();
|
||||
expect(block![0]).toMatch(
|
||||
/primaryLoading\s*\|\|\s*!heroSnapshot\s*\?\s*\(\s*<DashboardHeroSkeleton\s*\/>/,
|
||||
);
|
||||
expect(block![0]).toContain("onQuickEntry={setQuickEntryDialog}");
|
||||
});
|
||||
|
||||
it("derives primaryLoading once in the component body (shared with the tile gate)", () => {
|
||||
const matches = src.match(/const\s+primaryLoading\s*=\s*!mounted/g) ?? [];
|
||||
expect(matches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("the hero sits between the header and the strip", () => {
|
||||
const headerIdx = src.indexOf("<DashboardHeader");
|
||||
const heroIdx = src.indexOf("{heroVisible &&");
|
||||
it("mounts the TodayHero above the tile strip", () => {
|
||||
const heroIdx = src.indexOf("<TodayHero");
|
||||
const stripIdx = src.indexOf("dashboard-tile-strip");
|
||||
expect(headerIdx).toBeGreaterThan(-1);
|
||||
expect(heroIdx).toBeGreaterThan(headerIdx);
|
||||
expect(heroIdx).toBeGreaterThan(-1);
|
||||
expect(stripIdx).toBeGreaterThan(heroIdx);
|
||||
});
|
||||
|
||||
it("the page no longer derives the welcome text (it moved into the hero)", () => {
|
||||
it("no longer references the retired legacy hero", () => {
|
||||
expect(src).not.toContain("DashboardHero");
|
||||
expect(src).not.toContain("DashboardHeroSkeleton");
|
||||
expect(src).not.toContain("heroVisible");
|
||||
});
|
||||
|
||||
it("the page derives no welcome text (the header owns the greeting)", () => {
|
||||
expect(src).not.toContain("welcomeText");
|
||||
expect(src).not.toContain("welcomeBackWithName");
|
||||
expect(src).not.toContain("getHourForTimeZone");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dashboard hero — header handoff", () => {
|
||||
describe("dashboard hero — header greeting", () => {
|
||||
const header = readFileSync(HEADER_PATH, "utf8");
|
||||
|
||||
it("the header keeps the greeting behind showGreeting (hero-hidden fallback)", () => {
|
||||
// The greeting renders here ONLY when the hero band does not — the
|
||||
// prop gates the paragraph, and the `min-h-5` line-box reservation
|
||||
// keeps the header stable through the post-hydration name swap.
|
||||
expect(header).toContain("showGreeting");
|
||||
expect(header).toMatch(/\{\s*showGreeting\s*\?/);
|
||||
it("renders the greeting unconditionally (no showGreeting gate)", () => {
|
||||
// The greeting line renders on every mount — the legacy hero that
|
||||
// once owned it is gone, so there is no `showGreeting` prop to gate
|
||||
// it. The `min-h-5` line-box reservation keeps the header stable
|
||||
// through the post-hydration name swap.
|
||||
expect(header).not.toContain("showGreeting");
|
||||
expect(header).toContain("min-h-5");
|
||||
expect(header).toContain('data-slot="dashboard-header-greeting"');
|
||||
});
|
||||
|
||||
it("the page feeds showGreeting from the inverted hero gate", () => {
|
||||
it("the page mounts the header without a greeting prop", () => {
|
||||
const src = readFileSync(PAGE_PATH, "utf8");
|
||||
expect(src).toMatch(/showGreeting=\{!heroVisible\}/);
|
||||
expect(src).toContain(
|
||||
"<DashboardHeader onQuickEntry={setQuickEntryDialog} />",
|
||||
);
|
||||
});
|
||||
|
||||
it("the header keeps the title + customize + add actions", () => {
|
||||
|
||||
@@ -77,10 +77,10 @@ describe("v1.4.40 — dashboard per-cell Suspense boundaries", () => {
|
||||
it("keeps the per-render Intl hour walk off the page (audit-H4 successor)", () => {
|
||||
const src = load(PAGE_PATH);
|
||||
// The audit-H4 `useMemo(getHourForTimeZone, [userTimezone])` pin
|
||||
// retired with the greeting itself: the hero band derives the
|
||||
// daypart from the snapshot's server-computed `greetingHour`
|
||||
// (`dashboard-hero.tsx`), so the page no longer instantiates any
|
||||
// `Intl.DateTimeFormat` for the greeting — per render or otherwise.
|
||||
// moved off the page with the greeting itself: the greeting now
|
||||
// lives in the header (`dashboard-header.tsx`), so the page no
|
||||
// longer instantiates any `Intl.DateTimeFormat` for the greeting —
|
||||
// per render or otherwise.
|
||||
expect(src).not.toContain("getHourForTimeZone");
|
||||
expect(src).not.toContain("welcomeText");
|
||||
});
|
||||
|
||||
@@ -108,8 +108,12 @@ describe("resolveConfiguredTileCount (v1.16.8)", () => {
|
||||
// plus the v1.20.0 sleep / steps / glucose flips are tile-visible AND
|
||||
// tile-capable; medications + recentWorkouts are tile-visible in the
|
||||
// stored layout but paint NO strip tile, so they must not inflate the
|
||||
// silhouette. bp paints sys + dia = 2.
|
||||
expect(resolveConfiguredTileCount(DEFAULT_DASHBOARD_LAYOUT)).toBe(11);
|
||||
// silhouette. bp paints sys + dia = 2. v1.28.52 added seven vitals +
|
||||
// body-composition strip tiles (hrv, oxygenSaturation, respiratoryRate,
|
||||
// wristTemperature, muscleMass, totalBodyWater, boneMass), each
|
||||
// single-count: 11 + 7 = 18. v1.29 added the fluid-intake strip tile
|
||||
// (waterIntake), single-count: 18 + 1 = 19.
|
||||
expect(resolveConfiguredTileCount(DEFAULT_DASHBOARD_LAYOUT)).toBe(19);
|
||||
});
|
||||
|
||||
it("ignores chart-only and iOS-pin-only widget ids", () => {
|
||||
|
||||
@@ -334,7 +334,7 @@ export default function AchievementsPage() {
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="bg-card border-border flex min-h-34 flex-col justify-between rounded-xl border p-5">
|
||||
<div className="bg-card border-border flex min-h-34 flex-col justify-between rounded-xl border p-4 md:p-6">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("achievements.points")}
|
||||
</p>
|
||||
@@ -354,7 +354,7 @@ export default function AchievementsPage() {
|
||||
aria-label={t("achievements.points")}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-card border-border flex min-h-34 flex-col justify-between rounded-xl border p-5">
|
||||
<div className="bg-card border-border flex min-h-34 flex-col justify-between rounded-xl border p-4 md:p-6">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("achievements.unlocked")}
|
||||
</p>
|
||||
@@ -368,7 +368,7 @@ export default function AchievementsPage() {
|
||||
{t("achievements.remainingUnlocks", { count: remainingUnlocks })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-card border-border min-h-34 rounded-xl border p-5 sm:col-span-2 lg:col-span-1">
|
||||
<div className="bg-card border-border min-h-34 rounded-xl border p-4 sm:col-span-2 md:p-6 lg:col-span-1">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("achievements.nextGoal")}
|
||||
</p>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { AboutSection } from "@/components/settings/about-section";
|
||||
import { AiQualitySection } from "@/components/admin/ai-quality-section";
|
||||
import { AiServerKeySection } from "@/components/admin/ai-server-key-section";
|
||||
import { CentralCodexSection } from "@/components/admin/central-codex-section";
|
||||
import { AssistantSection } from "@/components/admin/assistant-section";
|
||||
import { CoachFeedbackSection } from "@/components/admin/coach-feedback-section";
|
||||
import { ApiTokenOverviewSection } from "@/components/admin/api-token-overview-section";
|
||||
@@ -104,6 +105,7 @@ export function AdminSectionRenderer({
|
||||
>
|
||||
<AssistantSection />
|
||||
<AiServerKeySection />
|
||||
<CentralCodexSection />
|
||||
<CoachFeedbackSection />
|
||||
<AiQualitySection />
|
||||
</SectionFrame>
|
||||
|
||||
@@ -111,6 +111,19 @@ const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
|
||||
// ungated insights AI route fails this test BY NAME rather than leaking the
|
||||
// surface over a Bearer token when the account turned insights off.
|
||||
"src/app/api/insights",
|
||||
// v1.28 — the unified daily-digest read (`GET /api/daily/digest`). The digest
|
||||
// is the AI-narrative daily layer, so it gates on the `insights` module via
|
||||
// `requireModuleEnabled(user.id, "insights")` directly. Walking the tree means
|
||||
// a NEW ungated daily route fails BY NAME rather than leaking over a Bearer
|
||||
// token when the account turned insights off.
|
||||
"src/app/api/daily",
|
||||
// v1.28 — the nutrient-intake sync (opt-in `nutrients` module). Unlike the
|
||||
// data-layer-exempt siblings this domain is REFUSE-INGEST-WHEN-OFF (the
|
||||
// mental-health posture): both the batch ingest and the window-summary
|
||||
// read call `requireModuleEnabled(user.id, "nutrients")` directly, so a
|
||||
// phone whose user never opted in cannot land rows server-side. Walking
|
||||
// the tree means a NEW ungated nutrients route fails BY NAME.
|
||||
"src/app/api/nutrients",
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -204,6 +217,12 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
|
||||
"src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts",
|
||||
"src/app/api/medications/[id]/side-effects/route.ts",
|
||||
"src/app/api/medications/[id]/side-effects/[logId]/route.ts",
|
||||
// The efficacy read + user-override target share the medication data-layer
|
||||
// reasoning: they compute over the user's own medication + metric/lab rows
|
||||
// and persist only a per-user override. The module gate governs the Wirkung
|
||||
// SURFACE (the detail-page tab + the insights summary), not the row store.
|
||||
"src/app/api/medications/[id]/efficacy/route.ts",
|
||||
"src/app/api/medications/[id]/efficacy/target/route.ts",
|
||||
// ── INFRA / UI-ONLY ───────────────────────────────────────────────
|
||||
// Static FHIR CapabilityStatement — server metadata, no user data.
|
||||
"src/app/api/fhir/metadata/route.ts",
|
||||
@@ -238,6 +257,11 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
|
||||
// inventory / side-effect events), and medications is a core, always-on
|
||||
// domain with no module gate. It is not an insights-module surface.
|
||||
"src/app/api/insights/glp1-timeline/route.ts",
|
||||
// GLP-1 weight-plateau read backing the plateau note beside the drug-level
|
||||
// curve (efficacy tab + /insights/medications) — the same MEDICATIONS-domain
|
||||
// rationale as glp1-timeline above: a deterministic detector over weight +
|
||||
// dose history, no AI narrative, medications is core/always-on.
|
||||
"src/app/api/insights/glp1-plateau/route.ts",
|
||||
];
|
||||
|
||||
const MODULE_GATE_NEEDLE = "requireModuleEnabled(";
|
||||
|
||||
@@ -57,6 +57,12 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
interface RestoreResponse {
|
||||
restored: true;
|
||||
// v1.28 — `summary` reports every domain PRESENT in the uploaded file
|
||||
// (informational — it now includes labs/illness/allergies/family-history/
|
||||
// workouts/documents counts too). `cleared` below is the narrower,
|
||||
// authoritative list of what this restore actually wiped + recreated —
|
||||
// those newer domains are export-only for now and are NOT touched by a
|
||||
// restore, so they are deliberately absent from `cleared`.
|
||||
summary: BackupSummary;
|
||||
cleared: {
|
||||
measurements: number;
|
||||
|
||||
@@ -218,6 +218,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
moodEntries: summary.moodEntries,
|
||||
cycles: summary.cycles,
|
||||
cycleDayLogs: summary.cycleDayLogs,
|
||||
labResults: summary.labResults,
|
||||
biomarkers: summary.biomarkers,
|
||||
illnessEpisodes: summary.illnessEpisodes,
|
||||
illnessDayLogs: summary.illnessDayLogs,
|
||||
allergies: summary.allergies,
|
||||
familyHistory: summary.familyHistory,
|
||||
workouts: summary.workouts,
|
||||
documents: summary.documents,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { pollDeviceCode, encryptAdminCodexCreds } from "@/lib/ai/codex-oauth";
|
||||
import { cookies } from "next/headers";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { getClientIp, apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
/**
|
||||
* Operator-shared central Codex — device-code poll (admin only).
|
||||
*
|
||||
* Cookie-only `requireAdmin()`. The client polls this every `intervalSeconds`;
|
||||
* on the operator finishing the approval on chatgpt.com we exchange the code,
|
||||
* then persist the three token pieces (access + refresh + `ChatGPT-Account-ID`),
|
||||
* each AES-256-GCM encrypted, on the `AppSettings` singleton. The device cookie
|
||||
* is deleted on success. Tokens are never logged and never leave the server.
|
||||
*/
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAdmin();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`central-codex-device-poll:${user.id}`,
|
||||
60,
|
||||
60_000,
|
||||
);
|
||||
if (!rl.allowed) throw new HttpError(429, "Too many requests");
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const cookie = cookieStore.get("central_codex_device")?.value;
|
||||
if (!cookie) {
|
||||
return apiError("No device-auth attempt in progress", 400);
|
||||
}
|
||||
|
||||
let deviceAuthId: string;
|
||||
let userCode: string;
|
||||
try {
|
||||
const decoded = JSON.parse(decrypt(cookie)) as {
|
||||
deviceAuthId: string;
|
||||
userCode: string;
|
||||
};
|
||||
deviceAuthId = decoded.deviceAuthId;
|
||||
userCode = decoded.userCode;
|
||||
} catch {
|
||||
cookieStore.delete("central_codex_device");
|
||||
return apiError("Invalid device-auth state — restart the flow", 400);
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await pollDeviceCode({ deviceAuthId, userCode });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "unknown";
|
||||
annotate({ meta: { central_codex_device_poll_error: message } });
|
||||
return apiError(
|
||||
"Central Codex device-poll failed — please restart the flow",
|
||||
502,
|
||||
);
|
||||
}
|
||||
|
||||
if (result.status === "pending") {
|
||||
annotate({
|
||||
action: { name: "admin.central-codex.device.poll" },
|
||||
meta: { pending: true },
|
||||
});
|
||||
return apiSuccess({ status: "pending" });
|
||||
}
|
||||
|
||||
const enc = encryptAdminCodexCreds(result.creds);
|
||||
await prisma.appSettings.upsert({
|
||||
where: { id: "singleton" },
|
||||
update: {
|
||||
adminCodexAccessTokenEncrypted: enc.accessEncrypted,
|
||||
adminCodexRefreshTokenEncrypted: enc.refreshEncrypted,
|
||||
adminCodexAccountIdEncrypted: enc.accountIdEncrypted,
|
||||
adminCodexTokenExpiresAt: enc.expiresAt,
|
||||
adminCodexConnectedAt: new Date(),
|
||||
adminCodexConnectionStatus: "connected",
|
||||
},
|
||||
create: {
|
||||
id: "singleton",
|
||||
adminCodexAccessTokenEncrypted: enc.accessEncrypted,
|
||||
adminCodexRefreshTokenEncrypted: enc.refreshEncrypted,
|
||||
adminCodexAccountIdEncrypted: enc.accountIdEncrypted,
|
||||
adminCodexTokenExpiresAt: enc.expiresAt,
|
||||
adminCodexConnectedAt: new Date(),
|
||||
adminCodexConnectionStatus: "connected",
|
||||
},
|
||||
});
|
||||
|
||||
cookieStore.delete("central_codex_device");
|
||||
|
||||
await auditLog("admin.central-codex.connected", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { method: "device_code" },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "admin.central-codex.device.poll" },
|
||||
meta: { connected: true },
|
||||
});
|
||||
|
||||
return apiSuccess({ status: "connected" });
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { requestDeviceCode } from "@/lib/ai/codex-oauth";
|
||||
import { cookies } from "next/headers";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
|
||||
/**
|
||||
* Operator-shared central Codex — device-code flow start (admin only).
|
||||
*
|
||||
* Mirrors the per-user `auth/codex/device-start`, but cookie-only
|
||||
* `requireAdmin()` gates it and the device-auth cookie is namespaced
|
||||
* (`central_codex_device`) so it never collides with an operator's own per-user
|
||||
* Codex connect flow. The device secret is persisted in an encrypted httpOnly
|
||||
* cookie for the 15-minute window Hydra allows.
|
||||
*/
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
void request;
|
||||
const { user } = await requireAdmin();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`central-codex-device-start:${user.id}`,
|
||||
5,
|
||||
60_000,
|
||||
);
|
||||
if (!rl.allowed) throw new HttpError(429, "Too many requests");
|
||||
|
||||
const code = await requestDeviceCode();
|
||||
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(
|
||||
"central_codex_device",
|
||||
encrypt(
|
||||
JSON.stringify({
|
||||
deviceAuthId: code.deviceAuthId,
|
||||
userCode: code.userCode,
|
||||
}),
|
||||
),
|
||||
{
|
||||
httpOnly: true,
|
||||
secure: shouldEmitSecureCookie(),
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 15 * 60,
|
||||
},
|
||||
);
|
||||
|
||||
annotate({
|
||||
action: { name: "admin.central-codex.device.start" },
|
||||
meta: { interval_seconds: code.intervalSeconds },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
userCode: code.userCode,
|
||||
verificationUrl: code.verificationUrl,
|
||||
intervalSeconds: code.intervalSeconds,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
/**
|
||||
* Operator-shared central Codex (ChatGPT subscription) — status + disconnect.
|
||||
*
|
||||
* GET /api/admin/central-codex — connection status + connected-at.
|
||||
* DELETE /api/admin/central-codex — clear the shared credential.
|
||||
*
|
||||
* Cookie-only `requireAdmin()`: a Bearer token, even with `["*"]` scope, can
|
||||
* never reach this. The encrypted token columns are NEVER returned — only the
|
||||
* status string and the connected-at timestamp leave the server.
|
||||
*/
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.central-codex.get" } });
|
||||
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: {
|
||||
adminCodexConnectionStatus: true,
|
||||
adminCodexConnectedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
status: settings?.adminCodexConnectionStatus ?? "disconnected",
|
||||
connectedAt: settings?.adminCodexConnectedAt ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAdmin();
|
||||
|
||||
const rl = await checkRateLimit("admin-central-codex-disconnect", 5, 60_000);
|
||||
if (!rl.allowed) throw new HttpError(429, "Too many requests");
|
||||
|
||||
await prisma.appSettings.upsert({
|
||||
where: { id: "singleton" },
|
||||
update: {
|
||||
adminCodexAccessTokenEncrypted: null,
|
||||
adminCodexRefreshTokenEncrypted: null,
|
||||
adminCodexAccountIdEncrypted: null,
|
||||
adminCodexTokenExpiresAt: null,
|
||||
adminCodexConnectedAt: null,
|
||||
adminCodexConnectionStatus: "disconnected",
|
||||
},
|
||||
create: { id: "singleton", adminCodexConnectionStatus: "disconnected" },
|
||||
});
|
||||
|
||||
await auditLog("admin.central-codex.disconnected", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
});
|
||||
annotate({ action: { name: "admin.central-codex.disconnect" } });
|
||||
|
||||
return apiSuccess({ disconnected: true });
|
||||
});
|
||||
@@ -32,6 +32,8 @@ const mocks = vi.hoisted(() => ({
|
||||
importJobFindFirst: vi.fn().mockResolvedValue(null),
|
||||
importJobUpdate: vi.fn(),
|
||||
userFindUnique: vi.fn(),
|
||||
streamToDisk: vi.fn(),
|
||||
unlink: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
@@ -42,6 +44,15 @@ vi.mock("@/lib/jobs/boss-instance", () => ({
|
||||
getGlobalBoss: mocks.getGlobalBoss,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/multipart/stream-to-disk", () => ({
|
||||
streamMultipartToDisk: mocks.streamToDisk,
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs/promises")>();
|
||||
return { ...actual, unlink: mocks.unlink };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
importJob: {
|
||||
@@ -67,6 +78,8 @@ const USER_SESSION = {
|
||||
user: { id: "user-2", username: "alice", role: "USER" as const },
|
||||
};
|
||||
|
||||
const STAGED_PATH = "/tmp/healthlog-admin-apple-health-import-abc.bin";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mocks.checkRateLimit.mockResolvedValue({ allowed: true });
|
||||
@@ -76,8 +89,27 @@ beforeEach(() => {
|
||||
mocks.importJobFindFirst.mockResolvedValue(null);
|
||||
mocks.importJobUpdate.mockResolvedValue({ id: "ij-1" });
|
||||
mocks.userFindUnique.mockResolvedValue({ id: "user-2", username: "alice" });
|
||||
mocks.streamToDisk.mockResolvedValue({
|
||||
filePath: STAGED_PATH,
|
||||
bytes: 100,
|
||||
sha256: "deadbeef",
|
||||
originalFilename: "export.zip",
|
||||
textFields: { userId: "user-2" },
|
||||
});
|
||||
mocks.unlink.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
function multipartReq(): NextRequest {
|
||||
return new NextRequest(
|
||||
"http://localhost/api/admin/import-apple-health-export",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "multipart/form-data; boundary=---x" },
|
||||
body: "stub",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe("POST /api/admin/import-apple-health-export", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
@@ -139,4 +171,45 @@ describe("POST /api/admin/import-apple-health-export", () => {
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(429);
|
||||
});
|
||||
|
||||
it("re-stages and enqueues when the only prior job for the same bytes is failed (issue #486)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_SESSION as never);
|
||||
mocks.importJobFindFirst.mockImplementation(async (args: unknown) => {
|
||||
const where = (args as { where?: { status?: { not?: string } } }).where;
|
||||
if (where?.status?.not === "failed") return null;
|
||||
return { id: "old-failed-job", status: "failed" };
|
||||
});
|
||||
|
||||
const res = await POST(multipartReq());
|
||||
expect(res.status).toBe(202);
|
||||
const body = await res.json();
|
||||
expect(body.data.status).toBe("queued");
|
||||
expect(body.data.idempotent).toBeUndefined();
|
||||
expect(body.data.jobId).not.toBe("old-failed-job");
|
||||
expect(mocks.bossSend).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.importJobCreate).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.importJobFindFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ status: { not: "failed" } }),
|
||||
}),
|
||||
);
|
||||
expect(mocks.unlink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("short-circuits to a viable prior job and unlinks the redundant upload", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_SESSION as never);
|
||||
mocks.importJobFindFirst.mockResolvedValue({
|
||||
id: "live-job",
|
||||
status: "parsing",
|
||||
});
|
||||
|
||||
const res = await POST(multipartReq());
|
||||
expect(res.status).toBe(202);
|
||||
const body = await res.json();
|
||||
expect(body.data.idempotent).toBe(true);
|
||||
expect(body.data.jobId).toBe("live-job");
|
||||
expect(mocks.bossSend).not.toHaveBeenCalled();
|
||||
expect(mocks.importJobCreate).not.toHaveBeenCalled();
|
||||
expect(mocks.unlink).toHaveBeenCalledWith(STAGED_PATH);
|
||||
});
|
||||
});
|
||||
@@ -22,9 +22,11 @@ import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import {
|
||||
APPLE_HEALTH_IMPORT_QUEUE,
|
||||
APPLE_HEALTH_IMPORT_SEND_OPTIONS,
|
||||
type AppleHealthImportPayload,
|
||||
} from "@/lib/jobs/apple-health-import-worker";
|
||||
import { streamMultipartToDisk } from "@/lib/multipart/stream-to-disk";
|
||||
import { unlink } from "node:fs/promises";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -111,15 +113,22 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
// Same content-hash idempotency as the user-facing route, but scoped
|
||||
// by the target user — an admin re-uploading the same file for the
|
||||
// same user resolves to the previous job.
|
||||
// same user resolves to the previous job. As on the user route, a
|
||||
// prior `failed` job is excluded (issue #486) so the same export can
|
||||
// be retried; only still-viable jobs (queued / in-flight / done)
|
||||
// short-circuit.
|
||||
const existing = await prisma.importJob.findFirst({
|
||||
where: {
|
||||
userId: targetUser.id,
|
||||
uploadSha256: uploaded.sha256,
|
||||
status: { not: "failed" },
|
||||
},
|
||||
orderBy: { startedAt: "desc" },
|
||||
});
|
||||
if (existing) {
|
||||
// Redundant staging file — the existing viable job owns the bytes.
|
||||
// Unlink so a deduped admin re-upload does not leak `/tmp`.
|
||||
await unlink(uploaded.filePath).catch(() => {});
|
||||
annotate({ meta: { idempotent_hit: true, job_id: existing.id } });
|
||||
return apiSuccess(
|
||||
{
|
||||
@@ -158,7 +167,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
uploadBytes: uploaded.bytes,
|
||||
enqueuedAt: new Date().toISOString(),
|
||||
};
|
||||
const bossJobId = await boss.send(APPLE_HEALTH_IMPORT_QUEUE, payload);
|
||||
// No retries + wide expiration: the staged upload is consumed by the
|
||||
// first run, so a redelivery could only fail on the deleted `/tmp`
|
||||
// file and mask the real outcome (see the worker's send-options doc).
|
||||
const bossJobId = await boss.send(
|
||||
APPLE_HEALTH_IMPORT_QUEUE,
|
||||
payload,
|
||||
APPLE_HEALTH_IMPORT_SEND_OPTIONS,
|
||||
);
|
||||
|
||||
await prisma.importJob.update({
|
||||
where: { id: importJob.id },
|
||||
|
||||
@@ -69,8 +69,14 @@ interface TestFailureEnvelope {
|
||||
data: {
|
||||
ok: false;
|
||||
providerType: string;
|
||||
reasonCode: "credentials" | "rate_limited" | "server_error" | "unreachable";
|
||||
reasonCode:
|
||||
| "credentials"
|
||||
| "rate_limited"
|
||||
| "server_error"
|
||||
| "bad_request"
|
||||
| "unreachable";
|
||||
reason: string;
|
||||
httpStatus: number | null;
|
||||
};
|
||||
error: null;
|
||||
}
|
||||
@@ -170,6 +176,40 @@ describe("POST /api/ai/test — provider error leak guard + non-5xx contract", (
|
||||
expect(body.data.reasonCode).toBe("unreachable");
|
||||
expect(body.data.reason).toMatch(/could not reach/i);
|
||||
});
|
||||
|
||||
// v1.28.28 (#470) — a 4xx means the endpoint ANSWERED and rejected the
|
||||
// request shape / model name; lumping it into "unreachable" sent operators
|
||||
// debugging connectivity when the fix was the model field or a gateway's
|
||||
// parameter strictness.
|
||||
it("categorises a 400 as bad_request, not unreachable", async () => {
|
||||
makeProviderThatThrows(
|
||||
Object.assign(new Error("Local AI request failed (400)"), {
|
||||
httpStatus: 400 as const,
|
||||
bodyExcerpt: '{"error":{"message":"unknown model: llama9"}}',
|
||||
}),
|
||||
);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as TestFailureEnvelope;
|
||||
expect(body.data.reasonCode).toBe("bad_request");
|
||||
expect(body.data.reason).toMatch(/HTTP 400/);
|
||||
expect(body.data.reason).toMatch(/not connectivity/i);
|
||||
expect(body.data.httpStatus).toBe(400);
|
||||
// Still secret-free: the upstream body excerpt is never echoed.
|
||||
expect(body.data.reason).not.toMatch(/llama9/);
|
||||
});
|
||||
|
||||
it("categorises a 404 (wrong path / model route) as bad_request", async () => {
|
||||
makeProviderThatThrows(
|
||||
Object.assign(new Error("Local AI request failed (404)"), {
|
||||
httpStatus: 404 as const,
|
||||
}),
|
||||
);
|
||||
const response = await POST(emptyRequest() as never);
|
||||
const body = (await response.json()) as TestFailureEnvelope;
|
||||
expect(body.data.reasonCode).toBe("bad_request");
|
||||
expect(body.data.httpStatus).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// v1.4 fix: the dropdown in /settings was not honoured — the test always
|
||||
|
||||
@@ -113,6 +113,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
providerType: provider.type,
|
||||
reasonCode,
|
||||
reason,
|
||||
// v1.28.28 (#470) — the upstream status (secret-free) so the client
|
||||
// can localise "rejected the request (HTTP 400)" with the real number.
|
||||
httpStatus: err.httpStatus ?? null,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -123,7 +126,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
* legacy / unmapped code. Secret-free by construction.
|
||||
*/
|
||||
type TestFailureCode =
|
||||
"credentials" | "rate_limited" | "server_error" | "unreachable";
|
||||
| "credentials"
|
||||
| "rate_limited"
|
||||
| "server_error"
|
||||
| "bad_request"
|
||||
| "unreachable";
|
||||
|
||||
/**
|
||||
* Map an upstream provider failure to a human-readable, secret-free
|
||||
@@ -161,6 +168,16 @@ function classifyTestFailure(
|
||||
reason: "The AI provider returned a server error.",
|
||||
};
|
||||
}
|
||||
// v1.28.28 (#470) — any other 4xx (400 / 404 / 405 / 415 / 422, …) means
|
||||
// the endpoint ANSWERED and rejected the request shape or model name. The
|
||||
// old lump into "unreachable" sent operators debugging connectivity when
|
||||
// the fix was the model field or a gateway's parameter strictness.
|
||||
if (status >= 400 && status < 500) {
|
||||
return {
|
||||
reasonCode: "bad_request",
|
||||
reason: `The provider rejected the request (HTTP ${status}) — the endpoint answered, so this is a request-shape or model-name problem, not connectivity.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
reasonCode: "unreachable",
|
||||
reason: "Could not reach the AI provider.",
|
||||
|
||||
@@ -485,6 +485,11 @@ async function buildAnalyticsResponse(user: AuthedUser) {
|
||||
correlations,
|
||||
healthScore,
|
||||
sleepStages,
|
||||
// v1.28.x — the latest night's source-discrepancy annotation, computed
|
||||
// inside the slim slice's per-night post-pass. Additive: the dashboard
|
||||
// sleep tile marks the headline with a discreet "sources disagree"
|
||||
// hint; clients that don't read the field stay unchanged.
|
||||
sleepSourceDiscrepancy: slim.sleepSourceDiscrepancy,
|
||||
// v1.4.34 IW-B — per-type freshness map drives the dashboard's
|
||||
// staleness caption on each `<TrendCard>`. Additive: clients that
|
||||
// don't read the field stay unchanged.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* only signal operators need; the identifier itself is PII and stays
|
||||
* out of the row.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
@@ -135,3 +135,48 @@ describe("POST /api/auth/login — auth.login.failed audit row (H-1)", () => {
|
||||
expect(JSON.stringify(payload)).not.toContain(TYPED_EMAIL);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/auth/login — OIDC_ONLY server-side enforcement", () => {
|
||||
const OIDC_ENV_KEYS = [
|
||||
"OIDC_ISSUER_URL",
|
||||
"OIDC_CLIENT_ID",
|
||||
"OIDC_CLIENT_SECRET",
|
||||
"OIDC_ONLY",
|
||||
] as const;
|
||||
const original: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of OIDC_ENV_KEYS) original[key] = process.env[key];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of OIDC_ENV_KEYS) {
|
||||
if (original[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = original[key];
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects password login before touching the DB when OIDC_ONLY is set", async () => {
|
||||
process.env.OIDC_ISSUER_URL = "https://idp.example.com";
|
||||
process.env.OIDC_CLIENT_ID = "client-1";
|
||||
process.env.OIDC_CLIENT_SECRET = "secret-1";
|
||||
process.env.OIDC_ONLY = "true";
|
||||
|
||||
const res = await POST(makeRequest());
|
||||
expect(res.status).toBe(403);
|
||||
expect(prisma.user.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still allows password login when OIDC_ONLY is set but the provider is half-configured", async () => {
|
||||
delete process.env.OIDC_ISSUER_URL;
|
||||
process.env.OIDC_CLIENT_ID = "client-1";
|
||||
process.env.OIDC_CLIENT_SECRET = "secret-1";
|
||||
process.env.OIDC_ONLY = "true";
|
||||
|
||||
const res = await POST(makeRequest());
|
||||
// Falls through to normal password verification (which fails here
|
||||
// since verifyPassword is mocked to return false) rather than 403 —
|
||||
// a half-set OIDC group must never lock everyone out.
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -13,8 +13,18 @@ import { finishLogin } from "@/lib/auth/login-response";
|
||||
import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
|
||||
import { consumeTrustedDevice } from "@/lib/auth/trusted-device";
|
||||
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
import { isOidcOnly } from "@/lib/auth/oidc";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// OIDC_ONLY means password login must be a dead end, not just a hidden
|
||||
// button — otherwise a pre-existing password (or a leaked/reused one)
|
||||
// stays a live bypass of the operator's SSO-only policy.
|
||||
if (isOidcOnly()) {
|
||||
return apiError("Password login is disabled. Sign in with SSO.", 403, {
|
||||
errorCode: "oidc_only",
|
||||
});
|
||||
}
|
||||
|
||||
// v1.4.43 W13 M-4 — `checkAuthSurfaceRateLimit` swaps to a tighter
|
||||
// global bucket when the trust chain is misconfigured; otherwise it
|
||||
// is byte-equivalent to the previous per-IP `auth:login:{ip}` key.
|
||||
|
||||
@@ -90,6 +90,7 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => {
|
||||
mood: true,
|
||||
compliance: true,
|
||||
sleep: false,
|
||||
glucose: true,
|
||||
cycle: false,
|
||||
labs: true,
|
||||
allergies: true,
|
||||
@@ -116,6 +117,7 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => {
|
||||
mood: true,
|
||||
compliance: false,
|
||||
sleep: false,
|
||||
glucose: false,
|
||||
cycle: false,
|
||||
labs: false,
|
||||
allergies: false,
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Per-user opt-in to the operator's shared central Codex (ChatGPT subscription).
|
||||
*
|
||||
* GET /api/auth/me/use-central-codex — current flag.
|
||||
* PATCH /api/auth/me/use-central-codex — body `{ useCentralCodex: boolean }`.
|
||||
*
|
||||
* OFF by default. When `true`, and the operator has connected the central Codex,
|
||||
* the user's resolved provider chain gains a trailing `admin-codex` entry — an
|
||||
* external, shared, train-by-default subscription account bound by the
|
||||
* operator's rate limits. Using it is external egress: the existing consent gate
|
||||
* (`admin-codex` is server-managed) still requires an active AI consent receipt
|
||||
* before any PHI leaves for it, so flipping this ON never egresses anything on
|
||||
* its own.
|
||||
*
|
||||
* Mirrors `auth/me/labs-local-ocr`: 60/min rate limit, Zod `safeParse` → 422 via
|
||||
* `returnAllZodIssues`, audit-log row, field-by-field write (no mass
|
||||
* assignment). Idempotent — always returns the resolved next state.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
|
||||
const patchBodySchema = z.object({
|
||||
useCentralCodex: z.boolean(),
|
||||
});
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const PATCH_RATE_LIMIT = 60;
|
||||
const PATCH_WINDOW_MS = 60_000;
|
||||
|
||||
type UseCentralCodexResponse = {
|
||||
useCentralCodex: boolean;
|
||||
};
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "auth.me.useCentralCodex.get" } });
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { useCentralCodex: true },
|
||||
});
|
||||
const payload: UseCentralCodexResponse = {
|
||||
useCentralCodex: row?.useCentralCodex ?? false,
|
||||
};
|
||||
return apiSuccess(payload);
|
||||
});
|
||||
|
||||
export const PATCH = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`use-central-codex:patch:${user.id}`,
|
||||
PATCH_RATE_LIMIT,
|
||||
PATCH_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(req, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = patchBodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "auth.me.useCentralCodex.patch.invalid_shape" },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const next = parsed.data.useCentralCodex;
|
||||
|
||||
const previous = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { useCentralCodex: true },
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { useCentralCodex: next },
|
||||
});
|
||||
|
||||
await auditLog("user.useCentralCodex.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: {
|
||||
previous: previous?.useCentralCodex ?? false,
|
||||
next,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "auth.me.useCentralCodex.patch" },
|
||||
meta: { useCentralCodex: next },
|
||||
});
|
||||
|
||||
const payload: UseCentralCodexResponse = { useCentralCodex: next };
|
||||
return apiSuccess(payload);
|
||||
});
|
||||
@@ -0,0 +1,579 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: () => ({ setError: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAuthSurfaceRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((s: string) => s.replace(/^enc\(/, "").replace(/\)$/, "")),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
webauthnMfaCredential: { count: vi.fn() },
|
||||
// v1.28.48 — the first-admin provision count+insert now runs inside a
|
||||
// transaction-scoped advisory lock. Default `$transaction` to run the
|
||||
// callback with the same prisma mock as its `tx` handle.
|
||||
$transaction: vi.fn(),
|
||||
$queryRaw: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/auth/session", () => ({ createSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/login-alert", () => ({ recordSignInDevice: vi.fn() }));
|
||||
vi.mock("@/lib/auth/mfa/challenge", () => ({
|
||||
createMfaChallenge: vi.fn(async () => ({
|
||||
ticket: "mfa-ticket-1",
|
||||
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/auth/mfa-enrollment", () => ({
|
||||
syncMfaEnrollCookie: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/auth/secure-cookie", () => ({
|
||||
shouldEmitSecureCookie: () => false,
|
||||
}));
|
||||
vi.mock("@/lib/tz/resolver", () => ({
|
||||
resolveServerDefaultTimezone: vi.fn(async () => "Europe/Berlin"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/oidc", () => ({
|
||||
getOidcConfig: vi.fn(),
|
||||
discoverOidcMetadata: vi.fn(),
|
||||
exchangeCodeForTokens: vi.fn(),
|
||||
verifyIdToken: vi.fn(),
|
||||
fetchUserinfoEmail: vi.fn(),
|
||||
getOidcRedirectUri: () =>
|
||||
"https://healthlog.example.com/api/auth/oidc/callback",
|
||||
oidcAppUrl: (path: string) => new URL(path, "https://healthlog.example.com"),
|
||||
deriveUniqueUsername: vi.fn(async (email: string) => email.split("@")[0]),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
|
||||
import {
|
||||
getOidcConfig,
|
||||
discoverOidcMetadata,
|
||||
exchangeCodeForTokens,
|
||||
verifyIdToken,
|
||||
} from "@/lib/auth/oidc";
|
||||
|
||||
const CONFIG = {
|
||||
issuerUrl: "https://idp.example.com",
|
||||
clientId: "client-1",
|
||||
clientSecret: "secret-1",
|
||||
scopes: "openid email profile",
|
||||
buttonLabel: "SSO",
|
||||
};
|
||||
const METADATA = {
|
||||
issuer: "https://idp.example.com",
|
||||
authorization_endpoint: "https://idp.example.com/authorize",
|
||||
token_endpoint: "https://idp.example.com/token",
|
||||
jwks_uri: "https://idp.example.com/jwks",
|
||||
};
|
||||
|
||||
/** A minimal user row shaped like what the callback consumes. */
|
||||
function userRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "user-1",
|
||||
email: "existing@example.com",
|
||||
onboardingCompletedAt: new Date(),
|
||||
oidcIssuer: null,
|
||||
oidcSub: null,
|
||||
totpConfirmedAt: null,
|
||||
mfaEnforced: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The callback's `prisma.user.findFirst` calls are distinguishable by
|
||||
* their `where` shape: the (issuer, sub) identity lookup, the display-
|
||||
* email collision probe (`NOT`), and the link/provision email match.
|
||||
*/
|
||||
function mockUserLookups(opts: {
|
||||
byIdentity?: ReturnType<typeof userRow> | null;
|
||||
byEmail?: ReturnType<typeof userRow> | null;
|
||||
emailTaken?: boolean;
|
||||
}) {
|
||||
vi.mocked(prisma.user.findFirst).mockImplementation(((args: {
|
||||
where: Record<string, unknown>;
|
||||
}) => {
|
||||
if (args.where.oidcIssuer !== undefined) {
|
||||
return Promise.resolve(opts.byIdentity ?? null);
|
||||
}
|
||||
if (args.where.NOT !== undefined) {
|
||||
return Promise.resolve(opts.emailTaken ? { id: "user-other" } : null);
|
||||
}
|
||||
return Promise.resolve(opts.byEmail ?? null);
|
||||
}) as never);
|
||||
}
|
||||
|
||||
function makeRequest(opts: { query?: string; cookie?: string }): NextRequest {
|
||||
const req = new NextRequest(
|
||||
`http://localhost/api/auth/oidc/callback${opts.query ?? ""}`,
|
||||
);
|
||||
if (opts.cookie) {
|
||||
req.cookies.set("oidc_auth_state", opts.cookie);
|
||||
}
|
||||
return req;
|
||||
}
|
||||
|
||||
function stateCookie(payload: Record<string, unknown>): string {
|
||||
return `enc(${JSON.stringify(payload)})`;
|
||||
}
|
||||
|
||||
/** A request whose query/state pass the CSRF gate — the default fixture. */
|
||||
function validRequest(next = "/"): NextRequest {
|
||||
return makeRequest({
|
||||
query: "?code=abc&state=STATE",
|
||||
cookie: stateCookie({
|
||||
state: "STATE",
|
||||
nonce: "nonce-1",
|
||||
codeVerifier: "verifier-1",
|
||||
next,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function mockIdentity(overrides: Record<string, unknown> = {}) {
|
||||
vi.mocked(exchangeCodeForTokens).mockResolvedValue({ id_token: "idt" });
|
||||
vi.mocked(verifyIdToken).mockResolvedValue({
|
||||
sub: "sub-1",
|
||||
email: "existing@example.com",
|
||||
emailVerified: true,
|
||||
name: null,
|
||||
...overrides,
|
||||
} as never);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 20,
|
||||
reset: 0,
|
||||
ip: "1.2.3.4",
|
||||
} as never);
|
||||
vi.mocked(getOidcConfig).mockReturnValue(CONFIG);
|
||||
vi.mocked(discoverOidcMetadata).mockResolvedValue(METADATA);
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0);
|
||||
vi.mocked(createMfaChallenge).mockResolvedValue({
|
||||
ticket: "mfa-ticket-1",
|
||||
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
||||
});
|
||||
// Run the provision transaction callback against the same prisma mock as
|
||||
// its `tx` handle, and let the advisory-lock `$queryRaw` resolve.
|
||||
vi.mocked(prisma.$transaction).mockImplementation(((
|
||||
fn: (tx: typeof prisma) => unknown,
|
||||
) => fn(prisma)) as never);
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ locked: 1 }] as never);
|
||||
});
|
||||
|
||||
describe("GET /api/auth/oidc/callback", () => {
|
||||
it("redirects with oidc_denied when the IdP reports an error", async () => {
|
||||
const res = await GET(makeRequest({ query: "?error=access_denied" }));
|
||||
expect(res.headers.get("location")).toContain("error=oidc_denied");
|
||||
});
|
||||
|
||||
it("redirects with oidc_state when code/state are missing", async () => {
|
||||
const res = await GET(makeRequest({}));
|
||||
expect(res.headers.get("location")).toContain("error=oidc_state");
|
||||
});
|
||||
|
||||
it("redirects with oidc_state when the state cookie is missing", async () => {
|
||||
const res = await GET(makeRequest({ query: "?code=abc&state=xyz" }));
|
||||
expect(res.headers.get("location")).toContain("error=oidc_state");
|
||||
});
|
||||
|
||||
it("redirects with oidc_state on a state/cookie mismatch (CSRF)", async () => {
|
||||
const res = await GET(
|
||||
makeRequest({
|
||||
query: "?code=abc&state=WRONG",
|
||||
cookie: stateCookie({
|
||||
state: "RIGHT",
|
||||
nonce: "nonce-1",
|
||||
codeVerifier: "verifier-1",
|
||||
next: "/",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(res.headers.get("location")).toContain("error=oidc_state");
|
||||
});
|
||||
|
||||
it("redirects with oidc_no_email when the identity has no email at all", async () => {
|
||||
mockIdentity({ email: null, emailVerified: undefined });
|
||||
mockUserLookups({ byIdentity: null });
|
||||
const res = await GET(validRequest());
|
||||
expect(res.headers.get("location")).toContain("error=oidc_no_email");
|
||||
});
|
||||
|
||||
it("redirects with oidc_email_unverified when email_verified is absent", async () => {
|
||||
// An IdP that never asserts verification cannot anchor a link or a
|
||||
// provision — absent is a reject, not a benefit of the doubt.
|
||||
mockIdentity({ emailVerified: undefined });
|
||||
mockUserLookups({ byIdentity: null, byEmail: null });
|
||||
const res = await GET(validRequest());
|
||||
expect(res.headers.get("location")).toContain(
|
||||
"error=oidc_email_unverified",
|
||||
);
|
||||
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||
expect(createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("redirects with oidc_email_unverified when email_verified is false", async () => {
|
||||
mockIdentity({ emailVerified: false });
|
||||
mockUserLookups({ byIdentity: null, byEmail: null });
|
||||
const res = await GET(validRequest());
|
||||
expect(res.headers.get("location")).toContain(
|
||||
"error=oidc_email_unverified",
|
||||
);
|
||||
});
|
||||
|
||||
it("logs in by (issuer, sub) without consulting the email", async () => {
|
||||
// Even with a brand-new email at the IdP, the stamped identity wins —
|
||||
// no second account is provisioned and no re-link happens.
|
||||
mockIdentity({ email: "renamed@example.com", emailVerified: true });
|
||||
mockUserLookups({
|
||||
byIdentity: userRow({
|
||||
oidcIssuer: METADATA.issuer,
|
||||
oidcSub: "sub-1",
|
||||
}),
|
||||
emailTaken: false,
|
||||
});
|
||||
vi.mocked(prisma.user.update).mockResolvedValue(
|
||||
userRow({
|
||||
email: "renamed@example.com",
|
||||
oidcIssuer: METADATA.issuer,
|
||||
oidcSub: "sub-1",
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const res = await GET(validRequest("/dashboard"));
|
||||
|
||||
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||
// Display email refresh — the only field the update touches.
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { email: "renamed@example.com" },
|
||||
});
|
||||
expect(createSession).toHaveBeenCalled();
|
||||
expect(res.headers.get("location")).toContain("/dashboard");
|
||||
});
|
||||
|
||||
it("skips the display-email refresh when another account holds the address", async () => {
|
||||
mockIdentity({ email: "collide@example.com", emailVerified: true });
|
||||
mockUserLookups({
|
||||
byIdentity: userRow({
|
||||
oidcIssuer: METADATA.issuer,
|
||||
oidcSub: "sub-1",
|
||||
}),
|
||||
emailTaken: true,
|
||||
});
|
||||
|
||||
const res = await GET(validRequest());
|
||||
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
expect(createSession).toHaveBeenCalled();
|
||||
expect(res.status).toBe(307);
|
||||
});
|
||||
|
||||
it("links an existing email-matched account ONCE, stamping (issuer, sub)", async () => {
|
||||
mockIdentity();
|
||||
mockUserLookups({ byIdentity: null, byEmail: userRow() });
|
||||
vi.mocked(prisma.user.update).mockResolvedValue(
|
||||
userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }) as never,
|
||||
);
|
||||
|
||||
const res = await GET(validRequest("/dashboard"));
|
||||
|
||||
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { oidcIssuer: METADATA.issuer, oidcSub: "sub-1" },
|
||||
});
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"auth.oidc.linked",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
expect(createSession).toHaveBeenCalled();
|
||||
expect(res.headers.get("location")).toContain("/dashboard");
|
||||
});
|
||||
|
||||
it("rejects a login whose email matches an account bound to a DIFFERENT identity", async () => {
|
||||
mockIdentity({ sub: "sub-intruder" });
|
||||
mockUserLookups({
|
||||
byIdentity: null,
|
||||
byEmail: userRow({
|
||||
oidcIssuer: "https://other-idp.example.com",
|
||||
oidcSub: "sub-original",
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await GET(validRequest());
|
||||
|
||||
expect(res.headers.get("location")).toContain(
|
||||
"error=oidc_identity_conflict",
|
||||
);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||
expect(createSession).not.toHaveBeenCalled();
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"auth.oidc.link_conflict",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("auto-provisions a new user when registration is open", async () => {
|
||||
mockIdentity({
|
||||
sub: "sub-2",
|
||||
email: "New@Example.com",
|
||||
emailVerified: true,
|
||||
});
|
||||
mockUserLookups({ byIdentity: null, byEmail: null });
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(3);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
registrationEnabled: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.create).mockResolvedValue(
|
||||
userRow({ id: "user-new", onboardingCompletedAt: null }) as never,
|
||||
);
|
||||
|
||||
const res = await GET(validRequest());
|
||||
|
||||
expect(prisma.user.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
// Lowercased at provision time; stamped with the durable identity
|
||||
// and the same server-default timezone the register route resolves.
|
||||
email: "new@example.com",
|
||||
passwordHash: null,
|
||||
role: "USER",
|
||||
timezone: "Europe/Berlin",
|
||||
oidcIssuer: METADATA.issuer,
|
||||
oidcSub: "sub-2",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(createSession).toHaveBeenCalled();
|
||||
expect(res.status).toBe(307);
|
||||
});
|
||||
|
||||
it("mints ADMIN for a genuine first OIDC user (in-lock count 0)", async () => {
|
||||
mockIdentity({
|
||||
sub: "sub-first",
|
||||
email: "first@example.com",
|
||||
emailVerified: true,
|
||||
});
|
||||
mockUserLookups({ byIdentity: null, byEmail: null });
|
||||
// Both the outer registration gate and the in-lock re-count observe an
|
||||
// empty DB → the provisioned account is the operator's first ADMIN.
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
registrationEnabled: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.create).mockResolvedValue(
|
||||
userRow({ id: "user-new", onboardingCompletedAt: null }) as never,
|
||||
);
|
||||
|
||||
const res = await GET(validRequest());
|
||||
|
||||
const createArgs = vi.mocked(prisma.user.create).mock.calls[0]?.[0] as {
|
||||
data: { role: string };
|
||||
};
|
||||
expect(createArgs.data.role).toBe("ADMIN");
|
||||
expect(createSession).toHaveBeenCalled();
|
||||
expect(res.status).toBe(307);
|
||||
});
|
||||
|
||||
it("mints USER when a concurrent first user committed first (stale outer read 0, in-lock re-count 1)", async () => {
|
||||
// The outer read observed the empty-DB window (0), but by the time the
|
||||
// advisory-locked transaction re-counts, the racing first provision has
|
||||
// committed → 1. Without the in-lock re-count this second user would also
|
||||
// be minted ADMIN (the double-admin bug); the fresh count closes it. The
|
||||
// shared lock key also serialises this against a racing password register.
|
||||
mockIdentity({
|
||||
sub: "sub-second",
|
||||
email: "second@example.com",
|
||||
emailVerified: true,
|
||||
});
|
||||
mockUserLookups({ byIdentity: null, byEmail: null });
|
||||
vi.mocked(prisma.user.count)
|
||||
.mockResolvedValueOnce(0) // outer registrationEnabled gate
|
||||
.mockResolvedValueOnce(1); // inside the advisory-locked transaction
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
registrationEnabled: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.create).mockResolvedValue(
|
||||
userRow({ id: "user-new", onboardingCompletedAt: null }) as never,
|
||||
);
|
||||
|
||||
const res = await GET(validRequest());
|
||||
|
||||
const createArgs = vi.mocked(prisma.user.create).mock.calls[0]?.[0] as {
|
||||
data: { role: string };
|
||||
};
|
||||
expect(createArgs.data.role).toBe("USER");
|
||||
expect(res.status).toBe(307);
|
||||
});
|
||||
|
||||
it("acquires the transaction-scoped advisory lock before provisioning", async () => {
|
||||
mockIdentity({
|
||||
sub: "sub-lock",
|
||||
email: "lock@example.com",
|
||||
emailVerified: true,
|
||||
});
|
||||
mockUserLookups({ byIdentity: null, byEmail: null });
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
registrationEnabled: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.create).mockResolvedValue(
|
||||
userRow({ id: "user-new", onboardingCompletedAt: null }) as never,
|
||||
);
|
||||
|
||||
await GET(validRequest());
|
||||
|
||||
// The lock (`$queryRaw` on `pg_advisory_xact_lock`) runs inside the
|
||||
// `$transaction`, and the create is gated behind both.
|
||||
expect(prisma.$queryRaw).toHaveBeenCalled();
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
expect(
|
||||
vi.mocked(prisma.$queryRaw).mock.invocationCallOrder[0],
|
||||
).toBeLessThan(vi.mocked(prisma.user.create).mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it("refuses to auto-provision when registration is closed", async () => {
|
||||
mockIdentity({ sub: "sub-3", email: "blocked@example.com" });
|
||||
mockUserLookups({ byIdentity: null, byEmail: null });
|
||||
vi.mocked(prisma.user.count).mockResolvedValue(5);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
registrationEnabled: false,
|
||||
} as never);
|
||||
|
||||
const res = await GET(validRequest());
|
||||
|
||||
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||
expect(createSession).not.toHaveBeenCalled();
|
||||
expect(res.headers.get("location")).toContain(
|
||||
"error=oidc_registration_disabled",
|
||||
);
|
||||
});
|
||||
|
||||
it("never stamps the session step-up fresh — the 5th createSession arg is null", async () => {
|
||||
mockIdentity();
|
||||
mockUserLookups({ byIdentity: null, byEmail: userRow() });
|
||||
vi.mocked(prisma.user.update).mockResolvedValue(
|
||||
userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }) as never,
|
||||
);
|
||||
|
||||
await GET(validRequest());
|
||||
|
||||
expect(createSession).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
false,
|
||||
"1.2.3.4",
|
||||
null,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("routes a TOTP-enrolled account through the MFA challenge instead of logging in", async () => {
|
||||
mockIdentity();
|
||||
mockUserLookups({
|
||||
byIdentity: userRow({
|
||||
oidcIssuer: METADATA.issuer,
|
||||
oidcSub: "sub-1",
|
||||
totpConfirmedAt: new Date(),
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await GET(validRequest("/dashboard"));
|
||||
|
||||
// No session — the partial state lives in the single-use ticket.
|
||||
expect(createSession).not.toHaveBeenCalled();
|
||||
expect(createMfaChallenge).toHaveBeenCalledWith("user-1", "login");
|
||||
|
||||
const location = res.headers.get("location")!;
|
||||
expect(location).toContain("/auth/login");
|
||||
expect(location).not.toContain("error=");
|
||||
// `next` continuation rides the query string into the login page.
|
||||
expect(location).toContain(`next=${encodeURIComponent("/dashboard")}`);
|
||||
|
||||
// The ticket + methods ride the login-page-scoped handoff cookie.
|
||||
const setCookie = res.headers.getSetCookie().join("\n");
|
||||
expect(setCookie).toContain("oidc_mfa_handoff=");
|
||||
expect(setCookie).toContain(encodeURIComponent("mfa-ticket-1"));
|
||||
expect(setCookie.toLowerCase()).toContain("path=/auth/login");
|
||||
expect(decodeURIComponent(setCookie)).toContain('"totp"');
|
||||
});
|
||||
|
||||
it("offers only the security-key method for a WebAuthn-only account", async () => {
|
||||
mockIdentity();
|
||||
mockUserLookups({
|
||||
byIdentity: userRow({
|
||||
oidcIssuer: METADATA.issuer,
|
||||
oidcSub: "sub-1",
|
||||
totpConfirmedAt: null,
|
||||
}),
|
||||
});
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(1);
|
||||
|
||||
const res = await GET(validRequest());
|
||||
|
||||
expect(createSession).not.toHaveBeenCalled();
|
||||
const setCookie = decodeURIComponent(res.headers.getSetCookie().join("\n"));
|
||||
expect(setCookie).toContain('"webauthn"');
|
||||
expect(setCookie).not.toContain('"totp"');
|
||||
});
|
||||
|
||||
it("redirects with oidc_failed when the token exchange throws", async () => {
|
||||
vi.mocked(exchangeCodeForTokens).mockRejectedValue(new Error("network"));
|
||||
const res = await GET(validRequest());
|
||||
expect(res.headers.get("location")).toContain("error=oidc_failed");
|
||||
});
|
||||
|
||||
it("deletes the state cookie with the exact path it was set under", async () => {
|
||||
// RFC 6265 keys cookies by (name, domain, path) — a deletion without
|
||||
// `Path=/api/auth/oidc` would target a `/`-scoped cookie that does not
|
||||
// exist and leave the real single-use blob alive.
|
||||
const errorRes = await GET(makeRequest({ query: "?error=access_denied" }));
|
||||
const errorCookie = errorRes.headers
|
||||
.getSetCookie()
|
||||
.find((c) => c.startsWith("oidc_auth_state="))!;
|
||||
expect(errorCookie.toLowerCase()).toContain("path=/api/auth/oidc");
|
||||
|
||||
mockIdentity();
|
||||
mockUserLookups({
|
||||
byIdentity: userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }),
|
||||
});
|
||||
const successRes = await GET(validRequest());
|
||||
const successCookie = successRes.headers
|
||||
.getSetCookie()
|
||||
.find((c) => c.startsWith("oidc_auth_state="))!;
|
||||
expect(successCookie.toLowerCase()).toContain("path=/api/auth/oidc");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,419 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { recordSignInDevice } from "@/lib/auth/login-alert";
|
||||
import {
|
||||
deriveUniqueUsername,
|
||||
discoverOidcMetadata,
|
||||
exchangeCodeForTokens,
|
||||
fetchUserinfoEmail,
|
||||
getOidcConfig,
|
||||
getOidcRedirectUri,
|
||||
oidcAppUrl,
|
||||
verifyIdToken,
|
||||
} from "@/lib/auth/oidc";
|
||||
import {
|
||||
OIDC_MFA_COOKIE,
|
||||
OIDC_MFA_COOKIE_PATH,
|
||||
OIDC_MFA_TTL_MS,
|
||||
OIDC_STATE_COOKIE,
|
||||
OIDC_STATE_COOKIE_PATH,
|
||||
} from "@/lib/auth/oidc-cookie";
|
||||
import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
|
||||
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
import { resolveServerDefaultTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
const LOGIN_ERROR_URL = "/auth/login";
|
||||
|
||||
function errorRedirect(reason: string): NextResponse {
|
||||
const response = NextResponse.redirect(
|
||||
oidcAppUrl(`${LOGIN_ERROR_URL}?error=${reason}`),
|
||||
);
|
||||
deleteStateCookie(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* The state cookie is set with `path: "/api/auth/oidc"`; the delete must
|
||||
* repeat that path (RFC 6265 keys cookies by name+domain+path — a bare
|
||||
* delete would target `/` and never match, leaving the single-use blob
|
||||
* alive for the rest of its TTL).
|
||||
*/
|
||||
function deleteStateCookie(response: NextResponse): void {
|
||||
response.cookies.delete({
|
||||
name: OIDC_STATE_COOKIE,
|
||||
path: OIDC_STATE_COOKIE_PATH,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Closed set of RFC 6749 §4.1.2.1 / OIDC Core §3.1.2.6 authorization error
|
||||
* codes. The wide-event meta only ever carries a member of this set (or
|
||||
* "other") — never the raw, attacker-influenceable query value.
|
||||
*/
|
||||
const KNOWN_IDP_ERROR_CODES = new Set([
|
||||
"access_denied",
|
||||
"invalid_request",
|
||||
"unauthorized_client",
|
||||
"unsupported_response_type",
|
||||
"invalid_scope",
|
||||
"server_error",
|
||||
"temporarily_unavailable",
|
||||
"interaction_required",
|
||||
"login_required",
|
||||
"account_selection_required",
|
||||
"consent_required",
|
||||
"invalid_request_uri",
|
||||
"invalid_request_object",
|
||||
"request_not_supported",
|
||||
"request_uri_not_supported",
|
||||
"registration_not_supported",
|
||||
]);
|
||||
|
||||
interface StoredOidcState {
|
||||
state: string;
|
||||
nonce: string;
|
||||
codeVerifier: string;
|
||||
next: string;
|
||||
}
|
||||
|
||||
function isStoredOidcState(value: unknown): value is StoredOidcState {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
typeof (value as Record<string, unknown>).state === "string" &&
|
||||
typeof (value as Record<string, unknown>).nonce === "string" &&
|
||||
typeof (value as Record<string, unknown>).codeVerifier === "string" &&
|
||||
typeof (value as Record<string, unknown>).next === "string"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* OIDC callback. Must work with NO existing session — this route
|
||||
* authenticates the user in the first place, unlike the per-account
|
||||
* integration callbacks (Withings/Polar/etc.) which all require
|
||||
* `requireAuth()`. Every failure branch redirects to `/auth/login?error=...`
|
||||
* (never JSON — this is a top-level browser navigation) and deletes the
|
||||
* single-use state cookie, mirroring `src/app/api/withings/callback/route.ts`.
|
||||
*/
|
||||
export const GET = apiHandler(async (req: NextRequest) => {
|
||||
annotate({ action: { name: "auth.oidc.callback" } });
|
||||
|
||||
const config = getOidcConfig();
|
||||
if (!config) return errorRedirect("oidc_disabled");
|
||||
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
req,
|
||||
"auth:oidc:callback",
|
||||
20,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
const ip = rl.ip ?? "unknown";
|
||||
if (!rl.allowed) return errorRedirect("oidc_rate_limited");
|
||||
|
||||
const { searchParams } = req.nextUrl;
|
||||
const idpError = searchParams.get("error");
|
||||
if (idpError) {
|
||||
annotate({
|
||||
meta: {
|
||||
reason: "idp_denied",
|
||||
idpError: KNOWN_IDP_ERROR_CODES.has(idpError) ? idpError : "other",
|
||||
},
|
||||
});
|
||||
return errorRedirect("oidc_denied");
|
||||
}
|
||||
|
||||
const code = searchParams.get("code");
|
||||
const state = searchParams.get("state");
|
||||
if (!code || !state) return errorRedirect("oidc_state");
|
||||
|
||||
const rawCookie = req.cookies.get(OIDC_STATE_COOKIE)?.value;
|
||||
let stored: StoredOidcState | null = null;
|
||||
if (rawCookie) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(decrypt(rawCookie));
|
||||
if (isStoredOidcState(parsed)) stored = parsed;
|
||||
} catch {
|
||||
stored = null;
|
||||
}
|
||||
}
|
||||
if (!stored) return errorRedirect("oidc_state");
|
||||
|
||||
// CSRF check — timing-safe, byte-for-byte, mirroring the Withings callback.
|
||||
if (
|
||||
state.length !== stored.state.length ||
|
||||
!timingSafeEqual(Buffer.from(state), Buffer.from(stored.state))
|
||||
) {
|
||||
annotate({ meta: { reason: "csrf" } });
|
||||
return errorRedirect("oidc_state");
|
||||
}
|
||||
|
||||
try {
|
||||
const metadata = await discoverOidcMetadata(config);
|
||||
const redirectUri = getOidcRedirectUri();
|
||||
|
||||
const tokens = await exchangeCodeForTokens({
|
||||
metadata,
|
||||
config,
|
||||
code,
|
||||
codeVerifier: stored.codeVerifier,
|
||||
redirectUri,
|
||||
});
|
||||
if (!tokens.id_token) return errorRedirect("oidc_failed");
|
||||
|
||||
const identity = await verifyIdToken({
|
||||
metadata,
|
||||
config,
|
||||
idToken: tokens.id_token,
|
||||
nonce: stored.nonce,
|
||||
});
|
||||
|
||||
let email = identity.email;
|
||||
let emailVerified = identity.emailVerified;
|
||||
if (!email && tokens.access_token) {
|
||||
const userinfo = await fetchUserinfoEmail({
|
||||
metadata,
|
||||
accessToken: tokens.access_token,
|
||||
});
|
||||
email = userinfo.email;
|
||||
emailVerified = userinfo.emailVerified;
|
||||
}
|
||||
|
||||
// (1) Durable identity match — the (issuer, sub) pair stamped on a
|
||||
// previous login, link, or provision. Email is deliberately NOT
|
||||
// consulted here: after the stamp it is a display field, so an
|
||||
// IdP-side email change can refresh it below but can never re-point
|
||||
// the login at a different account.
|
||||
let user = await prisma.user.findFirst({
|
||||
where: { oidcIssuer: metadata.issuer, oidcSub: identity.sub },
|
||||
});
|
||||
|
||||
if (user) {
|
||||
// Refresh the display email only — and only from a claim the IdP
|
||||
// explicitly marks verified. Skipped when another account already
|
||||
// holds the address (`email` is unique); the login itself proceeds
|
||||
// either way, keyed by (issuer, sub).
|
||||
const displayEmail = email ? email.toLowerCase() : null;
|
||||
if (
|
||||
displayEmail &&
|
||||
emailVerified === true &&
|
||||
user.email?.toLowerCase() !== displayEmail
|
||||
) {
|
||||
const emailTaken = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: { equals: displayEmail, mode: "insensitive" },
|
||||
NOT: { id: user.id },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!emailTaken) {
|
||||
user = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { email: displayEmail },
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No stamped identity yet — both remaining paths (link-once,
|
||||
// provision) bind this (issuer, sub) to an account keyed by email,
|
||||
// so the claim must be explicitly `email_verified: true`. An ABSENT
|
||||
// claim is a reject, not a benefit of the doubt: an IdP that does
|
||||
// not assert verification cannot anchor a link that later gates a
|
||||
// health record.
|
||||
if (!email) {
|
||||
annotate({ meta: { reason: "no_email" } });
|
||||
return errorRedirect("oidc_no_email");
|
||||
}
|
||||
if (emailVerified !== true) {
|
||||
annotate({ meta: { reason: "email_unverified" } });
|
||||
return errorRedirect("oidc_email_unverified");
|
||||
}
|
||||
|
||||
const byEmail = await prisma.user.findFirst({
|
||||
where: { email: { equals: email, mode: "insensitive" } },
|
||||
});
|
||||
|
||||
if (byEmail) {
|
||||
// (2) Link ONCE. An account already pinned to a different IdP
|
||||
// identity is never silently re-bound — a rebind would let a
|
||||
// second IdP identity that acquires the same email capture the
|
||||
// account. Audited so the operator can see the collision.
|
||||
if (byEmail.oidcIssuer !== null || byEmail.oidcSub !== null) {
|
||||
await auditLog("auth.oidc.link_conflict", {
|
||||
userId: byEmail.id,
|
||||
ipAddress: ip,
|
||||
});
|
||||
annotate({ meta: { reason: "identity_conflict" } });
|
||||
return errorRedirect("oidc_identity_conflict");
|
||||
}
|
||||
user = await prisma.user.update({
|
||||
where: { id: byEmail.id },
|
||||
data: { oidcIssuer: metadata.issuer, oidcSub: identity.sub },
|
||||
});
|
||||
await auditLog("auth.oidc.linked", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
});
|
||||
} else {
|
||||
// (3) Provision.
|
||||
const userCount = await prisma.user.count();
|
||||
let registrationEnabled = true;
|
||||
try {
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
});
|
||||
if (settings && !settings.registrationEnabled && userCount > 0) {
|
||||
registrationEnabled = false;
|
||||
}
|
||||
} catch {
|
||||
// Table may not exist yet; allow provisioning (matches register/route.ts).
|
||||
}
|
||||
if (!registrationEnabled) {
|
||||
annotate({ meta: { reason: "registration_disabled" } });
|
||||
return errorRedirect("oidc_registration_disabled");
|
||||
}
|
||||
|
||||
const username = await deriveUniqueUsername(email, (candidate) =>
|
||||
prisma.user
|
||||
.findUnique({ where: { username: candidate } })
|
||||
.then((u: unknown) => u !== null),
|
||||
);
|
||||
|
||||
// Same server-default resolution the register route uses — an
|
||||
// SSO-provisioned account has no registration form to carry the
|
||||
// browser-detected timezone.
|
||||
const timezone = await resolveServerDefaultTimezone();
|
||||
|
||||
// v1.28.48 — the first provisioned user becomes ADMIN. Deriving that
|
||||
// role from the early `userCount` (read above, before username
|
||||
// derivation) and then creating without coordination is the same
|
||||
// check-then-act race the password-register route closed in v1.28.42:
|
||||
// two concurrent OIDC first-logins racing the empty-DB window would
|
||||
// both observe `0` and both be minted ADMIN. Serialise the
|
||||
// count+insert behind a transaction-scoped advisory lock (released on
|
||||
// commit/rollback) and re-count *inside* the lock so the second
|
||||
// provision observes the first's committed row and is minted USER.
|
||||
// The lock key STRING matches the register route's — an OIDC
|
||||
// first-login racing a password first-registration is serialised
|
||||
// against the same lock, so only one ADMIN can ever be minted.
|
||||
user = await prisma.$transaction(async (tx) => {
|
||||
// `pg_advisory_xact_lock` returns void, which the client cannot
|
||||
// deserialize as a column — selecting FROM it yields a plain int row.
|
||||
await tx.$queryRaw`
|
||||
SELECT 1 AS locked
|
||||
FROM pg_advisory_xact_lock(hashtextextended('register:first-admin', 0))
|
||||
`;
|
||||
const priorUsers = await tx.user.count();
|
||||
const role = priorUsers === 0 ? "ADMIN" : "USER";
|
||||
return tx.user.create({
|
||||
data: {
|
||||
email: email.toLowerCase(),
|
||||
username,
|
||||
passwordHash: null,
|
||||
role,
|
||||
timezone,
|
||||
oidcIssuer: metadata.issuer,
|
||||
oidcSub: identity.sub,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await auditLog("auth.oidc.provisioned", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Native second factor. OIDC is a DELEGATED factor: it proves the IdP
|
||||
// authenticated someone, but it never substitutes for this app's own
|
||||
// second factor. An account with a confirmed TOTP secret or a
|
||||
// registered security key gets the same MFA challenge step password
|
||||
// login gets — no session is minted here; the single-use ticket rides
|
||||
// the handoff cookie to the login page, which renders the same
|
||||
// challenge UI and completes at `/api/auth/mfa/verify`. (The
|
||||
// trusted-device skip cannot apply on this path: its cookie is
|
||||
// SameSite=Strict, which the browser withholds on an IdP-initiated
|
||||
// redirect chain.)
|
||||
const hasTotp = Boolean(user.totpConfirmedAt);
|
||||
const webauthnKeyCount = await prisma.webauthnMfaCredential.count({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
const hasWebauthn = webauthnKeyCount > 0;
|
||||
|
||||
if (hasTotp || hasWebauthn) {
|
||||
const challenge = await createMfaChallenge(user.id, "login");
|
||||
const methods: ("totp" | "recovery" | "webauthn")[] = [];
|
||||
if (hasTotp) methods.push("totp", "recovery");
|
||||
if (hasWebauthn) methods.push("webauthn");
|
||||
await auditLog("auth.mfa.challenge", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { source: "login.oidc" },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.mfa.challenge" },
|
||||
meta: { mfa_required: true },
|
||||
});
|
||||
const loginUrl = oidcAppUrl(LOGIN_ERROR_URL);
|
||||
if (stored.next !== "/") {
|
||||
loginUrl.searchParams.set("next", stored.next);
|
||||
}
|
||||
const response = NextResponse.redirect(loginUrl);
|
||||
deleteStateCookie(response);
|
||||
response.cookies.set(
|
||||
OIDC_MFA_COOKIE,
|
||||
JSON.stringify({ ticket: challenge.ticket, methods }),
|
||||
{
|
||||
// Not httpOnly by design — see the constant's doc comment.
|
||||
httpOnly: false,
|
||||
secure: shouldEmitSecureCookie(),
|
||||
sameSite: "lax",
|
||||
maxAge: Math.floor(OIDC_MFA_TTL_MS / 1000),
|
||||
path: OIDC_MFA_COOKIE_PATH,
|
||||
},
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
const ua = req.headers.get("user-agent");
|
||||
|
||||
// v1.23 parity with password login — the admin-enforced-MFA hint cookie
|
||||
// sends a single-factor account into forced enrollment after sign-in.
|
||||
await syncMfaEnrollCookie(user.id, {
|
||||
totpConfirmedAt: user.totpConfirmedAt,
|
||||
mfaEnforced: user.mfaEnforced,
|
||||
});
|
||||
|
||||
// The 5th argument stays null: OIDC is a delegated factor, so an SSO
|
||||
// session must never satisfy `requireFreshMfa` step-up (MFA disable,
|
||||
// encryption-key rotation, encrypted export, account deletion) — the
|
||||
// same treatment a trusted-device login gets.
|
||||
await createSession(
|
||||
user.id,
|
||||
user.onboardingCompletedAt === null,
|
||||
ip,
|
||||
ua,
|
||||
null,
|
||||
);
|
||||
|
||||
void recordSignInDevice({ userId: user.id, ip, userAgent: ua });
|
||||
|
||||
await auditLog("auth.oidc.login", { userId: user.id, ipAddress: ip });
|
||||
|
||||
const response = NextResponse.redirect(oidcAppUrl(stored.next));
|
||||
deleteStateCookie(response);
|
||||
return response;
|
||||
} catch (err) {
|
||||
getEvent()?.setError(err);
|
||||
annotate({ meta: { reason: "exchange_or_verify_failed" } });
|
||||
return errorRedirect("oidc_failed");
|
||||
}
|
||||
});
|
||||
Loaded 100 of 967 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user