Commit Graph
17 Commits
Author SHA1 Message Date
Marc-André Bombeck 73f2004c54 feat(observability): break the heap sample down by V8 space
The heap sample named old_space alone, so a heap that stood well above
it could not be explained from the log: a buffer past V8's large-object
threshold lives in large_object_space, compiled code and bytecode in the
code and trusted spaces. The sampled and pressure lines now carry the
used size of every space, with any space the runtime reports that is
not named summed into other_spaces_mb, so the fields account for the
whole used heap whatever the Node version.
2026-10-20 20:27:24 +02:00
Marc-André Bombeck b79bd8370d fix(heap): report again at 95 % and leave a report on an out-of-memory exit
The pressure report re-armed only after the heap fell under 70 % and
half an hour passed, so a heap that climbed to its limit soon after an
85 % report died with only that older report on record. A 95 % tier
now writes the same report at once, whatever the cooldown, and re-arms
under 85 %.

The image sets NODE_OPTIONS so Node writes its diagnostic report to
stderr when the heap runs out, with environment variables and network
interfaces excluded. The heap-diagnostics and scaling docs say how to
keep the flags when overriding NODE_OPTIONS.

Heap figures share one megabytes helper with the support report.
2026-10-20 03:42:26 +02:00
Marc-André Bombeck d08e2b4e6d fix(observability): keep heap samples out of the admin log ring and harden the snapshot directory
runtime.heap.sampled is written every minute while the heap is above
70 % of its limit, which turned the 500-entry ring behind the admin log
page over in hours and pushed out the events an operator opens it for.
emitEvent takes a skipMemoryRing option; the sample sets it and still
reaches stdout and Loki, while the pressure and snapshot lines go
everywhere as before.

The snapshot directory was /tmp/healthlog-heap-<pid>, a predictable name
(pid 1 in most containers) that mkdirSync accepted when it already
existed or was a symlink. It now comes from mkdtempSync and is checked
with lstat to be a real directory owned by the process user and closed
to others before the snapshot is written.
2026-10-19 18:46:43 +02:00
Marc-André Bombeck 16024397c8 feat(observability): log heap samples and a one-time pressure report
runtime.heap.sampled records heap, old space, RSS, external memory and
the work in flight every five minutes, every minute at or above 70 % of
the heap limit. At 85 % runtime.heap.pressure lists the oldest requests
and jobs in flight and the routes most of them share, once, re-armed
only after the heap falls below 70 % and thirty minutes pass. A
heap-limit crash leaves no other record of what the process was doing.

HEALTHLOG_HEAP_SNAPSHOT=1 adds one heap snapshot at that moment into
HEALTHLOG_DIAG_DIR (0700 directory, 0600 file), off by default.
docs/ops/heap-diagnostics.md warns that a snapshot holds decrypted
health data and keys and says how to delete it.
2026-10-19 16:00:53 +02:00
Marc-André Bombeck 5923dac8aa feat(observability): keep a bounded register of requests and jobs in flight
Every HTTP request the server accepts is registered through Node's
http.server.request.start diagnostics channel and removed when its
response closes, finished or aborted, so pages, RSC payloads and server
actions are covered alongside API routes. Jobs register around their
handler in observeJob. The register holds strings only, cuts the query
string on entry, caps at 2000 entries and counts what it refuses; route
labels are scrubbed and collapsed only when a report is written.
2026-10-19 16:00:45 +02:00
Marc-André Bombeck 308470f229 feat(encryption): back up the key, and refuse to serve when it cannot open the database
Every configured key id now has a canary in encryption_key_canaries,
checked at boot. A key that cannot open its canary, or existing data
written before the canary existed, puts the process in a refusal state:
every API route answers 503 encryption.key_mismatch, /api/health reports
reason encryption_key_mismatch, the pages render one explanation, and
neither the queue producer nor the worker starts. The process stays up
so the reason is visible instead of hiding in a crash loop.

The admin Encryption page gains a "Back up your encryption key" step:
key id and fingerprint (never the key), where the key lives per
platform, a check of a pasted copy that is compared and discarded, and
a confirmation bound to the active key id and fingerprint. The overview
gets a tile and admins get a dashboard banner while it is due.

