Files
HealthLog/docs/integrations/apple-health.md
T
Marc-André Bombeck f5962497d6 v1.4.34.4 — audit-driven hotfix bundle (security, UX, code-quality, docs)
Consolidated hotfix landing the findings from a six-axis audit
covering mobile security, UX + accessibility, code quality + docs,
README + discoverability, web performance, and mobile-deep responsive
behaviour. None of the changes break the public API or the existing
data shape; every fix is additive and SAFE under the v1.4.34.x web
freeze. Full audit reports live in `.planning/round-audit-*.md`.

Security
  - Password rotation now wipes API tokens + refresh tokens in the
    same `$transaction` as session revocation. A stolen iOS Bearer no
    longer survives a user-initiated password change or an admin reset.
  - `/.well-known/` proxy bypass tightened to an explicit-allowlist
    `Set`. Stale `/api/auth/codex/callback` PUBLIC_PATHS entry dropped.
  - HSTS gains `preload`. Withings host removed from the global CSP
    and gated to the integration's own routes.
  - `getClientIp` warns once per process on `TRUST_PROXY_HOPS`/XFF
    mismatch + exposes a tagged helper for tighter universal buckets.
  - Service worker rejects off-origin notification-click URLs. Offline
    fallback body switched to a locale-neutral icon. Manifest gains
    `scope`, `id`, `display_override`.

UX + accessibility
  - i18n leaks closed on Dialog/Sheet close-X, ChartSkeleton's loading
    announcement, and the notifications-section breadcrumb.
  - Focus rings on bottom-nav, the More overflow button, and the
    mobile top-bar user menu.
  - Tap-target floor: Input / NativeSelect / Select / DateTimeInput
    switch to `h-11 sm:h-10`.
  - Admin chip-strip auto-scroll-into-view (matches v1.4.33 settings).
  - List-page subtitles visible on mobile.
  - Insights empty-state CTA vocabulary normalised so the link opens
    the right measurement form (was silently failing for
    BODY_TEMPERATURE / BLOOD_GLUCOSE / PULSE).

Mobile-deep
  - `viewport.themeColor` reacts to light/dark mode.
  - TopBar + Coach FAB reserve safe-area-inset-top / -bottom for
    notched iPhones.
  - Sonner toaster switches to `theme="system"`.

Code quality
  - `SESSION_SECRET` dropped — the env var was documented as required
    but never read by code. Three secrets, not four. Self-hosters
    upgrading can delete the existing line.
  - `toJson<T>()` helper consolidates seven Prisma JSON-cast sites.
  - `db-compat.ts` gains a header docstring naming it the
    schema-bootstrap path.
  - Passkey response types adopted from `@simplewebauthn/server`;
    `any` + eslint-disable pragmas drop out.
  - Zero-TODO CI gate added at `.github/workflows/no-todo-markers.yml`.

Documentation + discoverability
  - README rework: How-it-compares matrix, Apple Health + AI Coach
    promoted to standalone Key-Features bullets, status block, latest
    release + GHCR badges, OpenAPI pointer, three-secret Quick Start.
  - openGraph + twitter cards now carry `images` and a
    `summary_large_image` Twitter card.
  - GitHub repo metadata: description tightened from 290 to 156
    chars + keyword-front, `homepageUrl` flipped from a personal
    tenant to the demo site, six generic / PII-adjacent topics
    swapped for six high-intent ones.
  - Five new user-facing docs: `docs/self-hosting/getting-started.md`,
    `docs/self-hosting/reverse-proxy.md`,
    `docs/integrations/apple-health.md`,
    `docs/integrations/withings.md`,
    `docs/integrations/ai-providers.md`.
  - `docs/README.md` clarifies the docs tree as internal playbooks
    vs the user-facing `docs.healthlog.dev` site.
2026-05-17 00:39:58 +02:00

9.4 KiB

Apple Health import

