- TypeScript 96.2%
- JavaScript 2.7%
- CSS 0.6%
- Dockerfile 0.5%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
- 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>
|
||
| .woodpecker | ||
| apps | ||
| docs | ||
| .dockerignore | ||
| .gitignore | ||
| .npmrc | ||
| .prettierignore | ||
| .prettierrc | ||
| .trivyignore | ||
| CLAUDE.md | ||
| Dockerfile | ||
| eslint.config.mjs | ||
| mise.toml | ||
| package.json | ||
| pnpm-lock.yaml | ||
| pnpm-workspace.yaml | ||
| README.md | ||
| renovate.json | ||
| Taskfile.yml | ||
| tsconfig.json | ||
| vitest.workspace.ts | ||
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 (recommended)
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-assignedidback. 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.
tzis 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/ingestcan 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, orinvalid_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 (0–100)",
"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
/dataholds the SQLite database.docker volume inspect health-datashows 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_dumpflow.
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) soapps/webcan 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.