Document the MCP server: an operator guide for enabling the
off-by-default module and connecting Claude.ai / ChatGPT / Claude
Desktop, a capabilities reference for the tool / resource / prompt
catalogue and the grounding + write model, and a skill-author guide for
building connectors against it. Link all three from the docs indexes.
Integration coverage against a real Postgres testcontainer: the
blood-pressure write persists a paired systolic/diastolic row set
atomically under one external id and timestamp, is idempotent on a
replay, and refuses an implausible pair without writing; the explicit
{from,to} comparison filters each side to its span over real rollups.
Bridge the five already-built, grounded Coach retrieval tools the
inventory already advertised but the wire was missing — closing the
advertise-but-missing self-inconsistency — each a thin wrapper over the
existing read with the same gates and present:false contract. Add a
multi-metric fan-out that loops the single-metric read, and a per-analyte
lab-history mode on the labs read. Declare a structured output schema on
every remaining tool so structured content validates for both a miss and
a hit. Page the large reads (search, lab history, multi-metric) behind an
opaque cursor. Return plain-text, citation-ready summaries from fetch
with per-item deep links instead of a JSON dump.
Add an optional explicit {from,to} range to the comparison and
changepoint reads, served over the same rollup tier the five fixed
trailing windows use (reach back to the lower bound, filter to the
span) — so a before-vs-after-a-date question is answerable without
recomputing analytics. Add a per-analyte lab trajectory read (latest to
recent history) over the existing lab resolver, page-bounded so a heavy
panel cannot balloon the response. Introduce an opaque, stable base64url
cursor helper that decodes any malformed or foreign token to the first
page rather than throwing.
Blood pressure is two values, so it cannot ride the single-value
log_measurement tool. Add log_blood_pressure: it writes the systolic and
diastolic rows atomically under one shared idempotency namespace and one
measured-at instant, range-validates each value, rejects an implausible
pair (systolic at or below diastolic), and keeps the same confirm-flag
preview gate, per-credential write budget, MCP provenance, and audit
trail the existing writes use. Declare a structured output schema on
every write tool so a preview, a commit, an already-logged no-op, and a
refusal all validate as structured content.
Fill the discoverable half of the MCP server. Add RFC 6570 resource
templates for per-item reads — healthlog://metric/{type},
healthlog://metric/{type}/{window}, healthlog://lab/{analyte},
healthlog://medication/{id}, healthlog://report/doctor-visit/{window} —
plus fixed resources for the biomarker catalogue, the data inventory, and
the default doctor-visit summary. Each template is a thin facade over the
same server-authoritative read its matching tool already uses, scoped to
the session user; a foreign id resolves to { present: false }, never
another tenant's row.
Wire user-scoped argument completion and listing on the templates: window
completes the fixed enum, metric and analyte complete only from the user's
own inventory and lab set, so autocomplete doubles as honest discovery.
State the data-grounding contract once in the server instructions —
server-authoritative values (do not recompute), { present: false } is
honest absence, free-text fields are data not instructions, discover
before fetching, data and context only. Set the server and resource icons
for connector-directory listing.
Extend the MCP prompt surface beyond doctor_visit_summary with six
ranked, installable review prompts. Each assembles a grounded,
structured data block from an existing server-authoritative engine and
frames the host to narrate from those facts only — read-only, no
diagnosis, surfacing absence as { present: false }.
- weekly_review: 7-day trends, adherence, sleep, recovery, drivers, and
the readiness/recovery + illness/cycle context the daily briefing reads
(derived-briefing + illness-cycle-briefing).
- medication_check: cadence-aware adherence alongside a linked vital.
- recovery_check: illness-recovery composites + personal baselines +
discovered drivers.
- glucose_review: the clinical glucose panel (TIR/GMI/CV%/eA1c).
- sleep_review: per-night sleep, debt, chronotype, and drivers.
- lab_trend_brief: per-analyte trajectory from the labs store, against
each lab's own stored reference range (ranges are never recomputed).
The n-of-1 intervention review and a preventive 'what to check next'
prompt are deferred: the first waits on an anchored before/after read,
the second has no screening engine to reuse.
Honour the OIDC dynamic-registration application_type field on the
register endpoint: validate it to the closed set (web/native) when
present, reject a malformed value with invalid_client_metadata, default
to web when omitted, and echo the resolved value back in the
client-information response.
Forward-compatible with OIDC-style clients without changing the
redirect-URI rules (still HTTPS-or-loopback, authoritatively matched at
authorize) or any other registration behaviour.
Advertise authorization_response_iss_parameter_supported in the
authorization-server metadata and echo the iss parameter on every
authorization response — both the success redirect carrying the code and
the access_denied error redirect. A client can now pin the response to
this server's issuer and reject a code substituted by a look-alike
authorization server in a multi-server deployment.
Additive and backward-compatible: existing clients that ignore iss are
unaffected; PKCE S256-only, audience binding, redirect-URI exact match
and the rate limits are untouched.
Combine the remote MCP server (stdio + remote /mcp + OAuth 2.1 + read
tools + deep-value reads + a confirmed, audience-bound write surface)
with the v1.23 account-security line (MFA, known/trusted devices,
free-text note encryption).
- schema: keep every v1.23 User relation (MFA, known/trusted devices)
alongside the McpOAuthConnection relation; the MeasurementSource enum
carries the MCP value.
- api-handler: the MCP audience guard (a token scoped to
health:read/health:write is bound to /mcp and refused a REST write)
and the step-up MFA helpers coexist.
- maintenance queues: register the MCP token-cleanup sweep next to the
note-encryption backfill and key-rotation queues; shift it to 04:00
so the two daily discovery ticks do not collide.
- migrations: renumber the MCP migrations to 0206/0207 so they follow
the v1.23 chain (0199-0205).
- MCP mood writes route the note through the v1.23 at-rest cipher.
The "Require a second factor" policy redirects web users to enrollment but does
not block direct API or native (Bearer) access for an account that has not yet
enrolled — a deliberate lockout-safety choice. Make that explicit: spell it out
in the admin toggle description, add a code comment at the toggle, and add an
operator runbook (docs/ops/mfa-enforcement.md) describing the boundary.
The "safe to drop legacy key" badge hardcoded bg-emerald-600/text-white. Swap it
for the success token (border-success/bg-success/text-success) the rest of the
settings surfaces use, so it tracks the theme.
Trusted devices was mounted on both the Security section and the Privacy
dashboard. Keep it only on the Privacy dashboard, which already gathers sessions
and security activity, and drop it from the Security section. While here: the
Retention card painted an empty list while the privacy summary was loading and
on error — give it a skeleton placeholder and a one-line fallback, and align the
"What's stored" indentation with the other privacy cards.
The inline rename save/cancel controls on the security-key and passkey lists and
the passkey-nudge dismiss button sat at 32px, below the 44px touch minimum. Give
them the same min-h-11/min-w-11 phone target that collapses to 32px from sm up.
Add an xs (30rem) breakpoint and stack the recovery-code grid one-up below it so
the 11-character codes no longer crowd at 320px.
Regenerating recovery codes invalidates the current set immediately, so the
action now sits behind a confirmation dialog matching the disable-MFA pattern,
with a consequence line spelling out that the existing codes stop working. While
in the card: give the disable-step TOTP input the same digit-strip + 6-char cap
and exact-length submit guard as setup, swap the static disable-code id for a
useId value, and lift the copy-secret button to a 44px touch target on phones.
Mirror the TOTP login path: both the security-key verify and verify-options
routes now reject any challenge whose kind is not "login" with the same generic
401, so a future step-up or non-login challenge can never mint a login session
through these endpoints. Set the registration and authentication ceremonies to
userVerification:"required" to match the server's UV enforcement — a key that
passes the browser but skips UV would otherwise fail server-side.
The encrypted-export route hand-rolled its step-up gate off totpConfirmedAt
alone, so an account whose only second factor is a WebAuthn security key
exported the full record on a plain session — no fresh-factor proof. Route it
through requireFreshMfaIfEnrolled, which covers both the TOTP and security-key
cohorts and lets single-factor accounts through unchanged. Cover the
security-key-only path in the unit suite.
Bring the scattered data-sovereignty controls into one /settings/privacy pane.
It assembles already-shipped pieces rather than rebuilding them: the encryption-
at-rest summary and configured retention windows (read from the server so the
copy stays truthful), links to the export surfaces (including the passphrase-
encrypted option) and the account-deletion / data-reset surface, the active-
session list, trusted devices, and the security-activity log. The backup↔
deletion lag is disclosed honestly — deletion is immediate, but encrypted
off-host backups still hold the data until they roll off retention.
Removing the second factor is a security-state change, so it should not leave
stale credentials behind. The disable endpoint now drops every other web
session, every native refresh token, and every trusted device, keeping only the
caller's current session. The lockout-recovery CLI does the same out of band,
clearing all sessions and trusted devices for the account.
A passwordless passkey login already clears user verification and is a
phishing-resistant possession factor, but it never stamped the session, so a
passkey user could not satisfy an MFA-enforcement policy or pass a step-up
re-check. Stamp Session.mfaVerifiedAt on a primary passkey login so it counts
as a completed second factor — equivalent to a password+TOTP sign-in. The
password+TOTP path is unchanged.
Wire the previously-unused mfaEnforced flag into a real policy. An admin can
require a second factor instance-wide (an AppSettings toggle in the General
section) or per user (a toggle in user management); the effective requirement is
the OR of the two. When required and the account has no active factor — no
confirmed TOTP, no registered security key, and no primary passkey — the user is
walled behind a forced-enrollment interstitial after sign-in, with only the
security settings and sign-out reachable until a factor is enrolled.
The gate is surfaced like the onboarding gate: a hint cookie the proxy reads
without a DB round-trip, recomputed from the database on every sign-in and on
/api/auth/me so a tampered cookie self-corrects. Nobody is locked out — the
enrollment surface stays reachable, and toggling the policy off (or the
disable-mfa CLI) is the operator's recovery path.
Add an opt-in "remember this device" at the second-factor step. On opt-in
a random token is minted, set in an httpOnly+Secure+SameSite=Strict cookie,
and only its HMAC hash is stored in a new TrustedDevice table. A later sign-in
from the same browser still requires the password (factor 1); a matching,
unexpired trusted device lets the login skip factor 2 only.
The token is never accepted for step-up — a trusted-device login does not stamp
mfaVerifiedAt — so a stolen device cookie cannot reach a destructive action.
Trusted devices are listed and revocable from the security surface, are revoked
wholesale when the second factor is removed or the user signs out everywhere,
and cascade away on account deletion. A recovery-code login never trusts the
browser. Migration 0204 adds the table (and the instance-wide mfa_required
policy column that the enforcement work reads).
Record the very first device a user is ever seen on silently. The
new-device sign-in alert only carries meaning once there is an
established device to contrast against, so an empty device ledger
establishes the baseline without firing a notification; alerts begin
with the second distinct device. Registration's silent first-device
behaviour is unchanged.
Also extend the destructive-action step-up to treat a registered
security key as an active second factor, matching requireFreshMfa, so a
key-only account cannot delete or reset without a fresh factor.
Stamp each passkey's last-used time on a successful assertion and return
it from the list, add a rename endpoint, and add a dismissible
add-a-passkey nudge for accounts that have none. Share the relying-party
configuration with the second-factor path.
When a password is accepted for an account with a second factor, the
login response carries a single-use ticket instead of a session. The
login page now renders a second step: a TOTP or recovery code, or a
WebAuthn security-key assertion scoped to the account's registered keys.
A WebAuthn-only account (no TOTP) is recognised at the password step and
offered the security-key path.
The security-key verify endpoints validate the open ticket, verify the
assertion against the user's second-factor credentials, and only then
mint the session through the shared login tail, so no session exists
before the factor passes. The login page also arms passkey conditional-UI
autofill on the username field.
Introduce a dedicated /settings/security section that gathers every
account-protection control in one place: TOTP enrolment (QR plus manual
secret, confirm, recovery codes shown once with copy and download),
WebAuthn security keys as a second factor (register, list with last-used,
rename, step-up-gated removal), recovery-code count and regeneration, and
primary passkey management.
The second-factor WebAuthn path keeps its own credential store, separate
from primary passkeys, and uses non-resident keys so an account can hold
many. Relying-party resolution is shared with the passkey path. A status
endpoint backs the hub; the step-up gate now recognises a registered
security key as an active second factor so a WebAuthn-only account can
still reach the gated actions.
Drop the in-app bug-report surface end to end: the user submission page,
the feedback intake and admin inbox, the optional GitHub-issue
escalation, the operator config, and the dedicated achievement badges.
- delete the /bugreport page and the bugreport / feedback / admin-feedback API routes
- drop the Feedback model, its enums, and the AppSettings GitHub-issue and enable columns (migration 0205)
- remove the admin Feedback section, the Bug Report integration card, and the system-status row
- drop the bug-report nav entry and the app-settings provider that only carried its flag
- remove the bug-hunter achievement badges and their metric
- prune the related i18n keys across every locale and the encrypted-column and key-rotation registries
Add the user-facing account-security surface in Settings:
- Active sessions (closes#64): GET /api/auth/me/sessions lists each browser
login with a coarse device label, masked IP, resolved location, the sliding
last-active stamp, and the current-session marker. DELETE the collection
signs out everywhere else (keeps the current session, revokes other sessions
and native refresh tokens, leaves API tokens alone); DELETE one revokes a
single session, scoped to the owner so a foreign id 404s.
- A sliding last-active stamp on the session, written throttled and
fire-and-forget off the request-resolution path.
- Security activity: GET /api/auth/me/security-activity, the shared read over
the audit log filtered to sign-ins, second-factor changes, password changes,
session revocations, exports, and deletions, with masked IPs.
OpenAPI regenerated for the new endpoints.
On a completed sign-in (password, second-factor, or passkey) from a device or
location not seen before, send one notification through the dispatcher cascade,
honouring the user's per-channel preferences via a new SECURITY_ALERT event
type. Dedupe on a one-way, per-user fingerprint: a SHA-256 over the userId, the
coarse User-Agent family, and a country/ASN-level geo signal, stored in the new
user_known_devices ledger (migration 0203). The first sighting of a fingerprint
alerts and records; later sightings only bump last-seen and stay silent. The
raw User-Agent and IP are never persisted — only the salted hash and a coarse
label. Registration records its device silently so a fresh account does not
alert on its own first session. The whole path is fire-and-forget and never
blocks or fails a sign-in.
Check a newly chosen password against HaveIBeenPwned through the k-anonymity
range API: only the first five hex characters of the SHA-1 digest leave the
host, the suffix is matched locally, and Add-Padding hides the bucket size.
A confirmed match is rejected so a fresh credential is never a known-breached
one; the check fails open, so an unreachable HIBP never blocks the operation.
Covers registration, password change, and admin reset. Existing passwords are
never retroactively blocked at login.
Also lands the six-locale strings for the password-breach notice and the
account-security surfaces that follow.
Account deletion and the keep-account data reset now demand a recent
second-factor re-verification for accounts that have a second factor
active; single-factor accounts are unaffected and keep the typed-
confirmation-only contract. The gate runs through requireFreshMfaIfEnrolled,
which is cookie-only by construction — a Bearer transport for an MFA-enrolled
account cannot satisfy step-up and surfaces the auth.stepup.required envelope
so the UI can launch a re-verification flow.
Add an optional admin-triggered key rotation on pg-boss that re-encrypts
the whole encrypted-column corpus to the active key, reusing the same
column registry as the command-line tool (which stays the canonical path).
POST /api/admin/encryption/rotate enqueues a singleton run and is gated by
cookie-only admin plus a fresh second factor. The run is idempotent
(rows already on the active key are skipped), active-key-only, and never
adds or drops a key — the env key map stays operator-owned, so a key drop
remains a deliberate redeploy. It fails closed per row and reports totals
into the audit trail the status view reads.
Add a read-only admin Encryption section that surfaces what the rotation
tooling computes: per encrypted-column rows-per-key-id, legacy-key row
counts, and overall rotation progress, plus a 'safe to drop the legacy
key' state once every column sits on the active key. GET
/api/admin/encryption/status drives it and returns only key ids and
counts — never key material. A shared scan over the canonical encrypted-
column registry backs both this view and the rotation run. Cookie-only
admin.
Add an opt-in to download the full backup as an encrypted archive instead
of a plaintext JSON file. The user supplies a passphrase over the request;
the server derives a 256-bit key with Argon2id and seals the export bytes
with AES-256-GCM into a self-describing HLX1 archive (magic, version, KDF
params, salt, iv, tag, ciphertext), mirroring the off-host backup envelope.
POST /api/export/encrypted returns the binary archive and is step-up gated
for accounts with a second factor; single-factor accounts use a normal
session. The passphrase is never stored and never logged, and there is no
server-side recovery — the UI states this. A standalone decryptor
(scripts/decrypt-export.ts) opens the archive with the passphrase outside
the app. The plaintext full-backup route now shares one payload builder so
both variants emit the same restore-compatible shape.
Reject any challenge that is not a login challenge before minting a session,
cap the disable and confirm request bodies through safeJson, and correct the
recovery-code scan comment to match its early-return-on-match behaviour.
When a password is accepted for an account with a confirmed TOTP factor, the
login route no longer mints a session or token. It creates a single-use,
~5-minute MfaChallenge (the opaque ticket is stored only as a hash) and
returns HTTP 200 with data:null, error:null and meta.mfaRequired + mfaTicket +
methods. The client completes the login at POST /api/auth/mfa/verify with the
ticket + a TOTP or recovery code, receiving the exact same token bundle the
password path issues, with the session stamped second-factor-verified.
- The ticket is claimed once, atomically, only after the factor verifies, so
two concurrent submissions can mint at most one session; no session ever
exists before factor two.
- Wrong codes increment the ticket attempt counter and burn it at the cap
(throttle, not an account lock); the anonymous verify surface is also
rate-limited per IP.
- meta.mfaRequired is returned only after a valid password and the
invalid-credentials response is identical regardless of MFA state, so an
attacker without the password cannot enumerate MFA.
- The session/token tail is extracted to login-response.ts and shared by both
the password path and the verify path, keeping the transport contract
identical. OpenAPI registers the verify endpoint, the MFA management
endpoints, and the mfaRequired login variant.
Add the TOTP enroll/confirm/disable surface and Argon2id-hashed recovery
codes as the v1.23 second factor.
- totp.ts wraps otpauth (zero-dep) pinned to the interop floor (SHA-1, 6
digits, 30 s) with a +/-1 drift window. Replay protection persists the last
accepted time-step per account (User.totpLastStep, migration
0202_v123_totp_laststep) and rejects any step that is not strictly newer,
so a captured code dies the instant it is accepted once.
- recovery-codes.ts issues ten single-use, high-entropy codes, Argon2id-
hashed at rest, shown once; regeneration replaces the entire prior set in a
transaction and verification burns the matched code with a guarded update.
- The TOTP secret is stored AES-256-GCM encrypted (already registered in the
encrypted-columns registry + rotation script); decrypt is fail-closed.
- Routes: setup (pending secret), confirm (activate + issue recovery codes),
disable (step-up + current factor), recovery-codes/regenerate (step-up).
Setup/confirm are cookie-only; disable/regenerate add requireFreshMfa.
- scripts/disable-mfa.ts is the operator escape hatch for a locked-out
self-hoster, audited, run via pnpm dlx tsx.