No description
  • TypeScript 96.2%
  • JavaScript 2.7%
  • CSS 0.6%
  • Dockerfile 0.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Morten Olsen a472e0ed4d
All checks were successful
ci/woodpecker/push/compliance Pipeline was successful
ci/woodpecker/push/quality Pipeline was successful
ci/woodpecker/push/design-system Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
fix Aurora — pass quality gate (lint, types, complexity)
- Disable import/namespace (rule's exports-walker chokes on react-native's
  .flow + .d.ts public surface; we don't use namespace imports anywhere)
- Allow Node globals in *.config.{ts,mjs,js} for module/require/process
- Extract sub-components from PulseLine, Ring, RangeBar, MetricCard so each
  arrow function fits under the 80-line and complexity-15 ceilings
- Refactor Chip's visual-spec computation into a tone-narrowing helper so
  TypeScript can prove the icon tone never carries 'neutral'
- Drop the unused Intent import in icon.tsx, isReversed in philosophy, and
  hrvSeries in showcase
- Storybook decorator parameter is camelCase + aliased to a PascalCase
  variable so JSX can render it; mock haptics return Promise.resolve()
- Add .prettierignore for auto-generated artefacts (pnpm-lock.yaml,
  apps/server/openapi.json, gb-ingest.api-types.ts) so task format leaves
  them alone

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 20:21:23 +02:00
.woodpecker feat Aurora design system + publish Storybook to health.mortenolsen.pro/design-system 2026-05-11 20:09:25 +02:00
apps fix Aurora — pass quality gate (lint, types, complexity) 2026-05-11 20:21:23 +02:00
docs feat catalogued event payloads + per-kind sample config 2026-05-10 22:40:10 +02:00
.dockerignore init 2026-05-09 20:43:11 +02:00
.gitignore init 2026-05-09 20:43:11 +02:00
.npmrc feat scaffold apps/mobile (Expo + Storybook + NativeWind) 2026-05-11 16:49:48 +02:00
.prettierignore fix Aurora — pass quality gate (lint, types, complexity) 2026-05-11 20:21:23 +02:00
.prettierrc init 2026-05-09 20:43:11 +02:00
.trivyignore init 2026-05-09 20:43:11 +02:00
CLAUDE.md feat catalogued event payloads + per-kind sample config 2026-05-10 22:40:10 +02:00
Dockerfile init 2026-05-09 20:43:11 +02:00
eslint.config.mjs fix Aurora — pass quality gate (lint, types, complexity) 2026-05-11 20:21:23 +02:00
mise.toml feat scaffold apps/mobile (Expo + Storybook + NativeWind) 2026-05-11 16:49:48 +02:00
package.json init 2026-05-09 20:43:11 +02:00
pnpm-lock.yaml feat scaffold apps/mobile (Expo + Storybook + NativeWind) 2026-05-11 16:49:48 +02:00
pnpm-workspace.yaml init 2026-05-09 20:43:11 +02:00
README.md feat catalogued event payloads + per-kind sample config 2026-05-10 22:40:10 +02:00
renovate.json feat catalogued event payloads + per-kind sample config 2026-05-10 22:40:10 +02:00
Taskfile.yml feat Aurora design system + publish Storybook to health.mortenolsen.pro/design-system 2026-05-11 20:09:25 +02:00
tsconfig.json init 2026-05-09 20:43:11 +02:00
vitest.workspace.ts init 2026-05-09 20:43:11 +02:00

Health

A self-hostable, vendor-agnostic platform for your own health data. One backend that any tracker, ring, watch, phone, or custom integration can push into — and one canonical layer that consumers can query without caring where the data came from.

What this is, and why

If you wear an Oura ring, run with Garmin, log meals on your phone, and let Home Assistant track your location, your data lives in five different walled gardens and none of them talk to each other. Apple Health and Google Fit solve this if you commit to their ecosystem; this project solves it without committing to anyone's.

The platform is built around a few opinionated choices:

  • Lakehouse model. Every submission lands in an append-only raw log first, then gets validated and promoted to a clean canonical layer. Raw is the source of truth; canonical is derived. You can rebuild the canonical layer from raw at any time.
  • Canonical-first metrics. A core set of metrics (heart_rate, body_weight, sleep_stage, location, blood_pressure, …) is shipped, schematised, and shared across every integration. Querying "heart rate" works the same whether the source is an Apple Watch, a Garmin, or a Polar strap.
  • Extension without lock-in. Vendors emit data the canonical set doesn't cover (Garmin's stress score, Oura's readiness)? Register a vendor-namespaced custom metric (garmin.stress_score) and start ingesting. No platform release required.
  • Replayable. Submitted data that didn't validate (unknown metric, schema evolved, etc.) sits in raw with a reason. Add the missing catalogue entry, run POST /api/replay, and it promotes automatically.
  • SQLite or Postgres. SQLite by default — small footprint, single file, perfect for a home server. Switch to Postgres with one env var when you outgrow it.

This v1 focuses on a rock-solid ingest foundation. Aggregation across sources (e.g. "during my run, prefer Garmin HR over Oura HR") is a layer that builds on top — easy to add when the data underneath is right.

Quick start

docker build -t health:local .

docker run -d --name health \
  -p 3000:3000 \
  -v health-data:/data \
  -e ADMIN_USERNAME=admin \
  -e ADMIN_PASSWORD=change-this-on-first-run \
  -e JWT_SECRET=$(openssl rand -hex 32) \
  health:local

The container persists its SQLite database to the named volume health-data (mounted at /data). On first start, the admin user is created from ADMIN_USERNAME/ADMIN_PASSWORD; on subsequent starts, the admin user is reconciled (password reset, role forced to admin). Verify it's up:

curl http://localhost:3000/api/health
# {"status":"ok"}

Browse the live API docs at http://localhost:3000/api/docs.

Docker Compose

services:
  health:
    build: .
    # or: image: code.olsen.cloud/incubator/health:latest
    ports:
      - '3000:3000'
    volumes:
      - health-data:/data
    restart: unless-stopped
    environment:
      HEALTH_DB_DIALECT: sqlite
      HEALTH_DB_FILENAME: /data/health.db
      ADMIN_USERNAME: admin
      ADMIN_PASSWORD: ${ADMIN_PASSWORD}
      JWT_SECRET: ${JWT_SECRET}

volumes:
  health-data:

From source

Requires mise for tool versioning. Tools (Node 24, Task, Python for native deps) are pinned in mise.toml.

git clone <repo-url> health && cd health
mise install        # install pinned tool versions
task install        # pnpm install (corepack enables pnpm automatically)
task dev            # start the server in watch mode on :3000

Or run the production command without watch:

task start

Authentication

Every endpoint except GET /api/health and POST /api/auth/login requires a Bearer JWT.

# Get a token
TOKEN=$(curl -s -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"change-this-on-first-run"}' \
  | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")

# Use it
curl http://localhost:3000/api/auth/me -H "Authorization: Bearer $TOKEN"

v1 limitation: there is no POST /api/auth/register endpoint. The only way to create users in v1 is the ADMIN_USERNAME + ADMIN_PASSWORD env vars, which create exactly one user (the admin). OIDC and admin-driven user creation are planned for later versions. The user/auth schema is shaped for that future — username and password_hash on the users table are nullable specifically so OIDC-only users (no password) and future API-token users (no username) fit naturally.

The admin bootstrap is reconciliatory — every startup ensures the env-vared user exists with role=admin and the env-var password. Forgot the admin password? Change ADMIN_PASSWORD in your env, restart the container.

Sending data

All data goes through one endpoint: POST /api/ingest. It accepts a polymorphic batch of four item types — samples, sessions, events, and annotations. Authentication required — pass a Bearer token in the Authorization header.

curl -X POST http://localhost:3000/api/ingest \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{
    "source": {
      "integration": "gadgetbridge",
      "device": "garmin_fenix_7"
    },
    "items": [
      {
        "type": "sample",
        "idempotency_key": "hr-2026-05-09T17:02:00Z",
        "metric": "heart_rate",
        "start": "2026-05-09T17:02:00Z",
        "end":   "2026-05-09T17:02:00Z",
        "tz": "Europe/Copenhagen",
        "value": 142
      },
      {
        "type": "sample",
        "idempotency_key": "bp-2026-05-09T08:00:00Z",
        "metric": "blood_pressure",
        "start": "2026-05-09T08:00:00Z",
        "end":   "2026-05-09T08:00:00Z",
        "value": { "systolic": 120, "diastolic": 80 }
      },
      {
        "type": "session",
        "idempotency_key": "run-2026-05-09T17:02:00Z",
        "session_type": "run",
        "start": "2026-05-09T17:02:00Z",
        "end":   "2026-05-09T17:48:00Z",
        "tz": "Europe/Copenhagen",
        "metadata": { "perceived_exertion": 7 }
      },
      {
        "type": "event",
        "idempotency_key": "med-2026-05-09T08:00:00Z",
        "metric": "medication_taken",
        "at": "2026-05-09T08:00:00Z",
        "payload": { "name": "ibuprofen", "dose_amount": 400, "dose_unit": "mg" }
      },
      {
        "type": "event",
        "idempotency_key": "set-2026-05-09T18:15:00Z",
        "metric": "strength_set",
        "at": "2026-05-09T18:15:00Z",
        "payload": { "exercise": "back_squat", "reps": 5, "weight": 100, "rpe": 8 }
      },
      {
        "type": "annotation",
        "idempotency_key": "trip-jp-2026-05",
        "start": "2026-05-15T00:00:00Z",
        "end":   "2026-05-22T00:00:00Z",
        "tz": "Asia/Tokyo",
        "text": "Travelling in Japan — expect HR/sleep anomalies",
        "tags": ["travel", "context"]
      }
    ]
  }'

