mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
6ff24cf2cf4e4cd77716ff50054dbbebc029e1b1
12
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
0924285cd6 |
feat: serve the day, its index, the timeline and life events
Replace the day, timeline and life-event stubs with their implementations (#613) and drop the not-implemented helper, its error code and its OpenAPI response with the last stub. loadDay reads one local day for every reader: the readings live inside the record's own day window (never the UTC rollups), through the canonical-source engine the mood sheet already uses (resolveDayReadings in linked-context.ts), with the person's usual range over the 30 days before; the record sections from their own tables, each in the storage form it speaks (an instant in the local window, a date string with its own zone, a calendar date). A switched-off module's section is not read; a section the grant does not cover is named not_shared and not read. The day, the day index and the new visit preparation (GET /api/day/notable) are admitted on the readings and narrowed per section through actingDomainVisibility, so a scoped grant sees exactly its sections. The timeline and its readiness stay whole-record reads and narrow their lanes the same way. Notable days are deterministic keys: a three-month high or low with history behind it, a first reading, and in the visit preparation a regular reading that stopped. The timeline lays out spans and points from the owning tables, standing items without a date, and series by zoom: months from the rollup tier inside its five-year window and from one cached live query before it. The readiness inventory rates each lane with one link per gap. Life events are created, edited and soft-deleted as MANAGE verbs with audit rows, encrypted at rest, and join the sharing guard and the handler matrix. get_day reads a day for the Coach and the MCP endpoint without life events, notes or document names; its step carries the date it read. |
||
|
|
e63be67b5a |
feat: lay down the shared v1.42 contract
Land every file the v1.42 work shares before any of it is built, so the parts built side by side never edit the same file. Schema and migrations 0379-0386: the HEALTH_CONNECT measurement source, ImportJob.kind, the air-quality columns and the per-user switch on the environment tables, sealed environment locations with nullable plaintext, managed-profile handover links, the per-type HealthKit arrival ledger, lab unit-conversion provenance, and user-defined vaccines. Every new column is annotated for the column-reader sweep. HEALTH_CONNECT reaches every exhaustive list: the source labels, the read enum (not the write allowlist), the source-priority ladder cap, now the number of sources rather than a fixed eight, and the write-path smoke test. RMSSD gets its own priority class and a 300 ms ceiling. The batch syncTrigger gains `manual`, with its label on the Apple Health card. Queues for the Health Connect import, the compaction-tombstone purge and the measurement-table maintenance are registered with stub handlers; host-metric sampling moves to every five minutes. Environment and work_mem variables reach the compose whitelist, the production example and the manifest. The proxy admits the handover link and redirects it at the edge, and the Health Connect upload bypasses the proxy body limit. The new routes answer 501 behind their final authentication and are published in the contract; backup, wipe and key rotation classify the new models and columns; the seven message bundles carry the base copy. Guards that list release-pending keys and columns fail once an entry is wired, so those lists empty themselves before the tag. Also re-anchor a login-throttle fixture to the wall clock, where its expiry is compared, and re-base the aggregate bundle cap on a measured 12 KB of new locale strings. |
||
|
|
dd72634791 |
Rebuild the Coach thinking row as one calm line
While a turn runs the Coach now shows a single row: a brain icon, the current step in the answer's text size with a shimmer passing through it and fading dots, and a chevron once there are steps to open. There is no running clock any more, and the interim table preview with its sparkline no longer renders under the row; interim tables stay in state until the answer's own results show them. When the answer is done the row reads "Thought process · N steps" with proper plurals, and "Answer needed" while a clarifying question waits. It never opens by itself, and a row opened during the run closes when the turn ends. Opened, the steps form a vertical list with an icon per kind of work on a thin connector line, and a reasoning round shows its summary under its title. The shimmer and the dots are a `text-shimmer` utility and a reusable WaitingText component, both still under reduced motion. The demo's write block now carries `meta.errorCode: "demo.readOnly"`, listed in the error-code catalogue, and the Coach answers it with an honest line instead of the provider-outage copy. Removes the interim preview component, the elapsed-seconds and summary helpers, and their message keys in all seven locales. |
||
|
|
7d05f68f23 |
fix(http): say when proxy headers go unread, and trust X-Real-IP only at one hop
With TRUST_PROXY_HOPS=0 behind a proxy that sends only X-Real-IP, every client address resolves to nothing and all anonymous sign-ins share one rate-limit bucket, with nothing in the log to say why. The first request that carries a forwarding header the setting does not let HealthLog read now logs one warning naming the header and the setting to change. X-Real-IP stood in for a missing X-Forwarded-For at any hop count. It is a single address, so it is now read only with TRUST_PROXY_HOPS=1; with two or more hops the chain the count describes is X-Forwarded-For. The upload, import and native batch routes that bypass the proxy now carry Cross-Origin-Resource-Policy and Cross-Origin-Embedder-Policy like every other response, and so do the proxy's early exits. The env examples described an unset TRUST_PROXY_HOPS as right for a directly exposed host; unset means one hop, and that host wants 0. |
||
|
|
2139b49c47 |
fix(http): stop reading request bodies of up to 512 MB before authentication
Next reads the body of every non-GET request the proxy matches into memory before the route runs. The ceiling was 512 MB so the Apple Health importer could receive whole archives, which let any anonymous POST to any API path make the server hold that much. The ceiling is now 1 MB (proxyClientMaxBodySize). The fifteen routes whose real bodies are larger are left out of the proxy matcher and read their own body after authenticating; apiHandler gives them the proxy's refusals (worker-only, demo mode), security headers and a CSP. safeJson now counts bytes while it reads and stops at its cap instead of reading everything first, the CSV and dose-history imports do the same, and a multipart backup upload is counted while it is read. A test compiles the matcher the way Next does and checks it skips exactly the listed routes and every route with a larger body is listed. Local lab OCR runs its WebAssembly engine in a worker served from /tesseract/. Those responses, and the /labs and /documents pages that start the OCR, now carry 'wasm-unsafe-eval' in script-src; no other route does. |
||
|
|
93874af99b |
fix(api): record the retirement against the release that carries it
v1.38.19 was cut before this change and still answers on the path, so naming it as the version that removed the route would be wrong in the published contract from the day it appears. |
||
|
|
3ace3aad35 |
Answer 410 for the removed account-discovery path
The path stood in the published contract, so a caller outside this repository can hold a reference to it whether or not we can name one. A bare 404 reads as a broken deploy or a misrouted proxy, which is exactly the reading that kept a client retrying a removed path for twelve days once before. Registering the retirement makes the edge answer 410 with the removing version and no replacement, and republishes the path as gone rather than erasing it from the document. |
||
|
|
87929eb3b8 |
feat(api): answer a retired route with 410 rather than a bare 404
A route removed on purpose still leaves a client calling it. As reported in iOS #94: between 2026-08-08 and 2026-08-20 the native client asked `/api/auth/me/research-mode` on every GLP-1 detail screen, got a bare 404, and painted a permanent error card with a retry button aimed at the same removed path. Twelve days, on every reachable instance including the public demo, which was built after the removal. Deleting the route was right and the removing commit reasons it out in full; the answer said nothing. A 404 from a deployment is indistinguishable from a broken build, a reverse proxy pointed at the wrong upstream, or a typo in the caller's own path, so reading it as transient and retrying was the correct reading of the only evidence there was. One registry, `src/lib/http/retired-routes.ts`, carrying the path, the release that removed it, the replacement where there is one, and one sentence on why. Every other surface reads it. 410 over a 404 with an error code because it is the status whose entire meaning is "this existed here and is gone": a client that understands nothing else about this API still learns not to retry. The code is not dropped, it rides in the same envelope with `removedIn` and `replacedBy` for a client that reads further. The status is the floor, the code is the detail. Answered in the proxy, before routing, rather than by a catch-all route module: a catch-all under `/api` would intercept every unmatched path and make the ordinary 404 ours to manufacture, which is a far wider blast radius than the problem. The proxy already owns the exact-path decisions of this shape. It reads nothing off the request but the path, so an anonymous caller and an authenticated one get identical bytes and the tombstone cannot become an authentication oracle. Seeded from the removed-route set: every route path under `src/app` at every release tag, unioned and differenced against the current tree. Twenty-seven paths have been removed over the project's history; the five registered here are the ones that were published or client-facing, and each removal is pinned to its release by the route's presence at one tag and absence at the next. Whether any client still calls one of them is a separate question that evidence in this repository cannot answer, and the registry deliberately makes no claim about it. |
||
|
|
cc8c69aa07 |
Stop redirecting to the address the process bound to
Reported from the iOS side, iOS #96. `GET /api/auth/native/login` answers location: https://0.0.0.0:3000/auth/login?flow=native behind a proxy that does not pass `Host` through. `NextResponse.redirect` needs an absolute URL, the route built one with `new URL(path, req.url)`, and `req.url` there is the address the Node process bound to. That is the primary onboarding path. ASWebAuthenticationSession follows the redirect, iOS refuses the restricted port, and the person lands on a browser error page with no way forward. Distributed builds carry no associated domains on purpose, so the browser handoff is also how passkeys are meant to work; without it a new account is down to a password and a confirmation code. Two things hid it. Every error branch of that route builds the fixed healthlog:// scheme with no host at all, so the route behaved correctly on every failure and broke only when it was supposed to succeed. And its own test built a request whose host matched, so the absolute URL it produced looked right. The assertion there used toContain, which is satisfied by the broken value just as happily as the correct one. The same line was in six more places: the error redirects of the Withings, WHOOP and Fitbit connect routes, which send a browser to /settings/integrations. Behind the same proxy those land on the same unreachable address, so all seven move to a relative Location. A relative Location is valid per RFC 7231 and the client resolves it against the address it actually called, which is the public one by definition. The alternative is reading X-Forwarded-Host, and that would put one more proxy setting between a self-hoster and a working sign-in. Three checks, as the report asked for. The route test now drives a request whose URL names the bind address, which is what a proxy hop looks like from inside the handler. A structural test freezes the pattern out of the four files and, separately, requires each of them to still reach for the helper, because a file that simply deleted its redirect would pass the first check and fail its users. The helper refuses a protocol-relative value, which is a host in disguise. Verified by breaking it: putting the old call back fails four checks, the last of them reporting the reported value verbatim. Refs iOS #96. |
||
|
|
0f2b548138 |
style: apply prettier 3.9.1 formatting
Mechanical reflow from the prettier 3.9.1 bump; no behaviour change. |
||
|
|
22eb9cba0d |
fix(mcp): close OAuth 2.1 resource-server gaps before the remote surface ships
Harden the remote MCP endpoint and the in-repo OAuth bridge against the adversarial review of the milestone branch. The two ship-blockers made the "read-only, revocable" promise false; the rest tightened the surface. Audience binding at the resource server: a token whose only grant is the new `health:read` scope is the MCP read credential. The REST edge now accepts it on safe (read) methods and refuses it on writes/deletes with 403, so a connector token can read over `/mcp` and GET routes but can never POST/DELETE the record despite a "read-only" consent. The `/mcp` resolver is unaffected. Revocable, theft-detectable refresh tokens: refresh artifacts now anchor to a persistent connection row. Revoking a connection stops every future refresh and kills its access tokens; a replayed (already-rotated) refresh is treated as theft and revokes the whole connection. Connections surface in settings as the unit a user can disconnect. The old access token is revoked on rotation. Also: fail closed on the OAuth/`/mcp` surface when no canonical origin is configured and mark the discovery documents non-cacheable; honour the global API kill-switch on `/mcp`, `/token`, `/authorize`, and `/register`; cap the CIMD and registration body reads while streaming instead of after; show the verified client origin on consent; validate CIMD redirect-URI schemes; assert same-origin on the consent POST; and add a daily prune for expired connector tokens. New migration 0195 adds the connection anchor and links access tokens to it. |
||
|
|
affede31a4 |
v1.4.34 — apple-health import + reliability + web-freeze
The final functional web release before the iOS native client lands.
Headline work: a streaming Apple Health `export.zip` importer for
prospective iOS-migrating accounts, a server-side aggregation cache
that collapses the three hottest dashboard reads onto in-process LRU
slots with single-flight coalescing and per-user invalidation, a
broader compliance classifier with a dedicated `early` bucket so
ahead-of-window doses count as compliant, the Coach launch surface
hoisted onto every authed page with a dashboard-hero CTA, and a
trimmer Settings shelf that folds Sources into Targets. The release
also lights up the `cache.<name>.outcome` annotation on the active
wide event so production logs carry the cache hit-ratio signal
without leaking userIds.
### Added
- **Server-side aggregation cache.** A new `ServerCache<T>` primitive
(`src/lib/cache/server-cache.ts`) extends the v1.4.33 Coach snapshot
LRU shape into a reusable per-route layer with TTL expiry,
capacity-bounded LRU eviction, single-flight read coalescing, and
per-instance hit / miss / eviction / stampede counters. Wires the
three hottest dashboard reads — `/api/analytics` (slim + default),
`/api/gamification/achievements`, `/api/medications/intake?scope=compliance`
— and bolts per-user invalidation onto every measurement / mood /
medication / workout / dashboard-widget write endpoint so the next
read paints fresh data instead of waiting out the TTL. Every cache
hit / miss surfaces on the active wide event as
`cache.<name>.outcome` + `cache.<name>.key_hash` so production logs
carry the hit-ratio signal without leaking userIds.
- **Apple Health `export.zip` import.** New endpoint
`POST /api/import/apple-health-export` (synchronous multipart
upload, asynchronous ingest via a dedicated pg-boss
`apple-health-import` worker, per-`MeasurementType` ingestion
stats). Streams the upload straight to disk so a multi-gigabyte
export never lands in V8 heap, hashes the bytes inline for
content-based idempotency, and unpacks `apple_health_export/export.xml`
with a hand-rolled ZIP central-directory walker that handles Zip64
for archives past the 4 GB barrier. The parser folds every
`<Record>`, `<Workout>`, `<Correlation>`, and `<ClinicalRecord>`
into the existing `Measurement` and `Workout` row shapes — spot
rows keyed by `HKMetadataKeyExternalUUID` (or a deterministic
`sample:<sha256>` fallback), cumulative HK types collapsed into
one `stats:<HKType>:<YYYY-MM-DD>` row per user-local day to match
the iOS daily-aggregation convention. Live per-stage progress
surfaces through `GET /api/import/apple-health-export/{jobId}/status`.
An admin variant at `POST /api/admin/import-apple-health-export`
imports on behalf of a target user (cookie-only `requireAdmin()`
gate; Bearer tokens never elevate).
- **`ImportJob` schema model + migration.** New Prisma model captures
the per-upload state machine (`queued | unpacking | parsing |
upserting | done | failed`), the content-hash for idempotency
short-circuits, and the per-`MeasurementType` ingestion counters
the status route surfaces back to the client.
- **`lastSeenByType` on `/api/analytics`.** Both the slim
(`?slice=summaries`) and default slices return a per-type
`{ lastSeenAt, daysAgo }` map. The dashboard trend tiles wire 12
mounts to a new `tileStaleDays()` helper so an "X days / weeks /
months ago" hint paints under any per-metric tile whose last
sample crosses the 7-day floor. Six locales gained additive
week / month plural keys; Polish uses the genitive-plural form
that covers every non-1 count the bucket math can produce.
- **Dashboard "Ask the coach" hero CTA.** A new pill next to the
existing "Hinzufügen" / "Add" launches the Coach drawer directly
from the dashboard hero. The `<CoachLaunchProvider>` hoisted from
the insights layout to the auth shell so the drawer is reachable
from every authed route; the floating action button stays scoped
to `/insights/**` so it cannot distract on the dashboard.
- **Shared achievements query hook.** `useAchievementsQuery()`
centralises the three previous consumers — recent-achievements
card, the `/achievements` mother page, and the unlock notifier —
onto one TanStack queryKey + cache slot so dashboard cold mount
fires the endpoint once instead of twice.
- **Typed authed Cache-Control presets.** New
`src/lib/http/cache-headers.ts` module exports
`NO_STORE_BUT_BFCACHE = "private, max-age=0, must-revalidate"`,
`SHORT_LIVED_PUBLIC`, and an `applyAuthedHeaders()` helper. The
presets land at the framework level via a `next.config.ts`
`headers()` rule that stamps the bfcache-friendly directive on
every authed HTML response, restoring Chromium bfcache eligibility
for in-app back / forward navigation.
- **`scripts/print-bundle-report.mjs`.** Restores the at-a-glance
"top client chunks by parsed size" signal Turbopack dropped from
`next build`; reads `.next/analyze/client.json` (written by
`@next/bundle-analyzer` when `pnpm analyze` runs with `ANALYZE=1`)
and prints a sorted table plus totals.
### Changed
- **Settings sidebar: "Sources" folded into "Targets & Sources".**
Per-metric threshold ranges and per-metric source priority now
share one `/settings/thresholds` page so the same metric's
threshold and device-source preference sit together instead of
one sidebar entry away. `/settings/sources` keeps a
`permanentRedirect` so external bookmarks and docs links follow
through unchanged. Section count: 11 → 10.
- **Insights tab strip: vital pills collapse under a "Vitals"
parent.** Five vital pills (HRV, Resting HR, Oxygen, Body
Temperature, Active Energy) hide behind one parent pill that
opens a popover sub-list. Parent-pill active state mirrors the
URL so spatial orientation survives the collapse; each sub-page
keeps its own URL and bookmark resolves unchanged. Strip
footprint: 14 → 10 entries when every metric has data.
- **Coolify env-var audit.** `mcp__coolify-apps01__env_vars`
inspection captured the section-1 / section-2 duplicates that
have accumulated under apps-01 since v1.3.1. Audit pinned at
`.planning/round-v1434-iwa-coolify-env-audit.md`; no env-var
deletes performed (operator action).
### Fixed
- **Compliance classifier: early intakes count as compliant.** The
classifier picked up a dedicated `early` bucket (window-start
minus three hours through window-start), the `on_time` post-window
tolerance widened from one hour to three hours, and the `late`
band sits between the new `on_time` ceiling and the configurable
`lateMinutes` knob. Heatmap consumers route the new bucket
through the compliant path; the dedicated `early` counter rides
on `DailyComplianceEntry` for downstream consumers that want to
distinguish ahead-of-window from on-window intakes.
- **Compliance heatmap fallback retired.** The v1.4.33 defensive
`looksClassifierBug` fallthrough is gone now that the underlying
classifier widening removes the every-dose-very-late mode the
fallback covered.
### Performance
- **`/api/gamification/achievements` consumer collapse.** Two of
three previous consumers shared the same TanStack queryKey
literal; the third carried a per-user discriminator so the cache
treated it as a fresh slot. Dashboard cold mount fired the
endpoint twice. Collapsing onto the shared hook trims that to a
single request; with the new server-side cache slot warm, the
duplicate-mount worst case rides single-flight coalescing.
- **GHCR build fires once per release tag.** The
`docker-publish.yml` workflow lost its `push.branches: [main]`
trigger; the `:latest` raw-tag enable rule moved onto the tag
ref so each release produces exactly one multi-arch build that
refreshes both the semver tags and the `:latest` alias.
- **NFT-trace warnings silenced.** `next.config.ts` gained an
`outputFileTracingExcludes` block that narrows the Turbopack
tracer away from the `MAXMIND_LICENSE_KEY` env-access path so
the trace-report no longer warns on every dependent route file.
### Refactor
- **Dashboard analytics consumers.** The two cards that previously
decided their own freshness copy now lean on the typed
`tileStaleDays()` helper and the additive `lastSeenByType` field
on the analytics response. Mood and BD-Zielbereich tiles keep the
default `null` because they have no underlying per-type freshness
signal.
### Accessibility
- **Dashboard hero CTA hits the WCAG 2.5.5 touch-target floor.**
The new "Ask the coach" pill carries `min-h-11` on mobile and
the matching `sm:min-h-9` desktop floor so the touch target
stays above 44 px on the Pixel 5 boundary.
- **Insights tab-strip Vitals parent reads correctly to assistive
tech.** Parent pill carries `aria-current="page"` whenever the
current URL matches one of its child vital sub-pages, plus a
`data-slot="insights-tab-strip-group"` hook for visual-regression
testing.
### Internal
- **Two e2e flake windows tightened.** `onboarding-flicker` swapped
its 12-sample 50 ms poll loop for a single Playwright
auto-retrying `toBeHidden({ timeout: 700 })` assertion so the
1-2 ms race window between the analytics-pending shell and
`useAuth().user` resolving collapses to a single retry slot.
`mobile-viewport` dropped `nav a[href]` from the touch-target
sweep (the bottom-nav owns its own WCAG spec) and gated the
44-px floor on `matchMedia('(min-width: 640px)').matches === false`
so the Pixel 5 viewport never tripped into the `sm:` tier during
WebKit render commits.
- **Server-cache observability.** Every `cached()` invocation passes
an `annotate` callback that lands two keys on the wide event:
`cache.<name>.outcome` ∈ `{ hit | miss | stampede }` and
`cache.<name>.key_hash` (non-reversible djb2 32-bit hash). Ops
can grep `cache.analytics.outcome` over any time window to
compute the hit ratio per deployment.
- **AASA followups verified.** The v1.4.33 `/.well-known/apple-app-site-association`
handler serves a direct 200 on every fronting origin; Apple's
CDN has ingested matching bodies for all three domains.
### Web freeze
v1.4.34 marks the last functional release of the web codebase before
the iOS native client launches. Subsequent v1.4.x tags carry security
fixes, dependency bumps, and hotfix-only corrections. New feature work
is paused until the iOS app clears Apple review, at which point a
v1.5.0 version-bump-only release tags the milestone. The Prisma
schema head comment and `.planning/v15-strategic-plan.md` §5
decision-log row pin the freeze trigger in-tree.
|