HEALTHLOG_PLATFORM picks the platform the step opens on. Adds the
TrueNAS self-hosting page and key backup sections in the docs.
2026-10-03 18:06:28 +02:00
Marc-André Bombeck 5ea17398bc feat(security): encrypt medication free-text notes at rest + password-change MFA step-up
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.
2026-06-28 03:05:10 +02:00
Marc-André Bombeck fb986bc09d merge: bring the MCP server onto the v1.23 base
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.
2026-06-28 00:52:04 +02:00
Marc-André Bombeck 4d3a02cc1a feat(security): add second-factor schema and encrypt free-text health notes at rest
Lay the account-security and data-sovereignty substrate for v1.23. No
user-facing behaviour yet — schema, crypto registry, redaction, and the
note-encryption data path only.

Schema + migrations:
- 0199 — User totp_secret_encrypted / totp_confirmed_at / mfa_enforced /
  passkey_upgrade_nudge_dismissed; Session mfa_verified_at / last_active_at;
  Passkey last_used_at. All additive, nullable / defaulted.
- 0200 — mfa_challenges (single-use step-up ticket; only the ticket hash is
  stored, so no session or token is ever minted before the second factor),
  mfa_recovery_codes (Argon2id at rest), webauthn_mfa_credentials (second-factor
  keys, kept separate from passkeys, which remain the passwordless-primary set).
- 0201 — mood_entries.note_encrypted + measurements.notes_encrypted (BYTEA),
  matching the existing free-text encrypted-note convention.

Free-text health notes:
- New writes encrypt the mood / measurement note into the *_encrypted column and
  null the legacy plaintext column; reads prefer the ciphertext and fall back to
  plaintext only for not-yet-migrated rows, via a single read/write helper.
- A boot-time job migrates existing plaintext rows in an idempotent,
  transactional, fail-closed pass (encrypt, then null the plaintext, per row in
  one transaction; safe to re-run; a row with content is never left both-null).
- The legacy plaintext columns are kept this release and dropped in a follow-up
  once the backfill reports zero remaining rows — the same boundary the
  key-rotation playbook uses before dropping a legacy key.

Crypto coverage:
- Register the three new encrypted columns and wire them into the key-rotation
  script so a rotation never orphans them.
- Extend the observability and log redaction denylists so TOTP secrets, MFA /
  recovery / backup codes, and export passphrases never reach a wide event.
2026-06-27 19:33:10 +02:00
Marc-André Bombeck 7ec3bf0dad feat(mcp): add an OAuth 2.1 authorization-server bridge for remote connectors
Let a remote MCP client (Claude.ai, ChatGPT) self-register and connect to
the `/mcp` endpoint over OAuth 2.1 + PKCE, bridged onto the existing `hlk_`
Bearer model — no parallel identity store.

- Discovery: RFC 9728 Protected Resource Metadata and RFC 8414 Authorization
  Server Metadata at `/.well-known/oauth-protected-resource` and
  `/.well-known/oauth-authorization-server` (added to the proxy allowlist).
- `/api/mcp/oauth/authorize` — consent gate backed by the existing session;
  a code is only issued on an authenticated POST decision, never on a GET.
- `/api/mcp/oauth/token` — authorization-code and refresh-token grants with
  mandatory PKCE S256, single-use codes, and rotating refresh tokens. The
  access token is an ordinary short-lived `hlk_` token scoped `health:read`.
- `/api/mcp/oauth/register` — RFC 7591 DCR fallback; CIMD (SEP-991) is the
  preferred path and resolves client metadata through `safeFetch`.
- Audience binding (RFC 8707): every token is bound to the canonical `/mcp`
  resource URI; a mismatched `resource` is rejected at authorize and token.
- The grant artifacts (code, refresh, DCR client id) are HMAC-signed,
  self-describing, and domain-separated; the central log redactors scrub
  them plus the PKCE verifier.
