Skip to main content
Aletheia is a static web app on Cloudflare Pages backed by Supabase. The browser talks to Postgres directly through the publishable key, and row-level security decides what it can see. Anything that needs a secret, such as reading a PDF with Claude or calling Metriport, runs in an edge function.

Grading

Each biomarker in the catalogue carries three bands. grade_value() checks a value against them in order, and the first match wins: The alert thresholds exist because a value can sit inside a lab’s “normal” range and still call for action. Two views build on this:
  • latest_results takes each user’s most recent result per biomarker and grades it.
  • system_status rolls those up per system. A system takes its worst status. A system with no results at all is none, which is what leaves it dark on the figure.
A pending migration, 20260822160100_sex_aware_bands.sql, adds biomarker_bands with per-sex overrides for testosterone, haemoglobin, ferritin, creatinine and two body-composition markers. Until it is applied, every user is graded against a single band, and several of those are male bands. See the biomarker reference.

The figure

The body outline is not traced artwork. Landmarks were measured off a reference illustration and stored as proportions of figure height: the crotch at 53.4%, the knee at 71.7%, a deltoid span of 30.7%. The outline is drawn as a Catmull-Rom spline through half the figure, mirrored about the centre line. To change the shape, you edit numbers, not path data. dashboard.html inlines its fonts, logo and figure geometry, so it also renders as a static page with no backend.

Security

  • Every user table has row-level security, scoped to auth.uid().
  • Uploaded files live in a private storage bucket under <user_id>/…. The storage policies key on that first path segment.
  • The key in app.js is the publishable key. It is meant to ship in the browser and grants nothing on its own.
This has been checked both ways: a signed-in user reads only their own rows, and an anonymous client reads nothing. The account page can delete all of a user’s health data, including their stored files.

Importing data

Every import path ends in the same review form. None of them writes to results on its own.
Parsed in the browser by dist/parse.js. It is deterministic and sends nothing anywhere.Names are matched against each biomarker’s aliases, longest alias first. That way “Hemoglobin A1c” resolves to hba1c, and plain “Hemoglobin” resolves to hemoglobin.The parser handles comparator results like <0.1, thousands separators, decorated labels and several date formats. Anything it does not recognise is shown to you, not dropped.

The Metriport path in detail

Why a webhook

The consolidated query’s status endpoint never returns a download URL. That only arrives by webhook, and the presigned link expires after three minutes, so a stored URL would be dead before anyone polled it. The webhook answers 200 immediately and downloads in waitUntil. Metriport allows four seconds for a response, and a few hundred resources don’t fit in that.

Authenticating the callback

The webhook runs with verify_jwt off, because Metriport cannot present a Supabase JWT. Instead, it checks an HMAC-SHA256 of the raw body under METRIPORT_WEBHOOK_KEY, compared in constant time. The public URL grants nothing on its own. The body is hashed exactly as received. Parsing and re-serialising it would change the signature over details like key order.

Matching observations

Observations are matched by LOINC code first, then by display name. A code is a fact and a name is an inference, so the two carry different confidence into review. The LOINC table is deliberately incomplete. A wrong code is worse than a missing one: it files a value under the wrong marker while looking certain. Free PSA, the spirometry codes and plain glucose (2345-7, which is not fasting glucose) are left out for that reason and matched by name instead.

Picking a value

The most recent reading wins, not the first one in the bundle. FHIR bundles are not sorted by date: the first real import took 14 of 17 values from 2006, out of a history that ran to 2023.

Sandbox and production

  • Creating a patient attaches no data. You have to start a query separately, or the record stays at one resource.
  • POST /network/query answers 400 in the sandbox, while the legacy POST /document/query answers 200.
action: "seed" does both.
The functions use the sandbox unless METRIPORT_ENV=production is set, so a missing variable can never point them at real records.purposeOfUse defaults to treatment. A personal health record should use ias (Individual Access Services), which requires an AAL2 verified-identity session per patient.

Database migrations

supabase/migrations/20260808000000_baseline.sql is the schema as production actually has it, read out of the live database. The 41 biomarker rows were checked against production by an md5 fingerprint over every column. Everything in the baseline is idempotent (if not exists, or replace, on conflict do nothing). It does nothing against production and builds a complete schema against an empty database.
When adding a migration:
  • Name it <timestamp>_what_it_does.sql, using date -u +%Y%m%d%H%M%S. Other names are silently ignored by db push.
  • If it sets a grading threshold, put the source of that number in the row itself, not in a commit message. A number nobody can trace is a number nobody can check.

Home page

/ is a single fixed screen that never scrolls. The logo carries a scan: a brightened copy of the mark, revealed through a narrow band that moves down it, so the beam lights the artwork instead of sitting on top of it. It holds still when the visitor prefers reduced motion. The background is the login screen’s aurora and grid, pulled from the same aletheia.css, so the two screens can’t drift apart. Layout positions were measured off the approved design and live as custom properties at the top of index.html.