HealthLog accepts a full Apple Health export.zip upload and folds the contents into the same timeline as every other ingest source. The importer is streaming end-to-end — multi-gigabyte exports never land in V8 heap — and idempotent on the upload's SHA-256 so re-uploading the same archive merges instead of duplicating.

What you need

  • An iPhone with the Apple Health app and at least a few months of history (otherwise there is little to import).
  • A HealthLog account on an instance you can reach from the device or from a workstation. The upload goes over your authenticated browser session — no separate API token to provision.
  • Up to 1.5 GB of upload bandwidth. Apple's largest observed exports sit around 800 MB for a 10-year iCloud-synced account; the parser caps inbound bodies at 1.5 GB (src/app/api/import/apple-health-export/route.ts:41).

1. Generate export.zip on the iPhone

  1. Open the Health app.
  2. Tap the profile picture in the top-right.
  3. Scroll to the bottom and tap Export All Health Data.
  4. iOS spends a few minutes packaging the archive. When it finishes, share-sheet the resulting export.zip somewhere you can reach from your HealthLog instance — AirDrop to a workstation, save to iCloud Drive, send via email or any chat app you control.

The archive contains apple_health_export/export.xml (the canonical record stream) plus a handful of auxiliary files (workout routes, clinical records, electrocardiograms). The importer reads export.xml and ignores the rest.

2. Upload to HealthLog

Open the import page from the in-app settings tree. Drop the export.zip onto the upload control. The browser streams the body directly to the server; nothing is buffered into memory on either side.

The endpoint is POST /api/import/apple-health-export. The handler:

  1. Rate-limits to three uploads per minute per user to protect against a flood of multi-gigabyte uploads (src/app/api/import/apple-health-export/route.ts:43-46).
  2. Enforces the 1.5 GB cap twice — once cheaply against the declared Content-Length, then a second time at the streaming sink so a missing/wrong header cannot bypass it.
  3. Streams the body to a temp file at /tmp/healthlog-apple-health-import-* while computing a SHA-256 of the bytes inline.
  4. Looks up any prior ImportJob row with the same userId + uploadSha256. A re-upload of the same archive short-circuits to the previous job instead of re-queueing — the response carries idempotent: true and the original jobId.
  5. Otherwise creates a fresh ImportJob row in queued, enqueues the apple-health-import pg-boss job, and returns the new jobId.

The response body is { "jobId": "...", "status": "queued" } with a 202 Accepted.

3. Poll the status endpoint

GET /api/import/apple-health-export/{jobId}/status returns the canonical envelope:

{
  "data": {
    "jobId": "clx123…",
    "status": "parsing",
    "startedAt": "2026-05-16T12:34:56.000Z",
    "completedAt": null,
    "uploadBytes": 412345678,
    "exportedAt": "2026-04-30T18:12:00.000Z",
    "progress": { "records_seen": 124000, "workouts_seen": 230 },
    "result": null,
    "failureReason": null
  },
  "error": null
}

The lifecycle is queued → unpacking → parsing → upserting → done with failed as a terminal-error branch (src/app/api/import/apple-health-export/[jobId]/status/route.ts:8-9). The iOS native client polls every two seconds while active and every thirty seconds once the job is terminal; the web UI uses the same cadence. Authorisation: only the job owner can read it. The admin who kicked off an import via the admin variant can also read the row they triggered.

What gets imported

The parser folds four XML element types into the existing HealthLog schema (CHANGELOG v1.4.34):

XML element Maps to Notes
<Record> Measurement rows One row per spot sample. Keyed by HKMetadataKeyExternalUUID when present, otherwise a deterministic sample:<sha256-of-attributes> fallback so re-imports stay idempotent.
<Workout> Workout rows Activity type + duration + energy + distance.
<Correlation> Measurement rows Compound samples like blood pressure (systolic + diastolic) explode into their per-metric children.
<ClinicalRecord> Measurement rows where the type maps Health-record FHIR snapshots; only the metric-typed fields are ingested.

The HK quantity types that map to HealthLog metric types live in src/lib/measurements/apple-health-mapping.ts. Anything outside the mapped set (workout routes, ECG voltage traces) is counted in progress.records_skipped but not stored.