2026-06-27 14:27:09 +02:00
Marc-André Bombeck b24a893b66 feat(telegram): log mood and act on measurement reminders in chat
Extend the self-cleaning Telegram bot beyond medication. A mood reminder
now carries a 1-5 inline keyboard plus a "Note..." force-reply and a
"Later" action; a tap logs a MoodEntry (source TELEGRAM, server-side,
user resolved from the linked chat, idempotent on a per-tap externalId)
and confirms with a calm callback toast, no chat echo. An optional
free-text note attaches to that entry via the prompt-context correlation.
A measurement reminder carries "Done" (satisfies the cadence) and
"Later" (pushes nextDueAt forward) — completion and snooze only, no value
capture.

Prompts and replies self-clean in ~30 minutes: the window drops from 1h
and moves to a shared helper, the sender schedules an unanswered prompt's
deletion at send time, and the existing 15-min sweep also prunes expired
prompt-context rows. The webhook keeps the shared-secret + timingSafeEqual
+ rate-limit-before-secret posture; every write callback authenticates via
the stored chat binding. The Telegram chat id is added to the egress
redaction denylist.
2026-06-21 11:13:33 +02:00
Marc-André Bombeck aeb30464ac chore(observability): add headerValue to the redact denylist
The webhook shared secret lives only inside the AES-GCM-encrypted channel
config blob and the GET masks it, so no current path excerpts it into a
wide event. Add headervalue to the sensitive-key denylist as defence in
depth: if a future change ever routes a webhook body through the payload
diagnostic, the secret redacts instead of landing verbatim. The Live
Activity push token is already covered by the token pattern.
2026-06-15 01:28:19 +02:00
Marc-André Bombeck 954701b5f3 feat(observability): every wide event carries the recent event-loop lag
Production showed multi-second windows where every response — routes,
RSC payloads, static assets — waited together while handler durations
stayed under half a second: the process stalled, and nothing
route-shaped could name it. A self-rescheduling tick now records how
late it fires; a tick past the stall threshold emits its own
event_loop_stall background event, and every wide event carries the
recent worst lag so the first request finishing after a stall is
annotated with it. Measured via a timeout chain rather than
perf_hooks.monitorEventLoopDelay — the histogram's sampling timer races
a sibling read-and-reset interval in the same timers phase and the
stall sample can vanish into the reset window.
2026-06-12 20:58:30 +02:00
Marc-André BombeckandClaude Opus 4.8 3375bbabac fix(cycle): second-audit safety, privacy, and write-path hardening
Address the second-round audit (server cluster):

- AVOID_PREGNANCY surfaces the fertile window, so it must carry the
  stronger "not a contraceptive method" disclaimer — wire the existing
  strong copy via a shared cycleDisclaimerKey(goal), server and client.
- Re-anchor the FOLLOWING neighbour cycle on a back-filled period start so
  an inserted-between-two-cycles start no longer leaves a stale open cycle.
- Resolve the sensitive-category encryption flag once per bulk/import
  batch instead of re-reading the profile per day; hoist the bulk cycle
  attribution in-memory (no per-entry query).
- Drop the raw driver message from the bulk wide-event meta (annotate meta
  is not run through the central redactor); keep the stable reason + code.
- Hard-purge now clears the cycle audit trail and the cycle reminder rows
  in the push-attempts ledger, so no dated reproductive trace survives.
- Discreet mode collapses the Web-Push coalescing tag and drops cycle
  metadata (phase) from APNs, matching the existing APNs event collapse.
- Add the reproductive intent field names to the central redactor denylist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:15:22 +02:00
Marc-André BombeckandClaude Opus 4.8 50e0a65228 chore(release): v1.7.0
v1.7.0 — health-record export (PDF + FHIR), flexible medication schedules, first-paint dashboard snapshot, full HealthKit coverage, offline sync delta feed, Coach data clustering. See CHANGELOG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 14:36:17 +02:00
Marc-André BombeckandClaude Opus 4.7 2d5b4cfac1 fix(observability): extend the redaction denylist with passphrase, otp, recovery, dsn
The wide-event `received_shape_excerpt` surface walks every body key
through `SENSITIVE_KEY_PATTERNS` before the eventual JSON.stringify.
The original set covered passwords, tokens, secrets, API keys, the
Authorization header, CSRF state, and nonces. Four additions worth
keeping out of observability sinks:

