Add the opt-in PHQ-9 / GAD-7 questionnaire surface at /insights/mental-wellbeing:
instrument picker, 0–3 item scale, server-scored result with the validated
severity band, the verbatim Pfizer/PHQ attribution, the screening-not-diagnosis
disclaimer, and a calm crisis-resource card on a positive item-9. The surface
deliberately does NOT mount the shared Coach-launch card — mental-health item
content stays out of the AI.
- i18n: full mentalHealth block plus the six new measurement-type labels and the
longevity-marker catalogue names/descriptions across all six locales
(de/en/es/fr/it/pl). Instrument item wording uses the validated German text and
the canonical English elsewhere (re-wording invalidates the score).
- OpenAPI: regenerate the contract for /api/mental-health/assessments (response
excludes raw item answers by construction).
- Exclude the new manual / instrument types from the HealthKit-counterpart guard.
Add the validated mental-health self-assessment surface, beside mood tracking
(never replacing it) and off by default:
- MentalHealthAssessment stores per-item answers in a single AES-256-GCM Bytes
blob (registered for key rotation); only the server-authoritative total,
severity band, and item-9 flag are denormalised. The total projects to a
PHQ9_SCORE / GAD7_SCORE measurement row so the trend rides existing infra
without ever exposing item content.
- Item-9 safety: any non-zero PHQ-9 self-harm answer surfaces a calm,
locale-aware crisis-resource set (DE: TelefonSeelsorge 0800 111 0 111 / 112;
international fallback) and stamps crisisShownAt. No clinical interpretation,
no AI ingestion of item content, no third-party alerting.
- API + OpenAPI: POST/GET /api/mental-health/assessments; the response excludes
raw item answers by construction. Required Pfizer/PHQ attribution lives in the
instrument module, rendered verbatim.
The screener scores are kept off the Coach snapshot and MCP surfaces, and out of
exports by default — item content never reaches the AI pipeline.
The requirePublicHost egress paths now dispatch through undici's own
fetch, so the existing global fetch stubs in the AI, GeoIP, moodlog,
send, and ntfy suites no longer intercept them. Delegate undici's fetch
back to the installed global stub so each suite keeps asserting its
request shape and canned responses without exercising the network.
CIMD client resolution swallowed every fetch failure as ssrf_blocked and
/authorize logged a bare invalid event, so an unreachable metadata host
was indistinguishable from a genuine SSRF block and the consent flow
returned invalid_client with no diagnosable cause.
Split the resolution failure into ssrf_blocked (a real private-host
refusal) and fetch_failed (timeout / connect error), carrying the
concrete SafeFetchError kind and cause as a short non-secret detail.
Thread the resolution reason and detail into the authorize.invalid
annotation behind a pinned meta shape, and cache a resolved CIMD
document for up to 24h so the consent flow does not re-fetch on every
hit.
Add coverage for the new biomarker-assessment seam and the marker detail
surface:
- the generator serves today's cache, re-stamps unchanged readings without an
LLM call, and regenerates only when the latest-reading fingerprint changes
- the read-only endpoint returns the envelope, 422s a missing id, and honours
the auth + insightStatus gates
- the detail page paints the stat strip, the chart placeholder, and the
assessment card
- the biomarker resource route drops the marker with its readings in one
userId-narrowed transaction and rejects an inverted target range
Hold the input gate independent of the worker's forced run so an idle marker
re-stamps cached text instead of regenerating, and list the new route on the
non-Coach gate allowlist.
Give each biomarker a detail page that matches the metric sub-pages: a
numbers-first Min/Max/Median/Mean strip over the numeric readings and a
data-grounded AI assessment card.
The assessment is served by a new read-only endpoint
GET /api/insights/biomarker-assessment?biomarkerId=&locale=, returning the
same envelope as /api/insights/metric-status so InsightStatusCard consumes
it unchanged. A new generator reads the marker's LabResult history and
reuses the shared provider runner, the stale-while-revalidate read-only
path, and the persist helper. Regeneration is gated on a fingerprint of the
latest reading, so the assessment is rebuilt only when a new reading lands;
an idle marker re-stamps the cached text without an LLM call. The
biomarker: scope rides the existing on-demand status queue.
The stat strip and the assessment card both self-gate on numeric data, so a
qualitative-only or brand-new marker paints neither.
Deleting a biomarker now removes its recorded values in the same
userId-narrowed transaction instead of unlinking them via the SetNull
FK. Removing a marker is a deliberate "drop this and its history"
action, so leaving orphaned, unlinked readings behind was surprising.
Update the delete-confirmation copy in all six locales to state that
the values are removed too.
The daily briefing and the per-metric status assessments ignored the
per-user response-timeout setting: they hardcoded their per-surface
AI_BUDGETS default, so raising the timeout for a slow self-hosted or
local backend had no effect and the surfaces still aborted early. Only
the coach threaded the setting through.
Resolve the effective timeout from User.aiResponseTimeoutSeconds in one
shared helper and thread it onto every non-coach generation call:
- runStatusCompletion is the single chokepoint every status / reference
surface funnels through (per-metric cards, the batched assessment, the
derived assessments, the period narratives), so resolving it there
applies the setting to all of them at once — on both the upstream
call's own timeout and the outer wall-clock cap that previously clipped
a raised value at 60 s.
- The comprehensive briefing generator and the user-initiated regenerate
route thread it onto the main generation, the JSON retry, the grounding
retry, and the paragraph re-roll.
On a failed generation the briefing keeps its last good text — no cache
row is written, so the previous payload stays intact — and records a
dated, append-only failure marker (no migration; reuses the audit-log
JSON pattern). The read path compares the marker against the last
successful generation so it can tell a held briefing from a current one.
Remove the in-app water-intake logging feature: the WATER_INTAKE
measurement type, the per-user daily-goal field, the goal-ring card and
its endpoint, and the related query keys, OpenAPI route, and locale
strings. The feature is dropped from this release; the baseline-drift,
breathing-screening, and labs-changes insight cards are unaffected, and
the pre-existing TOTAL_BODY_WATER body-composition type stays intact.
A cluster of related correctness gaps in the compliance denominator and the
insight surfaces that read it:
- Paused medications. A doctor-ordered pause flipped `active` off and stamped
`pausedAt`, but resume nulled `pausedAt`, so the interval was irrecoverable
and every paused day became a missed dose after resume — collapsing
adherence across the Health Score, the BP gate, the Coach snapshot and the
report. Persist pause intervals as durable eras (new `MedicationPauseEra`,
migration 0211), close the open era on resume, thread the eras through the
compliance context and drop slots inside a paused interval from the
denominator. Every compliance call site now fetches and passes the eras.
- Health Score eras. The Health-Score fast path was the only compliance
caller that skipped the shared schedule select and schedule revisions, so
after a schedule edit it scored past days against the current schedule
while every other surface used the historical era. Switch it to the shared
select and thread revisions (and pause eras) like the canonical path.
- Insight bucketing timezone. The per-metric status series bucketed every
user in Europe/Berlin, so a near-midnight reading landed in the wrong day
and the relative-day phrasing was off for non-Berlin self-hosters. Thread
the resolved user timezone and key off `userDayKey`.
- Correlation significance. User-facing correlations surfaced from five
paired points with strength read straight off the coefficient. Route them
through the rigorous engine (at least twenty pairs, Student-t p below 0.05)
so small-N coincidences no longer render as confident findings.
- Resting-pulse input. The mood-versus-"resting pulse" correlation fed the
raw PULSE day-mean, which folds in workout HR, under a "Resting pulse"
label. Feed `resolveRestingPulseSeries` (clean RESTING_HEART_RATE, else a
daily low-percentile proxy) so the label matches the input.
A sustained fever (three calendar-consecutive days at or above the fever
red-flag floor) or sustained low SpO2 was already classified "seek care",
but the only escalation path ran inside a GET on the episode correlation
route — no push fired unless the user opened that tab, and no cron drove
it. The proactive on-write safety check covered only blood pressure and
glucose; the two signals with the strongest safety case had the weakest
delivery.
Add sustained-run temperature and SpO2 checks to the on-write
`runSafetyFloorCheck`, wired from both measurement-ingest seams. The run
length and clinical floors are shared with the retrospective detector (no
duplicated thresholds), day bucketing honours the user's timezone, and
escalation reuses the existing red-flag notification with its push-attempt
dedup so a febrile user logging repeatedly is alerted at most once per
reason per day.
Add three read-only awareness cards to the Insights overview, each drawn
off an existing engine and each honest about empty state — the section
un-mounts rather than surfacing a husk.
- Health status: surfaces what is drifting from the user's own normal by
folding the personal-band deviations (coincident-deviation engine) with
the dated, sustained level shifts (changepoint detector) at
GET /api/insights/health-status. Awareness only, never a diagnosis.
- Breathing screening: summarises the last 30 nights of the per-night
sleep-breathing-disturbance index plus device-flagged events at
GET /api/insights/breathing-screening. Mirrors the device's own
classification and states plainly that it is a screening signal, not a
diagnosis — discuss with a clinician.
- Lab changes: pairs the two most-recent numeric lab panels and reports
each shared analyte's signed delta and reference-band standing at
GET /api/insights/labs-changes. Neutral framing only.
Each reshaping helper is pure and unit-tested for its present/absent
states. Register the three sections in the insights-layout universe so
they merge in as default-visible, wire them into the overview and the
edit-mode title map, add the query-factory keys, document the routes in
the OpenAPI contract, and translate every new string across all six
locales.
Add a WATER_INTAKE measurement type and a per-user daily goal so the
Insights overview can track water intake against a goal ring. Logging
rides the existing measurements create path (type WATER_INTAKE, unit ml,
summed cumulatively); GET/PATCH /api/hydration read the day total and own
the goal. The goal-ring math (empty / partial / met / exceeded) lives in
a pure helper, free of Prisma and React, and is unit-tested in isolation.
Wire the new type through the measurement enum, value ranges, cumulative
HealthKit handling, categories, display transform, list metadata, record
direction, and the chartable-token allowlist. The HealthKit DietaryWater
import stays on the nutrition hold, so the type carries no import mapping
by design.
Document GET/PATCH /api/hydration in the OpenAPI contract.
Cap and page the unbounded reads that let a multi-year tenant pull its
whole measurement history into memory on hot paths.
- extractFeatures: read newest-first with a 6000-row cap (mirrors the
Coach snapshot) and reverse to ascending, so a 365-400 day window no
longer materialises hundreds of thousands of PULSE / glucose rows per
Coach turn and nightly briefing. Full-history extremes still come from
readAllTimeExtremes, so the cap never relabels an extreme.
- Add a (userId, measuredAt) index on measurements (migration 0210) for
the type-less range scans that extractFeatures and the consolidation
passes drive; the existing (userId, type, measuredAt) cannot serve them
as a prefix.
- runConsolidation: keyset-paginate the per-(user, type) source scan on
(measuredAt, id) instead of one unbounded findMany, removing the giant
single result-set allocation that drove the boot storm. Output is
identical; covered by a paging unit test.
- Stagger the boot-time backfill enqueues with increasing startAfter
offsets so the heavy full-history loads no longer all drain in parallel
onto one tenant at startup. Non-boot callers keep their immediate send.
- Backups: replace load-all-then-stringify with paged narrow-select reads
for the weekly and off-host writers, bounding transient heap. Off-host
still includes soft-deleted rows. Covered by a serialize/restore
round-trip test.
- Drop the per-request auth.bearer.success audit insert; the wide event
already records auth_method and user_id, so the polling client no longer
drives a write + pool checkout on every request. Failure-path audit
stays.
- Coach conversation detail: cap the message include to the newest 200 so
a long thread no longer decrypts every message on each open; response
shape unchanged.
- Admin audit-log: floor the scan at the retention horizon so the listing
and its count stop sweeping open-ended over the substring filters.
Encrypt MedicationSideEffect.notes, MedicationDoseChange.note and
MedicationInventoryItem.notes with AES-256-GCM, closing the last plaintext
PHI columns left after the v1.23 note-encryption rollout. Add the three
*Encrypted Bytes columns (migration 0208), register them in the encrypted-
column rotation set, route every read/write through the note cipher, and add
a transactional fail-closed boot-time backfill for legacy rows. Plaintext
columns drop in a later release once the backfill reports zero remaining rows.
Require a fresh second factor on password change for MFA-enrolled accounts,
matching the existing step-up on MFA-disable and recovery-code regen. Redact
free-text note values from the Zod validation excerpt that can reach a wide
event.
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.
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).
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.
Wrap GET /api/measurements/:id through shapeMeasurementNotes like the list,
create, and update paths already do, so the encrypted note column is decrypted
into notes and the raw ciphertext is stripped from the response. Harden the
rotation-coverage guard to require both the field literal and the model
delegate, so a same-named encrypted column whose table is not rotated is caught.