The response is per-item:

{
  "results": [
    { "idempotency_key": "hr-2026-05-09T17:02:00Z", "status": "accepted", "id": "smp_..." },
    { "idempotency_key": "run-2026-05-09T17:02:00Z", "status": "accepted", "id": "ses_..." },
    { "idempotency_key": "med-2026-05-09T08:00:00Z", "status": "accepted", "id": "evt_..." }
  ]
}

Notes worth knowing:

  • Idempotency keys are required, scoped per (integration, device, instance). Re-submitting with the same key is always safe — you get the previously-assigned id back. The platform is first-write-wins: if a retry reuses the key with a different payload, the original is retained and a warning is logged server-side (it's almost always an integration bug; if you genuinely want to record different data, use a different key).
  • Timestamps are RFC 3339 UTC instants. tz is an optional IANA timezone name — strongly encouraged because some queries (sleep, time-of-day analysis) need local time. Sources without timezone awareness can omit it.
  • Mixed batches are fine. A single POST /api/ingest can include samples, sessions, events, and annotations together — natural for syncing an end-of-run upload.
  • Per-item failures don't fail the batch. The HTTP request still succeeds (200); rejected items appear in the response with a closed-enum reason like unknown_metric, out_of_range, schema_mismatch, or invalid_timestamp.

The full request/response schema is browseable in the OpenAPI UI at /api/docs.

Sample primitives

A sample's value shape is determined by its catalogue entry's kind. The wire is bare — no {value, unit} envelope. The catalogue is the single source of truth for unit; integrations convert at the seam, consumers trust the catalogue.

kind wire value example catalogue declares
numeric number 142 { unit, range? } (e.g. unit: "bpm")
categorical string "deep" { values: [...] } (the allowed enum)
geo { lat, lng, altitude?, accuracy? } { lat: 55.6761, lng: 12.5683 } {} (fixed shape, no per-entry config)
composite { <component>: number, ... } { systolic: 120, diastolic: 80 } { components: { <name>: { unit, range? } } }

Sessions (run, sleep, meditation, strengthtraining, hiit, yoga, drive, …) are typed time-bounded activities — they don't _own samples. A run session and the HR samples from your Garmin during that window are independent records, joined by time-overlap at query time. This is what lets a single Oura HR stream cover both the run and the rest of the day without any session-attribution gymnastics.

Events (medication, meal, strength sets, cardio intervals, manual notes) are catalogued discrete instants with a JSON-Schema-validated structured payload. JSON Schema is the right tool here because event payloads are genuinely complex (variable shape, optional fields, arrays). A strength workout, for example, is a strength_training session containing a stream of strength_set events ({exercise, reps, weight, rpe?} — weight is in kg per the catalogue's x-unit), each validated against the catalogue. That's how the platform captures "20 reps of squat at 100 kg" without giving up the cross-vendor shape contract.

When a unit is a fixed property of an event field (strength_set weight = kg), it's declared on the schema via x-unit and never appears in the data — integrations convert. When a unit is genuinely variable per-record (medication dose can be mg/IU/tablets/ml), it stays as a regular field with an enum constraint (medication_taken has both dose_amount and dose_unit).

Annotations are free-form contextual enrichments to the timeline — about the data rather than data itself. Travel notes, calibrations ("recalibrated the scale today"), illness windows ("food poisoning, disregard sleep"), hardware swaps. They span a range (instant = start == end), carry text and optional tags, and don't go through the catalogue — they're notes, not measurements.

Adding custom metrics

The shipped canonical catalogue covers the obvious staples (~37 entries: cardiovascular, body, activity, sleep stages, location, common session types, structured events for medication, meals, strength sets, cardio intervals). When a vendor exposes something more specific, register it as a custom catalogue entry — vendor-namespaced so it never collides with canonical or other users' customs. Custom entries are per-user: each user can register their own without bothering the admin, and entries are invisible across users.

A custom entry is { id, kind, description?, config } where config is per-kind. For a numeric sample, declare unit + range:

curl -X POST http://localhost:3000/api/catalogue/custom \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{
    "id": "garmin.stress_score",
    "kind": "numeric",
    "description": "Garmin stress score (0100)",
    "config": { "unit": "score", "range": { "min": 0, "max": 100 } }
  }'

For an event, declare a JSON Schema 2020-12 payload contract:

curl -X POST http://localhost:3000/api/catalogue/custom \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{
    "id": "myapp.hiit_round",
    "kind": "event",
    "description": "One round of a HIIT workout",
    "config": {
      "schema": {
        "type": "object",
        "properties": {
          "movement":     { "type": "string" },
          "work_seconds": { "type": "number", "minimum": 0, "x-unit": "s" },
          "rest_seconds": { "type": "number", "minimum": 0, "x-unit": "s" },
          "rounds":       { "type": "integer", "minimum": 1 }
        },
        "required": ["movement", "work_seconds"],
        "additionalProperties": false
      }
    }
  }'