- `/passphrase/i` — backup-encryption passphrases (off-host backups
  use one) and any future user-facing recovery-phrase flows.
- `/otp/i` — TOTP / email-OTP one-time codes for future second-factor
  surfaces.
- `/recovery/i` — passkey-recovery codes and account-recovery flows.
- `/dsn/i` — Glitchtip / Sentry-style DSNs are `https://<key>@host`
  shapes where the key is auth material; today they enter the system
  via admin settings but a future debug surface might quote one back
  in a body excerpt.

Test pins the four new patterns alongside the existing required set,
and a positive-redaction case covers all four in one shot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:05:05 +02:00
Marc-André Bombeck f92e644154 chore(release): v1.4.49 — audit-backlog closure + server reminder suppression + diagnostic endpoint + analytics cold-path fix
Bundles the deferred v1.4.47 Mediums + Lows with the v1.4.48-deferred items
into a single release. Headline: server-side suppression of MEDICATION_REMINDER
APNs for iOS clients that own their local reminders (closes GH #206), a
push_attempts table backing /api/admin/notifications/diagnostic, and a sweep
of v1.4.48 QA forward-findings.

Performance (the user-visible win):
- /api/analytics cold-fallback path splits the heavy single-statement
  aggregate into two parallel queries. The linearly composable columns
  (count / min / max / mean) keep the full-partition scan; the windowed
  7 / 30 / 90-day avg / slope / r² columns take a 90-day outer cap so
  the planner does an index range scan on (user_id, type, measured_at)
  instead of a full-partition sequential scan. Output is bit-identical;
  cold slim slice drops from multi-second to sub-second on tenants with
  large measurement partitions.

Server-side notifications:
- GET + PATCH /api/auth/me/notification-prefs with deep-merge semantics.
  Initial category medication.clientManaged: boolean (default false). Cron
  medicationReminderJob skips dose-due APNs when the flag is true,
  emitting a medication_reminder.suppressed_client_managed wide-event
  annotation per skip.
- push_attempts table backs the diagnostic endpoint. Every APNS /
  Web-Push / Telegram / NTFY sender writes a fire-and-forget row.
  Daily 03:35 Europe/Berlin cleanup prunes rows older than 90 days.
- /api/admin/notifications/diagnostic — admin-only endpoint surfacing
  device APNs token presence (masked), channel config presence, and
  recent push attempts. Registered in OpenAPI.
- Per-channel APNs "Send test" button now fires with
  eventType: "MEDICATION_REMINDER" so the dispatcher's time-sensitive +
  priority-10 branch runs. A successful self-test now exercises the
  exact path production reminders take; an unsuccessful one points the
  user at iOS Settings → Time Sensitive Notifications rather than
  appearing to be a server-side bug.

Withings reliability:
- OAuth callback consumes the nonce atomically (delete-first with
  P2025 catch for the replay branch). The previous read-then-delete
  pattern allowed a race between two concurrent callbacks.
- ?reason=state redirect differentiated into csrf1 / replay / expired /
  cross_user so operators can tell mismatch types apart without DB
  shell access.
- connect endpoint rate-limited to 10/60s/user; row-create failures
  redirect to a consistent error surface.

Observability hardening:
- redactSensitiveFields(body) denylist helper covers password / token /
  secret / apiKey / authorization / csrfState / nonce, recursive,
  case-insensitive. Caught: /api/devices invalid_format issues were
  echoing apnsToken values verbatim into the audit log.
- sanitiseZodIssues(issues, { stripValuesFromMessage: true }) overload
  applied to 14 audit-log sites.

Migrations: 0079 (notification_prefs), 0080 (push_attempts).

Tests: 4815 → 5272 unit + integration. Workout-batch integration suite
that had been red on main since v1.4.45 (three release commits shipped
through it) now green again — updated to match v1.4.42 cross-source
dedup semantics.

Resolves PR #207.
2026-05-23 12:51:41 +02:00