Cumulative HK type → daily aggregate

Cumulative quantity types — steps, active energy, walking/running distance, flights climbed — get collapsed into one row per user-local day to match the iOS daily-aggregation convention.

  • External ID format: stats:<HKType>:<YYYY-MM-DD> (e.g. stats:HKQuantityTypeIdentifierStepCount:2026-05-15).
  • The day boundary respects the user's timezone preference. The worker defaults to Europe/Berlin when no preference is set (src/lib/jobs/apple-health-import-worker.ts:132-133).
  • A second import of the same archive re-folds the same days onto the same external IDs — the values update in place rather than duplicating.

Re-import idempotency

The two-axis idempotency story:

  1. File-level — re-uploading the same export.zip (same SHA-256 of bytes) short-circuits to the previous job before the parser even starts.
  2. Record-level — when an older export.zip is re-exported from iOS (same history, slightly newer device timestamps), every record's external ID stays stable. The upsert path matches on (userId, externalId) and updates instead of inserting.

Practically: a user who exports monthly and re-imports each archive sees fresh data merged in without duplicates. The job result line in the status response reports recordsInserted and recordsUpdated separately.

Admin variant — import on behalf of another user

POST /api/admin/import-apple-health-export is the cookie-only admin variant (Bearer tokens never elevate to admin). The multipart body adds a userId text field naming the target user; everything else is identical to the user-facing flow.

The ImportJob row carries triggeredByAdminId = admin.id so the status endpoint admits both the target user and the triggering admin. Idempotency is scoped by target user — an admin re-uploading the same archive for the same user resolves to the previous job.

Typical use: migrating a friend's history into their HealthLog account when they cannot run the import themselves, or backfilling a household member's data after the admin received the export.zip out of band.

Source-priority interaction

HealthLog's source ladder is APPLE_HEALTH ≻ WITHINGS ≻ MANUAL ≻ IMPORT for cumulative metrics (steps, active energy, distance, flights, sleep, HRV, resting HR) and WITHINGS ≻ APPLE_HEALTH ≻ MANUAL for point measurements where the Withings device is the primary sensor (weight, BP, body fat, body temperature, SpO₂, VO₂ max). The defaults live in src/lib/validations/source-priority.ts:205-220.

Concrete consequences:

  • If you already sync steps from a Withings ScanWatch and also import Apple Health, the Apple-Health-aggregated stream wins for the day — iOS HealthKit folds the watch sensor + iPhone motion data into a single canonical stream, so it is the most complete cumulative source. The Withings rows stay in the database as an audit trail but drop out of the per-day aggregation.
  • If you weigh yourself on a Withings scale and also import Apple Health (which received the same reading via the Health Mate iOS app), the Withings row wins for display. Apple Health is second-hand for that metric.
  • Manual entries always rank below either device source. The IMPORT source tag — the lowest rank — is reserved for legacy CSV/JSON imports that pre-date the Apple Health passthrough.

Override per-user via the Sources section of /settings/thresholds.

Failure modes

Failure What you see Recovery
Archive is missing apple_health_export/export.xml failureReason: "Archive is missing the apple_health_export/export.xml member" Re-export from the iPhone; a partial AirDrop or a renamed archive can lose the canonical entry.
Archive is encrypted failureReason: "Encrypted ZIP entries are not supported" Apple does not encrypt the export — this means the file was repacked by a third-party tool. Re-export from iOS directly.
Unsupported compression method failureReason: "Unsupported ZIP compression method <n> for export.xml" Same recovery — re-export from iOS, which always emits method 0 (stored) or method 8 (deflate).
Worker is not running 503 Background worker is not running from the kick-off endpoint The worker process is down. Check docker compose ps for the app-worker container (or HEALTHLOG_PROCESS_TYPE=all mode on the main app container) and restart.
Rate-limited 429 Too many import uploads, try again later Wait sixty seconds; the limit is three uploads per minute per user.

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.