Custom event schemas are meta-validated against JSON Schema 2020-12 itself plus a few soft guards: external $ref rejected, schema size capped (32 KB), nesting depth capped (12 levels), and format restricted to a known whitelist. Malformed schemas come back as 400 with the violation in the response body. Sample-kind configs (numeric/categorical/geo/composite) are tightly typed — Zod rejects ill-formed configs at the API boundary, no Ajv involved.

If submissions arrived before you registered the type, they're sitting in the raw log with rejection_reason: unknown_metric. Drain them:

curl -X POST http://localhost:3000/api/replay \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{ "rejection_reason": "unknown_metric" }'
# { "attempted": 142, "promoted": 142, "still_rejected": 0 }

(Regular users implicitly replay only their own data. Admins can pass an explicit "user_id": "..." to target one user, or omit it to span everyone.)

You can also register aliases so a vendor's native metric name resolves to a canonical id on write:

curl -X POST http://localhost:3000/api/catalogue/aliases \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{ "alias": "apple.heart_rate", "canonical_id": "heart_rate" }'

After this, your submissions (just yours — aliases are per-user) using apple.heart_rate are stored as canonical heart_rate. The integration doesn't have to know the canonical names if it declares aliases up front.

Browse the full catalogue at http://localhost:3000/api/catalogue or via GET /api/catalogue/:id.

Configuration

All configuration is via environment variables.

Variable Default Description
HOST 0.0.0.0 Bind address
PORT 3000 TCP port
HEALTH_DB_DIALECT sqlite sqlite or postgres
HEALTH_DB_FILENAME ./health.db (source) / /data/health.db (Docker) SQLite path. :memory: for ephemeral.
HEALTH_DB_URL Postgres connection string. Required when dialect is postgres.
JWT_SECRET (auto, ephemeral) Secret for signing JWTs. If absent, a random secret is generated per process — tokens reset on every restart. Set this in production.
ADMIN_USERNAME Bootstrap admin username. Required for v1 since there is no registration endpoint.
ADMIN_PASSWORD Bootstrap admin password. Reconciled on every startup — change here to reset a forgotten admin password.

SQLite vs Postgres

  • SQLite is the default and the right choice for personal / family-scale self-hosting. The whole database is a single file; backup is cp. Concurrency is limited to a single writer, but for one person's lifetime of health data that's plenty.
  • Postgres is there when you need it: multiple writers, replication, or you're already running Postgres for other things and want one backup target.

To run with Postgres:

docker run -d --name health \
  -p 3000:3000 \
  -e HEALTH_DB_DIALECT=postgres \
  -e HEALTH_DB_URL=postgres://user:pass@host:5432/health \
  health:local

Migrations are dialect-portable and run automatically on first connection.

Backup and persistence

  • Docker: the volume mounted at /data holds the SQLite database. docker volume inspect health-data shows the host path; back that file up regularly.
  • From source: ./health.db (and the WAL sidecars *.db-wal, *.db-shm) hold all state.
  • Postgres: standard pg_dump flow.

Because raw is the source of truth and validation is deterministic, the canonical samples/events/sessions tables are derivable from ingest_log. As long as you have raw, you can recover the rest.

API surface

Route Purpose
GET /api/health Liveness probe
GET /api/docs Scalar-rendered live OpenAPI documentation
POST /api/ingest Submit a batch of samples / sessions / events
POST /api/replay Re-validate quarantined raw records against the current catalogue
GET /api/catalogue List catalogue entries (filterable by namespace, kind)
GET /api/catalogue/:id Fetch a single entry
POST /api/catalogue/custom Register a vendor-namespaced custom metric
GET /api/catalogue/aliases List aliases
POST /api/catalogue/aliases Map a vendor metric name to a canonical id

The full machine-readable spec is at /api/docs/openapi.json.

What this isn't (yet)

Things deliberately out of scope for v1, in rough priority order for later:

  • Cross-source aggregation / dedup. "During this run, prefer Garmin HR over Oura HR." The data foundation supports it; the engine isn't built yet.
  • Registration & user management. Only the bootstrap admin exists in v1. OIDC-based signup and admin-driven user creation are next.
  • API tokens (long-lived, machine-friendly). Integrations currently log in with username/password and use the resulting JWT.
  • Refresh tokens / token expiration. Tokens don't expire in v1.
  • High-frequency raw waveforms (ECG at 250 Hz, audio). Different storage problem.
  • Images, videos, attachments. Same.
  • FHIR clinical record import. Different problem.
  • A frontend. The repo is structured as a monorepo (apps/server) so apps/web can be added later.

Development

task dev            # start the server with watch
task test           # run the test suite (57 tests, ~2s)
task check          # type-check across the workspace
task lint           # type-check + ESLint
task ci:quality     # what CI runs on every PR
task test:smoke     # boot the server, hit /api/health, shut down
task docker:build   # build the Docker image locally
task docker:smoke   # build + run + probe + tear down
task --list         # see everything

The data model and design rationale are written up in docs/architecture.md. Project conventions and gotchas are in CLAUDE.md.

License

TBD.