mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge pull request #909 from MBombeck/integration/night-audit
chore(release): v1.38.5 — the backup finishes, the dashboard stops scanning
This commit is contained in:
96 files changed
+5403
-348
No files matched your search
+132
-4
@@ -35,6 +35,40 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# own Withings developer app and pastes the pair into Settings (stored
|
||||
# encrypted in the DB). There is therefore no WITHINGS_CLIENT_ID/SECRET.
|
||||
# WITHINGS_WEBHOOK_SECRET=""
|
||||
# Overrides the derived "<NEXT_PUBLIC_APP_URL>/api/withings/callback". Set it
|
||||
# when the callback registered with Withings differs from the app's own
|
||||
# public URL -- e.g. a second hostname in front of the same instance.
|
||||
# WITHINGS_REDIRECT_URI=""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Google Health Connect -- optional integration (per-user BYO-keys)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Like Fitbit, the client id/secret are per-user and live encrypted in the DB.
|
||||
# This only overrides the derived
|
||||
# "<NEXT_PUBLIC_APP_URL>/api/google-health/callback"; it must stay same-origin
|
||||
# with NEXT_PUBLIC_APP_URL or the handshake is refused.
|
||||
# GOOGLE_HEALTH_REDIRECT_URI=""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Telegram -- optional notification channel
|
||||
# -----------------------------------------------------------------------------
|
||||
# Shared secret for the Telegram bot webhook. It is both the path segment the
|
||||
# bot posts to and the value compared against the update header, so set it
|
||||
# before installing the webhook from Settings -> Notifications. Without it the
|
||||
# settings page cannot register the webhook and inbound updates are refused.
|
||||
# TELEGRAM_WEBHOOK_SECRET="<openssl rand -hex 32>"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Nightscout -- optional integration
|
||||
# -----------------------------------------------------------------------------
|
||||
# Nightscout instances on your own network that the per-user connection form
|
||||
# should accept, comma-separated (e.g. "http://nightscout.lan:1337"). The
|
||||
# validator refuses private and internal hosts by default; this is the opt-in
|
||||
# for a self-hosted Nightscout that is not reachable from the public internet.
|
||||
# NIGHTSCOUT_PRIVATE_ORIGINS=""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -140,10 +174,9 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# The EASIEST path is the admin panel: /admin -> Web Push VAPID -> "Generate
|
||||
# keys" mints and stores the pair for you (private key encrypted at rest). Use
|
||||
# these env vars only if you prefer env-config over the DB. The loader reads
|
||||
# DB first, then env (src/lib/notifications/vapid-config.ts). NOTE: these are
|
||||
# NOT in docker-compose.yml's `environment:` whitelist -- if you go the env
|
||||
# route under compose, add them there too or the values never reach the
|
||||
# container (see docs/self-hosting/getting-started.md Troubleshooting).
|
||||
# DB first, then env (src/lib/notifications/vapid-config.ts). These three
|
||||
# names are the only ones it reads, and all three are on docker-compose.yml's
|
||||
# `environment:` whitelist, so a value set here reaches the container.
|
||||
# VAPID_PUBLIC_KEY=""
|
||||
# VAPID_PRIVATE_KEY=""
|
||||
# VAPID_SUBJECT="mailto:you@example.com"
|
||||
@@ -164,6 +197,7 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# APNS_KEY="" # OR APNS_KEY (raw PEM) OR APNS_KEY_FILE -- one satisfies the manifest
|
||||
# APNS_KEY_FILE=""
|
||||
# APNS_CRITICAL_ENTITLEMENT="" # "true" only if your build has Apple's Critical Alerts entitlement; else urgent = time-sensitive
|
||||
# APNS_PRODUCTION="" # "true" forces the production APNs gateway; normally derived from NODE_ENV
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -242,6 +276,26 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# docs/integrations/ai-providers.md.
|
||||
# DOCUMENT_AI_LIMIT_PER_HOUR=""
|
||||
|
||||
# Extra hosts the ADMIN-configured AI base URL may point at, on top of the
|
||||
# built-in public providers (comma-separated bare hostnames). Set it when you
|
||||
# run a gateway this instance should trust. It widens the admin setting only;
|
||||
# per-user BYO keys keep the pinned public-host posture either way.
|
||||
# ADMIN_AI_BASE_URL_ALLOWLIST=""
|
||||
|
||||
# Codex (ChatGPT sign-in) model slugs. CODEX_MODEL pins one slug ahead of the
|
||||
# built-in chain; CODEX_MODEL_FALLBACK_CHAIN replaces the chain outright
|
||||
# (comma-separated, first match wins). Reach for these when the provider
|
||||
# rotates a slug and requests start failing with 503 -- without them a
|
||||
# rotation needs a new release to fix.
|
||||
# CODEX_MODEL=""
|
||||
# CODEX_MODEL_FALLBACK_CHAIN=""
|
||||
|
||||
# Surfaces reviewed n-of-1 experiment outcomes in the Coach snapshot. Off by
|
||||
# default: the read-back is the most overclaim-prone Coach surface, so it is a
|
||||
# deliberate operator opt-in. Set to "1" to enable; anything else keeps it
|
||||
# dormant while the review worker still records outcomes.
|
||||
# COACH_EXPERIMENT_VERDICT="1"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Off-host backups -- all-or-none
|
||||
@@ -267,6 +321,37 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# says 90 on both surfaces.
|
||||
# AUDIT_LOG_RETENTION_DAYS="365"
|
||||
|
||||
# How long Coach conversations survive, in days. Default 365, floor 30. Like
|
||||
# the audit window above this number is shown to the user -- the Data &
|
||||
# Privacy page states it -- so an instance running 90 has to be able to say
|
||||
# 90 rather than repeat the default.
|
||||
# COACH_MESSAGE_RETENTION_DAYS="365"
|
||||
|
||||
# How long the host CPU/memory samples behind the admin monitoring charts are
|
||||
# kept, in days. Default 7. Raise it for a longer trend; lower it on a host
|
||||
# where disk is the scarce resource.
|
||||
# HOST_METRIC_RETENTION_DAYS="7"
|
||||
|
||||
# Thinning of dense intraday samples (heart rate, glucose) once they age out
|
||||
# of the detail window. On by default. Set "false" to keep every raw sample
|
||||
# forever -- accurate to the second, at the cost of unbounded table growth.
|
||||
# DENSE_INTRADAY_RETENTION_ENABLED="false"
|
||||
|
||||
# Consecutive failures before an integration is reported as persistently
|
||||
# failing and notified about. Default 3, minimum 1. Raise it when a flaky
|
||||
# upstream makes the notification noisy.
|
||||
# INTEGRATION_FAILURE_ALERT_THRESHOLD="3"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Database session timeout -- optional
|
||||
# -----------------------------------------------------------------------------
|
||||
# Statement timeout applied to every database session, in milliseconds.
|
||||
# Default 60000, which bounds a genuinely runaway query while leaving headroom
|
||||
# for the heaviest legitimate read (the live-aggregate analytics fallback on a
|
||||
# large account). Set "0" to disable it and restore unbounded behaviour.
|
||||
# DATABASE_STATEMENT_TIMEOUT_MS="60000"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# FHIR health-record export -- optional
|
||||
@@ -389,3 +474,46 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# off — force-disable the renderer
|
||||
# on — force-enable (crashes the container if the CPU truly lacks AVX2)
|
||||
# NATIVE_CANVAS="off"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging -- optional
|
||||
# -----------------------------------------------------------------------------
|
||||
# LOG_LEVEL is one of debug / info / warn / error. Default info: drop to debug
|
||||
# while chasing a problem, raise to warn on a busy instance. LOG_SAMPLE_RATE
|
||||
# keeps that fraction of SUCCESSFUL events (default 1.0 = all; 0.1 thins a
|
||||
# chatty log without losing errors). LOG_SLOW_THRESHOLD_MS is the duration
|
||||
# above which a request is flagged slow (default 3000). LOG_INCLUDE_STACK set
|
||||
# to "false" strips stack traces from the emitted events.
|
||||
# LOG_LEVEL="info"
|
||||
# LOG_SAMPLE_RATE="1.0"
|
||||
# LOG_SLOW_THRESHOLD_MS="3000"
|
||||
# LOG_INCLUDE_STACK="false"
|
||||
|
||||
# Optional log shipping to a Loki instance. LOKI_ENDPOINT is the push URL and
|
||||
# is what turns shipping on; the username/password pair is only needed when
|
||||
# your Loki sits behind basic auth. Leave all three unset and logs stay on
|
||||
# stdout for the Docker log driver to rotate.
|
||||
# LOKI_ENDPOINT=""
|
||||
# LOKI_USERNAME=""
|
||||
# LOKI_PASSWORD=""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Demo mode -- read-only showcase instance
|
||||
# -----------------------------------------------------------------------------
|
||||
# "true" makes the proxy refuse every mutation except login and paints a demo
|
||||
# banner. This is the switch behind a public try-it-out deployment, not a
|
||||
# safety belt for a real one -- leave it unset on an instance holding real
|
||||
# data.
|
||||
# DEMO_MODE="true"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Server-side prefetch -- diagnostics escape hatch
|
||||
# -----------------------------------------------------------------------------
|
||||
# The dashboard, insights, coach and checkups pages prefetch their data on the
|
||||
# server so the first paint arrives filled in. Set "false" to fall back to
|
||||
# pure client fetching. Reach for it only when a prefetch is misbehaving on
|
||||
# your host and you want to see whether the client path is healthy.
|
||||
# DASHBOARD_SSR_PREFETCH="false"
|
||||
+185
-1
@@ -1,6 +1,9 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
## [1.38.5] — 2026-09-03
|
||||
|
||||
The weekly backup finishes again, the dashboard stops scanning the two
|
||||
largest tables, and several settings now do what they say.
|
||||
|
||||
### Security
|
||||
|
||||
@@ -14,6 +17,155 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A large record no longer outruns the weekly backup.** On an account with a
|
||||
few hundred thousand readings the Sunday snapshot could not finish. It was
|
||||
never given a time limit of its own, so it inherited the queue's fifteen
|
||||
minutes, was killed part-way through, redelivered twice against the same
|
||||
record, and gave up three quarters of an hour later having never completed
|
||||
once. The reason it needed so long was memory: the whole account was built in
|
||||
memory, serialised whole, encrypted whole, and handed to the database whole,
|
||||
which is four full copies of the record at the same moment. The stored copy
|
||||
is now compressed before it is encrypted, which takes an order of magnitude
|
||||
off everything after it, and the pass releases each stage before allocating
|
||||
the next. A seeded 445 000-reading account went from dying of memory to
|
||||
finishing in about twenty seconds, with a stored copy of 25 MB instead of
|
||||
322 MB. The pass also has an explicit two-hour window now, so a genuinely
|
||||
slow disk cannot be mistaken for a broken run. The nightly off-host copy is
|
||||
built the same way and got the same treatment; files written before this
|
||||
still restore, and so do backups already sitting in the database.
|
||||
- **A failed backup run is now visible on the backups page.** The weekly pass
|
||||
can stop and leave no trace: the page listed whatever copies existed, and a
|
||||
copy from six weeks ago carries a timestamp exactly like Sunday's. The page
|
||||
now says how old the newest scheduled copy is when it has gone past due, and
|
||||
how the last scheduled run ended when it ended badly, with the reason the
|
||||
queue recorded. Both are shown because they stop being available at different
|
||||
times — a run's own record is kept for a week, the age of the newest copy for
|
||||
as long as the copy exists.
|
||||
- **Mood correlations no longer call five days of noise a strong finding.** The
|
||||
mood page painted a "Strong" or "Moderate" badge on a mood-versus-sleep,
|
||||
activity, pulse, weight or blood-pressure card as soon as five days had been
|
||||
paired, with the day count and the coefficient tucked behind an info icon.
|
||||
Five points sloping the right way by chance is not a finding, and the rest of
|
||||
the app has refused to call it one for a while: the weight, blood-pressure
|
||||
and mood status surfaces all require at least twenty paired days and a result
|
||||
that a significance test says is unlikely to be chance. The mood page now
|
||||
requires the same. Fewer cards will show a coefficient, and the ones that do
|
||||
mean something. A card that cannot show one says which of the two it is: how
|
||||
many of the twenty days you have so far, or that twenty days went by and
|
||||
nothing stood out. Those used to look identical.
|
||||
- **A medication with no schedule no longer reports perfect adherence.** If you
|
||||
saved a medication without a dose time, the insights list, the blood-pressure
|
||||
read and the medication-compliance summary all showed it at 100 percent and
|
||||
folded that into your average, because nothing was expected of it so nothing
|
||||
could be missed. It was never an adherence figure, and it made every other
|
||||
medication's average look better than it was. Such a medication is now left
|
||||
out of adherence entirely, which is what the doctor report has always done.
|
||||
Add a schedule and it comes back.
|
||||
- **A day you never logged is no longer counted as a day you took nothing.** The
|
||||
chart pairing blood-pressure medication continuity against your systolic
|
||||
readings treated every day without an intake record as a measured zero, so a
|
||||
week away from the app read as a week off your medication and dragged the
|
||||
relationship between the two around. Only days that carry an intake record
|
||||
are used now. A dose you marked as skipped is still a real zero and still
|
||||
counts; a day you simply did not open the app is not in the chart at all.
|
||||
- **A lab note this instance cannot decrypt survives an export instead of
|
||||
vanishing.** If your encryption key was rotated and the old one dropped
|
||||
before every row had moved, a lab note or a biomarker context written under
|
||||
the old key came out of a data export as empty. In the file that is
|
||||
indistinguishable from a note you never wrote, so restoring the export
|
||||
finished the job and the text was gone with nothing to show it had been
|
||||
there. Both now export as a short marker saying the text was encrypted with
|
||||
a key this instance no longer holds, and the marker survives a restore. The
|
||||
original text is still unrecoverable without the old key; what changes is
|
||||
that you can see something was lost.
|
||||
- **A deleted mood entry or custom-metric reading now counts as deleted in the
|
||||
AI surfaces.** Deleting a reading left it in place with a tombstone, and
|
||||
three reads had not been told to skip tombstones: the correlation channel
|
||||
built from a custom metric kept using deleted readings, and the check that
|
||||
decides whether an AI assessment is still current could not see a mood
|
||||
deletion or a custom-metric deletion at all, so yesterday's text was re-dated
|
||||
and served as today's over data you had removed. All three now skip deleted
|
||||
rows.
|
||||
- **Twenty-eight settings a self-hoster could write down but not actually
|
||||
change.** The `environment:` block in `docker-compose.yml` is a whitelist:
|
||||
compose reads `.env` for `${VAR}` substitution, so a value set there looks
|
||||
configured, but a variable the block does not name never reaches the app.
|
||||
Everything below was read by the server and missing from that list, so the
|
||||
default silently stood no matter what was in `.env`. Now forwarded, each
|
||||
with a line in `.env.production.example` saying what it does and when to
|
||||
reach for it: `DEMO_MODE`; the four log knobs (`LOG_LEVEL`,
|
||||
`LOG_SAMPLE_RATE`, `LOG_SLOW_THRESHOLD_MS`, `LOG_INCLUDE_STACK`) and log
|
||||
shipping (`LOKI_ENDPOINT`, `LOKI_USERNAME`, `LOKI_PASSWORD`);
|
||||
`DATABASE_STATEMENT_TIMEOUT_MS`; four retention and alerting windows
|
||||
(`COACH_MESSAGE_RETENTION_DAYS`, `HOST_METRIC_RETENTION_DAYS`,
|
||||
`DENSE_INTRADAY_RETENTION_ENABLED`, `INTEGRATION_FAILURE_ALERT_THRESHOLD`);
|
||||
`TELEGRAM_WEBHOOK_SECRET`; `NIGHTSCOUT_PRIVATE_ORIGINS`; the three callback
|
||||
overrides `WITHINGS_REDIRECT_URI`, `FITBIT_REDIRECT_URI` and
|
||||
`GOOGLE_HEALTH_REDIRECT_URI`; both Open-Meteo endpoints;
|
||||
`ADMIN_AI_BASE_URL_ALLOWLIST`, `CODEX_MODEL`, `CODEX_MODEL_FALLBACK_CHAIN`
|
||||
and `COACH_EXPERIMENT_VERDICT`; `APNS_KEY`, `APNS_KEY_FILE` and
|
||||
`APNS_PRODUCTION`; and `DASHBOARD_SSR_PREFETCH`. The Coach retention window
|
||||
matters twice over — the Data & Privacy page quotes it back to the user, so
|
||||
an instance keeping conversations for 90 days now says 90 instead of
|
||||
repeating the default. A Codex slug rotation is likewise something an
|
||||
operator can now ride out by pinning a model, rather than waiting for a
|
||||
release.
|
||||
- **`APNS_KEY` and `APNS_KEY_FILE` work where the documentation always said
|
||||
they did.** The example file and the manifest both describe a precedence
|
||||
chain of `APNS_KEY_B64` before `APNS_KEY` before `APNS_KEY_FILE`, but only
|
||||
the first ever reached the container, so an operator who chose either of the
|
||||
other two got a silently disabled APNs and no explanation.
|
||||
- **The Web Push key loader no longer advertises names it cannot read.** Six
|
||||
`WEB_PUSH_*` aliases and a `NEXT_PUBLIC_VAPID_PUBLIC_KEY` one sat behind the
|
||||
three real `VAPID_*` variables. None was ever on the compose whitelist and
|
||||
none appeared in any example file, so under the bundled stack a value set
|
||||
under those names could not arrive — an escape hatch that had never been
|
||||
open. They are gone, and the self-hosting guide no longer mentions them. The
|
||||
VAPID section of the example file also carried a note saying the three real
|
||||
names were absent from the whitelist; they have been on it since v1.17.1.
|
||||
- **A test now answers the question instead of a comment on each entry.** The
|
||||
whitelist is derived from the compose file and the read set from the source
|
||||
— including the four modules that resolve a variable name through a helper,
|
||||
which a plain search for `process.env.` does not see at all. A variable read
|
||||
without being forwarded fails the build unless it carries a written reason
|
||||
why forwarding it would be wrong. Thirteen do: build stamps, values the
|
||||
runtime supplies, two spellings of a knob that already reaches the container
|
||||
through `DATABASE_URL`, three that only ever run in CI, and the dashboard
|
||||
snapshot flag, which is compiled into the client bundle and so cannot be
|
||||
steered from a running container at all. This is the second time the class
|
||||
has shipped; v1.5.2 closed one instance of it by hand.
|
||||
- **A dismissed correlation finding comes back when its evidence has really
|
||||
moved.** Dismissing a finding stores a fingerprint of the evidence behind it,
|
||||
and the check that decides whether to raise it again never read that
|
||||
fingerprint; it compared two of the numbers instead. A dismissal restored
|
||||
from a backup that carried the fingerprint but not those numbers could not be
|
||||
released by anything and stayed hidden for good. The fingerprint is read now:
|
||||
evidence that has not moved at all is settled without going further, and a
|
||||
dismissal with no numbers to measure against is released once the fingerprint
|
||||
stops matching. A dismissal still survives ordinary sampling noise, which is
|
||||
the whole point of it.
|
||||
- **The five assistant switches save when they are sent to the general admin
|
||||
settings endpoint.** That endpoint accepted them, answered 200 and stored
|
||||
nothing; sent on their own they came back as "no valid fields". An operator
|
||||
scripting their settings over the one endpoint had no way to tell the
|
||||
difference between a switch that was written and one that was dropped. They
|
||||
are written now, and echoed in the response so the write reads back. The
|
||||
dedicated endpoint the admin console uses was never affected.
|
||||
|
||||
### Security
|
||||
|
||||
- **A notification channel the operator switched off stops delivering.**
|
||||
Telegram, ntfy and Web Push each carry an instance-wide switch in admin
|
||||
settings. Switching one off only hid its setup card: every account that had
|
||||
already configured the channel kept receiving on it, for as long as the
|
||||
channel was configured. The switch now holds where it has to, at delivery. A
|
||||
dispatch on a channel that is off sends nothing and writes the skip to the
|
||||
delivery ledger with a reason, so the admin notification diagnostics name the
|
||||
cause instead of showing a gap. Accounts keep their own channel settings
|
||||
untouched, so switching a channel back on restores delivery without anyone
|
||||
re-enabling anything. Turning such a channel on from the user side is now
|
||||
refused with a message that says why, rather than a toggle that flips and
|
||||
then delivers nothing.
|
||||
- **The dashboard opens faster on accounts with years of readings.** Two of the
|
||||
reads behind the metric tiles were doing far more work than the handful of
|
||||
rows they return. The first asked for the newest reading of every metric by
|
||||
@@ -28,6 +180,38 @@
|
||||
and the second from 6.9 ms to 1.6 ms, and both stop growing with the size of
|
||||
the archive. Nothing about the tiles changes: same numbers, same order, same
|
||||
metrics.
|
||||
- **The check that watches for ungated module routes now watches every
|
||||
module.** It walked a hand-written list of route trees, and the list had
|
||||
stopped keeping up: the Coach tree, the environmental-context tree, the
|
||||
mental-health screeners, the inbound-document vault and the MCP credential
|
||||
surface all shipped an API tree the check never looked at. Nothing was
|
||||
leaking — every route in those trees already refuses when the module is off,
|
||||
and the doctor-report export was gated too — but a green run was saying
|
||||
something narrower than it appeared to say, which is how the FHIR routes once
|
||||
sat in a green bucket while serving the whole record. The trees are declared
|
||||
per module now, so a new module cannot be added without saying where its
|
||||
routes live, and a directory named after a module is found on disk rather
|
||||
than remembered. For a self-hoster this changes no behaviour today; it is the
|
||||
difference between a module switch that is enforced and one that is only
|
||||
believed to be.
|
||||
|
||||
### Security
|
||||
|
||||
- **Every webhook verb that checks the shared secret is rate-limited now, not
|
||||
just the one that carries data.** The Withings entrypoints, the Telegram bot
|
||||
webhook and the Coolify deploy hook all limited their POST and left the
|
||||
reachability verbs — the GET and HEAD a webhook UI sends to confirm the URL
|
||||
before saving it — comparing the same secret with nothing in front of them.
|
||||
Those verbs answer 200 for the right secret and 401 for a wrong one, so
|
||||
unlimited they were a free guessing machine against the callback token of an
|
||||
instance whose URL is known, and a free load channel besides. The comparison
|
||||
was already constant-time, which hides how long the check took but not what
|
||||
it answered. Each verb now runs the per-source limit its POST sibling used,
|
||||
on the same bucket, before it looks at the secret. Nothing changes for a
|
||||
legitimate subscription: Withings, Telegram and Coolify send one or two
|
||||
probes, far below the limit. A new guard walks every webhook route and fails
|
||||
the build if a verb reaches a secret comparison without the limiter in front
|
||||
of it, so the next verb added to one of these files cannot repeat this.
|
||||
|
||||
## [1.38.4] — 2026-09-02
|
||||
|
||||
|
||||
@@ -64,7 +64,23 @@ x-healthlog-env: &healthlog_env
|
||||
# docker-compose reads .env for `${VAR}` substitution at compose-up time).
|
||||
SESSION_COOKIE_SECURE: "${SESSION_COOKIE_SECURE:-}"
|
||||
NATIVE_CANVAS: "${NATIVE_CANVAS:-}"
|
||||
# Read-only showcase instance. DEMO_MODE=true makes the proxy refuse every
|
||||
# mutation except login and paints the demo banner. Leave it unset on a
|
||||
# real self-host — it is a public-demo switch, not a safety belt.
|
||||
DEMO_MODE: "${DEMO_MODE:-}"
|
||||
# Statement timeout applied to every database session, in milliseconds
|
||||
# (default 60000; 0 disables it and restores the unbounded behaviour).
|
||||
# Raise it if a very large account's analytics fallback is being cut off.
|
||||
DATABASE_STATEMENT_TIMEOUT_MS: "${DATABASE_STATEMENT_TIMEOUT_MS:-}"
|
||||
WITHINGS_WEBHOOK_SECRET: "${WITHINGS_WEBHOOK_SECRET:-}"
|
||||
# Overrides the derived `${NEXT_PUBLIC_APP_URL}/api/withings/callback`;
|
||||
# set it when the URL registered with Withings differs from the app's own
|
||||
# public URL. The WHOOP / Polar / Oura / Strava equivalents are below.
|
||||
WITHINGS_REDIRECT_URI: "${WITHINGS_REDIRECT_URI:-}"
|
||||
# Path-segment + HMAC secret for the Telegram bot webhook. Set it to the
|
||||
# same value you register with BotFather; without it the settings page
|
||||
# cannot install the webhook and inbound updates are refused.
|
||||
TELEGRAM_WEBHOOK_SECRET: "${TELEGRAM_WEBHOOK_SECRET:-}"
|
||||
# v1.18.11 (W3) — client-IP resolution + geo lookup. Behind Cloudflare
|
||||
# the real visitor IP is in `cf-connecting-ip`; set
|
||||
# TRUST_CF_CONNECTING_IP=1 ONLY when Cloudflare is in front (off by
|
||||
@@ -125,6 +141,22 @@ x-healthlog-env: &healthlog_env
|
||||
# the block exists: `environment:` is a whitelist, and a value in .env that
|
||||
# is not named here never arrives.
|
||||
AUDIT_LOG_RETENTION_DAYS: "${AUDIT_LOG_RETENTION_DAYS:-}"
|
||||
# How long Coach conversations are kept, in days (default 365, floor 30).
|
||||
# Like the audit window this number is PUBLISHED to the user on the Data &
|
||||
# Privacy page, so an instance running 90 has to be able to say 90.
|
||||
COACH_MESSAGE_RETENTION_DAYS: "${COACH_MESSAGE_RETENTION_DAYS:-}"
|
||||
# How long the host CPU/memory samples behind the admin charts are kept,
|
||||
# in days (default 7). Raise it for a longer trend, lower it on a small
|
||||
# disk.
|
||||
HOST_METRIC_RETENTION_DAYS: "${HOST_METRIC_RETENTION_DAYS:-}"
|
||||
# Thinning of dense intraday samples (heart rate, glucose) once they age
|
||||
# out of the detail window. On by default; set "false" to keep every raw
|
||||
# sample forever, at the cost of table growth.
|
||||
DENSE_INTRADAY_RETENTION_ENABLED: "${DENSE_INTRADAY_RETENTION_ENABLED:-}"
|
||||
# Consecutive failures before an integration is reported as persistently
|
||||
# failing and notified about (default 3, minimum 1). Raise it if a flaky
|
||||
# upstream is noisy.
|
||||
INTEGRATION_FAILURE_ALERT_THRESHOLD: "${INTEGRATION_FAILURE_ALERT_THRESHOLD:-}"
|
||||
# Per-export ceiling on MedicationAdministration rows in the FHIR
|
||||
# health-record Bundle. Bounded 1..50000, default 5000. Listed here so
|
||||
# an operator override in .env actually reaches the container (the
|
||||
@@ -161,6 +193,23 @@ x-healthlog-env: &healthlog_env
|
||||
STRAVA_CLIENT_ID: "${STRAVA_CLIENT_ID:-}"
|
||||
STRAVA_CLIENT_SECRET: "${STRAVA_CLIENT_SECRET:-}"
|
||||
STRAVA_REDIRECT_URI: "${STRAVA_REDIRECT_URI:-}"
|
||||
# Fitbit + Google Health are per-user BYO-key integrations, so there is no
|
||||
# client id/secret env for either. These override the derived
|
||||
# `${NEXT_PUBLIC_APP_URL}/api/{fitbit,google-health}/callback` when the URL
|
||||
# registered with the provider differs from the app's public URL. Both are
|
||||
# asserted same-origin with NEXT_PUBLIC_APP_URL, so they cannot redirect
|
||||
# the authorization code somewhere else.
|
||||
FITBIT_REDIRECT_URI: "${FITBIT_REDIRECT_URI:-}"
|
||||
GOOGLE_HEALTH_REDIRECT_URI: "${GOOGLE_HEALTH_REDIRECT_URI:-}"
|
||||
# Nightscout origins on your own network that the per-user connection
|
||||
# validator should accept, comma-separated (e.g. "http://nightscout.lan").
|
||||
# Without it a LAN/private Nightscout URL is refused as an SSRF target.
|
||||
NIGHTSCOUT_PRIVATE_ORIGINS: "${NIGHTSCOUT_PRIVATE_ORIGINS:-}"
|
||||
# Open-Meteo endpoints for the environment/weather correlation. Default to
|
||||
# the hosted service; point them at your own Open-Meteo deployment to keep
|
||||
# the coordinate lookups off a third party.
|
||||
OPENMETEO_BASE_URL: "${OPENMETEO_BASE_URL:-}"
|
||||
OPENMETEO_GEOCODING_URL: "${OPENMETEO_GEOCODING_URL:-}"
|
||||
# OIDC SSO login (self-hosted IdP: Authentik/Keycloak/Authelia/etc).
|
||||
# All three of ISSUER_URL/CLIENT_ID/CLIENT_SECRET together enable the
|
||||
# "Sign in with SSO" button; set none to leave it disabled. OIDC_ONLY
|
||||
@@ -201,6 +250,22 @@ x-healthlog-env: &healthlog_env
|
||||
# in .env reaches the container (the `environment:` block is a whitelist).
|
||||
# See docs/integrations/ai-providers.md.
|
||||
ALLOW_LOCAL_AI_PRIVATE_HOSTS: "${ALLOW_LOCAL_AI_PRIVATE_HOSTS:-}"
|
||||
# Extra hosts the admin-configured AI base URL may point at, on top of the
|
||||
# built-in public providers. Comma-separated bare hostnames. Set it when
|
||||
# you run a gateway this instance should trust; it widens only the admin
|
||||
# setting, never the per-user BYO-key posture.
|
||||
ADMIN_AI_BASE_URL_ALLOWLIST: "${ADMIN_AI_BASE_URL_ALLOWLIST:-}"
|
||||
# Codex (ChatGPT sign-in) model slugs. Set CODEX_MODEL to pin one slug
|
||||
# ahead of the built-in chain, or CODEX_MODEL_FALLBACK_CHAIN (comma-
|
||||
# separated) to replace the chain outright. Reach for these when the
|
||||
# provider rotates a slug and every request starts coming back 503 —
|
||||
# without them a rotation needs a new release to fix.
|
||||
CODEX_MODEL: "${CODEX_MODEL:-}"
|
||||
CODEX_MODEL_FALLBACK_CHAIN: "${CODEX_MODEL_FALLBACK_CHAIN:-}"
|
||||
# Surfaces reviewed n-of-1 experiment outcomes in the Coach snapshot.
|
||||
# Off by default — the read-back is the most overclaim-prone Coach
|
||||
# surface, so it is an explicit operator opt-in. Set to "1" to enable.
|
||||
COACH_EXPERIMENT_VERDICT: "${COACH_EXPERIMENT_VERDICT:-}"
|
||||
# Daily-briefing output-token ceiling (optional; default 2500, clamped
|
||||
# 500-8000). Raise when a verbose model's briefing JSON is cut off.
|
||||
# Listed here so an operator value in .env reaches the container (the
|
||||
@@ -244,12 +309,46 @@ x-healthlog-env: &healthlog_env
|
||||
# PWA users need none of this (Web Push covers them). See
|
||||
# docs/self-hosting/notifications.md.
|
||||
APNS_KEY_B64: "${APNS_KEY_B64:-}"
|
||||
# The other two key sources, in precedence order after APNS_KEY_B64:
|
||||
# APNS_KEY is the raw PEM body, APNS_KEY_FILE a path to the .p8 inside the
|
||||
# container (mount it yourself). Both are documented in
|
||||
# .env.production.example and in the manifest check-env reads, and both
|
||||
# were absent from this whitelist — so an operator who followed the
|
||||
# documented precedence chain got a silently disabled APNs.
|
||||
APNS_KEY: "${APNS_KEY:-}"
|
||||
APNS_KEY_FILE: "${APNS_KEY_FILE:-}"
|
||||
APNS_KEY_ID: "${APNS_KEY_ID:-}"
|
||||
APNS_TEAM_ID: "${APNS_TEAM_ID:-}"
|
||||
APNS_BUNDLE_ID: "${APNS_BUNDLE_ID:-}"
|
||||
# Force the production APNs gateway. Normally derived from NODE_ENV; set
|
||||
# "true" when a production-signed build has to reach the production
|
||||
# gateway from a non-production deployment.
|
||||
APNS_PRODUCTION: "${APNS_PRODUCTION:-}"
|
||||
# Set "true" only if your own iOS build carries Apple's Critical Alerts
|
||||
# entitlement; otherwise urgent alerts use the time-sensitive level.
|
||||
APNS_CRITICAL_ENTITLEMENT: "${APNS_CRITICAL_ENTITLEMENT:-}"
|
||||
# Structured logging. LOG_LEVEL is one of debug/info/warn/error (default
|
||||
# info) — drop to debug while chasing a problem, raise to warn on a busy
|
||||
# instance. LOG_SAMPLE_RATE keeps that fraction of successful events
|
||||
# (default 1.0 = all; 0.1 thins a chatty log without losing errors).
|
||||
# LOG_SLOW_THRESHOLD_MS is the duration above which a request is flagged
|
||||
# slow (default 3000). LOG_INCLUDE_STACK="false" strips stack traces from
|
||||
# the emitted events.
|
||||
LOG_LEVEL: "${LOG_LEVEL:-}"
|
||||
LOG_SAMPLE_RATE: "${LOG_SAMPLE_RATE:-}"
|
||||
LOG_SLOW_THRESHOLD_MS: "${LOG_SLOW_THRESHOLD_MS:-}"
|
||||
LOG_INCLUDE_STACK: "${LOG_INCLUDE_STACK:-}"
|
||||
# Optional log shipping to a Loki instance. Set LOKI_ENDPOINT to the push
|
||||
# URL to turn it on; the username/password pair is only needed when your
|
||||
# Loki is behind basic auth. Leave all three unset and logs stay on stdout
|
||||
# for the Docker log driver to rotate.
|
||||
LOKI_ENDPOINT: "${LOKI_ENDPOINT:-}"
|
||||
LOKI_USERNAME: "${LOKI_USERNAME:-}"
|
||||
LOKI_PASSWORD: "${LOKI_PASSWORD:-}"
|
||||
# Server-side prefetch for the dashboard, insights, coach and checkups
|
||||
# pages. On by default; set "false" to fall back to pure client fetching,
|
||||
# which is the escape hatch when a prefetch is misbehaving on your host.
|
||||
DASHBOARD_SSR_PREFETCH: "${DASHBOARD_SSR_PREFETCH:-}"
|
||||
|
||||
services:
|
||||
# Single-container default (HEALTHLOG_PROCESS_TYPE=all). Existing
|
||||
|
||||
+45
-8
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.38.4
|
||||
version: 1.38.5
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -11763,9 +11763,16 @@ paths:
|
||||
tags:
|
||||
- Notifications
|
||||
summary: Update the caller's ntfy channel config
|
||||
description: "Upserts the channel. Accepts either the toggle-only body or the full config. The response is `{ saved:
|
||||
true }` and carries no config echo, so re-read the GET if the client needs the resolved state. Auth via cookie
|
||||
or Bearer."
|
||||
description: >-
|
||||
Upserts the channel. Accepts either the toggle-only body or the full config. The response is `{ saved: true }`
|
||||
and carries no config echo, so re-read the GET if the client needs the resolved state. Auth via cookie or
|
||||
Bearer.
|
||||
|
||||
|
||||
An operator can switch ntfy off for the whole instance. While that switch is off, a body that sets `enabled` to
|
||||
true is refused with 403 and nothing is stored — the channel would not deliver, so the toggle does not pretend
|
||||
otherwise. Disabling the channel keeps working either way. Read the switch from `GET
|
||||
/api/settings/global-services`.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -11786,6 +11793,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"401": *a1
|
||||
"403":
|
||||
description: The operator switched ntfy off for this instance and the body asked to enable it. Nothing was stored.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"413": &a16
|
||||
description: Body exceeds 64 KiB.
|
||||
content:
|
||||
@@ -11827,10 +11840,16 @@ paths:
|
||||
tags:
|
||||
- Notifications
|
||||
summary: Update the caller's Telegram channel config
|
||||
description: "Accepts either the toggle-only body or the full config, then registers (on enable) or deletes (on disable)
|
||||
the Telegram webhook for the bot before persisting. Webhook deletion is best-effort and its failure does not
|
||||
fail the write; webhook REGISTRATION is not — a rejected registration aborts the whole call and nothing is
|
||||
stored. The response is `{ updated: true }` with no config echo. Auth via cookie or Bearer."
|
||||
description: >-
|
||||
Accepts either the toggle-only body or the full config, then registers (on enable) or deletes (on disable) the
|
||||
Telegram webhook for the bot before persisting. Webhook deletion is best-effort and its failure does not fail
|
||||
the write; webhook REGISTRATION is not — a rejected registration aborts the whole call and nothing is stored.
|
||||
The response is `{ updated: true }` with no config echo. Auth via cookie or Bearer.
|
||||
|
||||
|
||||
An operator can switch Telegram off for the whole instance. While that switch is off, a body that sets `enabled`
|
||||
to true is refused with 403 before any webhook call and nothing is stored. Disabling the channel keeps working
|
||||
either way. Read the switch from `GET /api/settings/global-services`.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -11846,6 +11865,13 @@ paths:
|
||||
$ref: "#/components/schemas/PutTelegramSettingsResponse"
|
||||
"400": *a15
|
||||
"401": *a1
|
||||
"403":
|
||||
description: The operator switched Telegram off for this instance and the body asked to enable it. No webhook was
|
||||
registered and nothing was stored.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"404":
|
||||
description: The session's user row no longer exists.
|
||||
content:
|
||||
@@ -18649,6 +18675,11 @@ paths:
|
||||
Body capped at 64 KB. A rejected subscription answers the standard multi-issue 422, so a client can tell a
|
||||
non-HTTPS endpoint from an internal-host one from a missing key. The issue list carries `path`, `code` and
|
||||
`message` and never the rejected value — neither the endpoint nor the subscription keys are echoed back.
|
||||
|
||||
|
||||
An operator can switch Web Push off for the whole instance. While that switch is off this answers 403 before it
|
||||
reads the body, and no subscription and no channel row are created. Read the switch from `GET
|
||||
/api/settings/global-services` and hide the subscribe control rather than retrying.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -18663,6 +18694,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/WebPushSubscribeEnvelope"
|
||||
"401": *a1
|
||||
"403":
|
||||
description: The operator switched Web Push off for this instance. Nothing was stored.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"413":
|
||||
description: Request body exceeds 65536 bytes. Nothing was stored.
|
||||
content:
|
||||
|
||||
@@ -91,10 +91,8 @@ VAPID_PRIVATE_KEY="...(private key)"
|
||||
VAPID_SUBJECT="mailto:you@example.com"
|
||||
```
|
||||
|
||||
The database values win when both are present. The loader also accepts a
|
||||
few aliases (`WEB_PUSH_VAPID_PUBLIC_KEY`, `WEB_PUSH_PUBLIC_KEY`, and the
|
||||
private/subject equivalents) so a config copied from another deployment
|
||||
keeps working.
|
||||
The database values win when both are present. These three names are the
|
||||
only ones the loader reads.
|
||||
|
||||
### 3. Subscribe a device
|
||||
|
||||
|
||||
@@ -34,9 +34,32 @@ ruleTester.run("queryKey-factory", rule, {
|
||||
code: 'useQuery({ queryKey: ["fixture"] });',
|
||||
filename: "/repo/src/components/__tests__/example.test.tsx",
|
||||
},
|
||||
// A server module outside every guarded root stays unguarded — the roots
|
||||
// are the scope decision, not an accident of this fixture.
|
||||
{
|
||||
code: 'useQuery({ queryKey: ["server-prefetch"] });',
|
||||
filename: "/repo/src/app/server-page.tsx",
|
||||
code: 'useQuery({ queryKey: ["server-only-lib"] });',
|
||||
filename: "/repo/src/lib/reports/build-report.ts",
|
||||
},
|
||||
// The positional cache API, used correctly: the key still comes from the
|
||||
// factory, so the shape that poisons the cache never arises.
|
||||
{
|
||||
code: "queryClient.setQueryData(queryKeys.example(id), next);",
|
||||
filename: "/repo/src/app/page.tsx",
|
||||
},
|
||||
{
|
||||
code: "const key = queryKeys.example(id); queryClient.setQueryData(key, next);",
|
||||
filename: "/repo/src/components/example.tsx",
|
||||
},
|
||||
// Tests seed caches with literal tuples on purpose; the factory directory
|
||||
// constructs them by definition. Both stay exempt on the positional form
|
||||
// exactly as they are on the object-property form.
|
||||
{
|
||||
code: 'client.setQueryData(["fixture"], 1);',
|
||||
filename: "/repo/src/hooks/__tests__/use-example.test.ts",
|
||||
},
|
||||
{
|
||||
code: 'client.setQueryData(["fixture"], 1);',
|
||||
filename: "/repo/src/lib/query-keys/__fixtures__/seed.ts",
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
@@ -60,5 +83,65 @@ ruleTester.run("queryKey-factory", rule, {
|
||||
filename: "/repo/src/lib/client-state.tsx",
|
||||
errors: [{ messageId: "bareArray" }],
|
||||
},
|
||||
// src/app is where the server prefetch pages build the keys the client
|
||||
// then reads. A key that differs from the client's is a cache miss at
|
||||
// best and a poisoned cell at worst, and without `"use client"` on the
|
||||
// page the rule used to skip the file entirely.
|
||||
{
|
||||
code: 'useQuery({ queryKey: ["server-prefetch"] });',
|
||||
filename: "/repo/src/app/server-page.tsx",
|
||||
errors: [{ messageId: "bareArray" }],
|
||||
},
|
||||
{
|
||||
code: 'queryClient.prefetchQuery({ queryKey: ["dashboard"], queryFn: load });',
|
||||
filename: "/repo/src/app/coach/page.tsx",
|
||||
errors: [{ messageId: "bareArray" }],
|
||||
},
|
||||
// The positional form of the same mistake. `setQueryData` seeds a cache
|
||||
// cell by key exactly as `queryKey:` reads one, so a bare array here is
|
||||
// the same defect written differently.
|
||||
{
|
||||
code: 'queryClient.setQueryData(["bare", "positional"], 1);',
|
||||
filename: "/repo/src/app/coach/page.tsx",
|
||||
errors: [{ messageId: "positionalArray" }],
|
||||
},
|
||||
{
|
||||
code: 'const cached = queryClient.getQueryData(["bare"]);',
|
||||
filename: "/repo/src/hooks/use-example.ts",
|
||||
errors: [{ messageId: "positionalArray" }],
|
||||
},
|
||||
{
|
||||
code: 'queryClient.getQueryState(["bare"]);',
|
||||
filename: "/repo/src/components/example.tsx",
|
||||
errors: [{ messageId: "positionalArray" }],
|
||||
},
|
||||
{
|
||||
code: 'queryClient.setQueryDefaults(["bare"], { staleTime: 0 });',
|
||||
filename: "/repo/src/lib/queries/use-example.ts",
|
||||
errors: [{ messageId: "positionalArray" }],
|
||||
},
|
||||
{
|
||||
code: 'queryClient.setMutationDefaults(["bare"], { retry: 0 });',
|
||||
filename: "/repo/src/components/example.tsx",
|
||||
errors: [{ messageId: "positionalArray" }],
|
||||
},
|
||||
// Destructured off the client, and reached through a computed member —
|
||||
// both spell the same call and both must be seen.
|
||||
{
|
||||
code: 'const { setQueryData } = useQueryClient(); setQueryData(["bare"], 1);',
|
||||
filename: "/repo/src/components/example.tsx",
|
||||
errors: [{ messageId: "positionalArray" }],
|
||||
},
|
||||
{
|
||||
code: 'queryClient["setQueryData"](["bare"], 1);',
|
||||
filename: "/repo/src/components/example.tsx",
|
||||
errors: [{ messageId: "positionalArray" }],
|
||||
},
|
||||
// Both holes in one file, which is the probe that found them.
|
||||
{
|
||||
code: 'queryClient.setQueryData(["bare", "positional"], 1); useQuery({ queryKey: ["bare", "object-property"] });',
|
||||
filename: "/repo/src/app/coach/page.tsx",
|
||||
errors: [{ messageId: "positionalArray" }, { messageId: "bareArray" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it } from "vitest";
|
||||
import { RuleTester } from "eslint";
|
||||
import rule from "../safe-fetch-required.js";
|
||||
|
||||
RuleTester.describe = describe;
|
||||
RuleTester.it = it;
|
||||
RuleTester.itOnly = it.only;
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
ecmaVersion: 2023,
|
||||
sourceType: "module",
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
},
|
||||
});
|
||||
|
||||
ruleTester.run("safe-fetch-required", rule, {
|
||||
valid: [
|
||||
{
|
||||
code: "safeFetch(url, { requirePublicHost: true });",
|
||||
filename: "/repo/src/lib/notifications/senders/ntfy.ts",
|
||||
},
|
||||
// Same-origin relative paths never leave the origin.
|
||||
{
|
||||
code: 'fetch("/api/measurements");',
|
||||
filename: "/repo/src/app/dashboard/load.ts",
|
||||
},
|
||||
{
|
||||
code: "fetch(`/api/measurements/${id}`);",
|
||||
filename: "/repo/src/lib/queries/measurements.ts",
|
||||
},
|
||||
// The wrapper's own internals, including the aliased undici import it
|
||||
// actually uses. This is the spelling the rule learned to see, so the
|
||||
// exemption has to be proven, not assumed.
|
||||
{
|
||||
code: 'import { fetch as undiciFetch } from "undici"; undiciFetch(url);',
|
||||
filename: "/repo/src/lib/safe-fetch.ts",
|
||||
},
|
||||
{
|
||||
code: "fetch(url);",
|
||||
filename: "/repo/src/lib/safe-fetch-dispatcher.ts",
|
||||
},
|
||||
{
|
||||
code: "fetch(url);",
|
||||
filename: "/repo/src/lib/api/api-fetch.ts",
|
||||
},
|
||||
// Tests mock and assert against fetch directly.
|
||||
{
|
||||
code: 'import { fetch as f } from "undici"; f(url);',
|
||||
filename: "/repo/src/lib/__tests__/egress.test.ts",
|
||||
},
|
||||
{
|
||||
code: "fetch(url);",
|
||||
filename: "/repo/src/lib/__mocks__/transport.ts",
|
||||
},
|
||||
// Outside every enforced path: scripts and config are out of scope.
|
||||
{
|
||||
code: "fetch(url);",
|
||||
filename: "/repo/scripts/backfill.ts",
|
||||
},
|
||||
// An aliased import pointed at a relative path is still same-origin.
|
||||
{
|
||||
code: 'import { fetch as f } from "undici"; f("/api/version");',
|
||||
filename: "/repo/src/lib/version.ts",
|
||||
},
|
||||
// A named import that is not `fetch` binds nothing this rule cares about.
|
||||
{
|
||||
code: 'import { Agent } from "undici"; Agent(url);',
|
||||
filename: "/repo/src/lib/safe-fetch-dispatcher-helper.ts",
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: "fetch(url);",
|
||||
filename: "/repo/src/lib/ai/openai-client.ts",
|
||||
errors: [{ messageId: "rawFetch" }],
|
||||
},
|
||||
{
|
||||
code: "globalThis.fetch(url);",
|
||||
filename: "/repo/src/app/api/withings/route.ts",
|
||||
errors: [{ messageId: "rawFetch" }],
|
||||
},
|
||||
{
|
||||
code: "self.fetch(url);",
|
||||
filename: "/repo/src/lib/pwa/register.ts",
|
||||
errors: [{ messageId: "rawFetch" }],
|
||||
},
|
||||
{
|
||||
code: 'globalThis["fetch"](url);',
|
||||
filename: "/repo/src/lib/ai/local-client.ts",
|
||||
errors: [{ messageId: "rawFetch" }],
|
||||
},
|
||||
// Protocol-relative and backslash forms resolve off-origin, so the
|
||||
// same-origin exemption must not swallow them.
|
||||
{
|
||||
code: 'fetch("//evil.example/api");',
|
||||
filename: "/repo/src/lib/ai/anthropic-client.ts",
|
||||
errors: [{ messageId: "rawFetch" }],
|
||||
},
|
||||
// The three paths that sat outside the enforced roots. They are
|
||||
// egress-free today; instrumentation.ts is where an exporter pointed at
|
||||
// an operator-supplied URL would land, and nothing would have said so.
|
||||
{
|
||||
code: "fetch(url);",
|
||||
filename: "/repo/src/proxy.ts",
|
||||
errors: [{ messageId: "rawFetch" }],
|
||||
},
|
||||
{
|
||||
code: "fetch(otlpEndpoint, { method: 'POST' });",
|
||||
filename: "/repo/src/instrumentation.ts",
|
||||
errors: [{ messageId: "rawFetch" }],
|
||||
},
|
||||
{
|
||||
code: "fetch(url);",
|
||||
filename: "/repo/src/cli/mcp-stdio.ts",
|
||||
errors: [{ messageId: "rawFetch" }],
|
||||
},
|
||||
// The aliased import. `src/lib/safe-fetch.ts` writes exactly this
|
||||
// spelling, so it is a form the codebase demonstrably reaches for, and
|
||||
// the identifier check never matched it.
|
||||
{
|
||||
code: 'import { fetch as f } from "undici"; f(url);',
|
||||
filename: "/repo/src/lib/ai/codex-client.ts",
|
||||
errors: [{ messageId: "rawFetch" }],
|
||||
},
|
||||
{
|
||||
code: 'import { fetch as undiciFetch } from "undici"; undiciFetch(url);',
|
||||
filename: "/repo/src/instrumentation.ts",
|
||||
errors: [{ messageId: "rawFetch" }],
|
||||
},
|
||||
// The namespace form of the same escape.
|
||||
{
|
||||
code: 'import * as undici from "undici"; undici.fetch(url);',
|
||||
filename: "/repo/src/lib/notifications/senders/telegram.ts",
|
||||
errors: [{ messageId: "rawFetch" }],
|
||||
},
|
||||
// A default import named `fetch` was always caught; kept so the widening
|
||||
// cannot regress it.
|
||||
{
|
||||
code: 'import fetch from "node-fetch"; fetch(url);',
|
||||
filename: "/repo/src/lib/ai/local-client.ts",
|
||||
errors: [{ messageId: "rawFetch" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -2,16 +2,39 @@
|
||||
* @fileoverview ESLint rule — `queryKey` / `mutationKey` literal-array
|
||||
* factory enforcement across the complete client tree.
|
||||
*
|
||||
* Every TanStack key must come from `src/lib/query-keys/`. The rule flags
|
||||
* object-property `queryKey: [ … ]` and `mutationKey: [ … ]` values in
|
||||
* components, hooks, client query modules, and any source module declaring
|
||||
* top-level `"use client"`.
|
||||
* Every TanStack key must come from `src/lib/query-keys/`. The rule flags a
|
||||
* bare array wherever a key is supplied, in either of the two shapes TanStack
|
||||
* accepts:
|
||||
*
|
||||
* - the object property, `{ queryKey: [ … ] }` / `{ mutationKey: [ … ] }`;
|
||||
* - the FIRST POSITIONAL argument of the QueryClient methods that take a key
|
||||
* directly, `setQueryData([ … ], value)`, `getQueryData([ … ])`, and their
|
||||
* siblings below.
|
||||
*
|
||||
* The positional shape was invisible until 2026-09-03. `setQueryData` seeds
|
||||
* the same cache cell that `queryKey:` reads, so a literal there is the same
|
||||
* defect written differently — and a probe placing one in a component
|
||||
* produced no error at all.
|
||||
*
|
||||
* Enforced roots: `src/components/`, `src/hooks/`, `src/lib/queries/`,
|
||||
* `src/app/`, and any source module declaring top-level `"use client"`.
|
||||
* `src/app/` joined the list on the same date: the server prefetch pages there
|
||||
* build the keys the client subsequently reads, and a server key that differs
|
||||
* from the client's is a cache miss at best and a poisoned cell at worst. They
|
||||
* carry no `"use client"` directive, so nothing guarded them before.
|
||||
*
|
||||
* Accepted call-site shapes include direct factory calls, identifiers already
|
||||
* built from the factory, and conditionals selecting factory calls. Tests and
|
||||
* the factory definition directory are exempt because they intentionally
|
||||
* construct literal fixtures and tuples.
|
||||
*
|
||||
* Limits, so a green run is not read as more than it is: the positional check
|
||||
* matches by METHOD NAME, so a key handed to a helper of the project's own
|
||||
* naming (`seedCache(["…"], v)`) is not seen, and conversely an unrelated
|
||||
* object with a `setQueryData` method would be flagged. A key assembled into a
|
||||
* variable before the call site (`const k = ["a", id]`) is invisible to both
|
||||
* checks — only the literal at the key position is matched.
|
||||
*
|
||||
* @see src/lib/query-keys/
|
||||
* @see src/lib/__tests__/query-keys.test.ts
|
||||
*/
|
||||
@@ -22,7 +45,28 @@ const FACTORY_HOME_DIRECTORY = "src/lib/query-keys";
|
||||
|
||||
// Components and hooks are client-facing by convention. Query modules are
|
||||
// client-transitive even if a future refactor moves the directive to a barrel.
|
||||
const CLIENT_ROOTS = ["src/components/", "src/hooks/", "src/lib/queries/"];
|
||||
// `src/app/` is where the server prefetch pages seed the client's cache, so
|
||||
// their keys have to come from the same factory the client reads from.
|
||||
const GUARDED_ROOTS = [
|
||||
"src/components/",
|
||||
"src/hooks/",
|
||||
"src/lib/queries/",
|
||||
"src/app/",
|
||||
];
|
||||
|
||||
// QueryClient methods whose FIRST argument is a query key or mutation key,
|
||||
// supplied positionally rather than inside an options object. Everything else
|
||||
// on the client (`invalidateQueries`, `prefetchQuery`, …) takes a filters or
|
||||
// options object and is covered by the `queryKey:` property check.
|
||||
const POSITIONAL_KEY_METHODS = new Set([
|
||||
"setQueryData",
|
||||
"getQueryData",
|
||||
"getQueryState",
|
||||
"setQueryDefaults",
|
||||
"getQueryDefaults",
|
||||
"setMutationDefaults",
|
||||
"getMutationDefaults",
|
||||
]);
|
||||
|
||||
function toPosix(filename) {
|
||||
return filename.replace(/\\/g, "/");
|
||||
@@ -46,7 +90,7 @@ function isGuarded(filename, sourceCode) {
|
||||
return false;
|
||||
}
|
||||
if (isTestFile(posix)) return false;
|
||||
if (CLIENT_ROOTS.some((root) => posix.includes(root))) return true;
|
||||
if (GUARDED_ROOTS.some((root) => posix.includes(root))) return true;
|
||||
return sourceCode.ast.body.some(
|
||||
(statement) =>
|
||||
statement.type === "ExpressionStatement" &&
|
||||
@@ -54,6 +98,25 @@ function isGuarded(filename, sourceCode) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The called name, whether the call is bare (`setQueryData(…)`, destructured
|
||||
* off the client), a member (`qc.setQueryData(…)`), or a static computed
|
||||
* member (`qc["setQueryData"](…)`).
|
||||
*/
|
||||
function calleeName(callee) {
|
||||
if (callee.type === "Identifier") return callee.name;
|
||||
if (callee.type !== "MemberExpression") return null;
|
||||
if (!callee.computed && callee.property.type === "Identifier") {
|
||||
return callee.property.name;
|
||||
}
|
||||
if (callee.computed && callee.property.type === "Literal") {
|
||||
return typeof callee.property.value === "string"
|
||||
? callee.property.value
|
||||
: null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
const queryKeyFactoryRule = {
|
||||
meta: {
|
||||
@@ -66,6 +129,8 @@ const queryKeyFactoryRule = {
|
||||
messages: {
|
||||
bareArray:
|
||||
"Bare-array `{{prop}}: [ … ]` bypasses the queryKeys factory. Import from `@/lib/query-keys` and call `queryKeys.<entry>()` so cache-invalidation bundles stay in lockstep.",
|
||||
positionalArray:
|
||||
"Bare-array key passed to `{{call}}( … )` bypasses the queryKeys factory. It seeds or reads the same cache cell as a `queryKey`, so import from `@/lib/query-keys` and call `queryKeys.<entry>()` instead.",
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
@@ -76,7 +141,7 @@ const queryKeyFactoryRule = {
|
||||
return {};
|
||||
}
|
||||
|
||||
function check(node) {
|
||||
function checkProperty(node) {
|
||||
// `node` is a Property like `queryKey: [ … ]`. Only flag the
|
||||
// bare ArrayExpression form — identifiers, call expressions,
|
||||
// conditional expressions, etc. are all acceptable indirection
|
||||
@@ -98,8 +163,22 @@ const queryKeyFactoryRule = {
|
||||
});
|
||||
}
|
||||
|
||||
function checkPositional(node) {
|
||||
const name = calleeName(node.callee);
|
||||
if (!name || !POSITIONAL_KEY_METHODS.has(name)) return;
|
||||
const first = node.arguments[0];
|
||||
if (!first || first.type !== "ArrayExpression") return;
|
||||
|
||||
context.report({
|
||||
node: first,
|
||||
messageId: "positionalArray",
|
||||
data: { call: name },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
Property: check,
|
||||
Property: checkProperty,
|
||||
CallExpression: checkPositional,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,9 +16,22 @@
|
||||
* mock or assert against `fetch` directly.
|
||||
*
|
||||
* The check is a syntactic `CallExpression` match against a bare
|
||||
* `fetch(` callee (Identifier `fetch`) and `globalThis.fetch(` /
|
||||
* `window.fetch(` member forms. `safeFetch` is a distinct identifier and
|
||||
* never matches.
|
||||
* `fetch(` callee (Identifier `fetch`), the `globalThis.fetch(` /
|
||||
* `window.fetch(` / `self.fetch(` member forms, and — since 2026-09-03 —
|
||||
* any local name bound to a module's `fetch` export or reached through a
|
||||
* namespace import. `safeFetch` is a distinct identifier and never matches.
|
||||
*
|
||||
* The aliased spelling is not hypothetical: `src/lib/safe-fetch.ts` opens
|
||||
* with `import { fetch as undiciFetch } from "undici"`, so the codebase
|
||||
* demonstrably reaches for it, and every copy of that spelling outside the
|
||||
* wrapper escaped the rule in every file.
|
||||
*
|
||||
* Limits, so a green run is not read as more than it is: the binding scan
|
||||
* walks top-level `import` declarations only. A `require("undici").fetch`,
|
||||
* a dynamic `await import(...)`, a fetch handed through a parameter or
|
||||
* stored on an object, and a local shadow of an imported name are all
|
||||
* outside it — as is any egress that never spells `fetch` at all, such as
|
||||
* `http.request` or a client library with its own transport.
|
||||
*
|
||||
* Same-origin client fetches are exempt: a first argument that is a
|
||||
* string literal (or template head) starting with `/` is a relative,
|
||||
@@ -45,9 +58,27 @@ const EXEMPT_FILES = [
|
||||
"src/lib/api/api-fetch.ts",
|
||||
];
|
||||
|
||||
// Only enforce inside the application source roots. Scripts, config, and
|
||||
// generated code are out of scope.
|
||||
const ENFORCED_ROOTS = ["src/lib/", "src/app/"];
|
||||
// Only enforce inside the application source. Scripts, config, and generated
|
||||
// code are out of scope.
|
||||
//
|
||||
// The list carries three single files alongside the two directory roots.
|
||||
// `src/proxy.ts`, `src/instrumentation.ts` and `src/cli/` sit outside
|
||||
// `src/lib/` and `src/app/`, and the companion `healthlog/api-fetch-required`
|
||||
// does not reach them either (it guards the client surface), so between them
|
||||
// they were egress-unguarded. They are egress-free today; instrumentation is
|
||||
// precisely where an exporter pointed at an operator-supplied URL would land,
|
||||
// and nothing would have flagged it.
|
||||
const ENFORCED_PATHS = [
|
||||
"src/lib/",
|
||||
"src/app/",
|
||||
"src/proxy.ts",
|
||||
"src/instrumentation.ts",
|
||||
"src/cli/",
|
||||
];
|
||||
|
||||
// Objects that carry the platform `fetch`. Treated as namespaces below so the
|
||||
// member form goes through one code path with the imported-namespace case.
|
||||
const GLOBAL_FETCH_OBJECTS = ["globalThis", "window", "self"];
|
||||
|
||||
function toPosix(filename) {
|
||||
return filename.replace(/\\/g, "/");
|
||||
@@ -64,7 +95,7 @@ function isTestFile(posix) {
|
||||
|
||||
function isEnforced(filename) {
|
||||
const posix = toPosix(filename);
|
||||
if (!ENFORCED_ROOTS.some((root) => posix.includes(root))) return false;
|
||||
if (!ENFORCED_PATHS.some((path) => posix.includes(path))) return false;
|
||||
if (EXEMPT_FILES.some((f) => posix.includes(f))) return false;
|
||||
if (isTestFile(posix)) return false;
|
||||
return true;
|
||||
@@ -86,27 +117,54 @@ const safeFetchRequiredRule = {
|
||||
},
|
||||
create(context) {
|
||||
const filename = context.filename ?? context.getFilename?.();
|
||||
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
||||
if (!filename || !isEnforced(filename)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
function isFetchCallee(callee) {
|
||||
// Bare `fetch(...)`.
|
||||
if (callee.type === "Identifier" && callee.name === "fetch") return true;
|
||||
// `globalThis.fetch(...)` / `window.fetch(...)` / `self.fetch(...)`.
|
||||
if (
|
||||
callee.type === "MemberExpression" &&
|
||||
!callee.computed &&
|
||||
callee.property.type === "Identifier" &&
|
||||
callee.property.name === "fetch" &&
|
||||
callee.object.type === "Identifier" &&
|
||||
(callee.object.name === "globalThis" ||
|
||||
callee.object.name === "window" ||
|
||||
callee.object.name === "self")
|
||||
) {
|
||||
return true;
|
||||
// Local names this module bound to somebody's `fetch`, collected from the
|
||||
// top-level imports before any call site is visited. `import { fetch as f }
|
||||
// from "undici"` makes `f(...)` a fetch call; `import * as undici from
|
||||
// "undici"` makes `undici.fetch(...)` one.
|
||||
const aliasedFetch = new Set();
|
||||
const namespaces = new Set([...GLOBAL_FETCH_OBJECTS]);
|
||||
for (const node of sourceCode.ast.body) {
|
||||
if (node.type !== "ImportDeclaration") continue;
|
||||
for (const specifier of node.specifiers) {
|
||||
if (specifier.type === "ImportSpecifier") {
|
||||
const imported =
|
||||
specifier.imported.type === "Identifier"
|
||||
? specifier.imported.name
|
||||
: specifier.imported.value;
|
||||
if (imported === "fetch") aliasedFetch.add(specifier.local.name);
|
||||
} else if (specifier.type === "ImportNamespaceSpecifier") {
|
||||
namespaces.add(specifier.local.name);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isFetchCallee(callee) {
|
||||
// Bare `fetch(...)`, and any local name bound to an imported `fetch`.
|
||||
if (callee.type === "Identifier") {
|
||||
return callee.name === "fetch" || aliasedFetch.has(callee.name);
|
||||
}
|
||||
if (callee.type !== "MemberExpression") return false;
|
||||
// `globalThis.fetch(...)` / `window.fetch(...)` / `self.fetch(...)` and
|
||||
// `<namespace>.fetch(...)`, including the static computed spelling.
|
||||
if (
|
||||
callee.object.type !== "Identifier" ||
|
||||
!namespaces.has(callee.object.name)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const property = !callee.computed
|
||||
? callee.property.type === "Identifier"
|
||||
? callee.property.name
|
||||
: null
|
||||
: callee.property.type === "Literal"
|
||||
? callee.property.value
|
||||
: null;
|
||||
return property === "fetch";
|
||||
}
|
||||
|
||||
function isSameOriginRelative(arg) {
|
||||
|
||||
@@ -3707,6 +3707,9 @@
|
||||
"source": "{n} gepaarte Tage · r = {r}",
|
||||
"emptyTitle": "Noch nicht genug gepaarte Daten",
|
||||
"emptyDescription": "Erfasse Stimmung und diesen Wert an denselben Tagen, um einen Zusammenhang zu sehen.",
|
||||
"pendingDescription": "{n} von {needed} gepaarten Tagen. Ein Zusammenhang erscheint, sobald genug Tage da sind, um Muster von Zufall zu unterscheiden.",
|
||||
"noLinkTitle": "Kein klarer Zusammenhang",
|
||||
"noLinkDescription": "{n} gepaarte Tage, und nichts hebt sich vom Zufall ab.",
|
||||
"weightTitle": "Stimmung vs. Gewicht",
|
||||
"bloodPressureTitle": "Stimmung vs. Blutdruck",
|
||||
"weightAxis": "Gewicht",
|
||||
@@ -6920,6 +6923,12 @@
|
||||
"colCreatedAt": "Letzter Snapshot",
|
||||
"colActions": "Aktionen",
|
||||
"retentionLabel": "Aufbewahrung: {days} Tage",
|
||||
"scheduleStaleTitle": "Die wöchentliche Sicherung läuft nicht mehr",
|
||||
"scheduleStaleDescription": "Die neueste geplante Kopie ist {days} Tage alt. Ein funktionierender wöchentlicher Lauf lässt keine älter als {threshold} Tage werden.",
|
||||
"scheduleFailedTitle": "Der letzte geplante Sicherungslauf ist fehlgeschlagen",
|
||||
"scheduleFailedDescription": "{when}: {reason}",
|
||||
"scheduleFailedNoReason": "kein Grund aufgezeichnet",
|
||||
"scheduleRemedy": "Starte jetzt eine Sicherung und prüfe, ob eine frische Zeile erscheint. Schlägt sie erneut fehl, nennt das Worker-Protokoll das Konto, an dem sie stehen geblieben ist.",
|
||||
"download": "Herunterladen",
|
||||
"downloadAria": "Backup für {username} herunterladen",
|
||||
"downloadFailed": "Download fehlgeschlagen",
|
||||
|
||||
@@ -3708,6 +3708,9 @@
|
||||
"sourceLabel": "How this was computed",
|
||||
"emptyTitle": "Not enough paired data yet",
|
||||
"emptyDescription": "Log mood and this metric on the same days to see a correlation.",
|
||||
"pendingDescription": "{n} of {needed} paired days so far. A correlation appears once there are enough days to tell a pattern from chance.",
|
||||
"noLinkTitle": "No clear link",
|
||||
"noLinkDescription": "{n} paired days, and nothing stands out beyond what chance would produce.",
|
||||
"weightTitle": "Mood vs. weight",
|
||||
"bloodPressureTitle": "Mood vs. blood pressure",
|
||||
"weightAxis": "Weight",
|
||||
@@ -6920,6 +6923,12 @@
|
||||
"colCreatedAt": "Last snapshot",
|
||||
"colActions": "Actions",
|
||||
"retentionLabel": "Retention: {days} days",
|
||||
"scheduleStaleTitle": "The weekly backup has stopped",
|
||||
"scheduleStaleDescription": "The newest scheduled copy is {days} days old. A working weekly run never leaves one older than {threshold} days.",
|
||||
"scheduleFailedTitle": "The last scheduled backup run failed",
|
||||
"scheduleFailedDescription": "{when}: {reason}",
|
||||
"scheduleFailedNoReason": "no reason recorded",
|
||||
"scheduleRemedy": "Run a backup now and check that a fresh row appears. If it fails again, the worker log names the account it stopped on.",
|
||||
"download": "Download",
|
||||
"downloadAria": "Download backup for {username}",
|
||||
"downloadFailed": "Download failed",
|
||||
|
||||
@@ -3707,6 +3707,9 @@
|
||||
"source": "{n} días emparejados · r = {r}",
|
||||
"emptyTitle": "Aún no hay suficientes datos emparejados",
|
||||
"emptyDescription": "Registra el ánimo y esta métrica los mismos días para ver una correlación.",
|
||||
"pendingDescription": "{n} de {needed} días emparejados. La correlación aparece cuando hay días suficientes para distinguir un patrón del azar.",
|
||||
"noLinkTitle": "Sin relación clara",
|
||||
"noLinkDescription": "{n} días emparejados, y nada destaca más allá de lo que produciría el azar.",
|
||||
"weightTitle": "Ánimo vs. peso",
|
||||
"bloodPressureTitle": "Ánimo vs. presión arterial",
|
||||
"weightAxis": "Peso",
|
||||
@@ -6920,6 +6923,12 @@
|
||||
"colCreatedAt": "Última instantánea",
|
||||
"colActions": "Acciones",
|
||||
"retentionLabel": "Conservación: {days} días",
|
||||
"scheduleStaleTitle": "La copia de seguridad semanal se ha detenido",
|
||||
"scheduleStaleDescription": "La copia programada más reciente tiene {days} días. Una ejecución semanal que funciona nunca deja ninguna con más de {threshold} días.",
|
||||
"scheduleFailedTitle": "La última ejecución programada de la copia de seguridad falló",
|
||||
"scheduleFailedDescription": "{when}: {reason}",
|
||||
"scheduleFailedNoReason": "sin motivo registrado",
|
||||
"scheduleRemedy": "Ejecuta una copia ahora y comprueba que aparece una fila nueva. Si vuelve a fallar, el registro del worker indica la cuenta en la que se detuvo.",
|
||||
"download": "Descargar",
|
||||
"downloadAria": "Descargar copia de seguridad de {username}",
|
||||
"downloadFailed": "La descarga falló",
|
||||
|
||||
@@ -3707,6 +3707,9 @@
|
||||
"source": "{n} jours appariés · r = {r}",
|
||||
"emptyTitle": "Pas encore assez de données appariées",
|
||||
"emptyDescription": "Enregistrez l'humeur et cette mesure les mêmes jours pour voir une corrélation.",
|
||||
"pendingDescription": "{n} jours appariés sur {needed}. Une corrélation apparaît dès qu'il y a assez de jours pour distinguer un motif du hasard.",
|
||||
"noLinkTitle": "Aucun lien net",
|
||||
"noLinkDescription": "{n} jours appariés, et rien ne se démarque de ce que le hasard produirait.",
|
||||
"weightTitle": "Humeur vs. poids",
|
||||
"bloodPressureTitle": "Humeur vs. tension artérielle",
|
||||
"weightAxis": "Poids",
|
||||
@@ -6920,6 +6923,12 @@
|
||||
"colCreatedAt": "Dernier instantané",
|
||||
"colActions": "Actions",
|
||||
"retentionLabel": "Conservation : {days} jours",
|
||||
"scheduleStaleTitle": "La sauvegarde hebdomadaire s'est arrêtée",
|
||||
"scheduleStaleDescription": "La copie planifiée la plus récente date de {days} jours. Une exécution hebdomadaire qui fonctionne n'en laisse jamais une dépasser {threshold} jours.",
|
||||
"scheduleFailedTitle": "La dernière exécution planifiée de la sauvegarde a échoué",
|
||||
"scheduleFailedDescription": "{when} : {reason}",
|
||||
"scheduleFailedNoReason": "aucune raison enregistrée",
|
||||
"scheduleRemedy": "Lancez une sauvegarde maintenant et vérifiez qu'une nouvelle ligne apparaît. Si elle échoue à nouveau, le journal du worker indique le compte sur lequel elle s'est arrêtée.",
|
||||
"download": "Télécharger",
|
||||
"downloadAria": "Télécharger la sauvegarde de {username}",
|
||||
"downloadFailed": "Échec du téléchargement",
|
||||
|
||||
@@ -3707,6 +3707,9 @@
|
||||
"source": "{n} giorni appaiati · r = {r}",
|
||||
"emptyTitle": "Non ci sono ancora abbastanza dati appaiati",
|
||||
"emptyDescription": "Registra umore e questa metrica negli stessi giorni per vedere una correlazione.",
|
||||
"pendingDescription": "{n} di {needed} giorni appaiati. La correlazione compare quando i giorni bastano a distinguere un pattern dal caso.",
|
||||
"noLinkTitle": "Nessun legame chiaro",
|
||||
"noLinkDescription": "{n} giorni appaiati, e nulla emerge oltre a quanto produrrebbe il caso.",
|
||||
"weightTitle": "Umore vs. peso",
|
||||
"bloodPressureTitle": "Umore vs. pressione sanguigna",
|
||||
"weightAxis": "Peso",
|
||||
@@ -6920,6 +6923,12 @@
|
||||
"colCreatedAt": "Ultima istantanea",
|
||||
"colActions": "Azioni",
|
||||
"retentionLabel": "Conservazione: {days} giorni",
|
||||
"scheduleStaleTitle": "Il backup settimanale si è fermato",
|
||||
"scheduleStaleDescription": "La copia pianificata più recente ha {days} giorni. Un'esecuzione settimanale funzionante non ne lascia mai una più vecchia di {threshold} giorni.",
|
||||
"scheduleFailedTitle": "L'ultima esecuzione pianificata del backup non è riuscita",
|
||||
"scheduleFailedDescription": "{when}: {reason}",
|
||||
"scheduleFailedNoReason": "nessun motivo registrato",
|
||||
"scheduleRemedy": "Avvia subito un backup e verifica che compaia una riga nuova. Se fallisce di nuovo, il log del worker indica l'account su cui si è fermato.",
|
||||
"download": "Scarica",
|
||||
"downloadAria": "Scarica il backup di {username}",
|
||||
"downloadFailed": "Download non riuscito",
|
||||
|
||||
@@ -3708,6 +3708,9 @@
|
||||
"sourceLabel": "계산 방법",
|
||||
"emptyTitle": "아직 짝지을 데이터가 부족해요",
|
||||
"emptyDescription": "같은 날에 기분과 이 지표를 함께 기록하면 상관관계를 볼 수 있어요.",
|
||||
"pendingDescription": "짝지은 {n}일 / {needed}일. 우연과 패턴을 구분할 만큼 날이 쌓이면 상관관계가 나타나요.",
|
||||
"noLinkTitle": "뚜렷한 관계 없음",
|
||||
"noLinkDescription": "짝지은 {n}일 동안 우연으로 볼 수 있는 범위를 넘는 건 없었어요.",
|
||||
"weightTitle": "기분 대 체중",
|
||||
"bloodPressureTitle": "기분 대 혈압",
|
||||
"weightAxis": "체중",
|
||||
@@ -6901,6 +6904,12 @@
|
||||
"colCreatedAt": "최근 스냅샷",
|
||||
"colActions": "동작",
|
||||
"retentionLabel": "보관 기간: {days}일",
|
||||
"scheduleStaleTitle": "주간 백업이 중단되었습니다",
|
||||
"scheduleStaleDescription": "가장 최근의 예약 사본이 {days}일 전 것입니다. 정상적으로 도는 주간 실행은 {threshold}일보다 오래된 사본을 남기지 않습니다.",
|
||||
"scheduleFailedTitle": "마지막 예약 백업 실행이 실패했습니다",
|
||||
"scheduleFailedDescription": "{when}: {reason}",
|
||||
"scheduleFailedNoReason": "기록된 사유 없음",
|
||||
"scheduleRemedy": "지금 백업을 실행하고 새 항목이 나타나는지 확인하세요. 다시 실패하면 워커 로그에 중단된 계정이 표시됩니다.",
|
||||
"download": "다운로드",
|
||||
"downloadAria": "{username}의 백업 다운로드",
|
||||
"downloadFailed": "다운로드하지 못했어요",
|
||||
|
||||
@@ -3707,6 +3707,9 @@
|
||||
"source": "{n} sparowanych dni · r = {r}",
|
||||
"emptyTitle": "Za mało sparowanych danych",
|
||||
"emptyDescription": "Rejestruj nastrój i ten wskaźnik w te same dni, aby zobaczyć korelację.",
|
||||
"pendingDescription": "{n} z {needed} sparowanych dni. Korelacja pojawi się, gdy dni wystarczy, aby odróżnić wzorzec od przypadku.",
|
||||
"noLinkTitle": "Brak wyraźnego związku",
|
||||
"noLinkDescription": "{n} sparowanych dni i nic nie wykracza poza to, co dałby przypadek.",
|
||||
"weightTitle": "Nastrój vs. waga",
|
||||
"bloodPressureTitle": "Nastrój vs. ciśnienie krwi",
|
||||
"weightAxis": "Waga",
|
||||
@@ -6920,6 +6923,12 @@
|
||||
"colCreatedAt": "Ostatnia migawka",
|
||||
"colActions": "Akcje",
|
||||
"retentionLabel": "Przechowywanie: {days} dni",
|
||||
"scheduleStaleTitle": "Cotygodniowa kopia zapasowa przestała powstawać",
|
||||
"scheduleStaleDescription": "Najnowsza zaplanowana kopia ma {days} dni. Działające cotygodniowe uruchomienie nigdy nie zostawia kopii starszej niż {threshold} dni.",
|
||||
"scheduleFailedTitle": "Ostatnie zaplanowane uruchomienie kopii zapasowej nie powiodło się",
|
||||
"scheduleFailedDescription": "{when}: {reason}",
|
||||
"scheduleFailedNoReason": "nie zapisano przyczyny",
|
||||
"scheduleRemedy": "Uruchom kopię teraz i sprawdź, czy pojawi się nowy wiersz. Jeśli znowu się nie powiedzie, log workera wskaże konto, na którym się zatrzymał.",
|
||||
"download": "Pobierz",
|
||||
"downloadAria": "Pobierz kopię zapasową dla {username}",
|
||||
"downloadFailed": "Pobieranie nie powiodło się",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.38.4",
|
||||
"version": "1.38.5",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.38.4";
|
||||
/* @sw-version-fallback */ "v1.38.5";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* Structural guard: every runtime environment variable the server reads is on
|
||||
* the compose `environment:` whitelist.
|
||||
*
|
||||
* ## The failure this freezes
|
||||
*
|
||||
* `docker-compose.yml`'s `environment:` block is a WHITELIST. Compose reads
|
||||
* `.env` for `${VAR}` substitution at compose-up time, so a value an operator
|
||||
* sets there appears to be configured — but a variable the block does not name
|
||||
* never reaches the Node process. The symptom is the worst kind: no error, no
|
||||
* warning, the documented knob simply does nothing and the default silently
|
||||
* stands. v1.5.2 closed one instance of this (`SESSION_COOKIE_SECURE`); it
|
||||
* came back at scale, which is why the question is a gate now rather than a
|
||||
* comment on each entry.
|
||||
*
|
||||
* ## What it proves and what it does not
|
||||
*
|
||||
* It proves one thing exactly: no variable is read by shipped server code
|
||||
* without either reaching the container or carrying a written reason why it
|
||||
* must not. It does NOT prove the whitelist's defaults are right, that the
|
||||
* `.env.production.example` prose is accurate, or that a variable behaves as
|
||||
* documented. Those stay review questions.
|
||||
*
|
||||
* ## Why the matchers are what they are
|
||||
*
|
||||
* A grep for `process.env.X` alone is not the read set. Four modules resolve a
|
||||
* name through a helper and index `process.env` dynamically, so their reads
|
||||
* are invisible to the direct matcher — `ALLOW_LOCAL_AI_PRIVATE_HOSTS` and
|
||||
* every `OIDC_*` among them. Each is registered below with its own call-shape
|
||||
* matcher AND its own non-zero assertion, so a refactor that renames the
|
||||
* helper fails this file loudly instead of quietly shrinking the read set.
|
||||
*
|
||||
* An empty match set is the other way a sweep goes quiet: it agrees with any
|
||||
* allowlist. Both the walk and every matcher therefore assert a floor.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const SRC = join(ROOT, "src");
|
||||
|
||||
/** `process.env.NAME` and `process.env["NAME"]`. */
|
||||
const DIRECT_READ =
|
||||
/process\.env(?:\.([A-Za-z_][A-Za-z0-9_]*)|\[\s*["'`]([A-Za-z_][A-Za-z0-9_]*)["'`]\s*\])/g;
|
||||
|
||||
/**
|
||||
* Modules that index `process.env` through a variable rather than a literal.
|
||||
* The direct matcher cannot see their reads at all, so each names the exact
|
||||
* call shape that carries the variable name, and each is asserted non-empty
|
||||
* in T2 below. `minimum` is the count that must still match — pinned under
|
||||
* the real number so an added read does not need a test edit, but a removed
|
||||
* matcher does.
|
||||
*/
|
||||
const INDIRECT_READERS: ReadonlyArray<{
|
||||
file: string;
|
||||
pattern: RegExp;
|
||||
minimum: number;
|
||||
why: string;
|
||||
}> = [
|
||||
{
|
||||
file: "lib/auth/oidc.ts",
|
||||
pattern: /envOrEmpty\(\s*["']([A-Z][A-Z0-9_]*)["']/g,
|
||||
minimum: 6,
|
||||
why: "every OIDC_* name goes through the envOrEmpty() helper",
|
||||
},
|
||||
{
|
||||
file: "app/api/settings/privacy-summary/route.ts",
|
||||
pattern: /intEnv\(\s*["']([A-Z][A-Z0-9_]*)["']/g,
|
||||
minimum: 1,
|
||||
why: "the retention windows are parsed through a local intEnv() helper",
|
||||
},
|
||||
{
|
||||
file: "lib/ai/local-host-allowlist.ts",
|
||||
pattern: /ENV_VAR\s*=\s*["']([A-Z][A-Z0-9_]*)["']/g,
|
||||
minimum: 1,
|
||||
why: "the name is hoisted into an ENV_VAR constant, then indexed",
|
||||
},
|
||||
{
|
||||
file: "lib/boot/readiness-summary.ts",
|
||||
pattern: /\benv\.([A-Z][A-Z0-9_]*)/g,
|
||||
minimum: 5,
|
||||
why: "the readiness report reads a `process.env`-defaulted parameter",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Read but deliberately NOT on the compose whitelist. Every entry carries the
|
||||
* reason adding it would be wrong — not merely why nobody got round to it.
|
||||
* Kept honest from both ends by T4: an entry that stops being read, or that
|
||||
* later appears in compose, fails this file.
|
||||
*/
|
||||
const NOT_OPERATOR_CONFIGURABLE: Readonly<Record<string, string>> = {
|
||||
// --- Provided by the runtime or the platform, never by an operator. ---
|
||||
NEXT_RUNTIME: "set by Next.js to distinguish the node and edge runtimes",
|
||||
npm_package_version: "set by the package manager when it spawns the process",
|
||||
|
||||
// --- Baked at build time by the Dockerfile's ARG/ENV pairs. ---
|
||||
NEXT_PUBLIC_APP_VERSION:
|
||||
"Dockerfile build arg; a runtime value cannot change what is compiled in",
|
||||
NEXT_PUBLIC_APP_BUILD_SHA:
|
||||
"Dockerfile build arg, stamped from the release commit",
|
||||
NEXT_PUBLIC_APP_BUILT_AT: "Dockerfile build arg, stamped at image build",
|
||||
BUILD_TIMESTAMP:
|
||||
"build-provenance stamp; an operator value would be a lie about the image",
|
||||
GIT_COMMIT:
|
||||
"build-provenance stamp, read only as a fallback for the build SHA",
|
||||
COMMIT_SHA:
|
||||
"build-provenance stamp, read only as a fallback for the build SHA",
|
||||
SOURCE_COMMIT:
|
||||
"build-provenance stamp, read only as a fallback for the build SHA",
|
||||
|
||||
// --- Reachable, but through a different name that IS on the whitelist. ---
|
||||
DB_CONNECTION_LIMIT:
|
||||
"compose splices it into DATABASE_URL's connection_limit parameter; forwarding it as a second env var would be two paths to one knob",
|
||||
DB_POOL_TIMEOUT:
|
||||
"compose splices it into DATABASE_URL's pool_timeout parameter, same reasoning as DB_CONNECTION_LIMIT",
|
||||
DATABASE_POOL_MAX:
|
||||
"a third spelling of DB_CONNECTION_LIMIT, read only as its fallback; one operator-facing name is enough",
|
||||
|
||||
// --- Not an operator decision. ---
|
||||
CODEX_OAUTH_CLIENT_ID:
|
||||
"the ChatGPT OAuth application id is a fixed protocol constant, overridable only for development against a different app",
|
||||
|
||||
// --- Never executes inside the container. ---
|
||||
COACH_EVAL_API_KEY:
|
||||
"repository secret for the nightly model-graded Coach eval; that workflow runs in CI, never in the app image",
|
||||
COACH_EVAL_GENERATOR_MODEL: "CI-only knob for the same nightly eval workflow",
|
||||
COACH_EVAL_JUDGE_MODEL: "CI-only knob for the same nightly eval workflow",
|
||||
HEALTHLOG_MCP_TOKEN:
|
||||
"read by the local stdio MCP bridge the operator runs on their own machine; the production image strips tsx so it cannot run there",
|
||||
|
||||
// --- A whitelist entry would be obeyed by half the readers. ---
|
||||
NEXT_PUBLIC_DASHBOARD_SNAPSHOT:
|
||||
"NEXT_PUBLIC_* is inlined into the client bundle at build time and three of its four readers are client components, so a runtime value would steer the server half only — a split-brain worse than no knob. .env.production.example already documents it as a build-time toggle and says it is deliberately absent from the whitelist; adding it would make that note false.",
|
||||
};
|
||||
|
||||
function sourceFiles(): string[] {
|
||||
return walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((rel) => !rel.startsWith("generated/"))
|
||||
.filter((rel) => !rel.includes("__tests__"))
|
||||
.filter((rel) => !rel.endsWith(".test.ts") && !rel.endsWith(".test.tsx"));
|
||||
}
|
||||
|
||||
function read(rel: string): string {
|
||||
return readFileSync(join(SRC, rel), "utf8");
|
||||
}
|
||||
|
||||
/** Every environment variable shipped server code reads, however it reads it. */
|
||||
function runtimeReads(): Set<string> {
|
||||
const names = new Set<string>();
|
||||
for (const rel of sourceFiles()) {
|
||||
const src = read(rel);
|
||||
for (const m of src.matchAll(DIRECT_READ)) names.add(m[1] ?? m[2]);
|
||||
}
|
||||
for (const reader of INDIRECT_READERS) {
|
||||
for (const m of read(reader.file).matchAll(reader.pattern)) names.add(m[1]);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* The keys the compose `environment:` block (via the shared `x-healthlog-env`
|
||||
* anchor) forwards into the container. Two-space-indented `KEY: value` lines
|
||||
* are the anchor's own entries; the services merge the anchor rather than
|
||||
* repeating it.
|
||||
*/
|
||||
function composeWhitelist(): Set<string> {
|
||||
const compose = readFileSync(join(ROOT, "docker-compose.yml"), "utf8");
|
||||
const keys = new Set<string>();
|
||||
for (const line of compose.split("\n")) {
|
||||
const m = line.match(/^\s{2,}([A-Z][A-Z0-9_]*):\s/);
|
||||
if (m) keys.add(m[1]);
|
||||
}
|
||||
// The `db` service's own three; the app never reads them.
|
||||
for (const k of ["POSTGRES_USER", "POSTGRES_PASSWORD", "POSTGRES_DB"]) {
|
||||
keys.delete(k);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
describe("compose env whitelist", () => {
|
||||
it("T1 — the sweep actually reads something", () => {
|
||||
const reads = runtimeReads();
|
||||
// Pinned well under the real count. A sweep that collapses to a handful
|
||||
// agrees with any allowlist, which is how an empty scan passes as green.
|
||||
expect(reads.size).toBeGreaterThan(80);
|
||||
expect(composeWhitelist().size).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it("T2 — every indirect reader still matches its call shape", () => {
|
||||
for (const reader of INDIRECT_READERS) {
|
||||
const found = [...read(reader.file).matchAll(reader.pattern)].map(
|
||||
(m) => m[1],
|
||||
);
|
||||
expect(
|
||||
found.length,
|
||||
`${reader.file}: matched ${found.length} name(s), expected at least ` +
|
||||
`${reader.minimum} — ${reader.why}. A zero here means the matcher ` +
|
||||
`went stale, not that the reads went away.`,
|
||||
).toBeGreaterThanOrEqual(reader.minimum);
|
||||
}
|
||||
});
|
||||
|
||||
it("T3 — no runtime variable is read without reaching the container", () => {
|
||||
const whitelist = composeWhitelist();
|
||||
const missing = [...runtimeReads()]
|
||||
.filter((name) => !whitelist.has(name))
|
||||
.filter((name) => !(name in NOT_OPERATOR_CONFIGURABLE))
|
||||
.sort();
|
||||
|
||||
expect(
|
||||
missing,
|
||||
`These variables are read by shipped server code but are not on the ` +
|
||||
`docker-compose.yml \`environment:\` whitelist, so a value set in a ` +
|
||||
`self-hoster's .env never reaches the process and the default stands ` +
|
||||
`silently. Either add each to the whitelist (with a default and a ` +
|
||||
`line in .env.production.example) or record in ` +
|
||||
`NOT_OPERATOR_CONFIGURABLE why forwarding it would be wrong.`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("T4 — the exception list carries no stale entries", () => {
|
||||
const reads = runtimeReads();
|
||||
const whitelist = composeWhitelist();
|
||||
|
||||
const unread = Object.keys(NOT_OPERATOR_CONFIGURABLE)
|
||||
.filter((name) => !reads.has(name))
|
||||
.sort();
|
||||
expect(
|
||||
unread,
|
||||
"Listed as a deliberate exception but no longer read anywhere — drop the entry.",
|
||||
).toEqual([]);
|
||||
|
||||
const alsoForwarded = Object.keys(NOT_OPERATOR_CONFIGURABLE)
|
||||
.filter((name) => whitelist.has(name))
|
||||
.sort();
|
||||
expect(
|
||||
alsoForwarded,
|
||||
"Listed as deliberately not forwarded, yet present in the compose whitelist — one of the two is wrong.",
|
||||
).toEqual([]);
|
||||
|
||||
for (const [name, reason] of Object.entries(NOT_OPERATOR_CONFIGURABLE)) {
|
||||
expect(
|
||||
reason.length,
|
||||
`${name} needs a real reason, not a placeholder`,
|
||||
).toBeGreaterThan(30);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -20,16 +20,31 @@
|
||||
* would encode noise. It asserts agreement where both files speak, which is
|
||||
* the failure that actually happened.
|
||||
*
|
||||
* The matcher had a blind spot of its own until 2026-09-02: it refused any
|
||||
* workspace key beginning with `@`, so every scoped package was invisible to
|
||||
* it. `@hono/node-server` was pinned on one side and unpinned on the other and
|
||||
* the guard stayed green, which is the same shape of green the guard exists to
|
||||
* prevent. Scoped keys parse now, and a test below pins that they do.
|
||||
* The matcher has now been widened twice, and both widenings were the same
|
||||
* class of hole: a spelling the files accept and the regex did not.
|
||||
*
|
||||
* Limit, written down so the next reader does not over-trust a green run: this
|
||||
* compares declared ranges. It cannot see a NEW vulnerable transitive that
|
||||
* neither file mentions. Only the image scan can, and that job runs on
|
||||
* main-push, not on pull requests.
|
||||
* - 2026-09-02: the workspace side refused any key beginning with `@`, so
|
||||
* every scoped package was invisible. `@hono/node-server` was pinned on
|
||||
* one side and unpinned on the other and the guard stayed green.
|
||||
* - 2026-09-03: the workspace side demanded a QUOTED key, and the Dockerfile
|
||||
* side demanded the exact spelling `npm pkg set 'overrides.X=Y'`. Every
|
||||
* entry in the file happens to carry an `@<selector>` suffix, which needs
|
||||
* quoting, so the matcher looked complete — but `mysql2: "^3.22.0"` is a
|
||||
* legal pnpm override with no selector and no quotes, and it parsed to
|
||||
* nothing. Unquoting that key and pointing the Dockerfile at `^1.0.0`
|
||||
* left all six tests green.
|
||||
*
|
||||
* Both sides now parse line by line and collect what they could NOT read into
|
||||
* an `unparsed` list, which is asserted empty. A future spelling neither
|
||||
* matcher knows fails the guard loudly instead of shrinking the compared set
|
||||
* in silence, which is the only structural defence against a third repeat.
|
||||
*
|
||||
* Limits, written down so the next reader does not over-trust a green run:
|
||||
* this compares DECLARED ranges. It cannot see a NEW vulnerable transitive
|
||||
* that neither file mentions — only the image scan can, and that job runs on
|
||||
* main-push, not on pull requests. It also reads the Dockerfile as text, so a
|
||||
* pin injected by a build ARG, an `npm config` call, or a package.json copied
|
||||
* in from elsewhere is outside both matchers.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
@@ -40,21 +55,19 @@ const ROOT = process.cwd();
|
||||
const DOCKERFILE = readFileSync(join(ROOT, "Dockerfile"), "utf8");
|
||||
const WORKSPACE = readFileSync(join(ROOT, "pnpm-workspace.yaml"), "utf8");
|
||||
|
||||
/** `npm pkg set 'overrides.<name>=<range>'` occurrences in the Dockerfile. */
|
||||
function dockerfileOverrides(): Map<string, string> {
|
||||
const found = new Map<string, string>();
|
||||
const pattern = /npm\s+pkg\s+set\s+['"]overrides\.([^='"]+)=([^'"]+)['"]/g;
|
||||
for (const match of DOCKERFILE.matchAll(pattern)) {
|
||||
found.set(match[1].trim(), match[2].trim());
|
||||
}
|
||||
return found;
|
||||
}
|
||||
/** What a side of the comparison parsed, and what it had to give up on. */
|
||||
type ParsedOverrides = {
|
||||
/** Bare package name → declared range. */
|
||||
pins: Map<string, string>;
|
||||
/** Lines that name an override but no matcher could read. */
|
||||
unparsed: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Reduces an overrides key to the bare package name. The key carries a version
|
||||
* selector, so `deepmerge-ts@<8.0.0` and a bare `deepmerge-ts` both land on the
|
||||
* same name. A scoped name opens with its own `@`, so the selector starts at
|
||||
* the SECOND one, not the first.
|
||||
* Reduces an overrides key to the bare package name. The key MAY carry a
|
||||
* version selector, so `deepmerge-ts@<8.0.0` and a bare `deepmerge-ts` both
|
||||
* land on the same name. A scoped name opens with its own `@`, so the selector
|
||||
* starts at the SECOND one, not the first.
|
||||
*/
|
||||
function packageNameOf(key: string): string {
|
||||
const trimmed = key.trim();
|
||||
@@ -64,33 +77,172 @@ function packageNameOf(key: string): string {
|
||||
return (selectorAt === -1 ? trimmed : trimmed.slice(0, selectorAt)).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* `"<name>@<selector>": "<range>"` entries under the workspace `overrides:`
|
||||
* block.
|
||||
*/
|
||||
function workspaceOverrides(): Map<string, string> {
|
||||
const found = new Map<string, string>();
|
||||
const pattern = /^\s+"([^"]+)"\s*:\s*"([^"]+)"/gm;
|
||||
const block = WORKSPACE.split(/^allowBuilds:/m)[0];
|
||||
for (const match of block.matchAll(pattern)) {
|
||||
found.set(packageNameOf(match[1]), match[2].trim());
|
||||
}
|
||||
return found;
|
||||
/** Strips one layer of matching YAML/shell quotes, if present. */
|
||||
function unquote(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
const quoted = /^"([^"]*)"$/.exec(trimmed) ?? /^'([^']*)'$/.exec(trimmed);
|
||||
return quoted ? quoted[1] : trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits one `<key>: <value>` mapping line. A QUOTED key may itself contain a
|
||||
* colon, so the separator is looked for after the closing quote; an unquoted
|
||||
* key cannot contain one, so the first colon is the separator.
|
||||
*/
|
||||
function splitMapping(line: string): [string, string] | null {
|
||||
const body = line.trim();
|
||||
const quote = body.startsWith('"') ? '"' : body.startsWith("'") ? "'" : null;
|
||||
if (quote) {
|
||||
const close = body.indexOf(quote, 1);
|
||||
if (close === -1) return null;
|
||||
const rest = body.slice(close + 1).trimStart();
|
||||
if (!rest.startsWith(":")) return null;
|
||||
return [body.slice(0, close + 1), rest.slice(1)];
|
||||
}
|
||||
const colon = body.indexOf(":");
|
||||
if (colon === -1) return null;
|
||||
return [body.slice(0, colon), body.slice(colon + 1)];
|
||||
}
|
||||
|
||||
/** Reads a YAML scalar value, dropping any trailing `# comment`. */
|
||||
function scalarValue(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
const quoted = /^"([^"]*)"/.exec(trimmed) ?? /^'([^']*)'/.exec(trimmed);
|
||||
if (quoted) return quoted[1];
|
||||
return trimmed.split(/\s+#/)[0].trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Entries under the workspace `overrides:` block.
|
||||
*
|
||||
* pnpm accepts the key with or without a version selector and YAML accepts
|
||||
* either half quoted or bare, so all four spellings below are the same pin and
|
||||
* all four must parse:
|
||||
*
|
||||
* "mysql2@<3.22.0": "^3.22.0"
|
||||
* mysql2@<3.22.0: ^3.22.0
|
||||
* "mysql2": "^3.22.0"
|
||||
* mysql2: ^3.22.0
|
||||
*/
|
||||
export function parseWorkspaceOverrides(yaml: string): ParsedOverrides {
|
||||
const pins = new Map<string, string>();
|
||||
const unparsed: string[] = [];
|
||||
const lines = yaml.split(/\r?\n/);
|
||||
const start = lines.findIndex((line) => /^overrides:\s*$/.test(line));
|
||||
if (start === -1) {
|
||||
return { pins, unparsed: ["no `overrides:` block found"] };
|
||||
}
|
||||
|
||||
for (const line of lines.slice(start + 1)) {
|
||||
if (line.trim() === "") continue;
|
||||
// A line at column zero is the next top-level key; the block ends there.
|
||||
if (!/^\s/.test(line)) break;
|
||||
if (/^\s*#/.test(line)) continue;
|
||||
|
||||
const mapping = splitMapping(line);
|
||||
const range = mapping ? scalarValue(mapping[1]) : "";
|
||||
if (!mapping || range === "") {
|
||||
unparsed.push(line.trim());
|
||||
continue;
|
||||
}
|
||||
pins.set(packageNameOf(unquote(mapping[0])), range);
|
||||
}
|
||||
return { pins, unparsed };
|
||||
}
|
||||
|
||||
/**
|
||||
* `overrides` entries written into `/opt/prisma-cli/package.json`.
|
||||
*
|
||||
* `npm pkg set` is indifferent to shell quoting, so the assignment may be
|
||||
* quoted whole, quoted per half, or bare. Any non-comment Dockerfile line that
|
||||
* names `overrides` and matches none of the accepted spellings is reported as
|
||||
* unparsed rather than skipped — that is what stops a fresh spelling from
|
||||
* quietly shrinking the compared set.
|
||||
*/
|
||||
export function parseDockerfileOverrides(dockerfile: string): ParsedOverrides {
|
||||
const pins = new Map<string, string>();
|
||||
const unparsed: string[] = [];
|
||||
|
||||
// `npm pkg set 'overrides.<name>=<range>'`, `"overrides.<name>=<range>"`,
|
||||
// `overrides.<name>=<range>`, and the per-half-quoted variants. A package
|
||||
// name and a semver range never contain a quote character, so quoting is
|
||||
// pure shell decoration here: strip it and every spelling collapses onto the
|
||||
// same `overrides.<name>=<range>` token.
|
||||
const npmPkgSet = /npm\s+pkg\s+set\s+overrides\.([^\s=]+)\s*=\s*([^\s&;|]+)/;
|
||||
// A raw JSON field, e.g. inside a `cat > package.json` heredoc:
|
||||
// `"<name>": "<range>"` on a line under an `"overrides"` key.
|
||||
const jsonField = /"([^"]+)"\s*:\s*"([^"]+)"/;
|
||||
|
||||
let insideJsonOverrides = false;
|
||||
for (const rawLine of dockerfile.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (line.startsWith("#")) continue;
|
||||
|
||||
if (insideJsonOverrides) {
|
||||
if (line.startsWith("}")) {
|
||||
insideJsonOverrides = false;
|
||||
continue;
|
||||
}
|
||||
const field = jsonField.exec(line);
|
||||
if (field) {
|
||||
pins.set(packageNameOf(field[1]), field[2].trim());
|
||||
continue;
|
||||
}
|
||||
unparsed.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!/\boverrides\b/.test(line)) continue;
|
||||
|
||||
if (/"overrides"\s*:\s*\{\s*$/.test(line)) {
|
||||
insideJsonOverrides = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const set = npmPkgSet.exec(line.replace(/['"]/g, ""));
|
||||
if (set) {
|
||||
pins.set(packageNameOf(set[1]), set[2].trim());
|
||||
continue;
|
||||
}
|
||||
unparsed.push(line);
|
||||
}
|
||||
return { pins, unparsed };
|
||||
}
|
||||
|
||||
const dockerfileOverrides = () => parseDockerfileOverrides(DOCKERFILE);
|
||||
const workspaceOverrides = () => parseWorkspaceOverrides(WORKSPACE);
|
||||
|
||||
describe("dependency overrides — pnpm workspace and the npm install agree", () => {
|
||||
it("finds the Dockerfile's npm-side overrides at all", () => {
|
||||
const docker = dockerfileOverrides();
|
||||
// A zero-match regex would make every assertion below vacuously true.
|
||||
// This is the check that keeps the guard honest about its own matcher.
|
||||
expect(docker.size).toBeGreaterThan(0);
|
||||
expect(docker.has("deepmerge-ts")).toBe(true);
|
||||
expect(docker.pins.size).toBeGreaterThan(0);
|
||||
expect(docker.pins.has("deepmerge-ts")).toBe(true);
|
||||
});
|
||||
|
||||
it("finds the workspace overrides at all", () => {
|
||||
const workspace = workspaceOverrides();
|
||||
expect(workspace.size).toBeGreaterThan(0);
|
||||
expect(workspace.has("deepmerge-ts")).toBe(true);
|
||||
expect(workspace.pins.size).toBeGreaterThan(0);
|
||||
expect(workspace.pins.has("deepmerge-ts")).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses an overrides line it could not read on either side", () => {
|
||||
// The failure this replaces: a matcher that silently narrows the compared
|
||||
// set is indistinguishable from a matcher that found agreement. Anything
|
||||
// neither spelling table covers has to surface here.
|
||||
expect(
|
||||
workspaceOverrides().unparsed,
|
||||
"pnpm-workspace.yaml has an `overrides:` entry this guard cannot read. " +
|
||||
"Teach `parseWorkspaceOverrides` the spelling — do not leave it " +
|
||||
"unparsed, or the pin stops being compared against the Dockerfile.",
|
||||
).toEqual([]);
|
||||
expect(
|
||||
dockerfileOverrides().unparsed,
|
||||
"The Dockerfile names `overrides` on a line this guard cannot read. " +
|
||||
"Teach `parseDockerfileOverrides` the spelling — an unread pin is an " +
|
||||
"uncompared pin.",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("reads scoped workspace keys rather than skipping them", () => {
|
||||
@@ -105,14 +257,87 @@ describe("dependency overrides — pnpm workspace and the npm install agree", ()
|
||||
expect(packageNameOf("js-yaml@>=4.0.0 <4.3.1")).toBe("js-yaml");
|
||||
expect(packageNameOf("deepmerge-ts")).toBe("deepmerge-ts");
|
||||
|
||||
const workspace = workspaceOverrides();
|
||||
const scoped = [...workspace.keys()].filter((name) => name.startsWith("@"));
|
||||
const scoped = [...workspaceOverrides().pins.keys()].filter((name) =>
|
||||
name.startsWith("@"),
|
||||
);
|
||||
expect(scoped.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("reads a workspace pin that carries no selector and no quotes", () => {
|
||||
// `mysql2: "^3.22.0"` is a valid pnpm override and valid YAML. The old
|
||||
// matcher required a quoted key, so it parsed to nothing — and because
|
||||
// every entry in the file happens to need quoting for its selector, the
|
||||
// hole was invisible by inspection. All four spellings are one pin.
|
||||
const parsed = parseWorkspaceOverrides(
|
||||
[
|
||||
"overrides:",
|
||||
" # a comment inside the block",
|
||||
' "quoted-selector@<2.0.0": "^2.0.0"',
|
||||
" bare-selector@<2.0.0: ^2.0.0",
|
||||
' "quoted-plain": "^2.0.0"',
|
||||
" bare-plain: ^2.0.0",
|
||||
' "@scoped/bare-value@<2.0.0": ^2.0.0',
|
||||
" trailing-comment: ^2.0.0 # note",
|
||||
"",
|
||||
"allowBuilds:",
|
||||
" prisma: true",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
expect(parsed.unparsed).toEqual([]);
|
||||
expect(Object.fromEntries(parsed.pins)).toEqual({
|
||||
"quoted-selector": "^2.0.0",
|
||||
"bare-selector": "^2.0.0",
|
||||
"quoted-plain": "^2.0.0",
|
||||
"bare-plain": "^2.0.0",
|
||||
"@scoped/bare-value": "^2.0.0",
|
||||
"trailing-comment": "^2.0.0",
|
||||
});
|
||||
// The block ends at the next top-level key: `allowBuilds` is not a pin.
|
||||
expect(parsed.pins.has("prisma")).toBe(false);
|
||||
});
|
||||
|
||||
it("reads every spelling of the Dockerfile's npm-side pin", () => {
|
||||
// The old matcher accepted `npm pkg set 'overrides.X=Y'` and nothing else,
|
||||
// so re-quoting the same line removed the pin from the comparison without
|
||||
// changing what the image installs.
|
||||
const parsed = parseDockerfileOverrides(
|
||||
[
|
||||
"# a comment mentioning the `overrides` block",
|
||||
"RUN npm init -y && \\",
|
||||
" npm pkg set 'overrides.single-quoted=^1.0.0' && \\",
|
||||
' npm pkg set "overrides.double-quoted=^1.0.0" && \\',
|
||||
" npm pkg set overrides.unquoted=^1.0.0 && \\",
|
||||
" npm pkg set 'overrides.@scoped/split'='^1.0.0' && \\",
|
||||
" npm install --omit=dev prisma@7.8.0",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
expect(parsed.unparsed).toEqual([]);
|
||||
expect(Object.fromEntries(parsed.pins)).toEqual({
|
||||
"single-quoted": "^1.0.0",
|
||||
"double-quoted": "^1.0.0",
|
||||
unquoted: "^1.0.0",
|
||||
"@scoped/split": "^1.0.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an overrides spelling neither matcher knows", () => {
|
||||
// Written so the refusal itself is proven, not assumed. A guard that
|
||||
// cannot fail is the failure mode this file exists to close.
|
||||
expect(
|
||||
parseWorkspaceOverrides(["overrides:", " - not-a-mapping"].join("\n"))
|
||||
.unparsed,
|
||||
).toEqual(["- not-a-mapping"]);
|
||||
expect(
|
||||
parseDockerfileOverrides("RUN node -e \"pkg.overrides['x']='^1.0.0'\"")
|
||||
.unparsed.length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("pins the same range wherever both files name the same package", () => {
|
||||
const docker = dockerfileOverrides();
|
||||
const workspace = workspaceOverrides();
|
||||
const docker = dockerfileOverrides().pins;
|
||||
const workspace = workspaceOverrides().pins;
|
||||
|
||||
const shared = [...docker.keys()].filter((name) => workspace.has(name));
|
||||
expect(shared.length).toBeGreaterThan(0);
|
||||
@@ -131,7 +356,7 @@ describe("dependency overrides — pnpm workspace and the npm install agree", ()
|
||||
// These three were each reported by the image scan under
|
||||
// `/opt/prisma-cli`, reached through `prisma`. Losing a mirror is not a
|
||||
// style regression, it is the vulnerable version coming back.
|
||||
const docker = dockerfileOverrides();
|
||||
const docker = dockerfileOverrides().pins;
|
||||
for (const name of ["deepmerge-ts", "@hono/node-server", "valibot"]) {
|
||||
expect(
|
||||
docker.has(name),
|
||||
@@ -145,7 +370,7 @@ describe("dependency overrides — pnpm workspace and the npm install agree", ()
|
||||
it("keeps the override ahead of the version Prisma's config asks for", () => {
|
||||
// `@prisma/config` requests deepmerge-ts 7.x; the advisory is fixed in
|
||||
// 8.0.0. Anything that lets a 7.x back in re-opens CVE-2026-40345.
|
||||
const range = dockerfileOverrides().get("deepmerge-ts");
|
||||
const range = dockerfileOverrides().pins.get("deepmerge-ts");
|
||||
expect(range).toBeDefined();
|
||||
expect(range).toMatch(/\^?8\./);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Structural guard on the unguarded Pearson helper.
|
||||
*
|
||||
* `pearsonCorrelation` bands a coefficient from |r| alone above a floor of
|
||||
* five pairs, with no significance test. Its guarded twin
|
||||
* `significantPearsonCorrelation` sits directly beneath it in the same file
|
||||
* and exists because of exactly one failure: five days of noise sloping the
|
||||
* right way reaching a reader labelled "strong". The comment above the twin
|
||||
* says so in as many words.
|
||||
*
|
||||
* The twin was written once and adopted one surface at a time, which is how
|
||||
* the mood page kept the naive call for four minor lines after the surfaces
|
||||
* either side of it had moved. Nothing failed when it was skipped, because
|
||||
* nothing was watching. This is what watches: a new caller of the unguarded
|
||||
* helper has to be added to the list below, in a diff a reviewer reads, next
|
||||
* to a written reason.
|
||||
*
|
||||
* It is a tripwire, not a proof. It cannot say the remaining entries are
|
||||
* right — only that the set has not grown without someone editing this file.
|
||||
* The two entries it carries today are named as open work, not as blessings.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
/**
|
||||
* Files that may still call `pearsonCorrelation` directly, and why.
|
||||
*
|
||||
* Neither entry is settled. Both are the same migration the user-facing
|
||||
* surfaces already made, still owed by two payload builders whose consumers
|
||||
* pin the n >= 5 shape; moving them changes what those payloads contain and
|
||||
* belongs in its own change with its own tests, not smuggled into this one.
|
||||
*/
|
||||
const ALLOWED: Record<string, string> = {
|
||||
"app/api/insights/comprehensive/route.ts":
|
||||
"Five correlations on the /insights envelope (weight x BP, mood x BP, " +
|
||||
"mood x weight, mood x pulse, BP-medication continuity). Open: they " +
|
||||
"carry the same small-n exposure the mood page had, and the page's " +
|
||||
"cards read `n` straight off the envelope.",
|
||||
"lib/insights/features.ts":
|
||||
"Eight cross-metric coefficients on the AI feature payload. Open: the " +
|
||||
"prompt reads them as findings, so the same significance bar applies; " +
|
||||
"the snapshot shape and its budget tests move with it.",
|
||||
};
|
||||
|
||||
/** Every non-test, non-generated source file under `src/`. */
|
||||
function sourceFiles(): string[] {
|
||||
return walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => !p.startsWith("generated/"))
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function read(rel: string): string {
|
||||
return readFileSync(join(SRC, rel), "utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* A call to the unguarded helper. Whitespace-tolerant and anchored on a word
|
||||
* boundary so `significantPearsonCorrelation(` cannot match: a matcher that
|
||||
* quietly matches nothing is the failure mode this repository has shipped
|
||||
* before, so the count is asserted below as well.
|
||||
*/
|
||||
const CALL = /(?<![A-Za-z])pearsonCorrelation\s*\(/;
|
||||
|
||||
describe("the unguarded Pearson helper has a closed caller set", () => {
|
||||
it("finds callers at all, so an empty result cannot read as a pass", () => {
|
||||
const definition = read("lib/analytics/correlations.ts");
|
||||
expect(definition).toMatch(/export function pearsonCorrelation\s*\(/);
|
||||
expect(
|
||||
definition,
|
||||
"the guarded twin must exist for this guard to mean anything",
|
||||
).toMatch(/export function significantPearsonCorrelation\s*\(/);
|
||||
|
||||
const callers = sourceFiles().filter(
|
||||
(rel) => rel !== "lib/analytics/correlations.ts" && CALL.test(read(rel)),
|
||||
);
|
||||
expect(callers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("names every caller outside the allowlist", () => {
|
||||
const callers = sourceFiles().filter(
|
||||
(rel) => rel !== "lib/analytics/correlations.ts" && CALL.test(read(rel)),
|
||||
);
|
||||
|
||||
expect(
|
||||
callers.filter((rel) => !(rel in ALLOWED)),
|
||||
"A new caller of the unguarded `pearsonCorrelation`. A surface a " +
|
||||
"person reads must use `significantPearsonCorrelation` (n >= 20 " +
|
||||
"AND p < 0.05). If this one genuinely cannot, add it to ALLOWED " +
|
||||
"in this file with the reason, so the exception is reviewed rather " +
|
||||
"than assumed.",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the allowlist honest by refusing stale entries", () => {
|
||||
const callers = new Set(
|
||||
sourceFiles().filter(
|
||||
(rel) =>
|
||||
rel !== "lib/analytics/correlations.ts" && CALL.test(read(rel)),
|
||||
),
|
||||
);
|
||||
|
||||
// An entry whose file no longer calls the helper is a licence nobody
|
||||
// needs any more; leaving it standing is how the next one gets waved in.
|
||||
expect(
|
||||
Object.keys(ALLOWED).filter((rel) => !callers.has(rel)),
|
||||
"this file is on the allowlist but no longer calls the unguarded " +
|
||||
"helper — drop the entry",
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -74,80 +74,125 @@ import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
|
||||
* `BEHAVIOURAL_GATE_TESTS`. This test asserts those files still exist, so
|
||||
* deleting the behavioural coverage fails here with a pointer to what was
|
||||
* lost rather than silently leaving only a text-match behind.
|
||||
*
|
||||
* WHICH TREES GET WALKED, AND WHY THAT IS NOT A HAND-WRITTEN LIST ANY MORE
|
||||
* -----------------------------------------------------------------------
|
||||
* A discovery test is only worth what its discovery covers, and this one used
|
||||
* to enumerate its trees in a flat array that grew when somebody remembered to
|
||||
* grow it. It had stopped: `coach`, `environment`, `mentalHealth`,
|
||||
* `inboundDocuments` and `mcp` each shipped an API tree that this file never
|
||||
* looked at. No leak came of it — every route in the first four gates — but
|
||||
* that was the authors' care and not this test's, and a green run was saying
|
||||
* something narrower than it appeared to say.
|
||||
*
|
||||
* The trees are now declared per module key as an exhaustive
|
||||
* `Record<ModuleKey, …>`, so the REGISTRY drives coverage: a new key does not
|
||||
* compile without an entry, and an API directory named after a key is
|
||||
* discovered from the filesystem and must be declared for it. A tree at a
|
||||
* non-obvious address (`achievements` → `/api/gamification`) still has to be
|
||||
* named by hand — that is the residual gap, and it is the reason the
|
||||
* declaration carries a comment per key rather than a bare path.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The per-domain route trees that serve a toggleable module. Each entry
|
||||
* is a directory under `src/app/api`; every `route.ts` beneath it is
|
||||
* enumerated and classified. A domain that is owned entirely by one
|
||||
* builder (doctor-report / FHIR) or one delegated gate (cycle) is still
|
||||
* walked so a NEW route in the tree must justify itself.
|
||||
* The per-domain API route trees each toggleable module owns, keyed by the
|
||||
* registry key.
|
||||
*
|
||||
* Declared as an exhaustive `Record<ModuleKey, …>` so the REGISTRY drives
|
||||
* coverage rather than anybody's memory: adding a key to `MODULE_KEYS` without
|
||||
* deciding which API tree it owns does not compile, and the two tests below
|
||||
* catch the same omission at runtime — one asserting every registry key is
|
||||
* declared, one asserting that an API directory NAMED after a module key is
|
||||
* declared for that key.
|
||||
*
|
||||
* The hand-written array this replaced could only grow when a contributor
|
||||
* remembered to grow it, and it had stopped growing: `coach`, `environment`,
|
||||
* `mentalHealth`, `inboundDocuments` and `mcp` all shipped an API tree that
|
||||
* this inventory never walked. Every route in the first four turned out to be
|
||||
* gated, so nothing leaked — but that was the authors' care, not this file's
|
||||
* doing, and the next tree would have been just as invisible.
|
||||
*
|
||||
* An empty array is a claim, not a gap: the module owns no API tree of its
|
||||
* own, and the entry says where its data actually lives.
|
||||
*
|
||||
* A domain owned entirely by one builder (doctor-report / FHIR) or one
|
||||
* delegated gate (cycle) is still walked, so a NEW route in the tree must
|
||||
* justify itself.
|
||||
*/
|
||||
const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
|
||||
"src/app/api/mood",
|
||||
"src/app/api/mood-entries",
|
||||
"src/app/api/sleep",
|
||||
"src/app/api/workouts",
|
||||
"src/app/api/labs",
|
||||
const MODULE_ROUTE_TREES_BY_KEY: Readonly<
|
||||
Record<ModuleKey, readonly string[]>
|
||||
> = {
|
||||
cycle: ["src/app/api/cycle"],
|
||||
mood: ["src/app/api/mood", "src/app/api/mood-entries"],
|
||||
sleep: ["src/app/api/sleep"],
|
||||
// Glucose has no tree of its own: readings are Measurement rows on the core
|
||||
// measurement engine, and the glucose panel is an insights surface. The
|
||||
// module gates the surfaces, which live under trees already walked here.
|
||||
glucose: [],
|
||||
workouts: ["src/app/api/workouts"],
|
||||
// Recovery, like glucose, rides the measurement engine — no dedicated tree.
|
||||
recovery: [],
|
||||
// v1.18.1 — the user-scoped Biomarker catalog backs the Labs feature.
|
||||
"src/app/api/biomarkers",
|
||||
"src/app/api/cycle",
|
||||
labs: ["src/app/api/labs", "src/app/api/biomarkers"],
|
||||
// v1.18.1 (W-B) — the illness/condition journal. Every `/api/illness/*`
|
||||
// route gates on the `illness` module via the thin
|
||||
// `requireIllnessEnabled(...)` wrapper (which re-stamps the
|
||||
// illness-specific errorCode over `requireModuleEnabled("illness")`),
|
||||
// recognised below as a delegated gate. Walking the tree means a NEW
|
||||
// ungated illness route fails this test BY NAME rather than leaking the
|
||||
// surface over a Bearer token when the account turned the module off.
|
||||
"src/app/api/illness",
|
||||
"src/app/api/gamification",
|
||||
// recognised below as a delegated gate.
|
||||
illness: ["src/app/api/illness"],
|
||||
achievements: ["src/app/api/gamification"],
|
||||
// The Coach tree. Every route calls `requireModuleEnabled(userId, "coach")`
|
||||
// directly, which resolves the delegated two-layer state (assistant master
|
||||
// flag AND the per-user opt-out) through the registry — so the routes are
|
||||
// counted as directly gated here, and the sibling
|
||||
// `coach-route-gate-inventory.test.ts` covers the surface in depth.
|
||||
coach: ["src/app/api/coach"],
|
||||
// v1.18.0 (B2) — the AI-narrative insights tree, plus v1.28's unified
|
||||
// daily-digest read (`GET /api/daily/digest`), which is the AI-narrative
|
||||
// daily layer and gates on `insights` directly.
|
||||
insights: ["src/app/api/insights", "src/app/api/daily"],
|
||||
// v1.18.1 (D3) — medications graduated from CORE to a toggleable module.
|
||||
// It is SURFACE-gated (nav entry, dashboard widget, the dedicated
|
||||
// Medikamente settings entry), not data-layer-gated: every `/api/medications/*`
|
||||
// route is raw medication CRUD / intake / inventory / compliance over the
|
||||
// user's own rows, so they are EXEMPT below under the same data-layer
|
||||
// reasoning as mood/labs — disabling the module hides the surface, it does
|
||||
// not wedge an importer / sync / the user's ability to clean up, and
|
||||
// re-enabling finds the schedule + intake history intact. Walking the tree
|
||||
// means a NEW medication route must justify itself (gate or document-exempt)
|
||||
// rather than silently appearing.
|
||||
"src/app/api/medications",
|
||||
// v1.18.0 B3 — the legacy `/api/doctor-report` tree (JSON + server-PDF +
|
||||
// availability probe) was orphaned dead code (no production caller) and
|
||||
// removed. The doctor-report surface is `/api/export/health-record` plus
|
||||
// the FHIR REST face below; both gate on `doctorReport` directly. The FHIR
|
||||
// routes previously relied on the removed builder-mention bucket and were
|
||||
// in practice ungated — see the header note.
|
||||
"src/app/api/fhir",
|
||||
// v1.18.0 (B2) — the AI-narrative insights tree. Every status / cards /
|
||||
// correlations / derived / narrative / pregenerate / rhythm-events route
|
||||
// gates on the `insights` module; the Coach sub-tree delegates to the
|
||||
// coach assistant surface; the AI-settings / tile-layout / therapy-timeline
|
||||
// infra-and-config routes are EXEMPT below. Walking the tree means a NEW
|
||||
// ungated insights AI route fails this test BY NAME rather than leaking the
|
||||
// surface over a Bearer token when the account turned insights off.
|
||||
"src/app/api/insights",
|
||||
// v1.28 — the unified daily-digest read (`GET /api/daily/digest`). The digest
|
||||
// is the AI-narrative daily layer, so it gates on the `insights` module via
|
||||
// `requireModuleEnabled(user.id, "insights")` directly. Walking the tree means
|
||||
// a NEW ungated daily route fails BY NAME rather than leaking over a Bearer
|
||||
// token when the account turned insights off.
|
||||
"src/app/api/daily",
|
||||
// v1.28 — the nutrient-intake sync (opt-in `nutrients` module). Unlike the
|
||||
// SURFACE-gated (nav entry, dashboard widget, the dedicated Medikamente
|
||||
// settings entry), not data-layer-gated: every `/api/medications/*` route is
|
||||
// raw CRUD / intake / inventory / compliance over the user's own rows, so
|
||||
// they are EXEMPT below under the same data-layer reasoning as mood/labs.
|
||||
medications: ["src/app/api/medications"],
|
||||
// v1.18.0 B3 — the legacy `/api/doctor-report` tree was orphaned dead code
|
||||
// and removed. The doctor-report surface is `/api/export/health-record` plus
|
||||
// the FHIR REST face; both gate on `doctorReport` directly, and both are
|
||||
// key-pinned below so a future route cannot satisfy the inventory by gating
|
||||
// on some other, more permissive module.
|
||||
doctorReport: ["src/app/api/fhir", "src/app/api/export/health-record"],
|
||||
// v1.25.0 (W-ENV) — the environmental-context tree. Every route calls
|
||||
// `requireModuleEnabled(user.id, "environment")`, including the geocode
|
||||
// lookup and the backfill, because each one either reads the module's data
|
||||
// or spends the outbound weather/geocode budget on the user's behalf.
|
||||
environment: ["src/app/api/environment"],
|
||||
// v1.22.0 — the remote MCP endpoint is `src/app/mcp/route.ts`, OUTSIDE the
|
||||
// `/api` tree, and it gates on the module itself. What lives under
|
||||
// `/api/mcp` is the credential-management surface behind it, exempt below.
|
||||
mcp: ["src/app/api/mcp"],
|
||||
// v1.25.0 (W-DOCS-IN) — the inbound clinical-document vault. Every route
|
||||
// gates on `inboundDocuments`; the AI READ of a document is a separate
|
||||
// per-user opt-in on top.
|
||||
inboundDocuments: ["src/app/api/documents/inbound"],
|
||||
// v1.25.0 — the mental-health screeners. Both routes gate on `mentalHealth`.
|
||||
mentalHealth: ["src/app/api/mental-health"],
|
||||
// v1.28 — the nutrient-intake sync (opt-in module). Unlike the
|
||||
// data-layer-exempt siblings this domain is REFUSE-INGEST-WHEN-OFF (the
|
||||
// mental-health posture): both the batch ingest and the window-summary
|
||||
// read call `requireModuleEnabled(user.id, "nutrients")` directly, so a
|
||||
// phone whose user never opted in cannot land rows server-side. Walking
|
||||
// the tree means a NEW ungated nutrients route fails BY NAME.
|
||||
"src/app/api/nutrients",
|
||||
// v1.38.0 — the immunization log (`vaccinations` module). SURFACE-gated
|
||||
// like medications, not data-layer-gated: every `/api/vaccinations/*` route
|
||||
// is raw CRUD over the person's own doses, so all four are EXEMPT below
|
||||
// under the same reasoning. Walking the tree means a NEW vaccination route
|
||||
// must justify itself — gate or documented exemption — rather than
|
||||
// silently appearing.
|
||||
"src/app/api/vaccinations",
|
||||
];
|
||||
// mental-health posture): both the batch ingest and the window-summary read
|
||||
// gate directly, so a phone whose user never opted in cannot land rows.
|
||||
nutrients: ["src/app/api/nutrients"],
|
||||
// v1.38.0 — the immunization log. SURFACE-gated like medications, not
|
||||
// data-layer-gated: every `/api/vaccinations/*` route is raw CRUD over the
|
||||
// person's own doses, so all six are EXEMPT below under the same reasoning.
|
||||
vaccinations: ["src/app/api/vaccinations"],
|
||||
};
|
||||
|
||||
/** Every declared tree, de-duplicated — the set this inventory walks. */
|
||||
const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
|
||||
...new Set(Object.values(MODULE_ROUTE_TREES_BY_KEY).flat()),
|
||||
].sort();
|
||||
|
||||
/**
|
||||
* EXEMPT — routes that serve a toggleable domain but are deliberately
|
||||
@@ -309,6 +354,33 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
|
||||
// while the nav entry, the picker and the report leaf hide.
|
||||
"src/app/api/vaccinations/[id]/booster/route.ts",
|
||||
"src/app/api/vaccinations/suggest/route.ts",
|
||||
// ── INFRA / CREDENTIALS (mcp) ─────────────────────────────────────
|
||||
// The remote MCP endpoint itself is `src/app/mcp/route.ts`, outside `/api`,
|
||||
// and it carries the gate: an account with the module off gets a 404 there
|
||||
// regardless of the credential presented (proven by the behavioural test
|
||||
// listed below). What lives under `/api/mcp` is the credential surface
|
||||
// BEHIND that endpoint, and it is deliberately not gated:
|
||||
//
|
||||
// - the token list / mint / revoke and the connection rows are the
|
||||
// cookie-session settings card. Revoking must keep working while the
|
||||
// module is off — that is precisely when somebody wants to revoke — and
|
||||
// minting happens in the same breath as switching the module on.
|
||||
// - the OAuth bridge mints and exchanges the same `health:read`
|
||||
// credential for a cloud connector. It runs on `withBackgroundEvent`
|
||||
// with its protections applied by hand (rate limit, body cap, kill
|
||||
// switch, mandatory PKCE-S256, redirect-URI allowlist) per the
|
||||
// documented exception, and the credential it issues is inert while the
|
||||
// module is off because `/mcp` refuses it.
|
||||
//
|
||||
// Gating these would not close a data path; it would only wedge the surface
|
||||
// that turns the module on and off.
|
||||
"src/app/api/mcp/tokens/route.ts",
|
||||
"src/app/api/mcp/tokens/[id]/route.ts",
|
||||
"src/app/api/mcp/connections/route.ts",
|
||||
"src/app/api/mcp/connections/[id]/route.ts",
|
||||
"src/app/api/mcp/oauth/authorize/route.ts",
|
||||
"src/app/api/mcp/oauth/token/route.ts",
|
||||
"src/app/api/mcp/oauth/register/route.ts",
|
||||
];
|
||||
|
||||
const MODULE_GATE_NEEDLE = "requireModuleEnabled(";
|
||||
@@ -333,6 +405,7 @@ const ILLNESS_GATE_NEEDLE = "requireIllnessEnabled(";
|
||||
*/
|
||||
const REQUIRED_TREE_MODULE: Readonly<Record<string, ModuleKey>> = {
|
||||
"src/app/api/fhir": "doctorReport",
|
||||
"src/app/api/export/health-record": "doctorReport",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -368,6 +441,14 @@ const BEHAVIOURAL_GATE_TESTS: ReadonlyArray<{ path: string; proves: string }> =
|
||||
proves:
|
||||
"the dashboard summary drops disabled-module metric cards while core vitals still ship",
|
||||
},
|
||||
{
|
||||
// The `mcp` module's enforcement point is outside `/api` entirely, so
|
||||
// nothing in the tree walk above can see it. Listing the proof here is
|
||||
// what keeps it from disappearing unnoticed.
|
||||
path: "src/app/mcp/__tests__/route.test.ts",
|
||||
proves:
|
||||
"the remote MCP endpoint 404s when the mcp module is off, which is what makes the ungated /api/mcp credential surface safe",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -456,6 +537,12 @@ describe("module API route gate inventory", () => {
|
||||
"doctorReport",
|
||||
"insights",
|
||||
"medications",
|
||||
"environment",
|
||||
"mentalHealth",
|
||||
"inboundDocuments",
|
||||
"nutrients",
|
||||
"mcp",
|
||||
"vaccinations",
|
||||
] as const) {
|
||||
expect(known.has(key), `unknown module key in inventory: ${key}`).toBe(
|
||||
true,
|
||||
@@ -463,6 +550,75 @@ describe("module API route gate inventory", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("every module key declares which API trees it owns", () => {
|
||||
// The type already makes an omission a compile error. This is the runtime
|
||||
// half: it fails by NAME, so the message says which key was added without
|
||||
// anybody deciding where its routes live.
|
||||
const undeclared = MODULE_KEYS.filter(
|
||||
(key) => !(key in MODULE_ROUTE_TREES_BY_KEY),
|
||||
);
|
||||
|
||||
expect(
|
||||
undeclared,
|
||||
[
|
||||
"Module keys with no entry in MODULE_ROUTE_TREES_BY_KEY:",
|
||||
...undeclared.map((k) => ` - ${k}`),
|
||||
"Name the module's API tree(s), or declare [] and say in the comment",
|
||||
"where its data actually lives.",
|
||||
].join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("every declared route tree exists on disk", () => {
|
||||
const missing = Object.entries(MODULE_ROUTE_TREES_BY_KEY).flatMap(
|
||||
([key, trees]) =>
|
||||
trees
|
||||
.filter((tree) => !existsSync(resolve(repoRoot, tree)))
|
||||
.map((tree) => ` - ${key}: ${tree}`),
|
||||
);
|
||||
|
||||
expect(
|
||||
missing,
|
||||
[
|
||||
"MODULE_ROUTE_TREES_BY_KEY names directories that do not exist —",
|
||||
"a renamed or deleted tree silently stops being walked:",
|
||||
...missing,
|
||||
].join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("an API directory named after a module key is declared for that key", () => {
|
||||
// This is the check the hand-written array could not perform. A module
|
||||
// whose routes live at the obvious address (`/api/environment` for
|
||||
// `environment`, `/api/mental-health` for `mentalHealth`) is discovered
|
||||
// from the filesystem, so a tree cannot sit outside the inventory just
|
||||
// because nobody added a line here. Trees at a NON-obvious address
|
||||
// (`achievements` → `/api/gamification`, `inboundDocuments` →
|
||||
// `/api/documents/inbound`) still have to be declared by hand; the
|
||||
// declaration is what this test compares against.
|
||||
const apiRoot = resolve(repoRoot, "src/app/api");
|
||||
const undeclared: string[] = [];
|
||||
|
||||
for (const key of MODULE_KEYS) {
|
||||
const dirName = key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
|
||||
const tree = `src/app/api/${dirName}`;
|
||||
if (!existsSync(join(apiRoot, dirName))) continue;
|
||||
if (MODULE_ROUTE_TREES_BY_KEY[key].includes(tree)) continue;
|
||||
undeclared.push(` - ${key}: ${tree} exists but is not declared`);
|
||||
}
|
||||
|
||||
expect(
|
||||
undeclared,
|
||||
[
|
||||
"API trees named after a module key that this inventory does not walk:",
|
||||
...undeclared,
|
||||
"Declare the tree under its key in MODULE_ROUTE_TREES_BY_KEY. Every",
|
||||
"route beneath it then has to be gated, delegated, or exempt with a",
|
||||
"documented reason.",
|
||||
].join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("every toggleable-module route is gated, delegated, or explicitly exempt", () => {
|
||||
const routes = findAllModuleRouteFiles();
|
||||
expect(routes.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Webhook verb rate-limit guard.
|
||||
*
|
||||
* The project rule for an inbound webhook is: per-source rate limit BEFORE
|
||||
* secret verification. The reason is not throughput, it is the oracle. A
|
||||
* secret comparison that answers 200 against 401 tells the caller whether it
|
||||
* guessed right; constant-time comparison hides the timing channel but not the
|
||||
* status code, so the only thing standing between a wrong guess and the next
|
||||
* one is the limiter. An unlimited verb is a free guessing machine and a free
|
||||
* load channel.
|
||||
*
|
||||
* The rule was applied per HANDLER rather than per FILE, which is how it drifted:
|
||||
* both Withings entrypoints, the Telegram bot webhook and the Coolify deploy
|
||||
* hook limited POST and left the verification verbs (GET / HEAD, the URL
|
||||
* reachability probes every webhook UI sends) comparing the same secret with
|
||||
* nothing in front of them. Reviewing the POST proved nothing about the sibling
|
||||
* export three lines below it.
|
||||
*
|
||||
* So this guard reasons about EVERY exported verb, not about files:
|
||||
*
|
||||
* 1. Discover the webhook routes from the filesystem — every `route.ts`
|
||||
* under `src/app/api` whose path names a webhook, plus the explicitly
|
||||
* listed unauthenticated inbound endpoints below.
|
||||
* 2. Resolve which local helpers reach a constant-time secret comparison and
|
||||
* which reach the rate limiter, transitively, so a verb that delegates
|
||||
* (`verifyTokenSegment`, `hasValidSecret`, `checkAndWarn`) is judged on
|
||||
* what the helper does rather than on the call's spelling.
|
||||
* 3. For every exported verb that reaches a comparison, require that it also
|
||||
* reaches the limiter and reaches it FIRST.
|
||||
*
|
||||
* A verb that compares no secret is not the subject here and is skipped — the
|
||||
* CSP report endpoint is the example: nothing to guess, and it rate-limits
|
||||
* anyway. A verb that never gets exported cannot be reached, which is why
|
||||
* WHOOP passes trivially: it exports POST only.
|
||||
*
|
||||
* LIMIT, stated so nobody reads more into a green run than it earns: this is a
|
||||
* source-text guard. It proves the limiter call precedes the comparison call in
|
||||
* the handler body; it does not prove the limiter and the POST share a bucket,
|
||||
* and a comparison moved into an imported module (rather than a local helper)
|
||||
* would fall outside the transitive resolution below. The per-route tests are
|
||||
* what prove the refusal.
|
||||
*/
|
||||
|
||||
const repoRoot = resolve(__dirname, "..", "..", "..", "..");
|
||||
const API_ROOT = "src/app/api";
|
||||
|
||||
/**
|
||||
* Inbound endpoints that are webhooks by behaviour but not by path. Listed
|
||||
* explicitly so the discovery below is not silently narrower than the class it
|
||||
* claims to cover.
|
||||
*/
|
||||
const EXTRA_WEBHOOK_ROUTES: ReadonlyArray<string> = [
|
||||
// Browser-posted CSP violation reports. Unauthenticated by design (the
|
||||
// browser cannot carry a secret), rate-limited, no comparison to shield.
|
||||
"src/app/api/monitoring/csp-report/route.ts",
|
||||
];
|
||||
|
||||
/** The HTTP verbs Next.js recognises as route handlers. */
|
||||
const HTTP_VERBS = [
|
||||
"GET",
|
||||
"HEAD",
|
||||
"POST",
|
||||
"PUT",
|
||||
"PATCH",
|
||||
"DELETE",
|
||||
"OPTIONS",
|
||||
] as const;
|
||||
|
||||
/** A constant-time secret comparison, however it is spelled. */
|
||||
const COMPARISON_NEEDLES = [
|
||||
"timingSafeEqual(",
|
||||
"timingSafeStringEqual(",
|
||||
] as const;
|
||||
|
||||
/** The per-source rate limiter, direct or through the shared webhook helper. */
|
||||
const LIMITER_NEEDLES = [
|
||||
"checkRateLimit(",
|
||||
"checkAuthSurfaceRateLimit(",
|
||||
"applyWebhookRateLimit(",
|
||||
] as const;
|
||||
|
||||
/** Drop whole-line comments so a docstring mentioning a helper is not a call. */
|
||||
function stripCommentLines(text: string): string {
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (
|
||||
trimmed.startsWith("//") ||
|
||||
trimmed.startsWith("*") ||
|
||||
trimmed.startsWith("/*")
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
return line;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** Index of the earliest needle in `text`, or -1 when none appears. */
|
||||
function firstIndexOfAny(text: string, needles: ReadonlyArray<string>): number {
|
||||
let best = -1;
|
||||
for (const needle of needles) {
|
||||
const at = text.indexOf(needle);
|
||||
if (at === -1) continue;
|
||||
if (best === -1 || at < best) best = at;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Every `route.ts` under `src/app/api`, as repo-relative POSIX paths. */
|
||||
function findRouteFiles(): string[] {
|
||||
const hits: string[] = [];
|
||||
|
||||
function walk(dir: string): void {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
if (entry === "__tests__" || entry === "node_modules") continue;
|
||||
walk(full);
|
||||
continue;
|
||||
}
|
||||
if (entry !== "route.ts") continue;
|
||||
hits.push(relative(repoRoot, full).split(/[\\/]/).join("/"));
|
||||
}
|
||||
}
|
||||
|
||||
walk(resolve(repoRoot, API_ROOT));
|
||||
return hits.sort();
|
||||
}
|
||||
|
||||
function findWebhookRoutes(): string[] {
|
||||
const byPath = findRouteFiles().filter((p) =>
|
||||
p.toLowerCase().includes("webhook"),
|
||||
);
|
||||
return [...new Set([...byPath, ...EXTRA_WEBHOOK_ROUTES])].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a module into its top-level declarations. Crude on purpose: a
|
||||
* declaration runs from its own `const`/`function`/`export` keyword at column
|
||||
* zero to the next one. Good enough to attribute a call to the handler or the
|
||||
* helper that contains it.
|
||||
*/
|
||||
function topLevelBlocks(text: string): Array<{ name: string; body: string }> {
|
||||
const starts: Array<{ name: string; at: number }> = [];
|
||||
const re =
|
||||
/^(?:export\s+)?(?:async\s+)?(?:function|const|let|class)\s+([A-Za-z0-9_$]+)/gm;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
starts.push({ name: m[1], at: m.index });
|
||||
}
|
||||
return starts.map((s, i) => ({
|
||||
name: s.name,
|
||||
body: text.slice(
|
||||
s.at,
|
||||
i + 1 < starts.length ? starts[i + 1].at : undefined,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The local helper names that transitively reach one of `needles`. Two passes
|
||||
* over the declaration list resolve one level of delegation
|
||||
* (`checkAndWarn` → `hasValidWebhookSecret` → `timingSafeStringEqual`), which
|
||||
* is the depth the webhook routes actually use; a third pass changes nothing
|
||||
* today and would be the place to grow if a route nests deeper.
|
||||
*/
|
||||
function helpersReaching(
|
||||
blocks: ReadonlyArray<{ name: string; body: string }>,
|
||||
needles: ReadonlyArray<string>,
|
||||
): Set<string> {
|
||||
const reaching = new Set<string>();
|
||||
for (let pass = 0; pass < 4; pass += 1) {
|
||||
let grew = false;
|
||||
for (const block of blocks) {
|
||||
if (reaching.has(block.name)) continue;
|
||||
const direct = firstIndexOfAny(block.body, needles) !== -1;
|
||||
const viaHelper = [...reaching].some((h) => block.body.includes(`${h}(`));
|
||||
if (direct || viaHelper) {
|
||||
reaching.add(block.name);
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
if (!grew) break;
|
||||
}
|
||||
return reaching;
|
||||
}
|
||||
|
||||
interface VerbVerdict {
|
||||
path: string;
|
||||
verb: string;
|
||||
comparisonAt: number;
|
||||
limiterAt: number;
|
||||
}
|
||||
|
||||
function analyseRoute(path: string): VerbVerdict[] {
|
||||
const text = stripCommentLines(readFileSync(resolve(repoRoot, path), "utf8"));
|
||||
const blocks = topLevelBlocks(text);
|
||||
|
||||
const comparisonHelpers = helpersReaching(blocks, COMPARISON_NEEDLES);
|
||||
const limiterHelpers = helpersReaching(blocks, LIMITER_NEEDLES);
|
||||
|
||||
const verdicts: VerbVerdict[] = [];
|
||||
for (const block of blocks) {
|
||||
if (!HTTP_VERBS.includes(block.name as (typeof HTTP_VERBS)[number])) {
|
||||
continue;
|
||||
}
|
||||
if (!new RegExp(`^export\\s+const\\s+${block.name}\\b`).test(block.body)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const comparisonAt = firstIndexOfAny(block.body, [
|
||||
...COMPARISON_NEEDLES,
|
||||
...[...comparisonHelpers]
|
||||
.filter((h) => h !== block.name)
|
||||
.map((h) => `${h}(`),
|
||||
]);
|
||||
if (comparisonAt === -1) continue;
|
||||
|
||||
const limiterAt = firstIndexOfAny(block.body, [
|
||||
...LIMITER_NEEDLES,
|
||||
...[...limiterHelpers]
|
||||
.filter((h) => h !== block.name)
|
||||
.map((h) => `${h}(`),
|
||||
]);
|
||||
|
||||
verdicts.push({ path, verb: block.name, comparisonAt, limiterAt });
|
||||
}
|
||||
return verdicts;
|
||||
}
|
||||
|
||||
describe("webhook verb rate-limit guard", () => {
|
||||
const routes = findWebhookRoutes();
|
||||
const verdicts = routes.flatMap(analyseRoute);
|
||||
|
||||
it("finds the webhook routes it claims to judge", () => {
|
||||
// An empty match set is the failure mode this project has already been
|
||||
// bitten by: a guard that matches nothing is green because it looked at
|
||||
// nothing. Both counts are floors, not inventories.
|
||||
expect(routes.length).toBeGreaterThanOrEqual(5);
|
||||
expect(
|
||||
routes,
|
||||
"the Withings path-segment webhook must be in the scanned set",
|
||||
).toContain("src/app/api/withings/webhook/[token]/route.ts");
|
||||
expect(routes).toContain("src/app/api/telegram/webhook/route.ts");
|
||||
expect(routes).toContain("src/app/api/internal/deploy-webhook/route.ts");
|
||||
});
|
||||
|
||||
it("recognises the verbs that compare a secret", () => {
|
||||
expect(
|
||||
verdicts.length,
|
||||
"no secret-comparing webhook verb was recognised — the matchers have " +
|
||||
"drifted away from the code they are supposed to judge",
|
||||
).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
|
||||
it("every secret-comparing verb rate-limits before it compares", () => {
|
||||
const offenders = verdicts
|
||||
.filter((v) => v.limiterAt === -1 || v.limiterAt > v.comparisonAt)
|
||||
.map((v) =>
|
||||
v.limiterAt === -1
|
||||
? ` - ${v.path} → ${v.verb}: compares the secret with no rate limit at all`
|
||||
: ` - ${v.path} → ${v.verb}: rate limit runs AFTER the comparison`,
|
||||
);
|
||||
|
||||
expect(
|
||||
offenders,
|
||||
[
|
||||
"Webhook verbs that compare a shared secret without a rate limit in front:",
|
||||
...offenders,
|
||||
"",
|
||||
"A 200-against-401 answer is an oracle. Constant-time comparison hides",
|
||||
"the timing channel, not the status code — the limiter is what makes",
|
||||
"guessing expensive. Apply the same per-source limit the POST uses,",
|
||||
"on the same bucket, before the comparison.",
|
||||
].join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,7 @@ import { prisma } from "@/lib/db";
|
||||
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiError, getClientIp } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { unpackBackupBlob } from "@/lib/export/backup-blob";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { parseBackupPayload } from "@/lib/validations/backup";
|
||||
|
||||
@@ -52,7 +52,7 @@ export const GET = apiHandler(
|
||||
|
||||
let plaintext: string;
|
||||
try {
|
||||
plaintext = decrypt(backup.data);
|
||||
plaintext = unpackBackupBlob(backup.data);
|
||||
} catch (err) {
|
||||
// Decryption failure is rare but real — a rotated/missing key, or a
|
||||
// corrupted blob from a partially-failed write. Surface it as a 500
|
||||
|
||||
@@ -19,7 +19,7 @@ import { prisma, toJson } from "@/lib/db";
|
||||
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { unpackBackupBlob } from "@/lib/export/backup-blob";
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { encryptContextToBytes } from "@/lib/labs/biomarker-store";
|
||||
@@ -180,7 +180,7 @@ const handler = apiHandler(
|
||||
|
||||
let plaintext: string;
|
||||
try {
|
||||
plaintext = decrypt(backup.data);
|
||||
plaintext = unpackBackupBlob(backup.data);
|
||||
} catch (err) {
|
||||
await auditLog("admin.backups.restore.failed", {
|
||||
userId: admin.id,
|
||||
|
||||
@@ -16,7 +16,7 @@ import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { unpackBackupBlob } from "@/lib/export/backup-blob";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
isCompatibleSchemaVersion,
|
||||
@@ -44,7 +44,7 @@ export const GET = apiHandler(
|
||||
|
||||
let plaintext: string;
|
||||
try {
|
||||
plaintext = decrypt(backup.data);
|
||||
plaintext = unpackBackupBlob(backup.data);
|
||||
} catch {
|
||||
return apiError("Failed to decrypt backup payload", 500);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,20 @@
|
||||
* shipped — only metadata (id, userId, username, type, size in bytes,
|
||||
* createdAt). The encrypted blob remains server-side; admins can trigger
|
||||
* a re-snapshot but not download another user's payload from the UI.
|
||||
*
|
||||
* Alongside the rows, the response says whether the SCHEDULE is still alive:
|
||||
* how old the newest scheduled copy is and how the last scheduled run ended.
|
||||
* Without those two the page cannot tell a working backup from one that
|
||||
* stopped six weeks ago — every row it lists has a perfectly ordinary
|
||||
* timestamp either way.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { DATA_BACKUP_QUEUE } from "@/lib/jobs/data-backup-policy";
|
||||
import { readLastQueueRun } from "@/lib/jobs/job-failures";
|
||||
import { summariseBackupSchedule } from "@/lib/jobs/backup-schedule-status";
|
||||
// v1.4.41 W-ORG — `BackupRow` / `BackupsList` moved to `src/types/backups.ts`
|
||||
// so callers (in particular `components/admin/backups-section.tsx`) don't
|
||||
// have to reach across the component → route-handler layer boundary.
|
||||
@@ -17,31 +26,57 @@ import type { BackupRow, BackupsList } from "@/types/backups";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** The type the weekly worker writes; anything else was uploaded by hand. */
|
||||
const SCHEDULED_BACKUP_TYPE = "WEEKLY_AUTO";
|
||||
|
||||
interface BackupMetaRow {
|
||||
id: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
type: string;
|
||||
size_bytes: number;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.backups.list" } });
|
||||
|
||||
// Pull metadata only — `data` would balloon the response and reveal
|
||||
// ciphertext we don't need on the listing page.
|
||||
const backups = await prisma.dataBackup.findMany({
|
||||
include: { user: { select: { id: true, username: true } } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
// Metadata only, and the size read as `octet_length` in the database rather
|
||||
// than by pulling every blob into this process to measure it. The listing
|
||||
// needs a number, not the ciphertext, and on an instance with large records
|
||||
// fetching them all was its own way of running out of memory. Base64 is
|
||||
// ASCII, so the column's byte length is exactly what the wire reports.
|
||||
const rows = await prisma.$queryRaw<BackupMetaRow[]>`
|
||||
SELECT b.id, b.user_id, u.username, b.type,
|
||||
octet_length(b.data) AS size_bytes,
|
||||
b.created_at
|
||||
FROM data_backups b
|
||||
JOIN users u ON u.id = b.user_id
|
||||
ORDER BY b.created_at DESC
|
||||
`;
|
||||
|
||||
const rows: BackupRow[] = backups.map((b) => ({
|
||||
id: b.id,
|
||||
userId: b.userId,
|
||||
username: b.user.username,
|
||||
type: b.type,
|
||||
sizeBytes: Buffer.byteLength(b.data, "utf8"),
|
||||
createdAt: b.createdAt.toISOString(),
|
||||
const list: BackupRow[] = rows.map((row) => ({
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
username: row.username,
|
||||
type: row.type,
|
||||
sizeBytes: Number(row.size_bytes),
|
||||
createdAt: row.created_at.toISOString(),
|
||||
}));
|
||||
|
||||
const payload: BackupsList = {
|
||||
rows,
|
||||
rows: list,
|
||||
// Matches the retention window the backup-prune job enforces so the
|
||||
// backups page states the same number the worker acts on.
|
||||
retentionDays: 90,
|
||||
schedule: summariseBackupSchedule({
|
||||
scheduledCreatedAt: rows
|
||||
.filter((row) => row.type === SCHEDULED_BACKUP_TYPE)
|
||||
.map((row) => row.created_at),
|
||||
lastRun: await readLastQueueRun(DATA_BACKUP_QUEUE),
|
||||
now: new Date(),
|
||||
}),
|
||||
};
|
||||
|
||||
return apiSuccess(payload);
|
||||
|
||||
@@ -15,11 +15,13 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
DATA_BACKUP_QUEUE,
|
||||
DATA_BACKUP_SEND_OPTIONS,
|
||||
} from "@/lib/jobs/data-backup-policy";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const DATA_BACKUP_QUEUE = "data-backup";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user: admin } = await requireAdmin();
|
||||
annotate({ action: { name: "admin.backups.run" } });
|
||||
@@ -57,9 +59,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
throw new HttpError(503, "Background worker is not running");
|
||||
}
|
||||
|
||||
const jobId = await boss.send(DATA_BACKUP_QUEUE, {
|
||||
triggeredAt: new Date().toISOString(),
|
||||
});
|
||||
// Same expiration window as the weekly cron. An ad-hoc snapshot runs the
|
||||
// identical pass over the identical record, so it must not silently keep the
|
||||
// 15-minute queue default the cron was rescued from.
|
||||
const jobId = await boss.send(
|
||||
DATA_BACKUP_QUEUE,
|
||||
{ triggeredAt: new Date().toISOString() },
|
||||
DATA_BACKUP_SEND_OPTIONS,
|
||||
);
|
||||
|
||||
annotate({ meta: { job_id: jobId ?? null } });
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import { prisma } from "@/lib/db";
|
||||
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { packBackupBlob } from "@/lib/export/backup-blob";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
@@ -190,7 +190,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// intact so multiple uploads can coexist for the same user without
|
||||
// overwriting each other or the rolling WEEKLY_AUTO snapshot.
|
||||
const uploadType = `MANUAL_UPLOAD_${Date.now()}`;
|
||||
const encrypted = encrypt(JSON.stringify(payload));
|
||||
const encrypted = packBackupBlob(JSON.stringify(payload));
|
||||
|
||||
const created = await prisma.dataBackup.create({
|
||||
data: {
|
||||
|
||||
@@ -482,3 +482,134 @@ describe("PUT /api/admin/settings", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The five assistant flags were accepted by `adminSettingsSchema` and then
|
||||
* dropped on the floor: sending one alone answered 422 ("No valid fields"),
|
||||
* sending it alongside a real field answered 200 and stored nothing. The
|
||||
* schema comment promised the opposite, so the route was brought up to the
|
||||
* contract rather than the contract down to the route.
|
||||
*/
|
||||
describe("PUT /api/admin/settings — assistant flags", () => {
|
||||
const STORED = {
|
||||
registrationEnabled: true,
|
||||
mfaRequired: false,
|
||||
defaultLocale: "de",
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
webPushVapidPublicKey: null,
|
||||
webPushVapidPrivateKeyEncrypted: null,
|
||||
webPushVapidSubject: null,
|
||||
apiGlobal: true,
|
||||
umamiEnabled: false,
|
||||
umamiScriptUrl: null,
|
||||
umamiWebsiteId: null,
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
documentMaxFileBytes: 26_214_400,
|
||||
documentQuotaBytes: BigInt(1_073_741_824),
|
||||
defaultUserTimezone: null,
|
||||
assistantEnabled: true,
|
||||
assistantCoachEnabled: false,
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: true,
|
||||
assistantCorrelationsEnabled: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue(STORED as never);
|
||||
});
|
||||
|
||||
it("persists a lone assistant flag instead of answering 'No valid fields'", async () => {
|
||||
const res = await PUT(jsonReq({ assistantCoachEnabled: false }));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.appSettings.upsert).toHaveBeenCalledTimes(1);
|
||||
const args = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0] as {
|
||||
update: Record<string, unknown>;
|
||||
};
|
||||
expect(args.update).toEqual({ assistantCoachEnabled: false });
|
||||
});
|
||||
|
||||
it("persists an assistant flag sent alongside an unrelated field", async () => {
|
||||
const res = await PUT(
|
||||
jsonReq({ registrationEnabled: false, assistantCoachEnabled: false }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const args = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0] as {
|
||||
update: Record<string, unknown>;
|
||||
};
|
||||
expect(args.update).toEqual({
|
||||
registrationEnabled: false,
|
||||
assistantCoachEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("echoes the stored flags so a scripted write reads back", async () => {
|
||||
const res = await PUT(jsonReq({ assistantCoachEnabled: false }));
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
|
||||
expect(body.data).toMatchObject({
|
||||
assistantEnabled: true,
|
||||
assistantCoachEnabled: false,
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: true,
|
||||
assistantCorrelationsEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("audits the flag it changed", async () => {
|
||||
await PUT(jsonReq({ assistantCorrelationsEnabled: false }));
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"admin.settings.update",
|
||||
expect.objectContaining({
|
||||
details: expect.objectContaining({
|
||||
assistantCorrelationsEnabled: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("still refuses an unknown flag-shaped key", async () => {
|
||||
const res = await PUT(jsonReq({ assistantNotAThing: false }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/admin/settings — assistant flags", () => {
|
||||
it("defaults every flag to enabled when the row is absent", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
|
||||
expect(body.data).toMatchObject({
|
||||
assistantEnabled: true,
|
||||
assistantCoachEnabled: true,
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: true,
|
||||
assistantCorrelationsEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces the stored flags", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
assistantEnabled: false,
|
||||
assistantCoachEnabled: false,
|
||||
} as never);
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
|
||||
expect(body.data).toMatchObject({
|
||||
assistantEnabled: false,
|
||||
assistantCoachEnabled: false,
|
||||
assistantBriefingEnabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,16 @@ export const GET = apiHandler(async () => {
|
||||
// resolver"; surfacing the raw value lets the admin UI render
|
||||
// an empty picker placeholder until they opt in.
|
||||
defaultUserTimezone: settings?.defaultUserTimezone ?? null,
|
||||
// Raw column values, not the resolved matrix — the master flag is
|
||||
// applied to the sub-flags by `/settings/assistant-flags`, which owns
|
||||
// that shape. Echoed here so a write over this route reads back.
|
||||
assistantEnabled: settings?.assistantEnabled ?? true,
|
||||
assistantCoachEnabled: settings?.assistantCoachEnabled ?? true,
|
||||
assistantBriefingEnabled: settings?.assistantBriefingEnabled ?? true,
|
||||
assistantInsightStatusEnabled:
|
||||
settings?.assistantInsightStatusEnabled ?? true,
|
||||
assistantCorrelationsEnabled:
|
||||
settings?.assistantCorrelationsEnabled ?? true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,7 +93,13 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const updates: Record<string, unknown> = {};
|
||||
const auditDetails: Record<string, unknown> = {};
|
||||
|
||||
// Boolean fields — direct mapping
|
||||
// Boolean fields — direct mapping.
|
||||
//
|
||||
// The five assistant flags belong here and not only on the dedicated
|
||||
// `/settings/assistant-flags` endpoint: `adminSettingsSchema` accepts them
|
||||
// so an operator scripting their settings over a single route can carry
|
||||
// them, and a schema that accepts a field and then drops it is worse than
|
||||
// one that refuses it.
|
||||
const booleanFields = [
|
||||
"registrationEnabled",
|
||||
"mfaRequired",
|
||||
@@ -93,6 +109,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
"apiGlobal",
|
||||
"umamiEnabled",
|
||||
"glitchtipEnabled",
|
||||
"assistantEnabled",
|
||||
"assistantCoachEnabled",
|
||||
"assistantBriefingEnabled",
|
||||
"assistantInsightStatusEnabled",
|
||||
"assistantCorrelationsEnabled",
|
||||
] as const;
|
||||
for (const field of booleanFields) {
|
||||
if (data[field] !== undefined) {
|
||||
@@ -258,5 +279,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
documentMaxFileBytes: settings.documentMaxFileBytes,
|
||||
documentQuotaBytes: Number(settings.documentQuotaBytes),
|
||||
defaultUserTimezone: settings.defaultUserTimezone,
|
||||
assistantEnabled: settings.assistantEnabled,
|
||||
assistantCoachEnabled: settings.assistantCoachEnabled,
|
||||
assistantBriefingEnabled: settings.assistantBriefingEnabled,
|
||||
assistantInsightStatusEnabled: settings.assistantInsightStatusEnabled,
|
||||
assistantCorrelationsEnabled: settings.assistantCorrelationsEnabled,
|
||||
});
|
||||
});
|
||||
@@ -776,15 +776,20 @@ describe("GET /api/insights/comprehensive — mood × metric local-day pairing (
|
||||
scheduleRevisions: [],
|
||||
pauseEras: [],
|
||||
};
|
||||
// Intake taken on the first three days, missed on the last two → a
|
||||
// non-degenerate continuity series.
|
||||
const intakeEvents = days.slice(0, 3).map((d) => ({
|
||||
// Intake taken on the first three days, logged as skipped on the last
|
||||
// two → a non-degenerate continuity series. The skips used to be absent
|
||||
// rows: a day with no record read as a measured 0 % continuity, which is
|
||||
// the fabrication the route no longer makes, and a fixture that relies on
|
||||
// it would be pinning the defect rather than the timezone question this
|
||||
// test asks. Recording the two days as skips keeps five paired days AND
|
||||
// keeps every one of them a thing the person actually logged.
|
||||
const intakeEvents = days.map((d, i) => ({
|
||||
medicationId: "med-bp",
|
||||
userId: "user-comp-1",
|
||||
deletedAt: null,
|
||||
scheduledFor: new Date(`${d}T07:00:00Z`),
|
||||
takenAt: new Date(`${d}T07:05:00Z`),
|
||||
skipped: false,
|
||||
takenAt: i < 3 ? new Date(`${d}T07:05:00Z`) : null,
|
||||
skipped: i >= 3,
|
||||
}));
|
||||
|
||||
function primeMocks() {
|
||||
@@ -827,3 +832,227 @@ describe("GET /api/insights/comprehensive — mood × metric local-day pairing (
|
||||
expect(utcBody.data.bpMedicationCorrelation).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// A medication with no schedule expects no dose, so it has no adherence to
|
||||
// report. `calculateCompliance` answers `rate: 100, totalExpected: 0` for that
|
||||
// case — arithmetically true and a lie on a surface that reads the number as
|
||||
// adherence. The doctor report has excluded both the PRN arm and the empty
|
||||
// schedule since it was written, with the reason beside it; this envelope
|
||||
// filtered only PRN, so a scheduled medication saved without a schedule (which
|
||||
// `POST /api/medications` accepts) reported perfect adherence to the reader and
|
||||
// to the prompt built from the same array.
|
||||
describe("GET /api/insights/comprehensive — no adherence without an expectation", () => {
|
||||
const EMPTY_AGGREGATE = {
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
weightRawRows: [],
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: null,
|
||||
totalMeasurements: 0,
|
||||
};
|
||||
|
||||
type MedBody = {
|
||||
data: {
|
||||
medications: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
compliance7: number;
|
||||
compliance30: number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
function scheduledMed(id: string, name: string, schedules: unknown[]) {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
dose: "5mg",
|
||||
active: true,
|
||||
asNeeded: false,
|
||||
createdAt: new Date("2026-04-01T00:00:00Z"),
|
||||
schedules,
|
||||
scheduleRevisions: [],
|
||||
pauseEras: [],
|
||||
};
|
||||
}
|
||||
|
||||
const SCHEDULE = {
|
||||
id: "sch-1",
|
||||
windowStart: "07:00",
|
||||
windowEnd: "08:00",
|
||||
timesOfDay: ["07:00"],
|
||||
daysOfWeek: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
reminderGraceMinutes: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: [],
|
||||
};
|
||||
|
||||
it("leaves a scheduled medication with no schedule out of the compliance list", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
EMPTY_AGGREGATE,
|
||||
);
|
||||
(prisma.medication.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
scheduledMed("med-none", "Unscheduled", []),
|
||||
scheduledMed("med-real", "Amlodipine", [SCHEDULE]),
|
||||
]);
|
||||
|
||||
const body = (await (await callGet(makeReq())).json()) as MedBody;
|
||||
|
||||
const names = body.data.medications.map((m) => m.name);
|
||||
expect(names).not.toContain("Unscheduled");
|
||||
// The medication that DOES expect a dose still reports, so this is a
|
||||
// filter and not a blanket suppression.
|
||||
expect(names).toContain("Amlodipine");
|
||||
});
|
||||
|
||||
it("never reports 100 % for a medication with nothing expected of it", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
EMPTY_AGGREGATE,
|
||||
);
|
||||
(prisma.medication.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
scheduledMed("med-none", "Unscheduled", []),
|
||||
]);
|
||||
|
||||
const body = (await (await callGet(makeReq())).json()) as MedBody;
|
||||
|
||||
expect(body.data.medications).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// The medication-continuity correlation joined every day that had a systolic
|
||||
// reading against `takenByDay.get(day) ?? 0`. A day the reader simply never
|
||||
// logged an intake for therefore entered the scatter and the coefficient as a
|
||||
// measured 0 % continuity. Absence is not zero anywhere else in this project
|
||||
// and it is not zero here: a day with a logged skip is a real zero and stays,
|
||||
// a day with no record at all drops out.
|
||||
describe("GET /api/insights/comprehensive — an unlogged day is not a zero", () => {
|
||||
type BpBody = {
|
||||
data: {
|
||||
bpMedicationScatterData: Array<{ continuityPct: number; sysBP: number }>;
|
||||
bpMedicationCorrelation: { n: number } | null;
|
||||
};
|
||||
};
|
||||
|
||||
const DAYS = [
|
||||
"2026-05-01",
|
||||
"2026-05-02",
|
||||
"2026-05-03",
|
||||
"2026-05-04",
|
||||
"2026-05-05",
|
||||
];
|
||||
|
||||
const MEDICATION = {
|
||||
id: "med-bp",
|
||||
name: "Amlodipine",
|
||||
dose: "5mg",
|
||||
active: true,
|
||||
asNeeded: false,
|
||||
createdAt: new Date("2026-04-01T00:00:00Z"),
|
||||
schedules: [
|
||||
{
|
||||
id: "sch-1",
|
||||
windowStart: "07:00",
|
||||
windowEnd: "08:00",
|
||||
timesOfDay: ["07:00"],
|
||||
daysOfWeek: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
reminderGraceMinutes: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: [],
|
||||
},
|
||||
],
|
||||
scheduleRevisions: [],
|
||||
pauseEras: [],
|
||||
};
|
||||
|
||||
function primeBp(
|
||||
events: Array<{ day: string; takenAt: Date | null; skipped: boolean }>,
|
||||
) {
|
||||
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
{
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
weightRawRows: [],
|
||||
dailyByType: {
|
||||
BLOOD_PRESSURE_SYS: DAYS.map((d, i) => ({
|
||||
day: d,
|
||||
value: 120 + i * 2,
|
||||
})),
|
||||
},
|
||||
firstMeasurementAt: new Date(),
|
||||
totalMeasurements: 10,
|
||||
},
|
||||
);
|
||||
(prisma.medication.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
MEDICATION,
|
||||
]);
|
||||
(
|
||||
prisma.medicationIntakeEvent.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue(
|
||||
events.map((e) => ({
|
||||
medicationId: "med-bp",
|
||||
userId: "user-comp-1",
|
||||
deletedAt: null,
|
||||
scheduledFor: new Date(`${e.day}T07:00:00Z`),
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
})),
|
||||
);
|
||||
vi.mocked(getMedicationCategories).mockResolvedValue({
|
||||
"med-bp": "BLOOD_PRESSURE",
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps only the days that carry an intake record", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// Three days logged; the last two were never touched.
|
||||
primeBp(
|
||||
DAYS.slice(0, 3).map((day) => ({
|
||||
day,
|
||||
takenAt: new Date(`${day}T07:05:00Z`),
|
||||
skipped: false,
|
||||
})),
|
||||
);
|
||||
|
||||
const body = (await (await callGet(makeReq())).json()) as BpBody;
|
||||
|
||||
expect(body.data.bpMedicationScatterData).toHaveLength(3);
|
||||
// The two unlogged days would have entered as 0 % against their own
|
||||
// systolic reading; both are gone.
|
||||
expect(body.data.bpMedicationScatterData.map((p) => p.sysBP)).not.toContain(
|
||||
126,
|
||||
);
|
||||
expect(
|
||||
body.data.bpMedicationScatterData.every((p) => p.continuityPct === 100),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a logged skip, which is a real zero", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeBp([
|
||||
...DAYS.slice(0, 3).map((day) => ({
|
||||
day,
|
||||
takenAt: new Date(`${day}T07:05:00Z`),
|
||||
skipped: false,
|
||||
})),
|
||||
{ day: DAYS[3], takenAt: null, skipped: true },
|
||||
]);
|
||||
|
||||
const body = (await (await callGet(makeReq())).json()) as BpBody;
|
||||
|
||||
expect(body.data.bpMedicationScatterData).toHaveLength(4);
|
||||
expect(body.data.bpMedicationScatterData).toContainEqual({
|
||||
continuityPct: 0,
|
||||
sysBP: 126,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import {
|
||||
buildComplianceMedicationContext,
|
||||
calculateCompliance,
|
||||
expectsDoses,
|
||||
lastNonSkippedTakenAt,
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
} from "@/lib/analytics/compliance";
|
||||
@@ -365,7 +366,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
|
||||
// ── Medication compliance ────────────────────────────────
|
||||
// Bounded reads — left untouched per the directive.
|
||||
const medications = await prisma.medication.findMany({
|
||||
const medicationRows = await prisma.medication.findMany({
|
||||
// v1.16.11 — as-needed (PRN) medications never surface a compliance
|
||||
// rate (no expected doses).
|
||||
where: { userId, active: true, asNeeded: false },
|
||||
@@ -379,6 +380,12 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
pauseEras: { select: { pausedAt: true, resumedAt: true } },
|
||||
},
|
||||
});
|
||||
// A medication with no schedule expects no dose, and `calculateCompliance`
|
||||
// answers 100 % for that case. The `asNeeded: false` filter above catches
|
||||
// only the PRN arm; the empty schedule is reachable on a scheduled
|
||||
// medication and would otherwise report perfect adherence.
|
||||
const medications = medicationRows.filter(expectsDoses);
|
||||
|
||||
const categoryMap = await getMedicationCategories(
|
||||
medications.map((m) => m.id),
|
||||
);
|
||||
@@ -497,14 +504,23 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
}
|
||||
|
||||
const takenByDay = new Map<string, number>();
|
||||
// Days that carry an intake record of ANY kind. A logged skip is a real
|
||||
// zero and belongs in the series; a day the person never touched is not a
|
||||
// zero, it is nothing, and pairing it against that day's systolic reading
|
||||
// invented a data point they never produced. Absence is not zero, here as
|
||||
// everywhere else in this project, so the unlogged day drops out of both
|
||||
// the scatter and the coefficient rather than dragging continuity down.
|
||||
const loggedDays = new Set<string>();
|
||||
for (const event of bpMedicationEvents) {
|
||||
if (event.skipped || !event.takenAt) continue;
|
||||
const dayKey = event.scheduledFor.toISOString().slice(0, 10);
|
||||
loggedDays.add(dayKey);
|
||||
if (event.skipped || !event.takenAt) continue;
|
||||
takenByDay.set(dayKey, (takenByDay.get(dayKey) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const pairs: Array<{ a: number; b: number; date: Date }> = [];
|
||||
for (const [dayKey, avgSys] of sysByDay.entries()) {
|
||||
if (!loggedDays.has(dayKey)) continue;
|
||||
const taken = takenByDay.get(dayKey) ?? 0;
|
||||
const continuity = Math.min(1, taken / expectedBpIntakesPerDay);
|
||||
pairs.push({
|
||||
|
||||
@@ -329,6 +329,31 @@ describe("GET /api/internal/deploy-webhook (reachability check)", () => {
|
||||
const res = await GET(req);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
// The reachability GET compares the same shared secret the POST does and
|
||||
// answers 200 against 401. Unlimited, that is a free oracle for guessing it.
|
||||
// A VALID secret over the limit must still be refused — that proves the
|
||||
// limiter runs before the comparison, not merely beside it.
|
||||
it("returns 429 for a valid secret when over the limit, on the POST bucket", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/internal/deploy-webhook", {
|
||||
method: "GET",
|
||||
headers: { "x-deploy-webhook-secret": "test-secret" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(429);
|
||||
expect(checkRateLimit).toHaveBeenCalledTimes(1);
|
||||
expect(String(vi.mocked(checkRateLimit).mock.calls[0][0])).toMatch(
|
||||
/^deploy-webhook:/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/internal/deploy-webhook (timestamp replay protection)", () => {
|
||||
|
||||
@@ -202,24 +202,39 @@ async function notifyAdminsOfFailure(event: NormalizedEvent): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "deploy.webhook" } });
|
||||
|
||||
// Rate-limit by client IP. 60 requests / minute is comfortably above
|
||||
// Coolify's per-event burst (typically 1-2 per deploy) but well below
|
||||
// a hostile actor flooding to fish for a working secret.
|
||||
/**
|
||||
* Rate-limit by client IP. 60 requests / minute is comfortably above
|
||||
* Coolify's per-event burst (typically 1-2 per deploy) but well below a
|
||||
* hostile actor flooding to fish for a working secret.
|
||||
*
|
||||
* Both verbs share the bucket, and both run it before the comparison: the
|
||||
* reachability GET checks the same secret and answers 200 against 401, so
|
||||
* leaving it unlimited would hand back the free guessing channel the POST
|
||||
* limit exists to close.
|
||||
*/
|
||||
async function applyWebhookRateLimit(
|
||||
request: NextRequest,
|
||||
): Promise<NextResponse | null> {
|
||||
const ip = getClientIp(request);
|
||||
const rl = await checkRateLimit(
|
||||
`deploy-webhook:${ip ?? "unknown"}`,
|
||||
60,
|
||||
60_000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json(
|
||||
{ status: "rate_limited" },
|
||||
{ status: 429, headers: rateLimitHeaders(rl) },
|
||||
);
|
||||
}
|
||||
if (rl.allowed) return null;
|
||||
return NextResponse.json(
|
||||
{ status: "rate_limited" },
|
||||
{ status: 429, headers: rateLimitHeaders(rl) },
|
||||
);
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "deploy.webhook" } });
|
||||
|
||||
const limited = await applyWebhookRateLimit(request);
|
||||
if (limited) return limited;
|
||||
|
||||
const ip = getClientIp(request);
|
||||
|
||||
if (!hasValidSecret(request)) {
|
||||
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
|
||||
@@ -293,6 +308,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
*/
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "deploy.webhook.verify" } });
|
||||
|
||||
const limited = await applyWebhookRateLimit(request);
|
||||
if (limited) return limited;
|
||||
|
||||
if (!hasValidSecret(request)) {
|
||||
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -26,12 +26,19 @@ vi.mock("@/lib/api-handler", () => ({
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
// The route reads the operator's instance-wide Web Push switch before it
|
||||
// touches the body; the default stub leaves it on so these cases keep
|
||||
// asserting the validation refusals they were written for.
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
pushSubscription: { upsert: vi.fn(), deleteMany: vi.fn() },
|
||||
notificationChannel: { findFirst: vi.fn(), create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({ encrypt: (v: string) => `enc(${v})` }));
|
||||
|
||||
@@ -67,6 +74,12 @@ type Handler = (r: Request) => Promise<Response>;
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(requireAuth).mockResolvedValue({ user: USER } as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
apiGlobal: true,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/notifications/web-push — the subscribe refusal", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { isChannelGloballyEnabled } from "@/lib/app-settings";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
@@ -43,6 +44,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "notifications.web-push.subscribe" } });
|
||||
|
||||
// Subscribing IS the enable step for this channel — it creates the
|
||||
// WEB_PUSH `NotificationChannel` row. An operator who switched Web Push
|
||||
// off instance-wide (`AppSettings.webPushGlobal`) gets a refusal that says
|
||||
// so rather than a subscription that stores fine and delivers nothing.
|
||||
if (!(await isChannelGloballyEnabled("WEB_PUSH"))) {
|
||||
annotate({ meta: { refused: "globally_disabled" } });
|
||||
return apiError(
|
||||
"Web Push is disabled on this instance by the operator",
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
const raw = await request.text();
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Enabling a channel the operator switched off instance-wide is refused.
|
||||
*
|
||||
* The three switches (`AppSettings.telegramGlobal` / `.ntfyGlobal` /
|
||||
* `.webPushGlobal`) now stop delivery in the dispatcher. Without a matching
|
||||
* refusal on the way in, a user would still see the toggle flip, the row
|
||||
* would still say `enabled = true`, and nothing would ever arrive. These
|
||||
* tests pin the refusal and the absence of any write behind it.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
notificationChannel: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
pushSubscription: { upsert: vi.fn() },
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: vi.fn((value: string) => `encrypted:${value}`),
|
||||
decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")),
|
||||
}));
|
||||
|
||||
const setTelegramWebhookMock = vi.fn().mockResolvedValue(true);
|
||||
vi.mock("@/lib/telegram", () => ({
|
||||
setTelegramWebhook: (...args: unknown[]) => setTelegramWebhookMock(...args),
|
||||
deleteTelegramWebhook: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PUT as putNtfy } from "../ntfy/route";
|
||||
import { PUT as putTelegram } from "../telegram/route";
|
||||
import { POST as postWebPush } from "../../notifications/web-push/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
const WEB_PUSH_SUBSCRIPTION = {
|
||||
endpoint: "https://push.example.com/subscription/abc",
|
||||
keys: { p256dh: "a".repeat(87), auth: "b".repeat(22) },
|
||||
};
|
||||
|
||||
function stubSettings(over: Record<string, boolean>) {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
apiGlobal: true,
|
||||
...over,
|
||||
} as never);
|
||||
}
|
||||
|
||||
function request(path: string, method: string, body: unknown) {
|
||||
return new Request(`http://localhost${path}`, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.notificationChannel.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
|
||||
config: 'encrypted:{"serverUrl":"https://ntfy.sh","topic":"saved-topic"}',
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
telegramBotToken: "encrypted:saved-token",
|
||||
telegramChatId: "saved-chat",
|
||||
telegramEnabled: false,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.pushSubscription.upsert).mockResolvedValue({} as never);
|
||||
vi.mocked(prisma.notificationChannel.findFirst).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe("ntfy enable against the instance-wide switch", () => {
|
||||
it("refuses with 403 and writes nothing when the operator switched ntfy off", async () => {
|
||||
stubSettings({ ntfyGlobal: false });
|
||||
|
||||
const response = await putNtfy(
|
||||
request("/api/settings/ntfy", "PUT", { enabled: true }) as never,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
data: null,
|
||||
error: expect.stringContaining("disabled on this instance"),
|
||||
});
|
||||
expect(prisma.notificationChannel.updateMany).not.toHaveBeenCalled();
|
||||
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still allows disabling the channel while the switch is off", async () => {
|
||||
stubSettings({ ntfyGlobal: false });
|
||||
|
||||
const response = await putNtfy(
|
||||
request("/api/settings/ntfy", "PUT", { enabled: false }) as never,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1", type: "NTFY" },
|
||||
data: { enabled: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("allows enabling while the switch is on", async () => {
|
||||
stubSettings({ ntfyGlobal: true });
|
||||
|
||||
const response = await putNtfy(
|
||||
request("/api/settings/ntfy", "PUT", { enabled: true }) as never,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1", type: "NTFY" },
|
||||
data: { enabled: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Telegram enable against the instance-wide switch", () => {
|
||||
it("refuses with 403, registers no webhook, and writes nothing", async () => {
|
||||
stubSettings({ telegramGlobal: false });
|
||||
|
||||
const response = await putTelegram(
|
||||
request("/api/settings/telegram", "PUT", { enabled: true }) as never,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(setTelegramWebhookMock).not.toHaveBeenCalled();
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
expect(prisma.notificationChannel.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a full-config save that enables the channel", async () => {
|
||||
stubSettings({ telegramGlobal: false });
|
||||
|
||||
const response = await putTelegram(
|
||||
request("/api/settings/telegram", "PUT", {
|
||||
botToken: "123456:AAaaBBbbCCccDDddEEeeFFffGGgg-HHhhIIii",
|
||||
chatId: "9876543",
|
||||
enabled: true,
|
||||
}) as never,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(setTelegramWebhookMock).not.toHaveBeenCalled();
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Web Push subscribe against the instance-wide switch", () => {
|
||||
it("refuses with 403 and stores no subscription", async () => {
|
||||
stubSettings({ webPushGlobal: false });
|
||||
|
||||
const response = await postWebPush(
|
||||
request(
|
||||
"/api/notifications/web-push",
|
||||
"POST",
|
||||
WEB_PUSH_SUBSCRIPTION,
|
||||
) as never,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(prisma.pushSubscription.upsert).not.toHaveBeenCalled();
|
||||
expect(prisma.notificationChannel.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stores the subscription while the switch is on", async () => {
|
||||
stubSettings({ webPushGlobal: true });
|
||||
|
||||
const response = await postWebPush(
|
||||
request(
|
||||
"/api/notifications/web-push",
|
||||
"POST",
|
||||
WEB_PUSH_SUBSCRIPTION,
|
||||
) as never,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(prisma.pushSubscription.upsert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -7,8 +7,17 @@ import {
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { isChannelGloballyEnabled } from "@/lib/app-settings";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
/**
|
||||
* Refusal for an enable attempt on a channel the operator switched off
|
||||
* instance-wide (`AppSettings.ntfyGlobal`). A toggle that appeared to work
|
||||
* and then delivered nothing is worse than a refusal that says why.
|
||||
*/
|
||||
const NTFY_GLOBALLY_DISABLED =
|
||||
"ntfy is disabled on this instance by the operator";
|
||||
|
||||
/**
|
||||
* GET: Return current ntfy config (without auth token).
|
||||
* PUT: Update ntfy config.
|
||||
@@ -60,6 +69,14 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
if (enabledOnly.success) {
|
||||
const { enabled } = enabledOnly.data;
|
||||
|
||||
if (enabled && !(await isChannelGloballyEnabled("NTFY"))) {
|
||||
annotate({
|
||||
action: { name: "settings.ntfy.update" },
|
||||
meta: { refused: "globally_disabled" },
|
||||
});
|
||||
return apiError(NTFY_GLOBALLY_DISABLED, 403);
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
const existing = await prisma.notificationChannel.findUnique({
|
||||
where: { userId_type: { userId: user.id, type: "NTFY" } },
|
||||
@@ -106,6 +123,14 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const { serverUrl, topic, authToken, enabled } = parsed.data;
|
||||
|
||||
if (enabled && !(await isChannelGloballyEnabled("NTFY"))) {
|
||||
annotate({
|
||||
action: { name: "settings.ntfy.update" },
|
||||
meta: { refused: "globally_disabled" },
|
||||
});
|
||||
return apiError(NTFY_GLOBALLY_DISABLED, 403);
|
||||
}
|
||||
|
||||
if (enabled && (!serverUrl || !topic)) {
|
||||
return apiError(
|
||||
"Server URL and topic are required when ntfy is enabled",
|
||||
|
||||
@@ -7,8 +7,17 @@ import { notificationChannelEnabledSchema } from "@/lib/validations/notification
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { isChannelGloballyEnabled } from "@/lib/app-settings";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
/**
|
||||
* Refusal for an enable attempt on a channel the operator switched off
|
||||
* instance-wide (`AppSettings.telegramGlobal`). A toggle that appeared to
|
||||
* work and then delivered nothing is worse than a refusal that says why.
|
||||
*/
|
||||
const TELEGRAM_GLOBALLY_DISABLED =
|
||||
"Telegram is disabled on this instance by the operator";
|
||||
|
||||
/**
|
||||
* Get Telegram notification settings for the current user.
|
||||
* Never returns the bot token in plaintext.
|
||||
@@ -117,6 +126,14 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const enabledOnly = notificationChannelEnabledSchema.safeParse(body);
|
||||
if (enabledOnly.success) {
|
||||
const { enabled } = enabledOnly.data;
|
||||
if (enabled && !(await isChannelGloballyEnabled("TELEGRAM"))) {
|
||||
annotate({
|
||||
action: { name: "settings.telegram.update" },
|
||||
meta: { refused: "globally_disabled" },
|
||||
});
|
||||
return apiError(TELEGRAM_GLOBALLY_DISABLED, 403);
|
||||
}
|
||||
|
||||
if (enabled && (!current.telegramBotToken || !current.telegramChatId)) {
|
||||
return apiError(
|
||||
"Bot token and chat ID are required when Telegram is enabled",
|
||||
@@ -166,6 +183,14 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const hasChatIdAfter =
|
||||
chatId !== undefined ? !!trimmedChatId : !!current.telegramChatId;
|
||||
|
||||
if (enabled && !(await isChannelGloballyEnabled("TELEGRAM"))) {
|
||||
annotate({
|
||||
action: { name: "settings.telegram.update" },
|
||||
meta: { refused: "globally_disabled" },
|
||||
});
|
||||
return apiError(TELEGRAM_GLOBALLY_DISABLED, 403);
|
||||
}
|
||||
|
||||
if (enabled && (!hasTokenAfter || !hasChatIdAfter)) {
|
||||
return apiError(
|
||||
"Bot token and chat ID are required when Telegram is enabled",
|
||||
|
||||
@@ -1155,3 +1155,29 @@ describe("Telegram webhook — interactive measurement reminder (v1.19.0)", () =
|
||||
expect(deleteMessage).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Telegram webhook — GET verification is rate-limited first", () => {
|
||||
// The reachability GET compares the same bot secret the POST does and
|
||||
// answers 200 against 401. Without the limit in front it is an unthrottled
|
||||
// oracle for guessing that secret, so a VALID secret over the limit must
|
||||
// still be refused — that ordering is the whole point.
|
||||
it("returns 429 for a valid secret when over the limit", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/telegram/webhook", {
|
||||
headers: { "x-telegram-bot-api-secret-token": "test-secret" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(429);
|
||||
expect(checkRateLimit).toHaveBeenCalledTimes(1);
|
||||
expect(String(vi.mocked(checkRateLimit).mock.calls[0][0])).toMatch(
|
||||
/^telegram-webhook:/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -32,17 +32,29 @@ function hasValidSecret(request: NextRequest): boolean {
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-source limit both verbs share, on one bucket. It runs before the
|
||||
* secret comparison on every verb that performs one: the comparison answers
|
||||
* 200 against 401, and a constant-time compare hides the timing channel but
|
||||
* not the status code, so the limiter is what makes guessing expensive.
|
||||
*/
|
||||
async function applyWebhookRateLimit(
|
||||
request: NextRequest,
|
||||
): Promise<NextResponse | null> {
|
||||
const ip = getClientIp(request);
|
||||
const rl = await checkRateLimit(`telegram-webhook:${ip}`, 120, 60 * 1000);
|
||||
if (rl.allowed) return null;
|
||||
return NextResponse.json(
|
||||
{ status: "rate_limited" },
|
||||
{ status: 429, headers: rateLimitHeaders(rl) },
|
||||
);
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "telegram.webhook" } });
|
||||
|
||||
const ip = getClientIp(request);
|
||||
const rl = await checkRateLimit(`telegram-webhook:${ip}`, 120, 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json(
|
||||
{ status: "rate_limited" },
|
||||
{ status: 429, headers: rateLimitHeaders(rl) },
|
||||
);
|
||||
}
|
||||
const limited = await applyWebhookRateLimit(request);
|
||||
if (limited) return limited;
|
||||
|
||||
if (!hasValidSecret(request)) {
|
||||
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
|
||||
@@ -84,6 +96,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "telegram.webhook.verify" } });
|
||||
|
||||
const limited = await applyWebhookRateLimit(request);
|
||||
if (limited) return limited;
|
||||
|
||||
if (!hasValidSecret(request)) {
|
||||
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -216,3 +216,64 @@ describe("GET /api/withings/webhook/[token]", () => {
|
||||
expect(bad.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verification verbs are rate-limited before the secret comparison", () => {
|
||||
// A verification verb answers 200 for the right token and 401 for a wrong
|
||||
// one. Without a limit in front that is a free oracle for guessing the
|
||||
// secret — constant-time comparison hides the timing channel, not the
|
||||
// status code. Handing each verb a VALID token while over the limit is the
|
||||
// sharp proof of ordering: the comparison would have said 200.
|
||||
function overTheLimit(): void {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
}
|
||||
|
||||
it("HEAD returns 429 for a valid token when over the limit", async () => {
|
||||
overTheLimit();
|
||||
const res = await HEAD(
|
||||
new NextRequest("http://localhost/api/withings/webhook/test-secret", {
|
||||
method: "HEAD",
|
||||
}),
|
||||
paramsFor("test-secret"),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(checkRateLimit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("GET returns 429 for a valid token when over the limit", async () => {
|
||||
overTheLimit();
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/withings/webhook/test-secret"),
|
||||
paramsFor("test-secret"),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(checkRateLimit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("HEAD and GET share the POST bucket", async () => {
|
||||
await HEAD(
|
||||
new NextRequest("http://localhost/api/withings/webhook/test-secret", {
|
||||
method: "HEAD",
|
||||
}),
|
||||
paramsFor("test-secret"),
|
||||
);
|
||||
await GET(
|
||||
new NextRequest("http://localhost/api/withings/webhook/test-secret"),
|
||||
paramsFor("test-secret"),
|
||||
);
|
||||
await POST(
|
||||
jsonRequest("test-secret", { userid: "wu-1" }),
|
||||
paramsFor("test-secret"),
|
||||
);
|
||||
|
||||
const buckets = vi
|
||||
.mocked(checkRateLimit)
|
||||
.mock.calls.map((call) => String(call[0]));
|
||||
expect(buckets).toHaveLength(3);
|
||||
expect(new Set(buckets).size).toBe(1);
|
||||
expect(buckets[0]).toMatch(/^withings-webhook:/);
|
||||
});
|
||||
});
|
||||
@@ -55,10 +55,23 @@ export const POST = apiHandler(
|
||||
/**
|
||||
* Withings sends HEAD/GET to verify the URL when a subscription is
|
||||
* created.
|
||||
*
|
||||
* The verification verbs carry the same rate limit as the notification POST,
|
||||
* on the same bucket. They compare the same shared secret and answer 200
|
||||
* against 401, so without it they are a free oracle for guessing the token —
|
||||
* a constant-time comparison hides the timing channel, not the status code.
|
||||
*/
|
||||
export const HEAD = apiHandler(
|
||||
async (_request: NextRequest, context: RouteContext) => {
|
||||
async (request: NextRequest, context: RouteContext) => {
|
||||
annotate({ action: { name: "withings.webhook.verify" } });
|
||||
|
||||
// HEAD carries no body, so the limiter's JSON envelope is re-shaped into
|
||||
// a bodyless 429 that keeps the rate-limit headers.
|
||||
const limited = await applyWebhookRateLimit(request);
|
||||
if (limited) {
|
||||
return new NextResponse(null, { status: 429, headers: limited.headers });
|
||||
}
|
||||
|
||||
const { token } = await context.params;
|
||||
if (!(await verifyTokenSegment(token))) {
|
||||
return new NextResponse(null, { status: 401 });
|
||||
@@ -69,8 +82,12 @@ export const HEAD = apiHandler(
|
||||
);
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, context: RouteContext) => {
|
||||
async (request: NextRequest, context: RouteContext) => {
|
||||
annotate({ action: { name: "withings.webhook.verify" } });
|
||||
|
||||
const limited = await applyWebhookRateLimit(request);
|
||||
if (limited) return limited;
|
||||
|
||||
const { token } = await context.params;
|
||||
if (!(await verifyTokenSegment(token))) {
|
||||
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
|
||||
|
||||
@@ -473,3 +473,53 @@ describe("GET /api/withings/webhook", () => {
|
||||
expect(fail.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy verification verbs are rate-limited before the secret comparison", () => {
|
||||
// Same reasoning as the path-segment sibling: a verb that answers 200 for
|
||||
// the right secret and 401 for a wrong one is a guessing oracle unless the
|
||||
// limiter runs first. A VALID secret over the limit must still be refused.
|
||||
function overTheLimit(): void {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
}
|
||||
|
||||
it("HEAD returns 429 for a valid secret when over the limit", async () => {
|
||||
overTheLimit();
|
||||
const res = await HEAD(
|
||||
new NextRequest("http://localhost/api/withings/webhook", {
|
||||
method: "HEAD",
|
||||
headers: { "x-withings-webhook-secret": "test-secret" },
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(checkRateLimit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("GET returns 429 for a valid secret when over the limit", async () => {
|
||||
overTheLimit();
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/withings/webhook", {
|
||||
headers: { "x-withings-webhook-secret": "test-secret" },
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(checkRateLimit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("the legacy ?secret= form is limited on the same bucket as the POST", async () => {
|
||||
overTheLimit();
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/withings/webhook?secret=WRONG"),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
|
||||
const buckets = vi
|
||||
.mocked(checkRateLimit)
|
||||
.mock.calls.map((call) => String(call[0]));
|
||||
expect(buckets).toHaveLength(1);
|
||||
expect(buckets[0]).toMatch(/^withings-webhook:/);
|
||||
});
|
||||
});
|
||||
@@ -91,9 +91,21 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
/**
|
||||
* Withings sends a HEAD request to verify the webhook URL.
|
||||
*
|
||||
* Rate-limited on the same bucket as the notification POST, and before the
|
||||
* comparison: HEAD and GET check the same shared secret and answer 200 against
|
||||
* 401, which is an oracle for guessing it unless the limiter runs first.
|
||||
*/
|
||||
export const HEAD = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "withings.webhook.verify" } });
|
||||
|
||||
// HEAD carries no body, so the limiter's JSON envelope is re-shaped into a
|
||||
// bodyless 429 that keeps the rate-limit headers.
|
||||
const limited = await applyWebhookRateLimit(request);
|
||||
if (limited) {
|
||||
return new NextResponse(null, { status: 429, headers: limited.headers });
|
||||
}
|
||||
|
||||
const auth = await hasValidWebhookSecret(request);
|
||||
if (!auth.ok) {
|
||||
return new NextResponse(null, { status: 401 });
|
||||
@@ -104,6 +116,10 @@ export const HEAD = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "withings.webhook.verify" } });
|
||||
|
||||
const limited = await applyWebhookRateLimit(request);
|
||||
if (limited) return limited;
|
||||
|
||||
const auth = await hasValidWebhookSecret(request);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
|
||||
|
||||
@@ -3,11 +3,27 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import de from "../../../../messages/de.json";
|
||||
import en from "../../../../messages/en.json";
|
||||
|
||||
const healthySchedule = {
|
||||
lastSuccessAt: "2026-09-01T03:00:00.000Z",
|
||||
lastSuccessAgeDays: 2,
|
||||
staleAfterDays: 10,
|
||||
stale: false,
|
||||
lastRun: {
|
||||
at: "2026-09-01T03:00:12.000Z",
|
||||
state: "completed" as const,
|
||||
error: null,
|
||||
},
|
||||
lastRunFailed: false,
|
||||
};
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
mutationOptions: [] as Array<{
|
||||
onSuccess?: (data: { summary: Record<string, number> }) => void;
|
||||
}>,
|
||||
toastSuccess: vi.fn(),
|
||||
// Mutable so one file can render both a healthy schedule and a broken one;
|
||||
// the query mock reads it on every render.
|
||||
queryData: { rows: [], retentionDays: 30 } as Record<string, unknown>,
|
||||
}));
|
||||
|
||||
/**
|
||||
@@ -34,7 +50,7 @@ vi.mock("sonner", () => ({
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: { rows: [], retentionDays: 30 },
|
||||
data: mocks.queryData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
@@ -69,6 +85,11 @@ import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { BackupsSection } from "../backups-section";
|
||||
|
||||
function render(locale: "en" | "de" = "en") {
|
||||
mocks.queryData = {
|
||||
rows: [],
|
||||
retentionDays: 30,
|
||||
schedule: healthySchedule,
|
||||
};
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<BackupsSection />
|
||||
@@ -129,6 +150,68 @@ describe("BackupsSection — empty state", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The gap that let a weekly backup stop for a month and a half without anyone
|
||||
* noticing: the page rendered whatever rows it had, and a copy from six weeks
|
||||
* ago is indistinguishable from Sunday's until somebody subtracts the dates.
|
||||
*/
|
||||
describe("BackupsSection — schedule health", () => {
|
||||
function withSchedule(schedule: Record<string, unknown>) {
|
||||
mocks.queryData = { rows: [], retentionDays: 30, schedule };
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<BackupsSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
it("stays quiet while the schedule is healthy", () => {
|
||||
const html = render();
|
||||
expect(html).not.toContain('data-slot="backup-schedule-notice"');
|
||||
});
|
||||
|
||||
it("says so when the newest scheduled copy has gone stale", () => {
|
||||
const html = withSchedule({
|
||||
...healthySchedule,
|
||||
lastSuccessAt: "2026-07-19T03:00:00.000Z",
|
||||
lastSuccessAgeDays: 46,
|
||||
stale: true,
|
||||
lastRun: null,
|
||||
lastRunFailed: false,
|
||||
});
|
||||
|
||||
expect(html).toContain('data-slot="backup-schedule-notice"');
|
||||
expect(html).toContain("The weekly backup has stopped");
|
||||
expect(html).toContain("46");
|
||||
});
|
||||
|
||||
it("names the reason when the last scheduled run failed", () => {
|
||||
const html = withSchedule({
|
||||
...healthySchedule,
|
||||
lastRun: {
|
||||
at: "2026-08-30T03:46:00.000Z",
|
||||
state: "failed",
|
||||
error: "job timed out",
|
||||
},
|
||||
lastRunFailed: true,
|
||||
});
|
||||
|
||||
expect(html).toContain("The last scheduled backup run failed");
|
||||
expect(html).toContain("job timed out");
|
||||
});
|
||||
|
||||
it("survives a response that predates the schedule field", () => {
|
||||
mocks.queryData = { rows: [], retentionDays: 30 };
|
||||
expect(() =>
|
||||
renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<BackupsSection />
|
||||
</I18nProvider>,
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BackupsSection — upload outcome", () => {
|
||||
it("includes health profile fact revisions in the restored record total", () => {
|
||||
mocks.toastSuccess.mockClear();
|
||||
|
||||
@@ -48,6 +48,7 @@ import { ListRow } from "@/components/ui/list-row";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { BackupRow, BackupsList } from "@/types/backups";
|
||||
import type { BackupScheduleStatus } from "@/lib/jobs/backup-schedule-status";
|
||||
import type {
|
||||
MissingBackupSection,
|
||||
RestoreSkipSummary,
|
||||
@@ -560,6 +561,71 @@ export function RestoreSkipReport({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Says out loud whether the SCHEDULE is still alive.
|
||||
*
|
||||
* The weekly pass on the maintainer's own instance stopped producing copies
|
||||
* and nothing surfaced it for a month and a half: the table listed the rows it
|
||||
* had, each with an ordinary timestamp, and a copy from six weeks ago reads
|
||||
* exactly like one made on Sunday until somebody does the subtraction. Two
|
||||
* independent facts are shown, because they fail at different times — the age
|
||||
* of the newest scheduled copy never ages out, while the run's own outcome
|
||||
* carries the reason ("job timed out") and survives only as long as pg-boss
|
||||
* keeps the row.
|
||||
*
|
||||
* Not dismissible: it reflects live state, so the only way to clear it is to
|
||||
* make a backup succeed.
|
||||
*/
|
||||
function ScheduleHealthNotice({
|
||||
schedule,
|
||||
}: {
|
||||
schedule: BackupScheduleStatus;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
if (!schedule.stale && !schedule.lastRunFailed) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="backup-schedule-notice"
|
||||
className="border-destructive/40 bg-destructive/10 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{schedule.stale
|
||||
? t("admin.section.backups.scheduleStaleTitle")
|
||||
: t("admin.section.backups.scheduleFailedTitle")}
|
||||
</p>
|
||||
{schedule.stale ? (
|
||||
<p className="text-xs">
|
||||
{t("admin.section.backups.scheduleStaleDescription", {
|
||||
days: schedule.lastSuccessAgeDays ?? 0,
|
||||
threshold: schedule.staleAfterDays,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{schedule.lastRunFailed && schedule.lastRun ? (
|
||||
<p className="text-xs">
|
||||
{t("admin.section.backups.scheduleFailedDescription", {
|
||||
when: fmt.dateTime(schedule.lastRun.at),
|
||||
reason:
|
||||
schedule.lastRun.error ||
|
||||
t("admin.section.backups.scheduleFailedNoReason"),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
{t("admin.section.backups.scheduleRemedy")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BackupsSection() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -833,6 +899,10 @@ export function BackupsSection() {
|
||||
}
|
||||
/>
|
||||
|
||||
{data?.schedule ? (
|
||||
<ScheduleHealthNotice schedule={data.schedule} />
|
||||
) : null}
|
||||
|
||||
{/* Upload card — separate from the table so admins can ingest a
|
||||
backup file independently of any existing rows. The visible
|
||||
button proxies a hidden file input so the layout stays clean
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* What the mood correlation card is allowed to claim.
|
||||
*
|
||||
* The card used to paint a "Strong" badge from any coefficient it was handed,
|
||||
* as long as five days had been paired. The coefficient itself now clears the
|
||||
* significance bar before it leaves the server, so the card's job here is the
|
||||
* other half: when the bar refuses, say which bar and how far off the reader
|
||||
* is, rather than falling back to the same "not enough paired data" blank that
|
||||
* a reader with no data at all sees.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
MoodCorrelationCards,
|
||||
type MoodMetricCorrelationData,
|
||||
} from "../mood-correlation-cards";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const NOTHING: MoodMetricCorrelationData = {
|
||||
result: null,
|
||||
points: [],
|
||||
n: 0,
|
||||
suppressed: "insufficientPairs",
|
||||
};
|
||||
|
||||
/** The sleep card's own markup — the four siblings carry their own copy. */
|
||||
function renderSleep(sleep: MoodMetricCorrelationData) {
|
||||
const html = render(
|
||||
<MoodCorrelationCards
|
||||
sleep={sleep}
|
||||
steps={NOTHING}
|
||||
pulse={NOTHING}
|
||||
weight={NOTHING}
|
||||
bloodPressureSystolic={NOTHING}
|
||||
/>,
|
||||
);
|
||||
const start = html.indexOf('data-kind="sleep"');
|
||||
const end = html.indexOf('data-kind="steps"');
|
||||
return html.slice(start, end);
|
||||
}
|
||||
|
||||
describe("<MoodCorrelationCards>", () => {
|
||||
it("shows no strength badge for a refused correlation", () => {
|
||||
const html = renderSleep({
|
||||
result: null,
|
||||
points: Array.from({ length: 5 }, (_, i) => ({ x: i, y: i })),
|
||||
n: 5,
|
||||
suppressed: "insufficientPairs",
|
||||
});
|
||||
|
||||
expect(html).not.toContain("Strong");
|
||||
expect(html).not.toContain("Moderate");
|
||||
});
|
||||
|
||||
it("names how many paired days are still missing instead of a bare blank", () => {
|
||||
const html = renderSleep({
|
||||
result: null,
|
||||
points: Array.from({ length: 5 }, (_, i) => ({ x: i, y: i })),
|
||||
n: 5,
|
||||
suppressed: "insufficientPairs",
|
||||
});
|
||||
|
||||
// The reader is told where they stand, not only that something is absent.
|
||||
expect(html).toContain("5 of 20");
|
||||
});
|
||||
|
||||
it("keeps the plain empty copy when nothing has been paired at all", () => {
|
||||
const html = renderSleep(NOTHING);
|
||||
|
||||
expect(html).toContain("Not enough paired data yet");
|
||||
expect(html).not.toContain("0 of 20");
|
||||
});
|
||||
|
||||
it("says a twenty-day pairing showed no reliable link", () => {
|
||||
const html = renderSleep({
|
||||
result: null,
|
||||
points: Array.from({ length: 20 }, (_, i) => ({ x: i, y: i })),
|
||||
n: 20,
|
||||
suppressed: "notSignificant",
|
||||
});
|
||||
|
||||
expect(html).toContain("No clear link");
|
||||
expect(html).not.toContain("Not enough paired data yet");
|
||||
expect(html).not.toContain("Weak");
|
||||
});
|
||||
|
||||
it("still shows the badge and the n · r caption for a correlation that cleared the bar", () => {
|
||||
const html = renderSleep({
|
||||
result: { r: 0.82, strength: "stark", n: 24 },
|
||||
points: Array.from({ length: 24 }, (_, i) => ({ x: i, y: i })),
|
||||
n: 24,
|
||||
});
|
||||
|
||||
expect(html).toContain("Strong");
|
||||
expect(html).toContain("24 paired days");
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,9 @@ import { MoodExplainerIcon } from "./mood-explainer-icon";
|
||||
* pulse) pair. The Pearson math comes pre-computed from
|
||||
* `/api/mood/insights` (single source of truth with the LLM snapshot).
|
||||
* Below-threshold pairs render an EmptyState rather than a misleading
|
||||
* near-zero scatter.
|
||||
* near-zero scatter — and the EmptyState names which threshold, because a
|
||||
* pairing that is still building and one that found nothing are different
|
||||
* answers and used to read as the same blank.
|
||||
*/
|
||||
|
||||
const ScatterCorrelationChart = dynamic(
|
||||
@@ -46,8 +48,17 @@ export interface MoodMetricCorrelationData {
|
||||
result: CorrelationResult | null;
|
||||
points: MoodCorrelationPoint[];
|
||||
n: number;
|
||||
/** Which bar the pairing fell short of, when there is no coefficient. */
|
||||
suppressed?: "insufficientPairs" | "notSignificant";
|
||||
}
|
||||
|
||||
/**
|
||||
* The paired-day floor the server's significance gate enforces. Repeated
|
||||
* here only to tell the reader how far off they are; the decision itself is
|
||||
* the server's and arrives as `suppressed`.
|
||||
*/
|
||||
const MIN_PAIRED_DAYS = 20;
|
||||
|
||||
type MetricKind = "sleep" | "steps" | "pulse" | "weight" | "bloodPressure";
|
||||
|
||||
const TITLE_KEY: Record<MetricKind, string> = {
|
||||
@@ -95,7 +106,33 @@ function MoodCorrelationCard({
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const hasResult = data.result != null && data.n >= 5;
|
||||
const hasResult = data.result != null;
|
||||
|
||||
// Three different silences, and the reader deserves to know which one they
|
||||
// are looking at: nothing paired yet, some days paired but not enough to
|
||||
// separate a pattern from chance, or enough days and no pattern in them.
|
||||
// The last one is a finding, not a gap, and used to render as the same
|
||||
// "not enough data" blank as the first.
|
||||
const emptyCopy =
|
||||
data.suppressed === "notSignificant"
|
||||
? {
|
||||
title: t("insights.mood.correlation.noLinkTitle"),
|
||||
description: t("insights.mood.correlation.noLinkDescription", {
|
||||
n: data.n,
|
||||
}),
|
||||
}
|
||||
: data.n > 0
|
||||
? {
|
||||
title: t("insights.mood.correlation.emptyTitle"),
|
||||
description: t("insights.mood.correlation.pendingDescription", {
|
||||
n: data.n,
|
||||
needed: MIN_PAIRED_DAYS,
|
||||
}),
|
||||
}
|
||||
: {
|
||||
title: t("insights.mood.correlation.emptyTitle"),
|
||||
description: t("insights.mood.correlation.emptyDescription"),
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -168,8 +205,8 @@ function MoodCorrelationCard({
|
||||
variant="plain"
|
||||
size="compact"
|
||||
icon={<Activity className="size-5" />}
|
||||
title={t("insights.mood.correlation.emptyTitle")}
|
||||
description={t("insights.mood.correlation.emptyDescription")}
|
||||
title={emptyCopy.title}
|
||||
description={emptyCopy.description}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -243,6 +243,28 @@ export function buildComplianceDisplay(
|
||||
* can be driven deterministically from a caller
|
||||
* that already pinned its own `now`.
|
||||
*/
|
||||
/**
|
||||
* Whether a medication expects any dose at all, and so has an adherence rate
|
||||
* worth showing.
|
||||
*
|
||||
* `calculateCompliance` below answers `rate: 100, totalExpected: 0` for a
|
||||
* medication with no schedule. That is arithmetically honest — nothing was
|
||||
* expected, so nothing was missed — and it is a lie on every surface that
|
||||
* reads the number as adherence. PRN is the obvious arm and every caller
|
||||
* already filtered it; the empty schedule on a NON-PRN medication is the arm
|
||||
* that got through, and it is reachable: `POST /api/medications` accepts
|
||||
* `schedules ?? []` on a scheduled medication. The doctor report has excluded
|
||||
* both arms since it was written, so the report never prints a fabricated
|
||||
* 100 %; the same reason applies to every other surface, so the two arms live
|
||||
* in one predicate rather than being re-derived at each call site.
|
||||
*/
|
||||
export function expectsDoses(medication: {
|
||||
asNeeded: boolean;
|
||||
schedules: readonly unknown[];
|
||||
}): boolean {
|
||||
return !medication.asNeeded && medication.schedules.length > 0;
|
||||
}
|
||||
|
||||
export function calculateCompliance(
|
||||
events: IntakeEvent[],
|
||||
schedules: ComplianceSchedule[],
|
||||
|
||||
@@ -42,6 +42,56 @@ export async function isApiGloballyEnabled(): Promise<boolean> {
|
||||
return settings.apiGlobal;
|
||||
}
|
||||
|
||||
/**
|
||||
* The notification channels an operator can switch off instance-wide, mapped
|
||||
* to the `AppSettings` column that carries the switch. A channel absent from
|
||||
* this map (APNs, generic webhook, email) has no instance-wide switch and is
|
||||
* never gated by it — the operator turns those off at their source (the APNs
|
||||
* credentials, the SMTP env) instead.
|
||||
*/
|
||||
const GLOBAL_CHANNEL_SWITCHES = {
|
||||
TELEGRAM: "telegramGlobal",
|
||||
NTFY: "ntfyGlobal",
|
||||
WEB_PUSH: "webPushGlobal",
|
||||
} as const;
|
||||
|
||||
export type GloballySwitchableChannel = keyof typeof GLOBAL_CHANNEL_SWITCHES;
|
||||
|
||||
export function hasGlobalChannelSwitch(
|
||||
channelType: string,
|
||||
): channelType is GloballySwitchableChannel {
|
||||
return Object.hasOwn(GLOBAL_CHANNEL_SWITCHES, channelType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one channel's instance-wide switch out of an availability snapshot
|
||||
* the caller already holds. Split from the async form so a dispatch that
|
||||
* walks several channels reads the settings row once.
|
||||
*/
|
||||
export function resolveChannelGloballyEnabled(
|
||||
availability: GlobalServiceAvailability,
|
||||
channelType: string,
|
||||
): boolean {
|
||||
if (!hasGlobalChannelSwitch(channelType)) return true;
|
||||
return availability[GLOBAL_CHANNEL_SWITCHES[channelType]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a notification channel may deliver on this instance. Same shape and
|
||||
* same posture as `isApiGloballyEnabled()`: the switch is the operator's and
|
||||
* sits above every per-user channel setting, and an unreadable settings row
|
||||
* resolves to "enabled" so a storage blip never silently mutes an instance.
|
||||
*/
|
||||
export async function isChannelGloballyEnabled(
|
||||
channelType: string,
|
||||
): Promise<boolean> {
|
||||
if (!hasGlobalChannelSwitch(channelType)) return true;
|
||||
return resolveChannelGloballyEnabled(
|
||||
await getGlobalServiceAvailability(),
|
||||
channelType,
|
||||
);
|
||||
}
|
||||
|
||||
export interface ReminderThresholds {
|
||||
lateMinutes: number;
|
||||
missedMinutes: number;
|
||||
|
||||
@@ -21,6 +21,7 @@ import { userDayKey } from "@/lib/tz/resolver";
|
||||
import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve";
|
||||
import {
|
||||
buildComplianceMedicationContext,
|
||||
expectsDoses,
|
||||
lastNonSkippedTakenAt,
|
||||
tallyComplianceFromLedger,
|
||||
} from "@/lib/analytics/compliance";
|
||||
@@ -363,9 +364,9 @@ export function buildLedgerCompliance(
|
||||
const compliance: Record<string, DoctorReportCompliance> = {};
|
||||
for (const med of medications) {
|
||||
// PRN / as-needed carry no schedule and no expected dose — excluded so
|
||||
// the report never prints a fabricated 100 %.
|
||||
if (med.asNeeded) continue;
|
||||
if (med.schedules.length === 0) continue;
|
||||
// the report never prints a fabricated 100 %. Both arms now live in the
|
||||
// shared predicate, which is what the other adherence surfaces read.
|
||||
if (!expectsDoses(med)) continue;
|
||||
|
||||
const events = eventsByMedId.get(med.id) ?? [];
|
||||
const ctx = buildComplianceMedicationContext(
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* The stored-backup envelope: compress, then encrypt.
|
||||
*
|
||||
* The weekly worker used to hand `encrypt(JSON.stringify(payload))` straight to
|
||||
* Postgres. For an account with a few hundred thousand measurements that means
|
||||
* the object graph, the JSON string, the base64 ciphertext (1.33× the JSON) and
|
||||
* the copy the database driver makes of it all alive at once — well over a
|
||||
* gigabyte for a record whose JSON is a few hundred megabytes. The run died of
|
||||
* memory, not of slow SQL.
|
||||
*
|
||||
* Compressing before the cipher is what makes the tail of that pipeline cheap:
|
||||
* health JSON is extremely repetitive, so everything downstream of the gzip
|
||||
* shrinks by an order of magnitude. The envelope has to stay readable both
|
||||
* ways — rows written before this change are plain `encrypt(json)` and must
|
||||
* keep restoring.
|
||||
*/
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { packBackupBlob, unpackBackupBlob } from "../backup-blob";
|
||||
|
||||
/** A record-shaped string: many rows, few distinct keys. */
|
||||
function sampleJson(rows: number): string {
|
||||
return JSON.stringify({
|
||||
schemaVersion: "2",
|
||||
measurements: Array.from({ length: rows }, (_, i) => ({
|
||||
id: `measurement-${i}`,
|
||||
type: "PULSE",
|
||||
value: 60 + (i % 30),
|
||||
unit: "bpm",
|
||||
measuredAt: new Date(Date.UTC(2026, 0, 1, 0, i % 60)).toISOString(),
|
||||
source: "APPLE_HEALTH",
|
||||
deletedAt: null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
describe("backup blob envelope", () => {
|
||||
it("round-trips the exact JSON it was handed", () => {
|
||||
const json = sampleJson(200);
|
||||
expect(unpackBackupBlob(packBackupBlob(json))).toBe(json);
|
||||
});
|
||||
|
||||
it("stores a record-shaped payload far smaller than the plaintext", () => {
|
||||
const json = sampleJson(5_000);
|
||||
const packed = packBackupBlob(json);
|
||||
// The old envelope was 1.33× the JSON. Anything near that means the
|
||||
// compression leg is not running.
|
||||
expect(packed.length).toBeLessThan(json.length / 4);
|
||||
});
|
||||
|
||||
it("still reads a row written before the envelope existed", () => {
|
||||
const json = sampleJson(10);
|
||||
expect(unpackBackupBlob(encrypt(json))).toBe(json);
|
||||
});
|
||||
|
||||
it("fails closed on a corrupt blob rather than returning junk", () => {
|
||||
const packed = packBackupBlob(sampleJson(10));
|
||||
const mangled = `${packed.slice(0, -8)}AAAAAAAA`;
|
||||
expect(() => unpackBackupBlob(mangled)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { encryptNoteToBytes } from "@/lib/labs/store";
|
||||
import { encryptContextToBytes } from "@/lib/labs/biomarker-store";
|
||||
import { encryptDocumentSummary } from "@/lib/documents/store";
|
||||
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
|
||||
import {
|
||||
buildRecordsBackupSection,
|
||||
countRecordsBackupSection,
|
||||
@@ -450,10 +451,14 @@ describe("buildRecordsBackupSection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("fails soft (null, not a throw) on an undecryptable note", async () => {
|
||||
it("marks an undecryptable note (not a throw, and not a silent null)", async () => {
|
||||
const prisma = buildMockPrisma();
|
||||
// Corrupt the ciphertext so decrypt throws internally — the row must
|
||||
// still come back with `note: null`, never abort the whole backup.
|
||||
// Corrupt the ciphertext so decrypt throws internally. The row must still
|
||||
// come back and must never abort the whole backup — that part is
|
||||
// unchanged. What it must NOT come back as is `null`, which this test used
|
||||
// to require: in an export file a null note is byte-identical to a note
|
||||
// that was never written, so the restore writes nothing and the loss is
|
||||
// invisible. It comes back as a marker instead.
|
||||
prisma.labResult.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
panel: null,
|
||||
@@ -471,6 +476,6 @@ describe("buildRecordsBackupSection", () => {
|
||||
]);
|
||||
|
||||
const section = await buildRecordsBackupSection(prisma as never, USER_ID);
|
||||
expect(section.labResults[0].note).toBeNull();
|
||||
expect(section.labResults[0].note).toBe(UNREADABLE_EXPORT_MARKER);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* The envelope a `DataBackup.data` row is stored in: compress, then encrypt.
|
||||
*
|
||||
* Why it exists. The weekly worker handed `encrypt(JSON.stringify(payload))`
|
||||
* straight to Postgres. For an account with a few hundred thousand
|
||||
* measurements that is four full copies of the record alive at the same
|
||||
* moment: the payload object graph, the JSON string, the base64 ciphertext at
|
||||
* 1.33× the JSON, and the copy the database driver makes of that parameter on
|
||||
* its way to the wire. Measured on a seeded 445 000-measurement account, the
|
||||
* JSON alone is 242 MB and the run dies of heap exhaustion — under a 1 GB heap
|
||||
* it never reaches the insert, and on a bigger heap it only reaches it later.
|
||||
* The weekly pass was not slow; it could not finish.
|
||||
*
|
||||
* Compressing first is what makes the whole tail of that pipeline cheap. A
|
||||
* health record's JSON is extremely repetitive — the same twenty keys per row,
|
||||
* timestamps sharing a prefix — so gzip takes that 242 MB to 14 MB, and every
|
||||
* copy after it shrinks with it. The same run then completes in about eight
|
||||
* seconds with a peak heap of 434 MB. It is also the reason the stored row
|
||||
* stops being a liability of its own: the backup lives INSIDE the database it
|
||||
* would be needed to restore, so its size is not a cosmetic concern.
|
||||
*
|
||||
* Both directions. Rows written before this envelope existed are plain
|
||||
* `encrypt(json)` and have to keep restoring — an operator whose newest usable
|
||||
* copy predates the fix is exactly the person who needs it to work. The marker
|
||||
* sits inside the ciphertext rather than in front of it so the stored column
|
||||
* keeps the one shape `decrypt()` already understands, key ids and all.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
import { gunzipSync, gzipSync } from "node:zlib";
|
||||
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
|
||||
/**
|
||||
* Prefix of the DECRYPTED plaintext when the body is gzipped-then-base64'd.
|
||||
* Chosen so it can never be mistaken for the alternative: a plain payload is
|
||||
* always a JSON object and therefore always starts with `{`.
|
||||
*/
|
||||
const GZIP_MARKER = "HLZ1:";
|
||||
|
||||
/** Serialised backup JSON → the string stored in `DataBackup.data`. */
|
||||
export function packBackupBlob(json: string): string {
|
||||
const compressed = gzipSync(json).toString("base64");
|
||||
return encrypt(`${GZIP_MARKER}${compressed}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored `DataBackup.data` string → the backup JSON.
|
||||
*
|
||||
* Fails closed on every arm: a bad key, a mangled ciphertext or a truncated
|
||||
* gzip member throws rather than returning a partial document, because every
|
||||
* caller goes on to parse the result as a whole account.
|
||||
*/
|
||||
export function unpackBackupBlob(stored: string): string {
|
||||
const plaintext = decrypt(stored);
|
||||
if (!plaintext.startsWith(GZIP_MARKER)) return plaintext;
|
||||
return gunzipSync(
|
||||
Buffer.from(plaintext.slice(GZIP_MARKER.length), "base64"),
|
||||
).toString("utf8");
|
||||
}
|
||||
@@ -53,6 +53,7 @@
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { UNREADABLE_EXPORT_MARKER } from "./unreadable-marker";
|
||||
import {
|
||||
recordUnknownKeys,
|
||||
type RestoreSkipLog,
|
||||
@@ -169,7 +170,7 @@ function decryptTurnSoft(bytes: Uint8Array | null): string | null {
|
||||
try {
|
||||
return decryptFromBytes(bytes);
|
||||
} catch {
|
||||
return "[unreadable: encrypted with a key this instance no longer holds]";
|
||||
return UNREADABLE_EXPORT_MARKER;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -316,6 +316,26 @@ function serialiseMoodContext(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The same payload, already serialised, with the object graph released.
|
||||
*
|
||||
* Its own frame on purpose, and both backup jobs go through it. The payload
|
||||
* graph is the largest thing either job allocates — on an account with a few
|
||||
* hundred thousand measurements it is several hundred megabytes of small
|
||||
* objects — and neither job wants anything but the string. Returning from here
|
||||
* makes the graph unreachable before the compress-and-encrypt step allocates
|
||||
* anything of its own, which is part of why the weekly pass now fits in
|
||||
* memory. Inlining these two statements back into a caller quietly undoes it.
|
||||
*/
|
||||
export async function buildFullBackupJson(
|
||||
prisma: PrismaClient,
|
||||
userId: string,
|
||||
options: FullBackupOptions = {},
|
||||
): Promise<string> {
|
||||
const { payload } = await buildFullBackupPayload(prisma, userId, options);
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the canonical full-backup payload for `userId`. Portable exports omit
|
||||
* tombstones and document ciphertext; disaster-recovery payloads preserve
|
||||
|
||||
@@ -7,14 +7,16 @@
|
||||
* verbatim. The DR path never decrypts document content or summaries.
|
||||
*
|
||||
* Every read is scoped to the owner. Soft-deleted rows are excluded so a
|
||||
* restore cannot resurrect tombstones. Other encrypted free-text fields keep
|
||||
* the established fail-soft portable representation and are re-encrypted by
|
||||
* the restore importer.
|
||||
* restore cannot resurrect tombstones. Other encrypted free-text fields are
|
||||
* carried as plaintext and re-encrypted by the restore importer; one this
|
||||
* instance cannot decrypt is carried as a visible marker rather than as
|
||||
* nothing (see `readForExport` below).
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import { decryptNoteSoft } from "@/lib/labs/store";
|
||||
import { decryptContextSoft } from "@/lib/labs/biomarker-store";
|
||||
import { decryptNoteFromBytes } from "@/lib/labs/store";
|
||||
import { decryptContextFromBytes } from "@/lib/labs/biomarker-store";
|
||||
import { UNREADABLE_EXPORT_MARKER } from "./unreadable-marker";
|
||||
import { decryptDocumentSummary } from "@/lib/documents/store";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import {
|
||||
@@ -44,6 +46,39 @@ export const WORKOUTS_MANIFEST_NOTE =
|
||||
"steps) are included. GPS routes and per-sample heart-rate/pace time " +
|
||||
"series are not included in this export.";
|
||||
|
||||
/**
|
||||
* Decrypt an encrypted free-text column for a portable export, or say plainly
|
||||
* that this one could not be read.
|
||||
*
|
||||
* A fail-soft decrypt answers `null` on a wrong or dropped key. In a list view
|
||||
* that is right — one bad row must not take the page down. In an export it is
|
||||
* not: `null` is exactly what a column that was never written looks like, so
|
||||
* the restore importer writes nothing and a note the person did write
|
||||
* disappears without anyone being told. The Coach transcript and the doctor
|
||||
* report both refuse that trade, one with a placeholder sentence and one with
|
||||
* a rendered flag; this follows the same rule.
|
||||
*
|
||||
* The warning the soft helpers emitted is kept, so a systemic key gap still
|
||||
* shows up in the wide event rather than only in the file.
|
||||
*/
|
||||
function readForExport(
|
||||
buf: Uint8Array | null,
|
||||
label: string,
|
||||
decode: (bytes: Uint8Array) => string,
|
||||
): string | null {
|
||||
if (!buf || buf.byteLength === 0) return null;
|
||||
try {
|
||||
return decode(buf);
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`${label} decrypt failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
return UNREADABLE_EXPORT_MARKER;
|
||||
}
|
||||
}
|
||||
|
||||
export interface LabResultBackupEntry {
|
||||
/** Present in canonical DR payloads so rows keep a stable identity. */
|
||||
id?: string;
|
||||
@@ -339,7 +374,13 @@ export async function buildRecordsBackupSection(
|
||||
deletedAt: r.deletedAt?.toISOString() ?? null,
|
||||
note: null,
|
||||
}
|
||||
: { note: decryptNoteSoft(r.noteEncrypted) }),
|
||||
: {
|
||||
note: readForExport(
|
||||
r.noteEncrypted,
|
||||
"lab note",
|
||||
decryptNoteFromBytes,
|
||||
),
|
||||
}),
|
||||
panel: r.panel,
|
||||
analyte: r.analyte,
|
||||
value: r.value,
|
||||
@@ -369,7 +410,11 @@ export async function buildRecordsBackupSection(
|
||||
upperBound: b.upperBound,
|
||||
panel: b.panel,
|
||||
hidden: b.hidden,
|
||||
context: decryptContextSoft(b.contextEncrypted),
|
||||
context: readForExport(
|
||||
b.contextEncrypted,
|
||||
"biomarker context",
|
||||
decryptContextFromBytes,
|
||||
),
|
||||
}));
|
||||
|
||||
const illnessEpisodes: CanonicalIllnessEpisode[] = disasterRecovery
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* What an export writes where a row could not be decrypted.
|
||||
*
|
||||
* A portable export carries plaintext, so an encrypted column that this
|
||||
* instance can no longer read has to become something. The fail-soft decrypt
|
||||
* helpers answer `null`, which in an export file is indistinguishable from a
|
||||
* column that was never written — and a restore then writes nothing, so the
|
||||
* loss is complete and silent. It is not: the ciphertext was there, and the
|
||||
* person is entitled to know a key gap ate it rather than to find a note they
|
||||
* remember writing simply gone.
|
||||
*
|
||||
* The marker is a sentence, not an empty string, for the same reason the Coach
|
||||
* transcript's placeholder is: an empty string reads as "there was nothing
|
||||
* here", and that is not what happened. It survives a round trip through the
|
||||
* restore importer like any other text, so the gap stays visible in the
|
||||
* restored account instead of closing over.
|
||||
*/
|
||||
export const UNREADABLE_EXPORT_MARKER =
|
||||
"[unreadable: encrypted with a key this instance no longer holds]";
|
||||
@@ -177,8 +177,13 @@ function adherenceObservations(
|
||||
for (const [name, comp] of Object.entries(data.compliance)) {
|
||||
if (comp.total <= 0) continue;
|
||||
// Integer percent — the one canonical rounding the app card + PDF use, so
|
||||
// a clinician sees the same adherence figure on every surface.
|
||||
const rate = adherenceRatePercent(comp.taken, comp.total) ?? 0;
|
||||
// a clinician sees the same adherence figure on every surface. The helper
|
||||
// returns null exactly when nothing was expected, and a `?? 0` here would
|
||||
// put a clinical claim of 0 % adherence into an exported bundle in that
|
||||
// case. The `total <= 0` guard above already means it cannot happen; skip
|
||||
// rather than coalesce so it stays impossible if that guard ever moves.
|
||||
const rate = adherenceRatePercent(comp.taken, comp.total);
|
||||
if (rate === null) continue;
|
||||
observations.push({
|
||||
resourceType: "Observation",
|
||||
id: nextId(),
|
||||
|
||||
@@ -260,3 +260,76 @@ describe("generateBloodPressureStatusForUser — token-leak hardening (v1.4.27 F
|
||||
expect(parsed.text).not.toContain("metric:");
|
||||
});
|
||||
});
|
||||
|
||||
// The BP-status gate reads its medications' adherence to decide what to say
|
||||
// about a reading. `calculateCompliance` answers `rate: 100, totalExpected: 0`
|
||||
// for a medication with no schedule, so a blood-pressure medication saved
|
||||
// without one told the model the person was perfectly adherent — the exact
|
||||
// fabrication the doctor report has excluded since it was written.
|
||||
describe("generateBloodPressureStatusForUser — no rate without an expectation", () => {
|
||||
const SCHEDULE = {
|
||||
id: "sch-1",
|
||||
windowStart: "07:00",
|
||||
windowEnd: "08:00",
|
||||
timesOfDay: ["07:00"],
|
||||
daysOfWeek: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
reminderGraceMinutes: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: [],
|
||||
};
|
||||
|
||||
function bpMed(id: string, name: string, schedules: unknown[]) {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
dose: "5mg",
|
||||
active: true,
|
||||
asNeeded: false,
|
||||
createdAt: new Date(Date.now() - 60 * dayMs),
|
||||
schedules,
|
||||
scheduleRevisions: [],
|
||||
pauseEras: [],
|
||||
};
|
||||
}
|
||||
|
||||
it("omits a blood-pressure medication that expects no dose", async () => {
|
||||
const now = new Date();
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dateOfBirth: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ type: "BLOOD_PRESSURE_SYS", value: 132, measuredAt: now },
|
||||
{ type: "BLOOD_PRESSURE_DIA", value: 84, measuredAt: now },
|
||||
] as never);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
bpMed("med-empty", "Unscheduled", []),
|
||||
bpMed("med-real", "Amlodipine", [SCHEDULE]),
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({
|
||||
createdAt: new Date(),
|
||||
} as never);
|
||||
vi.mocked(getMedicationCategories).mockResolvedValue({
|
||||
"med-empty": "BLOOD_PRESSURE",
|
||||
"med-real": "BLOOD_PRESSURE",
|
||||
});
|
||||
|
||||
const captured: { userPrompt: string | null } = { userPrompt: null };
|
||||
stubCompletion('{"summary":"OK"}', captured);
|
||||
|
||||
await generateBloodPressureStatusForUser("user-1", { locale: "en" });
|
||||
|
||||
const snapshot = JSON.parse(captured.userPrompt!.match(/\{[\s\S]*\}/)![0]);
|
||||
const names = snapshot.bpMedications.map((m: { name: string }) => m.name);
|
||||
expect(names).not.toContain("Unscheduled");
|
||||
expect(names).toContain("Amlodipine");
|
||||
});
|
||||
});
|
||||
@@ -109,6 +109,7 @@ import {
|
||||
canonicalPatternKey,
|
||||
isMaterialEvidenceChange,
|
||||
PATTERN_FAMILIES,
|
||||
shouldReleaseDismissal,
|
||||
syncAcceptedPatterns,
|
||||
} from "@/lib/insights/correlation-patterns";
|
||||
|
||||
@@ -201,3 +202,137 @@ describe("correlation pattern identity and dismissal", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `dismissedEvidenceHash` was written on every dismissal and read by nothing:
|
||||
* the gate compared effect size and sample size only. These tests pin it as a
|
||||
* reader, in both directions.
|
||||
*/
|
||||
describe("dismissal release against the stored evidence hash", () => {
|
||||
const DISMISSED_AT = new Date("2026-07-02T00:00:00.000Z");
|
||||
|
||||
it("holds a dismissal whose evidence has not moved at all", () => {
|
||||
expect(
|
||||
shouldReleaseDismissal({
|
||||
dismissedAt: DISMISSED_AT,
|
||||
dismissedEvidenceHash: "a".repeat(64),
|
||||
dismissedEffectSize: 0.3,
|
||||
dismissedSampleSize: 40,
|
||||
currentEvidenceHash: "a".repeat(64),
|
||||
currentEffectSize: 0.3,
|
||||
currentSampleSize: 40,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("releases a dismissal whose evidence moved materially", () => {
|
||||
expect(
|
||||
shouldReleaseDismissal({
|
||||
dismissedAt: DISMISSED_AT,
|
||||
dismissedEvidenceHash: "a".repeat(64),
|
||||
dismissedEffectSize: 0.3,
|
||||
dismissedSampleSize: 40,
|
||||
currentEvidenceHash: "b".repeat(64),
|
||||
currentEffectSize: -0.2,
|
||||
currentSampleSize: 40,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("holds a dismissal whose evidence moved only within the noise band", () => {
|
||||
expect(
|
||||
shouldReleaseDismissal({
|
||||
dismissedAt: DISMISSED_AT,
|
||||
dismissedEvidenceHash: "a".repeat(64),
|
||||
dismissedEffectSize: 0.3,
|
||||
dismissedSampleSize: 40,
|
||||
currentEvidenceHash: "b".repeat(64),
|
||||
currentEffectSize: 0.35,
|
||||
currentSampleSize: 45,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("releases a snapshot that has only a hash once that hash no longer matches", () => {
|
||||
expect(
|
||||
shouldReleaseDismissal({
|
||||
dismissedAt: DISMISSED_AT,
|
||||
dismissedEvidenceHash: "a".repeat(64),
|
||||
dismissedEffectSize: null,
|
||||
dismissedSampleSize: null,
|
||||
currentEvidenceHash: "b".repeat(64),
|
||||
currentEffectSize: 0.3,
|
||||
currentSampleSize: 40,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("holds a snapshot that has only a hash while that hash still matches", () => {
|
||||
expect(
|
||||
shouldReleaseDismissal({
|
||||
dismissedAt: DISMISSED_AT,
|
||||
dismissedEvidenceHash: "a".repeat(64),
|
||||
dismissedEffectSize: null,
|
||||
dismissedSampleSize: null,
|
||||
currentEvidenceHash: "a".repeat(64),
|
||||
currentEffectSize: 0.3,
|
||||
currentSampleSize: 40,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("holds a pattern that was never dismissed", () => {
|
||||
expect(
|
||||
shouldReleaseDismissal({
|
||||
dismissedAt: null,
|
||||
dismissedEvidenceHash: null,
|
||||
dismissedEffectSize: null,
|
||||
dismissedSampleSize: null,
|
||||
currentEvidenceHash: "b".repeat(64),
|
||||
currentEffectSize: 0.9,
|
||||
currentSampleSize: 400,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncAcceptedPatterns honours the stored hash", () => {
|
||||
beforeEach(() => {
|
||||
rows.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("resurfaces a hash-only dismissal on the next run and leaves an unchanged one dismissed", async () => {
|
||||
const first = await syncAcceptedPatterns({
|
||||
userId: "owner-2",
|
||||
family: PATTERN_FAMILIES.moodTagCrosstab,
|
||||
accepted: [baseEvidence],
|
||||
computedAt: new Date("2026-07-01T00:00:00.000Z"),
|
||||
});
|
||||
const canonicalKey = [...first.values()][0].canonicalKey;
|
||||
const row = [...rows.values()][0];
|
||||
|
||||
// A dismissal snapshot carrying the hash and nothing else — the shape a
|
||||
// partial restore leaves behind. It used to be frozen dismissed forever.
|
||||
row.dismissedAt = new Date("2026-07-02T00:00:00.000Z");
|
||||
row.dismissedEvidenceHash = row.evidenceHash;
|
||||
row.dismissedEffectSize = null;
|
||||
row.dismissedSampleSize = null;
|
||||
|
||||
const unchanged = await syncAcceptedPatterns({
|
||||
userId: "owner-2",
|
||||
family: PATTERN_FAMILIES.moodTagCrosstab,
|
||||
accepted: [baseEvidence],
|
||||
});
|
||||
expect(unchanged.get(canonicalKey)?.dismissed).toBe(true);
|
||||
|
||||
const moved = await syncAcceptedPatterns({
|
||||
userId: "owner-2",
|
||||
family: PATTERN_FAMILIES.moodTagCrosstab,
|
||||
accepted: [{ ...baseEvidence, pValue: 0.002 }],
|
||||
});
|
||||
expect(moved.get(canonicalKey)?.dismissed).toBe(false);
|
||||
expect(row.dismissedAt).toBeNull();
|
||||
expect(row.dismissedEvidenceHash).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -360,3 +360,85 @@ describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1
|
||||
expect(parsed.medications[0].text).not.toContain("metric:");
|
||||
});
|
||||
});
|
||||
|
||||
// A medication with no schedule expects no dose. `calculateCompliance` answers
|
||||
// `rate: 100, totalExpected: 0` for that case, and this surface averaged that
|
||||
// 100 into `overall.averageCompliance30` and handed it to the model as the
|
||||
// person's adherence. The doctor report has excluded the empty schedule
|
||||
// alongside PRN since it was written — "so the report never prints a
|
||||
// fabricated 100 %" — and the same reason applies wherever a rate is shown.
|
||||
describe("generateMedicationComplianceStatusForUser — no rate without an expectation", () => {
|
||||
function scheduleless(now: Date) {
|
||||
return {
|
||||
id: "med-empty",
|
||||
name: "Unscheduled",
|
||||
dose: "5mg",
|
||||
active: true,
|
||||
asNeeded: false,
|
||||
createdAt: new Date(now.getTime() - 60 * dayMs),
|
||||
schedules: [],
|
||||
scheduleRevisions: [],
|
||||
pauseEras: [],
|
||||
};
|
||||
}
|
||||
|
||||
it("keeps a schedule-less medication out of the snapshot and its average", async () => {
|
||||
const now = new Date();
|
||||
const scheduled = { ...medFixture(now), asNeeded: false };
|
||||
const events = Array.from({ length: 30 }, (_, day) => {
|
||||
const scheduledFor = new Date(now.getTime() - day * dayMs);
|
||||
// Half the doses taken, so the real medication sits well below 100 %.
|
||||
return {
|
||||
medicationId: "med-1",
|
||||
scheduledFor,
|
||||
takenAt: day % 2 === 0 ? scheduledFor : null,
|
||||
skipped: false,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
scheduled,
|
||||
scheduleless(now),
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
events as never,
|
||||
);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({
|
||||
createdAt: new Date(),
|
||||
} as never);
|
||||
|
||||
const captured: { userPrompt: string | null } = { userPrompt: null };
|
||||
stubCompletion('{"summary":"OK","medications":[]}', captured);
|
||||
|
||||
await generateMedicationComplianceStatusForUser("user-1", { locale: "en" });
|
||||
|
||||
const snapshot = JSON.parse(captured.userPrompt!.match(/\{[\s\S]*\}/)![0]);
|
||||
const names = snapshot.medications.map((m: { name: string }) => m.name);
|
||||
expect(names).not.toContain("Unscheduled");
|
||||
expect(names).toContain("Ramipril");
|
||||
expect(snapshot.overall.medicationCount).toBe(1);
|
||||
// The fabricated 100 is no longer half of the average handed to the model.
|
||||
expect(snapshot.overall.averageCompliance30).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it("reports no active medications when the only one has no schedule", async () => {
|
||||
const now = new Date();
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
scheduleless(now),
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
|
||||
const outcome = (await generateMedicationComplianceStatusForUser("user-1", {
|
||||
locale: "en",
|
||||
})) as { summary: string | null; medications: unknown[] };
|
||||
|
||||
expect(outcome.medications).toEqual([]);
|
||||
expect(outcome.summary).toContain("no active medications");
|
||||
// Nothing to assess, so no model call and no cache row either.
|
||||
expect(runStatusCompletion).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -292,25 +292,43 @@ describe("computeHeatmapCells", () => {
|
||||
|
||||
describe("computeMoodMetricCorrelation", () => {
|
||||
it("pairs on dayOffset and returns scatter points + a coefficient", () => {
|
||||
const moodDaily = [
|
||||
{ dayOffset: 0, value: 5 },
|
||||
{ dayOffset: 1, value: 4 },
|
||||
{ dayOffset: 2, value: 3 },
|
||||
{ dayOffset: 3, value: 2 },
|
||||
{ dayOffset: 4, value: 1 },
|
||||
];
|
||||
// Twenty days, not five. This test used to run on five and assert a
|
||||
// coefficient came back, which pinned the very thing the significance
|
||||
// gate exists to refuse: five points that happen to line up are not a
|
||||
// finding. The pairing and the scatter projection are what it is asking
|
||||
// about, and they are unchanged — it just has to ask above the bar.
|
||||
const moodDaily = Array.from({ length: 20 }, (_, i) => ({
|
||||
dayOffset: i,
|
||||
value: 5 - (i % 5),
|
||||
}));
|
||||
// Perfectly positively correlated metric.
|
||||
const metricDaily = moodDaily.map((b) => ({
|
||||
dayOffset: b.dayOffset,
|
||||
value: b.value * 10,
|
||||
}));
|
||||
const corr = computeMoodMetricCorrelation(moodDaily, metricDaily, NOW);
|
||||
expect(corr.n).toBe(5);
|
||||
expect(corr.points).toHaveLength(5);
|
||||
expect(corr.n).toBe(20);
|
||||
expect(corr.points).toHaveLength(20);
|
||||
expect(corr.result?.r).toBe(1);
|
||||
expect(corr.suppressed).toBeUndefined();
|
||||
expect(corr.points[0]).toEqual({ x: 5, y: 50 });
|
||||
});
|
||||
|
||||
it("refuses the same shape at five days", () => {
|
||||
const moodDaily = Array.from({ length: 5 }, (_, i) => ({
|
||||
dayOffset: i,
|
||||
value: 5 - i,
|
||||
}));
|
||||
const metricDaily = moodDaily.map((b) => ({
|
||||
dayOffset: b.dayOffset,
|
||||
value: b.value * 10,
|
||||
}));
|
||||
const corr = computeMoodMetricCorrelation(moodDaily, metricDaily, NOW);
|
||||
expect(corr.n).toBe(5);
|
||||
expect(corr.result).toBeNull();
|
||||
expect(corr.suppressed).toBe("insufficientPairs");
|
||||
});
|
||||
|
||||
it("returns a null coefficient below the minimum pair count", () => {
|
||||
const moodDaily = [
|
||||
{ dayOffset: 0, value: 5 },
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* The mood page's cross-metric correlations clear the same bar as every
|
||||
* other user-facing correlation surface.
|
||||
*
|
||||
* `significantPearsonCorrelation` was written for exactly this failure — its
|
||||
* own comment names a five-point r ≈ 0.7 fluke rendering as "stark" — and the
|
||||
* weight, blood-pressure and mood-status surfaces were moved onto it. The
|
||||
* mood page stayed on the plain `pearsonCorrelation` with its floor of five
|
||||
* pairs and no significance test, so the same five points that the other
|
||||
* surfaces refuse still painted a "strong" badge here.
|
||||
*
|
||||
* These assertions are about what the reader is told, not about which helper
|
||||
* is called: below the bar there is no coefficient to show, and the card is
|
||||
* handed a reason it can name instead of a blank.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
computeMoodMetricCorrelation,
|
||||
type DailyPoint,
|
||||
} from "@/lib/insights/mood-aggregates";
|
||||
|
||||
const NOW = new Date("2026-06-01T12:00:00.000Z");
|
||||
|
||||
/** One point per day-offset, values taken from the list in order. */
|
||||
function series(values: number[]): DailyPoint[] {
|
||||
return values.map((value, i) => ({ dayOffset: i, value }));
|
||||
}
|
||||
|
||||
describe("computeMoodMetricCorrelation — significance gate", () => {
|
||||
it("refuses a five-day strong-looking slope and says the pairs are too few", () => {
|
||||
const mood = series([1, 2, 3, 4, 5]);
|
||||
const metric = series([1, 2, 2, 4, 3]);
|
||||
|
||||
const out = computeMoodMetricCorrelation(mood, metric, NOW);
|
||||
|
||||
expect(out.n).toBe(5);
|
||||
// No coefficient reaches the card, so no strength band can be painted.
|
||||
expect(out.result).toBeNull();
|
||||
// ... and the card is told WHY, so it can say so rather than showing an
|
||||
// unexplained blank.
|
||||
expect(out.suppressed).toBe("insufficientPairs");
|
||||
});
|
||||
|
||||
it("refuses twenty noisy days as not significant rather than as too few", () => {
|
||||
const ys = [5, 2, 7, 1, 6, 3, 8, 2, 5, 4, 6, 1, 7, 3, 5, 2, 6, 4, 5, 3];
|
||||
const mood = series(ys.map((_, i) => i));
|
||||
const metric = series(ys);
|
||||
|
||||
const out = computeMoodMetricCorrelation(mood, metric, NOW);
|
||||
|
||||
expect(out.n).toBe(20);
|
||||
expect(out.result).toBeNull();
|
||||
expect(out.suppressed).toBe("notSignificant");
|
||||
});
|
||||
|
||||
it("surfaces twenty days of a real relationship", () => {
|
||||
const mood = series(Array.from({ length: 20 }, (_, i) => i));
|
||||
const metric = series(
|
||||
Array.from({ length: 20 }, (_, i) => i * 2 + (i % 2 === 0 ? 0.3 : -0.3)),
|
||||
);
|
||||
|
||||
const out = computeMoodMetricCorrelation(mood, metric, NOW);
|
||||
|
||||
expect(out.suppressed).toBeUndefined();
|
||||
expect(out.result).not.toBeNull();
|
||||
expect(out.result!.n).toBe(20);
|
||||
expect(out.result!.strength).toBe("stark");
|
||||
});
|
||||
|
||||
it("reports no overlap as too few pairs, not as an insignificant finding", () => {
|
||||
const out = computeMoodMetricCorrelation(series([3, 4]), [], NOW);
|
||||
|
||||
expect(out.n).toBe(0);
|
||||
expect(out.result).toBeNull();
|
||||
expect(out.suppressed).toBe("insufficientPairs");
|
||||
});
|
||||
});
|
||||
@@ -50,4 +50,44 @@ describe("prompt reads exclude tombstoned rows", () => {
|
||||
expect(intakeRead).not.toBeNull();
|
||||
expect(intakeRead![0]).toContain("deletedAt: null");
|
||||
});
|
||||
// A soft delete has to be able to reach the CACHE GATE as well as the
|
||||
// prompt. `buildStatusInputHash` fingerprints "how much data is there and
|
||||
// how new is it"; a tombstoned row that still counts leaves the fingerprint
|
||||
// unchanged, the gate re-dates yesterday's assessment, and text reasoning
|
||||
// over a deleted reading is served as today's. The measurement arm of that
|
||||
// same query already filters, which is what makes the two that did not so
|
||||
// easy to miss.
|
||||
it("the status input hash filters its mood aggregate", () => {
|
||||
const src = read("../status-cache.ts");
|
||||
const moodRead = src.match(
|
||||
/moodEntry\s*\n?\s*\.aggregate\(\{[\s\S]{0,800}?where:\s*\{[^}]*\}/,
|
||||
);
|
||||
expect(moodRead).not.toBeNull();
|
||||
expect(moodRead![0]).toContain("deletedAt: null");
|
||||
});
|
||||
|
||||
it("the status input hash filters its custom-metric entry reads", () => {
|
||||
const src = read("../status-cache.ts");
|
||||
// Both the count and the newest-entry probe: either one left unfiltered
|
||||
// keeps the fingerprint still after a delete.
|
||||
expect(src).toMatch(
|
||||
/_count:\s*\{\s*select:\s*\{\s*entries:\s*\{[\s\S]{0,200}?deletedAt:\s*null/,
|
||||
);
|
||||
const newest = src.match(
|
||||
/entries:\s*\{\s*where:\s*\{[^}]*\}[\s\S]{0,200}?take:\s*1/,
|
||||
);
|
||||
expect(newest).not.toBeNull();
|
||||
expect(newest![0]).toContain("deletedAt: null");
|
||||
});
|
||||
|
||||
it("the custom-metric correlation channel filters its entry read", () => {
|
||||
// The parent metric's own tombstone is filtered; its entries' were not,
|
||||
// so a reading the person deleted still shaped the correlation cards.
|
||||
const src = read("../correlation-channel-series.ts");
|
||||
const entryRead = src.match(
|
||||
/entries:\s*\{[\s\S]{0,400}?where:\s*\{[^}]*\}/,
|
||||
);
|
||||
expect(entryRead).not.toBeNull();
|
||||
expect(entryRead![0]).toContain("deletedAt: null");
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import {
|
||||
buildComplianceMedicationContext,
|
||||
calculateCompliance,
|
||||
expectsDoses,
|
||||
lastNonSkippedTakenAt,
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
} from "@/lib/analytics/compliance";
|
||||
@@ -324,7 +325,7 @@ export async function prepareBloodPressureStatusForUser(
|
||||
weightVsSystolicPairs,
|
||||
);
|
||||
|
||||
const activeMedications = await prisma.medication.findMany({
|
||||
const activeMedicationRows = await prisma.medication.findMany({
|
||||
// v1.16.11 — as-needed (PRN) medications never feed the BP-status
|
||||
// compliance gate (no expected doses, no rate).
|
||||
where: { userId, active: true, asNeeded: false },
|
||||
@@ -339,6 +340,12 @@ export async function prepareBloodPressureStatusForUser(
|
||||
},
|
||||
});
|
||||
|
||||
// A medication with no schedule expects no dose, and `calculateCompliance`
|
||||
// answers 100 % for that case. The `asNeeded: false` filter above catches
|
||||
// only the PRN arm; the empty schedule is reachable on a scheduled
|
||||
// medication and would otherwise report perfect adherence.
|
||||
const activeMedications = activeMedicationRows.filter(expectsDoses);
|
||||
|
||||
const categoryMap = await getMedicationCategories(
|
||||
activeMedications.map((medication) => medication.id),
|
||||
);
|
||||
|
||||
@@ -288,7 +288,10 @@ export async function fetchCustomMetricBehaviourSeries(
|
||||
name: true,
|
||||
unit: true,
|
||||
entries: {
|
||||
where: { measuredAt: { gte: since } },
|
||||
// The parent metric's tombstone is filtered above; the entries' own
|
||||
// one has to be too, or a reading the person deleted keeps shaping
|
||||
// the correlation they are shown.
|
||||
where: { deletedAt: null, measuredAt: { gte: since } },
|
||||
orderBy: [{ measuredAt: "desc" }, { id: "desc" }],
|
||||
take: 2000,
|
||||
select: { value: true, unit: true, measuredAt: true },
|
||||
|
||||
@@ -73,6 +73,51 @@ export function isMaterialEvidenceChange(args: {
|
||||
return signChanged || effectMoved || sampleDeepened;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this run releases a dismissal.
|
||||
*
|
||||
* Two things have to hold. The evidence must have MOVED since the dismissal,
|
||||
* which is what `dismissedEvidenceHash` records — the only part of the
|
||||
* snapshot that covers the whole evidence tuple rather than two of its
|
||||
* numbers — and the move must be material by the thresholds above, so an
|
||||
* ordinary sampling wobble does not undo a dismissal the user made on
|
||||
* purpose.
|
||||
*
|
||||
* The hash also answers the case the two scalars cannot. A snapshot that
|
||||
* carries a hash but no effect size and no sample size — a restore from a
|
||||
* partial backup — used to be frozen dismissed forever, because the gate had
|
||||
* no number to compare and fell through to `false` on every future run. A
|
||||
* hash that no longer matches is still proof the evidence moved, so it
|
||||
* releases the dismissal rather than holding it against a snapshot nobody
|
||||
* can evaluate.
|
||||
*/
|
||||
export function shouldReleaseDismissal(args: {
|
||||
dismissedAt: Date | null;
|
||||
dismissedEvidenceHash: string | null;
|
||||
dismissedEffectSize: number | null;
|
||||
dismissedSampleSize: number | null;
|
||||
currentEvidenceHash: string;
|
||||
currentEffectSize: number;
|
||||
currentSampleSize: number;
|
||||
}): boolean {
|
||||
if (args.dismissedAt == null) return false;
|
||||
|
||||
// Byte-identical to what was dismissed: nothing has changed, so there is
|
||||
// nothing to reconsider and no threshold worth evaluating.
|
||||
if (args.dismissedEvidenceHash === args.currentEvidenceHash) return false;
|
||||
|
||||
if (args.dismissedEffectSize != null && args.dismissedSampleSize != null) {
|
||||
return isMaterialEvidenceChange({
|
||||
currentEffectSize: args.currentEffectSize,
|
||||
currentSampleSize: args.currentSampleSize,
|
||||
dismissedEffectSize: args.dismissedEffectSize,
|
||||
dismissedSampleSize: args.dismissedSampleSize,
|
||||
});
|
||||
}
|
||||
|
||||
return args.dismissedEvidenceHash != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist one accepted family run, retire findings no longer accepted, and
|
||||
* return the server's presentation decision for every accepted finding.
|
||||
@@ -123,16 +168,15 @@ export async function syncAcceptedPatterns(args: {
|
||||
const decisions = new Map<string, PatternDecision>();
|
||||
for (const item of prepared) {
|
||||
const prior = existingByKey.get(item.canonicalKey);
|
||||
const shouldResurface =
|
||||
prior?.dismissedAt != null &&
|
||||
prior.dismissedEffectSize != null &&
|
||||
prior.dismissedSampleSize != null &&
|
||||
isMaterialEvidenceChange({
|
||||
currentEffectSize: item.evidence.effectSize,
|
||||
currentSampleSize: item.evidence.sampleSize,
|
||||
dismissedEffectSize: prior.dismissedEffectSize,
|
||||
dismissedSampleSize: prior.dismissedSampleSize,
|
||||
});
|
||||
const shouldResurface = shouldReleaseDismissal({
|
||||
dismissedAt: prior?.dismissedAt ?? null,
|
||||
dismissedEvidenceHash: prior?.dismissedEvidenceHash ?? null,
|
||||
dismissedEffectSize: prior?.dismissedEffectSize ?? null,
|
||||
dismissedSampleSize: prior?.dismissedSampleSize ?? null,
|
||||
currentEvidenceHash: item.evidenceHash,
|
||||
currentEffectSize: item.evidence.effectSize,
|
||||
currentSampleSize: item.evidence.sampleSize,
|
||||
});
|
||||
|
||||
const row = await tx.correlationPattern.upsert({
|
||||
where: {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
buildComplianceMedicationContext,
|
||||
buildMedicationComplianceBundle,
|
||||
dailyComplianceRatesFromLedger,
|
||||
expectsDoses,
|
||||
lastNonSkippedTakenAt,
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
} from "@/lib/analytics/compliance";
|
||||
@@ -208,7 +209,7 @@ export async function prepareMedicationComplianceStatusForUser(
|
||||
};
|
||||
}
|
||||
|
||||
const medications = await prisma.medication.findMany({
|
||||
const medicationRows = await prisma.medication.findMany({
|
||||
// v1.16.11 — as-needed (PRN) medications never surface a compliance
|
||||
// rate (no expected doses).
|
||||
where: { userId, active: true, asNeeded: false },
|
||||
@@ -224,6 +225,12 @@ export async function prepareMedicationComplianceStatusForUser(
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
// A medication with no schedule expects no dose, and `calculateCompliance`
|
||||
// answers 100 % for that case. The `asNeeded: false` filter above catches
|
||||
// only the PRN arm; the empty schedule is reachable on a scheduled
|
||||
// medication and would otherwise report perfect adherence.
|
||||
const medications = medicationRows.filter(expectsDoses);
|
||||
|
||||
if (medications.length === 0) {
|
||||
return {
|
||||
phase: "served",
|
||||
|
||||
@@ -20,10 +20,11 @@
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
pearsonCorrelation,
|
||||
significantPearsonCorrelation,
|
||||
type CorrelationResult,
|
||||
type PairedPoint,
|
||||
} from "@/lib/analytics/correlations";
|
||||
import { MIN_PAIRED_N } from "@/lib/insights/correlations";
|
||||
import {
|
||||
applyPayloadBudget,
|
||||
dayOffsetToBerlinDayKey,
|
||||
@@ -384,11 +385,20 @@ export interface CorrelationScatterPoint {
|
||||
[key: string]: number;
|
||||
}
|
||||
|
||||
/** Why a pairing produced no coefficient. */
|
||||
export type MoodCorrelationSuppression = "insufficientPairs" | "notSignificant";
|
||||
|
||||
export interface MoodMetricCorrelation {
|
||||
result: CorrelationResult | null;
|
||||
/** Mood (x) vs metric (y) scatter points, paired by day. */
|
||||
points: CorrelationScatterPoint[];
|
||||
n: number;
|
||||
/**
|
||||
* Which bar the pairing fell short of, so the card can name it rather
|
||||
* than showing a blank that reads the same whether nothing was logged or
|
||||
* a full year of days simply did not line up. Absent when `result` is set.
|
||||
*/
|
||||
suppressed?: MoodCorrelationSuppression;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -396,6 +406,14 @@ export interface MoodMetricCorrelation {
|
||||
* series, paired on `dayOffset`. Returns the scatter points so the
|
||||
* caller can paint a `<ScatterCorrelationChart>` from the same pairing
|
||||
* the coefficient was computed over.
|
||||
*
|
||||
* This routes through `significantPearsonCorrelation` — n >= 20 AND
|
||||
* p < 0.05 — for the same reason the weight, blood-pressure and
|
||||
* mood-status surfaces do: the plain `pearsonCorrelation` bands from |r|
|
||||
* alone above a floor of five pairs, so five days of noise sloping the
|
||||
* right way arrive at the reader labelled "strong". A mood page is where
|
||||
* that costs the most, because the reader is looking for something to
|
||||
* change about their week.
|
||||
*/
|
||||
export function computeMoodMetricCorrelation(
|
||||
moodDaily: DailyPoint[],
|
||||
@@ -404,9 +422,16 @@ export function computeMoodMetricCorrelation(
|
||||
tz: string = DEFAULT_TIMEZONE,
|
||||
): MoodMetricCorrelation {
|
||||
const pairs = pairDailyBuckets(moodDaily, metricDaily, now, tz);
|
||||
const result = pearsonCorrelation(pairs);
|
||||
const points = pairs.map((p) => ({ x: p.a, y: p.b }));
|
||||
return { result, points, n: pairs.length };
|
||||
const result = significantPearsonCorrelation(pairs);
|
||||
if (result) return { result, points, n: pairs.length };
|
||||
return {
|
||||
result: null,
|
||||
points,
|
||||
n: pairs.length,
|
||||
suppressed:
|
||||
pairs.length < MIN_PAIRED_N ? "insufficientPairs" : "notSignificant",
|
||||
};
|
||||
}
|
||||
|
||||
// --- Orchestrated aggregate shape ---
|
||||
|
||||
@@ -324,7 +324,12 @@ export async function computeStatusInputFingerprint(args: {
|
||||
}),
|
||||
args.includeMood
|
||||
? prisma.moodEntry.aggregate({
|
||||
where: { userId: args.userId },
|
||||
// Tombstoned rows are excluded here for the same reason they are in
|
||||
// the measurement arm above: this aggregate IS the freshness
|
||||
// fingerprint, so a deletion that leaves the count and the newest
|
||||
// timestamp untouched cannot invalidate the cached assessment, and
|
||||
// yesterday's text gets re-dated as today's over data that is gone.
|
||||
where: { userId: args.userId, deletedAt: null },
|
||||
_count: { _all: true },
|
||||
_max: { moodLoggedAt: true },
|
||||
})
|
||||
@@ -342,8 +347,9 @@ export async function computeStatusInputFingerprint(args: {
|
||||
id: true,
|
||||
unit: true,
|
||||
updatedAt: true,
|
||||
_count: { select: { entries: true } },
|
||||
_count: { select: { entries: { where: { deletedAt: null } } } },
|
||||
entries: {
|
||||
where: { deletedAt: null },
|
||||
orderBy: [{ measuredAt: "desc" }, { id: "desc" }],
|
||||
take: 1,
|
||||
select: { measuredAt: true, unit: true },
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* What the backups page has to be able to say out loud.
|
||||
*
|
||||
* The weekly snapshot stopped producing a new copy and nothing surfaced it: the
|
||||
* page listed the rows it had, each with a perfectly ordinary timestamp, and a
|
||||
* copy that was a month and a half old looked exactly like one made on Sunday.
|
||||
* The two facts that would have shown it are the age of the newest scheduled
|
||||
* copy and the terminal state of the last scheduled run.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
BACKUP_STALE_AFTER_DAYS,
|
||||
summariseBackupSchedule,
|
||||
} from "../backup-schedule-status";
|
||||
|
||||
const NOW = new Date("2026-09-03T09:00:00.000Z");
|
||||
const daysAgo = (days: number) =>
|
||||
new Date(NOW.getTime() - days * 24 * 60 * 60 * 1000);
|
||||
|
||||
describe("summariseBackupSchedule", () => {
|
||||
it("reports a fresh weekly copy as neither stale nor failed", () => {
|
||||
const status = summariseBackupSchedule({
|
||||
scheduledCreatedAt: [daysAgo(2), daysAgo(9)],
|
||||
lastRun: {
|
||||
at: daysAgo(2).toISOString(),
|
||||
state: "completed",
|
||||
error: null,
|
||||
},
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
expect(status.lastSuccessAt).toBe(daysAgo(2).toISOString());
|
||||
expect(status.lastSuccessAgeDays).toBe(2);
|
||||
expect(status.stale).toBe(false);
|
||||
expect(status.lastRunFailed).toBe(false);
|
||||
expect(status.staleAfterDays).toBe(BACKUP_STALE_AFTER_DAYS);
|
||||
});
|
||||
|
||||
it("calls a copy older than the window stale", () => {
|
||||
const status = summariseBackupSchedule({
|
||||
scheduledCreatedAt: [daysAgo(46)],
|
||||
lastRun: null,
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
expect(status.lastSuccessAgeDays).toBe(46);
|
||||
expect(status.stale).toBe(true);
|
||||
// No run row survives that long — the age is the durable signal.
|
||||
expect(status.lastRunFailed).toBe(false);
|
||||
});
|
||||
|
||||
it("surfaces a failed last run even while the copy is still in window", () => {
|
||||
const status = summariseBackupSchedule({
|
||||
scheduledCreatedAt: [daysAgo(3)],
|
||||
lastRun: {
|
||||
at: daysAgo(1).toISOString(),
|
||||
state: "failed",
|
||||
error: "job timed out",
|
||||
},
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
expect(status.stale).toBe(false);
|
||||
expect(status.lastRunFailed).toBe(true);
|
||||
expect(status.lastRun?.error).toBe("job timed out");
|
||||
});
|
||||
|
||||
it("does not call a brand-new instance stale", () => {
|
||||
const status = summariseBackupSchedule({
|
||||
scheduledCreatedAt: [],
|
||||
lastRun: null,
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
expect(status.lastSuccessAt).toBeNull();
|
||||
expect(status.lastSuccessAgeDays).toBeNull();
|
||||
expect(status.stale).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores manual uploads when judging the scheduled cadence", () => {
|
||||
// The caller passes only WEEKLY_AUTO rows; a manual upload made today must
|
||||
// not make a dead cron look alive. Proven here by the shape of the input:
|
||||
// the summary knows nothing but scheduled copies.
|
||||
const status = summariseBackupSchedule({
|
||||
scheduledCreatedAt: [daysAgo(30)],
|
||||
lastRun: null,
|
||||
now: NOW,
|
||||
});
|
||||
expect(status.stale).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Send-policy pins for the weekly `data-backup` queue.
|
||||
*
|
||||
* The queue was registered with no send options at all, so every job it minted
|
||||
* inherited pg-boss's queue defaults — `expireInSeconds: 900`, `retryLimit: 2`.
|
||||
* A full-record snapshot of a large account does not fit in fifteen minutes, so
|
||||
* the pass was killed mid-run, redelivered twice against the same record, and
|
||||
* gave up roughly 46 minutes after it started. Weekly, silently, for as long as
|
||||
* the record stayed large.
|
||||
*
|
||||
* Three facts have to hold together or the window is not real:
|
||||
*
|
||||
* 1. the policy names a window well past the 15-minute default,
|
||||
* 2. the CRON SCHEDULE carries it — send options ride the schedule because
|
||||
* pg-boss's `create_queue()` ends in ON CONFLICT DO NOTHING, so a
|
||||
* queue-level default would be inert on every already-running instance,
|
||||
* 3. the admin "run now" route sends with the same options, or an ad-hoc
|
||||
* snapshot silently keeps the default that broke the cron one.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DATA_BACKUP_QUEUE,
|
||||
DATA_BACKUP_SEND_OPTIONS,
|
||||
} from "../data-backup-policy";
|
||||
|
||||
const read = (file: string) => readFileSync(join(process.cwd(), file), "utf8");
|
||||
|
||||
describe("data-backup — pg-boss send policy", () => {
|
||||
it("widens the expiration far past the 15-minute queue default", () => {
|
||||
expect(DATA_BACKUP_QUEUE).toBe("data-backup");
|
||||
// 900 s is the pg-boss 12.26 queue default (`QUEUE_DEFAULTS.expire_seconds`
|
||||
// in `dist/plans.js`); anything at or below it re-opens the bug.
|
||||
expect(DATA_BACKUP_SEND_OPTIONS.expireInSeconds).toBeGreaterThan(900);
|
||||
expect(DATA_BACKUP_SEND_OPTIONS.expireInSeconds).toBeGreaterThanOrEqual(
|
||||
60 * 60,
|
||||
);
|
||||
});
|
||||
|
||||
it("the weekly schedule carries the policy", () => {
|
||||
const source = read("src/lib/jobs/reminder/register-maintenance.ts");
|
||||
expect(source).toMatch(
|
||||
/\[\s*DATA_BACKUP_QUEUE,\s*DATA_BACKUP_CRON,\s*DATA_BACKUP_SEND_OPTIONS,?\s*\]/,
|
||||
);
|
||||
});
|
||||
|
||||
it("the admin run-now route sends with the same policy", () => {
|
||||
const source = read("src/app/api/admin/backups/run/route.ts");
|
||||
expect(source).toMatch(
|
||||
/boss\.send\(\s*DATA_BACKUP_QUEUE,[\s\S]{0,200}DATA_BACKUP_SEND_OPTIONS,?\s*\)/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -26,4 +26,16 @@ describe("pg-boss failed-row ledger retention", () => {
|
||||
PG_BOSS_FAILED_ROW_AVAILABILITY_HOURS,
|
||||
);
|
||||
});
|
||||
|
||||
it("pins the queue defaults the data-backup window had to escape", () => {
|
||||
const plans = readFileSync(join(PG_BOSS_ROOT, "dist/plans.js"), "utf8");
|
||||
|
||||
// These two lines are the whole story behind the weekly backup's "job
|
||||
// timed out": a pass with no send options gets fifteen minutes and two
|
||||
// redeliveries, so a snapshot that cannot finish in fifteen minutes gives
|
||||
// up 45 minutes later having never once completed. If a pg-boss upgrade
|
||||
// changes either default, `DATA_BACKUP_SEND_OPTIONS` deserves a re-read.
|
||||
expect(plans).toContain("expire_seconds: FIFTEEN_MINUTES");
|
||||
expect(plans).toContain("retry_limit: 2");
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createCipheriv, randomBytes } from "node:crypto";
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import {
|
||||
BACKUP_SCHEMA_VERSION,
|
||||
@@ -8,8 +10,20 @@ const mocks = vi.hoisted(() => ({
|
||||
buildFullBackupPayload: vi.fn(),
|
||||
}));
|
||||
|
||||
// The uploader takes the already-serialised form, so the stand-in for
|
||||
// `buildFullBackupJson` serialises whatever the payload mock was told to
|
||||
// return. Every case below keeps stubbing the PAYLOAD, which is the thing
|
||||
// these tests are actually about.
|
||||
vi.mock("@/lib/export/full-backup-payload", () => ({
|
||||
buildFullBackupPayload: mocks.buildFullBackupPayload,
|
||||
buildFullBackupJson: async (...args: unknown[]) =>
|
||||
JSON.stringify(
|
||||
(
|
||||
(await mocks.buildFullBackupPayload(...args)) as {
|
||||
payload: unknown;
|
||||
}
|
||||
).payload,
|
||||
),
|
||||
}));
|
||||
import {
|
||||
encryptBackup,
|
||||
@@ -27,10 +41,33 @@ describe("offhost-backup envelope", () => {
|
||||
const key = Buffer.from(ENC_KEY, "hex");
|
||||
const payload = JSON.stringify({ hello: "world", n: 42 });
|
||||
const buf = encryptBackup(payload, key);
|
||||
expect(buf.subarray(0, 5).toString("binary")).toBe("HLBK\x01");
|
||||
// Version 2: the plaintext is gzipped before the cipher sees it. This
|
||||
// assertion used to pin version 1, which is the version that could not
|
||||
// encrypt a large account's dump without four full copies of it resident
|
||||
// at once. The pin is flipped rather than dropped — the byte is the whole
|
||||
// reason a reader can tell the two apart.
|
||||
expect(buf.subarray(0, 5).toString("binary")).toBe("HLBK\x02");
|
||||
expect(decryptBackup(buf, key)).toBe(payload);
|
||||
});
|
||||
|
||||
it("still reads a version-1 object written before compression", () => {
|
||||
const key = Buffer.from(ENC_KEY, "hex");
|
||||
const payload = JSON.stringify({ hello: "world", n: 42 });
|
||||
// Hand-built v1 envelope: magic || 0x01 || iv || tag || ciphertext, the
|
||||
// exact bytes every object already sitting in an operator's bucket has.
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||
const ct = Buffer.concat([cipher.update(payload, "utf8"), cipher.final()]);
|
||||
const legacy = Buffer.concat([
|
||||
Buffer.from("HLBK\x01", "binary"),
|
||||
iv,
|
||||
cipher.getAuthTag(),
|
||||
ct,
|
||||
]);
|
||||
|
||||
expect(decryptBackup(legacy, key)).toBe(payload);
|
||||
});
|
||||
|
||||
it("rejects tampered ciphertext", () => {
|
||||
const key = Buffer.from(ENC_KEY, "hex");
|
||||
const buf = encryptBackup("data", key);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Whether the weekly backup is actually still happening, in two facts.
|
||||
*
|
||||
* The pass stopped producing a new copy and nothing said so for a month and a
|
||||
* half. Everything that could have told the operator was either invisible or
|
||||
* short-lived: the handler's own error never ran (the job was killed, not
|
||||
* thrown), `recordError()` increments an unnamed counter, and the admin status
|
||||
* page's failing-queue panel only looks back 72 hours — which for a weekly
|
||||
* queue means a permanent failure is on screen three days in seven. The backups
|
||||
* page itself showed a perfectly ordinary row with a perfectly ordinary
|
||||
* timestamp, because a 46-day-old copy looks exactly like a fresh one until you
|
||||
* read the date.
|
||||
*
|
||||
* So the surface carries both legs, and neither is derived from the other:
|
||||
*
|
||||
* - the AGE of the newest scheduled copy, which never ages out of any
|
||||
* retention window and would have caught this on the second missed Sunday;
|
||||
* - the terminal state of the last scheduled RUN, which is the only place the
|
||||
* reason ("job timed out") is written down at all.
|
||||
*
|
||||
* Manual uploads are excluded by the shape of the input: the caller passes only
|
||||
* the scheduled rows, so an admin's ad-hoc upload cannot make a dead cron look
|
||||
* alive.
|
||||
*/
|
||||
import type { LastQueueRun } from "@/lib/jobs/job-failures";
|
||||
|
||||
/**
|
||||
* How old the newest scheduled copy may get before the page says so. The cron
|
||||
* is weekly, so seven days is normal and eight is a rounding error; ten means
|
||||
* at least one Sunday produced nothing.
|
||||
*/
|
||||
export const BACKUP_STALE_AFTER_DAYS = 10;
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface BackupScheduleStatus {
|
||||
/** ISO instant of the newest scheduled copy, or null when there is none. */
|
||||
lastSuccessAt: string | null;
|
||||
/** Whole days since that copy was written. */
|
||||
lastSuccessAgeDays: number | null;
|
||||
/** The threshold behind `stale`, so the UI can name it. */
|
||||
staleAfterDays: number;
|
||||
/** A scheduled copy exists and is older than the threshold. */
|
||||
stale: boolean;
|
||||
/** The last scheduled run pg-boss still remembers. */
|
||||
lastRun: LastQueueRun | null;
|
||||
/** That run ended `failed` or `cancelled`. */
|
||||
lastRunFailed: boolean;
|
||||
}
|
||||
|
||||
export function summariseBackupSchedule(args: {
|
||||
/** `createdAt` of every SCHEDULED backup row; manual uploads excluded. */
|
||||
scheduledCreatedAt: readonly Date[];
|
||||
lastRun: LastQueueRun | null;
|
||||
now: Date;
|
||||
}): BackupScheduleStatus {
|
||||
const newest = args.scheduledCreatedAt.reduce<Date | null>(
|
||||
(best, at) => (best === null || at > best ? at : best),
|
||||
null,
|
||||
);
|
||||
const ageDays =
|
||||
newest === null
|
||||
? null
|
||||
: Math.floor((args.now.getTime() - newest.getTime()) / DAY_MS);
|
||||
|
||||
return {
|
||||
lastSuccessAt: newest?.toISOString() ?? null,
|
||||
lastSuccessAgeDays: ageDays,
|
||||
staleAfterDays: BACKUP_STALE_AFTER_DAYS,
|
||||
// An instance that has never produced a scheduled copy is not stale, it is
|
||||
// new — the page's empty state already says there is nothing there, and
|
||||
// calling a fresh install "overdue" would train the operator to ignore the
|
||||
// warning that matters.
|
||||
stale: ageDays !== null && ageDays > BACKUP_STALE_AFTER_DAYS,
|
||||
lastRun: args.lastRun,
|
||||
lastRunFailed: args.lastRun !== null && args.lastRun.state !== "completed",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Queue name and pg-boss send policy for the weekly on-host data backup.
|
||||
*
|
||||
* A leaf module on purpose: the cron registrar, the admin "run now" route and
|
||||
* the policy test all need these two facts, and none of them should have to
|
||||
* pull in the worker's handler graph to get them.
|
||||
*
|
||||
* The queue was registered with no send options, so every job it minted took
|
||||
* pg-boss 12.26's queue defaults — `expireInSeconds: 900`, `retryLimit: 2`
|
||||
* (`QUEUE_DEFAULTS` in `pg-boss/dist/plans.js`). Fifteen minutes is a fine
|
||||
* default for a cleanup tick and far too short for a pass that serialises every
|
||||
* account's whole record: the job was killed mid-run, redelivered twice against
|
||||
* the same record, and reached `failed` with `job timed out` about 46 minutes
|
||||
* after it started. Nothing about that says "too slow" — the pass never got to
|
||||
* finish once.
|
||||
*
|
||||
* Two hours is deliberately generous rather than tuned. The handler now costs a
|
||||
* fraction of that on a large record (see the CHANGELOG entry), but the pass
|
||||
* loops over EVERY account on the instance and a self-host's disk is whatever
|
||||
* it is; the window's job is to be impossible to hit for the right reasons, and
|
||||
* a failure that arrives at the two-hour mark is a real one worth reading.
|
||||
* pg-boss refuses anything at or past 24 hours.
|
||||
*
|
||||
* The default `retryLimit: 2` stays. The old redeliveries were pointless
|
||||
* because the first attempt could not succeed either; a transient failure — a
|
||||
* pool hiccup, a locked row — genuinely benefits from a second attempt, and the
|
||||
* alternative is losing the week.
|
||||
*
|
||||
* The options ride on the SEND, not on `createQueue`: pg-boss's `create_queue()`
|
||||
* ends in `ON CONFLICT DO NOTHING`, so a queue-level default would apply to a
|
||||
* fresh database and be inert on every instance that already has the queue —
|
||||
* exactly the trap `reconcileQueuePolicies` exists to work around.
|
||||
*/
|
||||
|
||||
export const DATA_BACKUP_QUEUE = "data-backup";
|
||||
|
||||
export const DATA_BACKUP_SEND_OPTIONS = {
|
||||
expireInSeconds: 2 * 60 * 60,
|
||||
} as const;
|
||||
@@ -98,7 +98,11 @@ export async function readFailingQueues(
|
||||
max(completed_on) AS last_failed_at,
|
||||
(
|
||||
array_agg(
|
||||
COALESCE(output->>'message', output::text)
|
||||
COALESCE(
|
||||
output->>'message',
|
||||
output->'value'->>'message',
|
||||
output::text
|
||||
)
|
||||
ORDER BY completed_on DESC NULLS LAST
|
||||
)
|
||||
)[1] AS last_error
|
||||
@@ -124,6 +128,75 @@ export async function readFailingQueues(
|
||||
}
|
||||
}
|
||||
|
||||
/** The terminal state pg-boss recorded for the newest run of one queue. */
|
||||
export interface LastQueueRun {
|
||||
/** ISO instant the run reached its terminal state. */
|
||||
at: string;
|
||||
/** `failed` covers both a thrown handler and an expired (killed) job. */
|
||||
state: "completed" | "failed" | "cancelled";
|
||||
/** The failure text, redacted and truncated. Null when the run succeeded. */
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest terminal run of one queue, whatever its outcome.
|
||||
*
|
||||
* `readFailingQueues` answers "what broke recently" and is deliberately scoped
|
||||
* to a 72-hour window, which is the right shape for a status page watching
|
||||
* thirty queues that tick by the minute. It is the wrong shape for a WEEKLY
|
||||
* pass: a Sunday-night failure has fallen out of the window by Wednesday, so a
|
||||
* job that fails every single week is visible for three days in seven and
|
||||
* invisible for four. This reader takes the newest run instead of a window, so
|
||||
* a weekly queue's last outcome is readable right up until the next one.
|
||||
*
|
||||
* Bounded by pg-boss's own retention: terminal rows live `deletion_seconds`
|
||||
* (seven days by default) past completion, so `null` here means "no run row
|
||||
* survives", which is not the same as "the pass never ran". Callers pair it
|
||||
* with a fact that does not age out — for backups, the age of the newest
|
||||
* stored copy.
|
||||
*/
|
||||
export async function readLastQueueRun(
|
||||
queue: string,
|
||||
): Promise<LastQueueRun | null> {
|
||||
try {
|
||||
const rows = await prisma.$queryRaw<
|
||||
Array<{ state: string; completed_on: Date | null; output: string | null }>
|
||||
>`
|
||||
SELECT
|
||||
state,
|
||||
completed_on,
|
||||
-- pg-boss wraps a thrown value: an expired job's row reads
|
||||
-- {"value":{"message":"job timed out"}}, so a plain ->>'message'
|
||||
-- misses it and the operator gets raw JSON instead of the sentence.
|
||||
COALESCE(
|
||||
output->>'message',
|
||||
output->'value'->>'message',
|
||||
output::text
|
||||
) AS output
|
||||
FROM pgboss.job
|
||||
WHERE name = ${queue}
|
||||
AND state IN ('completed', 'failed', 'cancelled')
|
||||
AND completed_on IS NOT NULL
|
||||
ORDER BY completed_on DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
const row = rows[0];
|
||||
if (!row || row.completed_on === null) return null;
|
||||
const state =
|
||||
row.state === "failed" || row.state === "cancelled"
|
||||
? row.state
|
||||
: "completed";
|
||||
return {
|
||||
at: row.completed_on.toISOString(),
|
||||
state,
|
||||
error: state === "completed" ? null : presentError(row.output),
|
||||
};
|
||||
} catch {
|
||||
// No `pgboss` schema (web-only deployment) or no permission to read it.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface QueueFailureForUser {
|
||||
/** ISO instant the user's newest job on this queue gave up. */
|
||||
lastFailedAt: string;
|
||||
|
||||
@@ -18,8 +18,9 @@
|
||||
* the backup history. See docs/ops/backup-restore.md.
|
||||
*/
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
||||
import { gunzipSync, gzipSync } from "node:zlib";
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { buildFullBackupJson } from "@/lib/export/full-backup-payload";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
@@ -84,20 +85,43 @@ export function loadOffhostConfig(): OffhostBackupConfig | null {
|
||||
* Encrypt a JSON payload with the dedicated backup key.
|
||||
*
|
||||
* Wire format (binary):
|
||||
* magic(4)="HLBK" || version(1)=0x01 || iv(12) || tag(16) || ciphertext
|
||||
* magic(4)="HLBK" || version(1) || iv(12) || tag(16) || ciphertext
|
||||
*
|
||||
* Version 1 encrypts the JSON directly. Version 2 gzips it first, which is
|
||||
* what the version byte was there for: a large account's dump is hundreds of
|
||||
* megabytes of extremely repetitive JSON, and encrypting it whole means the
|
||||
* string, the cipher's input copy, the ciphertext and the concatenation are
|
||||
* all resident at once — the same arithmetic that made the in-database weekly
|
||||
* backup unfinishable. Gzip takes an order of magnitude off every one of them
|
||||
* and off the object in the bucket. Objects written under version 1 stay
|
||||
* readable; both the restore drill and `scripts/restore-backup.ts` come
|
||||
* through `decryptBackup`, so neither needs to know which it got.
|
||||
*/
|
||||
const BACKUP_ENVELOPE_PLAIN = 0x01;
|
||||
const BACKUP_ENVELOPE_GZIP = 0x02;
|
||||
|
||||
export function encryptBackup(plaintext: string, key: Buffer): Buffer {
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||
const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
const ct = Buffer.concat([
|
||||
cipher.update(gzipSync(plaintext)),
|
||||
cipher.final(),
|
||||
]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return Buffer.concat([Buffer.from("HLBK\x01", "binary"), iv, tag, ct]);
|
||||
const header = Buffer.from([
|
||||
...Buffer.from("HLBK", "binary"),
|
||||
BACKUP_ENVELOPE_GZIP,
|
||||
]);
|
||||
return Buffer.concat([header, iv, tag, ct]);
|
||||
}
|
||||
|
||||
export function decryptBackup(buf: Buffer, key: Buffer): string {
|
||||
const magic = buf.subarray(0, 4).toString("binary");
|
||||
const version = buf[4];
|
||||
if (magic !== "HLBK" || version !== 0x01) {
|
||||
if (
|
||||
magic !== "HLBK" ||
|
||||
(version !== BACKUP_ENVELOPE_PLAIN && version !== BACKUP_ENVELOPE_GZIP)
|
||||
) {
|
||||
throw new Error("Invalid backup envelope (bad magic or version)");
|
||||
}
|
||||
const iv = buf.subarray(5, 5 + IV_LENGTH);
|
||||
@@ -105,7 +129,10 @@ export function decryptBackup(buf: Buffer, key: Buffer): string {
|
||||
const ct = buf.subarray(5 + IV_LENGTH + TAG_LENGTH);
|
||||
const dec = createDecipheriv(ALGORITHM, key, iv);
|
||||
dec.setAuthTag(tag);
|
||||
return Buffer.concat([dec.update(ct), dec.final()]).toString("utf8");
|
||||
const plaintext = Buffer.concat([dec.update(ct), dec.final()]);
|
||||
return version === BACKUP_ENVELOPE_GZIP
|
||||
? gunzipSync(plaintext).toString("utf8")
|
||||
: plaintext.toString("utf8");
|
||||
}
|
||||
|
||||
export interface S3Like {
|
||||
@@ -215,12 +242,13 @@ export async function runOffhostBackup(
|
||||
const evt = getEvent();
|
||||
for (const user of users) {
|
||||
try {
|
||||
const { payload } = await buildFullBackupPayload(prisma, user.id, {
|
||||
purpose: "disaster-recovery",
|
||||
exportedAt: now,
|
||||
});
|
||||
const plaintext = JSON.stringify(payload);
|
||||
const ciphertext = encryptBackup(plaintext, cfg.encryptionKey);
|
||||
const ciphertext = encryptBackup(
|
||||
await buildFullBackupJson(prisma, user.id, {
|
||||
purpose: "disaster-recovery",
|
||||
exportedAt: now,
|
||||
}),
|
||||
cfg.encryptionKey,
|
||||
);
|
||||
const key = `${dateKey}/user-${user.id}.json.enc`;
|
||||
await s3.putObject(key, ciphertext);
|
||||
uploaded++;
|
||||
|
||||
@@ -4,16 +4,29 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
buildFullBackupPayload: vi.fn(),
|
||||
encrypt: vi.fn((value: string) => value),
|
||||
packBlob: vi.fn((value: string) => value),
|
||||
getWorkerPrisma: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
}));
|
||||
|
||||
// The handler takes the already-serialised form; the stand-in serialises
|
||||
// whatever the payload mock was told to return, so this file keeps stubbing
|
||||
// and asserting the PAYLOAD, which is what it is about.
|
||||
vi.mock("@/lib/export/full-backup-payload", () => ({
|
||||
buildFullBackupPayload: mocks.buildFullBackupPayload,
|
||||
buildFullBackupJson: async (...args: unknown[]) =>
|
||||
JSON.stringify(
|
||||
((await mocks.buildFullBackupPayload(...args)) as { payload: unknown })
|
||||
.payload,
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({ encrypt: mocks.encrypt }));
|
||||
// The envelope is exercised end-to-end in
|
||||
// `src/lib/export/__tests__/backup-blob.test.ts`; here it stays transparent so
|
||||
// the stored bytes can be read back as JSON.
|
||||
vi.mock("@/lib/export/backup-blob", () => ({
|
||||
packBackupBlob: mocks.packBlob,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/background", () => ({
|
||||
withBackgroundEvent: vi.fn(
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
*/
|
||||
import { type Job } from "pg-boss";
|
||||
import { recordError } from "@/lib/jobs/worker-status";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { buildFullBackupJson } from "@/lib/export/full-backup-payload";
|
||||
import { packBackupBlob } from "@/lib/export/backup-blob";
|
||||
import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome";
|
||||
import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
import {
|
||||
@@ -79,15 +79,21 @@ export async function handleDataBackup(
|
||||
|
||||
let backed = 0;
|
||||
let usersFailed = 0;
|
||||
let largestBlobBytes = 0;
|
||||
for (const user of users) {
|
||||
try {
|
||||
const { payload } = await buildFullBackupPayload(prisma, user.id, {
|
||||
purpose: "disaster-recovery",
|
||||
});
|
||||
const backupJson = JSON.stringify(payload);
|
||||
|
||||
// Encrypt the backup data (contains sensitive health information)
|
||||
const encryptedBackup = encrypt(backupJson);
|
||||
// Compressed, then encrypted (the record contains sensitive health
|
||||
// information). Both legs live in `packBackupBlob`, which is also
|
||||
// what the restore path reads back through.
|
||||
const encryptedBackup = packBackupBlob(
|
||||
await buildFullBackupJson(prisma, user.id, {
|
||||
purpose: "disaster-recovery",
|
||||
}),
|
||||
);
|
||||
largestBlobBytes = Math.max(
|
||||
largestBlobBytes,
|
||||
Buffer.byteLength(encryptedBackup, "utf8"),
|
||||
);
|
||||
|
||||
await prisma.dataBackup.upsert({
|
||||
where: {
|
||||
@@ -113,6 +119,9 @@ export async function handleDataBackup(
|
||||
}
|
||||
}
|
||||
|
||||
// The stored size is the one number that says whether this pass is
|
||||
// heading back towards the wall it hit before: it tracks the record.
|
||||
evt.addMeta("data_backup_largest_blob_bytes", largestBlobBytes);
|
||||
evt.setBackground({
|
||||
task_name: "job.data_backup",
|
||||
result: { backed, total: users.length },
|
||||
|
||||
@@ -138,6 +138,10 @@ import {
|
||||
} from "@/lib/jobs/environment-fetch";
|
||||
import { recordError } from "@/lib/jobs/worker-status";
|
||||
import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome";
|
||||
import {
|
||||
DATA_BACKUP_QUEUE,
|
||||
DATA_BACKUP_SEND_OPTIONS,
|
||||
} from "@/lib/jobs/data-backup-policy";
|
||||
import { workerLog, BOOT_BACKFILL_STAGGER_SECONDS } from "./shared";
|
||||
import {
|
||||
createAndSchedule,
|
||||
@@ -221,8 +225,6 @@ import {
|
||||
type AchievementUnlockSweepPayload,
|
||||
} from "@/lib/jobs/achievement-unlock-sweep";
|
||||
|
||||
const DATA_BACKUP_QUEUE = "data-backup";
|
||||
|
||||
const DATA_BACKUP_CRON = "0 3 * * 0"; // weekly Sunday at 03:00
|
||||
|
||||
const RATE_LIMIT_CLEANUP_QUEUE = "rate-limit-cleanup";
|
||||
@@ -478,7 +480,11 @@ const schedules: ScheduleEntry[] = [
|
||||
// v1.25 (W-ENV) — daily 02:10 Europe/Berlin discovery tick (empty payload)
|
||||
// that fans out one per-user environment fetch per opted-in account.
|
||||
[ENVIRONMENT_FETCH_QUEUE, ENVIRONMENT_FETCH_CRON],
|
||||
[DATA_BACKUP_QUEUE, DATA_BACKUP_CRON],
|
||||
// The send options are the point of this tuple: without them the weekly
|
||||
// snapshot inherits pg-boss's 15-minute expiration and is killed mid-run on
|
||||
// any sizeable record. See `data-backup-policy.ts` for why the window rides
|
||||
// the schedule rather than the queue.
|
||||
[DATA_BACKUP_QUEUE, DATA_BACKUP_CRON, DATA_BACKUP_SEND_OPTIONS],
|
||||
[RATE_LIMIT_CLEANUP_QUEUE, RATE_LIMIT_CLEANUP_CRON, cronIsTheRetry],
|
||||
[IDEMPOTENCY_CLEANUP_QUEUE, IDEMPOTENCY_CLEANUP_CRON, cronIsTheRetry],
|
||||
[AUDIT_LOG_CLEANUP_QUEUE, AUDIT_LOG_CLEANUP_CRON],
|
||||
|
||||
@@ -33,10 +33,12 @@ export function decryptContextFromBytes(buf: Uint8Array): string {
|
||||
|
||||
/** Decrypt a stored `Bytes` context note, fail-soft to `null` on any error.
|
||||
*
|
||||
* Mirrors `decryptNoteSoft` in the illness DTO layer: a single bad-key /
|
||||
* malformed row must not 500 the whole catalog list. The single-resource GET
|
||||
* path uses the throwing `decryptContextFromBytes` so a genuine decrypt
|
||||
* failure there surfaces instead of silently masking a key-rotation gap. */
|
||||
* A single bad-key / malformed row must not 500 the whole catalog list, and
|
||||
* the list surface renders a missing context as nothing either way. The
|
||||
* single-resource GET path uses the throwing `decryptContextFromBytes` so a
|
||||
* genuine decrypt failure there surfaces instead of silently masking a
|
||||
* key-rotation gap, and the backup export reads through its own helper that
|
||||
* marks the row rather than blanking it. */
|
||||
export function decryptContextSoft(buf: Uint8Array | null): string | null {
|
||||
if (!buf) return null;
|
||||
try {
|
||||
|
||||
+5
-24
@@ -5,11 +5,15 @@
|
||||
* free-text note is the only sensitive column on the model; it shares the
|
||||
* `encrypt()` string format (`"<keyId>.<base64>"`) every other `*Encrypted`
|
||||
* column uses, encoded as UTF-8 bytes.
|
||||
*
|
||||
* There is one decrypt here and it throws. A fail-soft twin used to sit
|
||||
* beside it for bulk reads; the list DTO never carries the note (it reports
|
||||
* `hasNote` only) and the export now writes a visible marker instead of a
|
||||
* null, so nothing was left that wanted a silent one.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
|
||||
/** Encrypt a UTF-8 note into the `Bytes` payload the schema stores. */
|
||||
export function encryptNoteToBytes(plaintext: string): Uint8Array<ArrayBuffer> {
|
||||
@@ -26,26 +30,3 @@ export function encryptNoteToBytes(plaintext: string): Uint8Array<ArrayBuffer> {
|
||||
export function decryptNoteFromBytes(buf: Uint8Array): string {
|
||||
return decrypt(Buffer.from(buf).toString("utf8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a stored `Bytes` note, fail-soft to `null` on any error.
|
||||
*
|
||||
* Mirrors `decryptContextSoft` in the biomarker store: a single bad-key /
|
||||
* malformed row must not fail a bulk read (list view, backup export) that
|
||||
* spans many rows. The single-resource GET path uses the throwing
|
||||
* `decryptNoteFromBytes` so a genuine decrypt failure there surfaces instead
|
||||
* of silently masking a key-rotation gap.
|
||||
*/
|
||||
export function decryptNoteSoft(buf: Uint8Array | null): string | null {
|
||||
if (!buf || buf.byteLength === 0) return null;
|
||||
try {
|
||||
return decryptNoteFromBytes(buf);
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`lab note decrypt failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* The operator's instance-wide channel switches, asserted where they have to
|
||||
* hold: at dispatch.
|
||||
*
|
||||
* `AppSettings.telegramGlobal` / `.ntfyGlobal` / `.webPushGlobal` used to
|
||||
* decide only whether the setup card rendered under Settings → Integrations.
|
||||
* A channel a self-hoster had switched off kept delivering to every account
|
||||
* that had already configured it. These tests drive the real dispatcher over
|
||||
* a stubbed settings row and assert the user-visible outcome: nothing is
|
||||
* sent, and the push-attempt ledger says why.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
notificationChannel: { findMany: vi.fn(), update: vi.fn() },
|
||||
pushAttempt: { create: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn(async () => undefined) }));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: (s: string) => s,
|
||||
decrypt: (s: string) => s,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
getEvent: () => ({
|
||||
addWarning: vi.fn(),
|
||||
addMeta: vi.fn(),
|
||||
addExternalCall: vi.fn(),
|
||||
setError: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const sendViaTelegramMock = vi.fn();
|
||||
const sendViaNtfyMock = vi.fn();
|
||||
const sendViaWebPushMock = vi.fn();
|
||||
const sendViaEmailMock = vi.fn();
|
||||
|
||||
vi.mock("@/lib/notifications/senders/email", () => ({
|
||||
sendViaEmail: (...args: unknown[]) => sendViaEmailMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/notifications/senders/telegram", () => ({
|
||||
sendViaTelegram: (...args: unknown[]) => sendViaTelegramMock(...args),
|
||||
}));
|
||||
vi.mock("@/lib/notifications/senders/ntfy", () => ({
|
||||
sendViaNtfy: (...args: unknown[]) => sendViaNtfyMock(...args),
|
||||
}));
|
||||
vi.mock("@/lib/notifications/senders/web-push", () => ({
|
||||
sendViaWebPush: (...args: unknown[]) => sendViaWebPushMock(...args),
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { dispatchNotification } from "@/lib/notifications/dispatcher";
|
||||
|
||||
type ChannelKind = "TELEGRAM" | "NTFY" | "WEB_PUSH" | "EMAIL";
|
||||
|
||||
function makeChannel(type: ChannelKind) {
|
||||
return {
|
||||
id: `ch-${type}`,
|
||||
userId: "u-1",
|
||||
type,
|
||||
enabled: true,
|
||||
config: JSON.stringify({ serverUrl: "https://ntfy.example", topic: "t" }),
|
||||
consecutiveFailures: 0,
|
||||
nextRetryAt: null,
|
||||
preferences: [],
|
||||
};
|
||||
}
|
||||
|
||||
function stubSettings(over: Record<string, boolean> = {}) {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
apiGlobal: true,
|
||||
...over,
|
||||
} as never);
|
||||
}
|
||||
|
||||
const SENDERS: Record<ChannelKind, ReturnType<typeof vi.fn>> = {
|
||||
TELEGRAM: sendViaTelegramMock,
|
||||
NTFY: sendViaNtfyMock,
|
||||
WEB_PUSH: sendViaWebPushMock,
|
||||
EMAIL: sendViaEmailMock,
|
||||
};
|
||||
|
||||
const SWITCH_COLUMN = {
|
||||
TELEGRAM: "telegramGlobal",
|
||||
NTFY: "ntfyGlobal",
|
||||
WEB_PUSH: "webPushGlobal",
|
||||
} as const;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
managedProfileAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.notificationChannel.update).mockResolvedValue({
|
||||
consecutiveFailures: 0,
|
||||
} as never);
|
||||
vi.mocked(prisma.pushAttempt.create).mockResolvedValue({} as never);
|
||||
stubSettings();
|
||||
});
|
||||
|
||||
describe.each(["TELEGRAM", "NTFY", "WEB_PUSH"] as const)(
|
||||
"dispatchNotification — %s instance-wide switch",
|
||||
(type) => {
|
||||
it("sends nothing and writes a globally_disabled ledger row when the operator switched the channel off", async () => {
|
||||
stubSettings({ [SWITCH_COLUMN[type]]: false });
|
||||
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
|
||||
makeChannel(type),
|
||||
] as never);
|
||||
|
||||
const outcome = await dispatchNotification({
|
||||
eventType: "SYSTEM_ALERT",
|
||||
userId: "u-1",
|
||||
title: "t",
|
||||
message: "m",
|
||||
});
|
||||
|
||||
expect(SENDERS[type]).not.toHaveBeenCalled();
|
||||
expect(outcome).toEqual({
|
||||
dispatched: false,
|
||||
channelsAttempted: 0,
|
||||
channelsSucceeded: 0,
|
||||
});
|
||||
expect(prisma.pushAttempt.create).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
vi.mocked(prisma.pushAttempt.create).mock.calls[0][0],
|
||||
).toMatchObject({
|
||||
data: {
|
||||
channel: type,
|
||||
eventType: "SYSTEM_ALERT",
|
||||
result: "skipped",
|
||||
reason: "globally_disabled",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("sends normally when the switch is on", async () => {
|
||||
stubSettings({ [SWITCH_COLUMN[type]]: true });
|
||||
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
|
||||
makeChannel(type),
|
||||
] as never);
|
||||
SENDERS[type].mockResolvedValue({ ok: true });
|
||||
|
||||
const outcome = await dispatchNotification({
|
||||
eventType: "SYSTEM_ALERT",
|
||||
userId: "u-1",
|
||||
title: "t",
|
||||
message: "m",
|
||||
});
|
||||
|
||||
expect(SENDERS[type]).toHaveBeenCalledTimes(1);
|
||||
expect(outcome.dispatched).toBe(true);
|
||||
expect(outcome.channelsSucceeded).toBe(1);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe("dispatchNotification — switch scope", () => {
|
||||
it("reads the settings row once for a cascade that carries several switchable channels", async () => {
|
||||
stubSettings({ telegramGlobal: false, ntfyGlobal: false });
|
||||
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
|
||||
makeChannel("TELEGRAM"),
|
||||
makeChannel("NTFY"),
|
||||
] as never);
|
||||
|
||||
await dispatchNotification({
|
||||
eventType: "SYSTEM_ALERT",
|
||||
userId: "u-1",
|
||||
title: "t",
|
||||
message: "m",
|
||||
});
|
||||
|
||||
expect(sendViaTelegramMock).not.toHaveBeenCalled();
|
||||
expect(sendViaNtfyMock).not.toHaveBeenCalled();
|
||||
expect(prisma.appSettings.findUnique).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("never reads the settings row for a cascade with no switchable channel", async () => {
|
||||
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
|
||||
makeChannel("EMAIL"),
|
||||
] as never);
|
||||
|
||||
await dispatchNotification({
|
||||
eventType: "SYSTEM_ALERT",
|
||||
userId: "u-1",
|
||||
title: "t",
|
||||
message: "m",
|
||||
});
|
||||
|
||||
expect(prisma.appSettings.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,12 @@ import {
|
||||
recordChannelTransientFailure,
|
||||
} from "@/lib/notifications/channel-state";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import {
|
||||
getGlobalServiceAvailability,
|
||||
hasGlobalChannelSwitch,
|
||||
resolveChannelGloballyEnabled,
|
||||
type GlobalServiceAvailability,
|
||||
} from "@/lib/app-settings";
|
||||
import {
|
||||
CLIENT_MANAGED_APNS_EVENTS,
|
||||
hasClientManagedApnsGate,
|
||||
@@ -43,11 +49,12 @@ import {
|
||||
*
|
||||
* For each channel:
|
||||
* 1. Skip if `enabled=false` (manually or auto-disabled).
|
||||
* 2. Skip if currently in retry-cooldown (`nextRetryAt > now`).
|
||||
* 3. Check the per-channel preference for this eventType (default: enabled).
|
||||
* 4. Skip the APNs channel — and only that one — when the user's iOS
|
||||
* 2. Check the per-channel preference for this eventType (default: enabled).
|
||||
* 3. Skip if the operator switched the channel off instance-wide.
|
||||
* 4. Skip if currently in retry-cooldown (`nextRetryAt > now`).
|
||||
* 5. Skip the APNs channel — and only that one — when the user's iOS
|
||||
* client owns the local reminder for this event type.
|
||||
* 5. Call the appropriate sender — which now returns a `SendOutcome` so
|
||||
* 6. Call the appropriate sender — which now returns a `SendOutcome` so
|
||||
* the dispatcher can classify hard rejects (410, blocked-by-user) vs
|
||||
* soft errors (5xx, 429, network) and update channel state accordingly.
|
||||
*
|
||||
@@ -231,6 +238,16 @@ export async function dispatchNotification(
|
||||
});
|
||||
defaultEnabled = user?.moodReminderEnabled === true;
|
||||
}
|
||||
// Operator kill switches for the three channels that carry one, read at
|
||||
// most once per dispatch and only when such a channel is actually in the
|
||||
// cascade — a cascade of APNs + webhook + email pays nothing for it.
|
||||
let globalAvailability: GlobalServiceAvailability | null = null;
|
||||
const resolveGlobalAvailability =
|
||||
async (): Promise<GlobalServiceAvailability> => {
|
||||
globalAvailability ??= await getGlobalServiceAvailability();
|
||||
return globalAvailability;
|
||||
};
|
||||
|
||||
// Client-managed APNs suppression, resolved at most once per dispatch
|
||||
// and only when an APNs channel is actually in the cascade. `null`
|
||||
// means "not applicable"; the read is lazy so the common event types
|
||||
@@ -259,6 +276,37 @@ export async function dispatchNotification(
|
||||
continue;
|
||||
}
|
||||
|
||||
// The operator's instance-wide switch for this channel
|
||||
// (`AppSettings.{telegram,ntfy,webPush}Global`). It sits above every
|
||||
// per-user setting: a channel switched off here delivers nothing, no
|
||||
// matter what the account has configured. Not counted as an attempt —
|
||||
// nothing was sent — but the ledger carries a `globally_disabled` row
|
||||
// so the operator reads a reason instead of inferring silence.
|
||||
const globalSwitch = hasGlobalChannelSwitch(channel.type)
|
||||
? channel.type
|
||||
: null;
|
||||
if (
|
||||
globalSwitch &&
|
||||
!resolveChannelGloballyEnabled(
|
||||
await resolveGlobalAvailability(),
|
||||
globalSwitch,
|
||||
)
|
||||
) {
|
||||
getEvent()?.addMeta(
|
||||
`notification_skip_${globalSwitch.toLowerCase()}_globally_disabled`,
|
||||
recipientPayload.eventType,
|
||||
);
|
||||
recordPushAttempt({
|
||||
recordUserId: delivery.recordUserId,
|
||||
recipientUserId: delivery.recipientUserId,
|
||||
channel: globalSwitch,
|
||||
eventType: recipientPayload.eventType,
|
||||
result: "skipped",
|
||||
reason: "globally_disabled",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Backoff cooldown — skip the channel until `nextRetryAt`. Without
|
||||
// this guard, a flapping upstream would burn API quota every
|
||||
// reminder-tick (every 60s by default), defeating the whole point
|
||||
|
||||
@@ -8,20 +8,21 @@ export interface VapidConfig {
|
||||
subject: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The env fallback, read when the admin Settings panel has no key pair stored.
|
||||
*
|
||||
* `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` / `VAPID_SUBJECT` are the only
|
||||
* names. Six `WEB_PUSH_*` aliases and a `NEXT_PUBLIC_VAPID_PUBLIC_KEY` one
|
||||
* used to sit behind them; none was ever on the compose `environment:`
|
||||
* whitelist, so under the bundled stack a value set under any of those names
|
||||
* could not reach this process, and none appeared in `.env.example`,
|
||||
* `.env.production.example` or the env manifest. They read as an escape hatch
|
||||
* that had never been open.
|
||||
*/
|
||||
function fromEnv(): VapidConfig | null {
|
||||
const publicKey =
|
||||
process.env.VAPID_PUBLIC_KEY ??
|
||||
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY ??
|
||||
process.env.WEB_PUSH_VAPID_PUBLIC_KEY ??
|
||||
process.env.WEB_PUSH_PUBLIC_KEY;
|
||||
const privateKey =
|
||||
process.env.VAPID_PRIVATE_KEY ??
|
||||
process.env.WEB_PUSH_VAPID_PRIVATE_KEY ??
|
||||
process.env.WEB_PUSH_PRIVATE_KEY;
|
||||
const subject =
|
||||
process.env.VAPID_SUBJECT ??
|
||||
process.env.WEB_PUSH_VAPID_SUBJECT ??
|
||||
process.env.WEB_PUSH_SUBJECT;
|
||||
const publicKey = process.env.VAPID_PUBLIC_KEY;
|
||||
const privateKey = process.env.VAPID_PRIVATE_KEY;
|
||||
const subject = process.env.VAPID_SUBJECT;
|
||||
|
||||
if (!publicKey || !privateKey || !subject) return null;
|
||||
return { publicKey, privateKey, subject };
|
||||
|
||||
@@ -136,7 +136,8 @@ export const notificationTransportPaths: NonNullable<
|
||||
description:
|
||||
"Stores a browser Push subscription for the caller and, on the first one, creates the account's Web Push notification channel enabled. Upsert by endpoint: re-posting the same endpoint refreshes its keys rather than duplicating the row, so a client can send this on every page load without checking first.\n\n" +
|
||||
"The subscription keys are encrypted at rest. The endpoint is validated as HTTPS and as a public host before anything is written — the push library dials it later through its own fetch, outside the shared egress wrapper, so input time is where that has to be caught.\n\n" +
|
||||
"Body capped at 64 KB. A rejected subscription answers the standard multi-issue 422, so a client can tell a non-HTTPS endpoint from an internal-host one from a missing key. The issue list carries `path`, `code` and `message` and never the rejected value — neither the endpoint nor the subscription keys are echoed back.",
|
||||
"Body capped at 64 KB. A rejected subscription answers the standard multi-issue 422, so a client can tell a non-HTTPS endpoint from an internal-host one from a missing key. The issue list carries `path`, `code` and `message` and never the rejected value — neither the endpoint nor the subscription keys are echoed back.\n\n" +
|
||||
"An operator can switch Web Push off for the whole instance. While that switch is off this answers 403 before it reads the body, and no subscription and no channel row are created. Read the switch from `GET /api/settings/global-services` and hide the subscribe control rather than retrying.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: webPushSubscribeRequest } },
|
||||
@@ -153,6 +154,11 @@ export const notificationTransportPaths: NonNullable<
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description:
|
||||
"The operator switched Web Push off for this instance. Nothing was stored.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"413": {
|
||||
description: "Request body exceeds 65536 bytes. Nothing was stored.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
|
||||
@@ -375,7 +375,8 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Notifications"],
|
||||
summary: "Update the caller's ntfy channel config",
|
||||
description:
|
||||
"Upserts the channel. Accepts either the toggle-only body or the full config. The response is `{ saved: true }` and carries no config echo, so re-read the GET if the client needs the resolved state. Auth via cookie or Bearer.",
|
||||
"Upserts the channel. Accepts either the toggle-only body or the full config. The response is `{ saved: true }` and carries no config echo, so re-read the GET if the client needs the resolved state. Auth via cookie or Bearer.\n\n" +
|
||||
"An operator can switch ntfy off for the whole instance. While that switch is off, a body that sets `enabled` to true is refused with 403 and nothing is stored — the channel would not deliver, so the toggle does not pretend otherwise. Disabling the channel keeps working either way. Read the switch from `GET /api/settings/global-services`.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: ntfyPutRequest } },
|
||||
@@ -389,6 +390,11 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description:
|
||||
"The operator switched ntfy off for this instance and the body asked to enable it. Nothing was stored.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...jsonBodyRefusals,
|
||||
...stdResponses,
|
||||
"422": {
|
||||
@@ -424,7 +430,8 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Notifications"],
|
||||
summary: "Update the caller's Telegram channel config",
|
||||
description:
|
||||
"Accepts either the toggle-only body or the full config, then registers (on enable) or deletes (on disable) the Telegram webhook for the bot before persisting. Webhook deletion is best-effort and its failure does not fail the write; webhook REGISTRATION is not — a rejected registration aborts the whole call and nothing is stored. The response is `{ updated: true }` with no config echo. Auth via cookie or Bearer.",
|
||||
"Accepts either the toggle-only body or the full config, then registers (on enable) or deletes (on disable) the Telegram webhook for the bot before persisting. Webhook deletion is best-effort and its failure does not fail the write; webhook REGISTRATION is not — a rejected registration aborts the whole call and nothing is stored. The response is `{ updated: true }` with no config echo. Auth via cookie or Bearer.\n\n" +
|
||||
"An operator can switch Telegram off for the whole instance. While that switch is off, a body that sets `enabled` to true is refused with 403 before any webhook call and nothing is stored. Disabling the channel keeps working either way. Read the switch from `GET /api/settings/global-services`.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: telegramPutRequest } },
|
||||
@@ -441,6 +448,11 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description:
|
||||
"The operator switched Telegram off for this instance and the body asked to enable it. No webhook was registered and nothing was stored.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"404": {
|
||||
description: "The session's user row no longer exists.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* both sides a route-handler-independent shared type home; the route
|
||||
* keeps owning the HTTP contract, the component keeps owning the UI.
|
||||
*/
|
||||
import type { BackupScheduleStatus } from "@/lib/jobs/backup-schedule-status";
|
||||
|
||||
export interface BackupRow {
|
||||
id: string;
|
||||
@@ -25,6 +26,12 @@ export interface BackupRow {
|
||||
|
||||
export interface BackupsList {
|
||||
rows: BackupRow[];
|
||||
/**
|
||||
* Whether the weekly schedule is still producing copies, and how the last
|
||||
* scheduled run ended. A row's own timestamp cannot answer that — a copy
|
||||
* made six weeks ago and one made on Sunday look identical in a table.
|
||||
*/
|
||||
schedule: BackupScheduleStatus;
|
||||
/**
|
||||
* Soft retention hint — the worker is configured for weekly backups
|
||||
* (see `DATA_BACKUP_CRON` in `src/lib/jobs/reminder-worker.ts`), and
|
||||
|
||||
@@ -197,3 +197,69 @@ describe("backup endpoints — AuditLog contract", () => {
|
||||
expect(restore?.details ?? "").toContain(seeded.id);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The listing route is what an operator looks at to answer "are my backups
|
||||
* still being made". It could not answer that: it returned rows and a
|
||||
* retention hint, and a copy from six weeks ago carries a timestamp exactly
|
||||
* like Sunday's. It now carries the schedule's own health, and the size comes
|
||||
* from `octet_length` in the database rather than from pulling every blob into
|
||||
* the web process to measure it.
|
||||
*/
|
||||
describe("GET /api/admin/backups — schedule health", () => {
|
||||
it("calls a stalled weekly schedule out, and a fresh manual upload does not hide it", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const admin = await seedAdminSession();
|
||||
const blob = encrypt(JSON.stringify(payloadFor(admin.id)));
|
||||
|
||||
const stalled = new Date(Date.now() - 46 * 24 * 60 * 60 * 1000);
|
||||
await prisma.dataBackup.create({
|
||||
data: {
|
||||
userId: admin.id,
|
||||
type: "WEEKLY_AUTO",
|
||||
data: blob,
|
||||
createdAt: stalled,
|
||||
},
|
||||
});
|
||||
// Uploaded by hand today. It must not make the dead cron look alive.
|
||||
await prisma.dataBackup.create({
|
||||
data: { userId: admin.id, type: "MANUAL_UPLOAD_1", data: blob },
|
||||
});
|
||||
|
||||
// The listing handler declares no parameters; `apiHandler` reads the
|
||||
// request defensively out of its arguments, so calling it bare is what a
|
||||
// zero-argument handler supports.
|
||||
const { GET } = await import("@/app/api/admin/backups/route");
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
rows: Array<{ type: string; sizeBytes: number; username: string }>;
|
||||
schedule: {
|
||||
stale: boolean;
|
||||
lastSuccessAgeDays: number | null;
|
||||
lastRun: unknown;
|
||||
lastRunFailed: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
expect(body.data.rows).toHaveLength(2);
|
||||
// Sizes are read in SQL now; a wrong column or a wrong cast shows up here
|
||||
// as a zero or a NaN rather than as a plausible-looking number.
|
||||
expect(body.data.rows.every((row) => row.sizeBytes === blob.length)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(body.data.rows.every((row) => row.username === admin.username)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
expect(body.data.schedule.lastSuccessAgeDays).toBe(46);
|
||||
expect(body.data.schedule.stale).toBe(true);
|
||||
// No `pgboss` schema in the test database — the run reader fails soft to
|
||||
// null rather than taking the whole page down, which is the arm a web-only
|
||||
// deployment lives on permanently.
|
||||
expect(body.data.schedule.lastRun).toBeNull();
|
||||
expect(body.data.schedule.lastRunFailed).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
* Verifies:
|
||||
* - 201 + a `DataBackup` row with type prefix `MANUAL_UPLOAD_` is
|
||||
* created when the JSON file matches the schema.
|
||||
* - the original payload round-trips through encrypt → decrypt.
|
||||
* - the original payload round-trips through the stored backup envelope.
|
||||
* - 422 when the JSON is malformed.
|
||||
* - 422 when the schema validation fails (missing required field).
|
||||
* - 422 when the userId on the file doesn't exist.
|
||||
@@ -15,7 +15,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { unpackBackupBlob } from "@/lib/export/backup-blob";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
@@ -166,9 +166,11 @@ describe("POST /api/admin/backups/upload", () => {
|
||||
expect(row?.type).toMatch(/^MANUAL_UPLOAD_\d+$/);
|
||||
expect(row?.userId).toBe(admin.id);
|
||||
|
||||
// Round-trip the ciphertext to make sure we stored the same payload
|
||||
// we received.
|
||||
const decrypted = JSON.parse(decrypt(row!.data));
|
||||
// Round-trip the stored blob to make sure we kept the same payload we
|
||||
// received. Through the envelope rather than through `decrypt` alone: an
|
||||
// uploaded file is stored exactly the way the weekly worker stores one, so
|
||||
// the restore path reads both without knowing where the row came from.
|
||||
const decrypted = JSON.parse(unpackBackupBlob(row!.data));
|
||||
expect(decrypted.userId).toBe(admin.id);
|
||||
expect(decrypted.measurements).toHaveLength(1);
|
||||
|
||||
|
||||
@@ -63,6 +63,10 @@ import {
|
||||
encryptWaveformToBytes,
|
||||
} from "@/lib/withings/ecg-waveform-codec";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
|
||||
import { decryptNoteFromBytes } from "@/lib/labs/store";
|
||||
import { decryptContextFromBytes } from "@/lib/labs/biomarker-store";
|
||||
import { packBackupBlob } from "@/lib/export/backup-blob";
|
||||
import { TWO_ENDED_MODELS, type TwoEndedModel } from "@/lib/export/backup-plan";
|
||||
import { POST } from "@/app/api/admin/backups/[id]/restore/route";
|
||||
|
||||
@@ -259,6 +263,27 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
|
||||
source: "MANUAL",
|
||||
},
|
||||
});
|
||||
// A DELETED reading, kept as a tombstone. It is here because the obvious way
|
||||
// to make the weekly backup cheaper — stop exporting soft-deleted rows, which
|
||||
// on a real record is four rows in five — is wrong, and nothing in the tree
|
||||
// said so out loud. A tombstone inside the retention window is what tells a
|
||||
// synced client the row is gone (`/api/sync/changes` reads exactly these) and
|
||||
// what makes a re-sent Apple Health sample a no-op instead of a resurrection.
|
||||
// Drop them from a disaster-recovery file and a restore quietly brings back
|
||||
// every measurement the account ever deleted on the next device sync.
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
type: "PULSE",
|
||||
value: 61,
|
||||
unit: "bpm",
|
||||
measuredAt: AT("2026-07-02T06:30:00.000Z"),
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "round-trip-deleted-sample",
|
||||
syncVersion: 4,
|
||||
deletedAt: AT("2026-07-20T09:15:00.000Z"),
|
||||
},
|
||||
});
|
||||
// The EVENT row an ECG strip is filed against, and the strip itself. The
|
||||
// pairing is the point: `EcgRecording.measurementId` is a real foreign key,
|
||||
// so this is the reference whose remap has to survive the round trip rather
|
||||
@@ -1231,6 +1256,26 @@ describe("every model the plan claims two-ended survives a real restore", () =>
|
||||
purpose: "disaster-recovery",
|
||||
});
|
||||
|
||||
// The tombstone has to be IN the file. A disaster-recovery payload that
|
||||
// carries only live rows is smaller and cheaper and wrong: the restore
|
||||
// wipes the account first, so a deletion that is not in the file is a
|
||||
// deletion the account no longer knows it made, and the next device sync
|
||||
// re-inserts every one of those samples as fresh readings.
|
||||
const exportedMeasurements = payload.measurements as Array<{
|
||||
externalId: string | null;
|
||||
deletedAt: string | null;
|
||||
syncVersion: number;
|
||||
}>;
|
||||
expect(
|
||||
exportedMeasurements.find(
|
||||
(row) => row.externalId === "round-trip-deleted-sample",
|
||||
),
|
||||
"a soft-deleted reading is missing from the disaster-recovery payload",
|
||||
).toMatchObject({
|
||||
deletedAt: "2026-07-20T09:15:00.000Z",
|
||||
syncVersion: 4,
|
||||
});
|
||||
|
||||
// Cascade rather than a hand-listed sweep — see the file comment.
|
||||
await prisma.user.delete({ where: { id: OWNER_ID } });
|
||||
await createOwner(prisma);
|
||||
@@ -1247,7 +1292,12 @@ describe("every model the plan claims two-ended survives a real restore", () =>
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
type: "TWO_ENDED_ROUND_TRIP",
|
||||
data: encrypt(JSON.stringify(payload)),
|
||||
// The envelope the weekly worker actually writes: gzip, then
|
||||
// encrypt. The three cases further down deliberately keep the plain
|
||||
// `encrypt(json)` form, which is what every row written before this
|
||||
// envelope existed looks like — an operator's newest usable copy may
|
||||
// well be one of those, so both arms have to reach the restore route.
|
||||
data: packBackupBlob(JSON.stringify(payload)),
|
||||
},
|
||||
});
|
||||
const response = await POST(
|
||||
@@ -2026,6 +2076,18 @@ describe("every model the plan claims two-ended survives a real restore", () =>
|
||||
// measurement pointer is asserted against the row the RESTORE wrote rather
|
||||
// than against the id the fixture used, because resolving to something
|
||||
// that exists is the property the reference owes.
|
||||
const restoredTombstone = await prisma.measurement.findFirstOrThrow({
|
||||
where: { userId: OWNER_ID, externalId: "round-trip-deleted-sample" },
|
||||
});
|
||||
expect(
|
||||
{
|
||||
deletedAt: restoredTombstone.deletedAt?.toISOString() ?? null,
|
||||
syncVersion: restoredTombstone.syncVersion,
|
||||
},
|
||||
"the deleted reading came back, but not as a deletion — a tombstone " +
|
||||
"restored without its `deletedAt` is a resurrected measurement",
|
||||
).toEqual({ deletedAt: "2026-07-20T09:15:00.000Z", syncVersion: 4 });
|
||||
|
||||
const restoredMeasurement = await prisma.measurement.findFirstOrThrow({
|
||||
where: { userId: OWNER_ID, type: "WEIGHT" },
|
||||
});
|
||||
@@ -2619,4 +2681,103 @@ describe("every model the plan claims two-ended survives a real restore", () =>
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A note this instance can no longer decrypt leaves the export as a marker,
|
||||
* not as nothing.
|
||||
*
|
||||
* A fail-soft decrypt answers `null` on a wrong or dropped key, and in a
|
||||
* portable file `null` is byte-for-byte what a note that was never written
|
||||
* looks like. The restore importer then writes no note, so a note the person
|
||||
* did write is gone and no one is told — the whole loss fits inside a
|
||||
* fail-soft `catch` written for a list view, where returning null is right.
|
||||
*
|
||||
* The Coach transcript and the doctor report both refuse that trade, one
|
||||
* with a placeholder sentence and one with a rendered flag. This asserts the
|
||||
* lab note now does the same, and that the marker is still there after the
|
||||
* file has been through a real restore: the ciphertext is unreadable either
|
||||
* way, but a person opening the restored account can see that something was
|
||||
* there rather than believing they never wrote it.
|
||||
*/
|
||||
it("carries a lab note it cannot decrypt through the round trip as a visible marker", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await seedAdminSession(prisma);
|
||||
await createOwner(prisma);
|
||||
|
||||
const biomarker = await prisma.biomarker.create({
|
||||
data: { userId: OWNER_ID, name: "Ferritin", unit: "ng/mL" },
|
||||
});
|
||||
// Ciphertext under a key id this instance does not hold — the shape a row
|
||||
// written before a key rotation that dropped its key has. `decrypt` is
|
||||
// fail-closed on an unknown key id, so this throws rather than returning
|
||||
// garbage, which is the case the soft helper swallowed.
|
||||
const unreadable = new Uint8Array(
|
||||
Buffer.from("v9.AAAAAAAAAAAAAAAAAAAAAAAA", "utf8"),
|
||||
);
|
||||
await prisma.labResult.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
biomarkerId: biomarker.id,
|
||||
analyte: "Ferritin",
|
||||
value: 91,
|
||||
unit: "ng/mL",
|
||||
takenAt: AT("2026-06-30T09:00:00.000Z"),
|
||||
noteEncrypted: unreadable,
|
||||
},
|
||||
});
|
||||
await prisma.biomarker.update({
|
||||
where: { id: biomarker.id },
|
||||
data: { contextEncrypted: unreadable },
|
||||
});
|
||||
|
||||
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
|
||||
purpose: "portable-export",
|
||||
});
|
||||
|
||||
const exported = payload.labResults as Array<{ note: string | null }>;
|
||||
expect(exported).toHaveLength(1);
|
||||
expect(exported[0].note).toContain("unreadable");
|
||||
const exportedBiomarkers = payload.biomarkers as Array<{
|
||||
context: string | null;
|
||||
}>;
|
||||
expect(exportedBiomarkers[0].context).toContain("unreadable");
|
||||
|
||||
await prisma.user.delete({ where: { id: OWNER_ID } });
|
||||
await createOwner(prisma);
|
||||
|
||||
const backup = await prisma.dataBackup.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
type: "TWO_ENDED_ROUND_TRIP",
|
||||
data: encrypt(JSON.stringify(payload)),
|
||||
},
|
||||
});
|
||||
const response = await POST(
|
||||
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "RESTORE" }),
|
||||
}) as never,
|
||||
{ params: Promise.resolve({ id: backup.id }) },
|
||||
);
|
||||
const body = await response.json();
|
||||
expect(response.status, JSON.stringify(body)).toBe(200);
|
||||
|
||||
const restoredLab = await prisma.labResult.findFirstOrThrow({
|
||||
where: { userId: OWNER_ID },
|
||||
});
|
||||
// Back in the column it came from, readable again because the restore
|
||||
// re-encrypted it under the CURRENT key — and saying what happened.
|
||||
expect(restoredLab.noteEncrypted).not.toBeNull();
|
||||
expect(decryptNoteFromBytes(restoredLab.noteEncrypted!)).toBe(
|
||||
UNREADABLE_EXPORT_MARKER,
|
||||
);
|
||||
|
||||
const restoredBiomarker = await prisma.biomarker.findFirstOrThrow({
|
||||
where: { userId: OWNER_ID },
|
||||
});
|
||||
expect(decryptContextFromBytes(restoredBiomarker.contextEncrypted!)).toBe(
|
||||
UNREADABLE_EXPORT_MARKER,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* The operator's instance-wide channel switches, against a real settings row.
|
||||
*
|
||||
* The unit suite stubs `prisma.appSettings.findUnique`; this one writes the
|
||||
* actual `app_settings` singleton, dispatches through the real cascade, and
|
||||
* reads the `push_attempts` ledger back out of Postgres. It is the layer that
|
||||
* proves the column the admin route writes is the column the dispatcher
|
||||
* reads — a unit mock would agree with itself even if those two drifted apart.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
const ntfySendMock = vi.fn();
|
||||
|
||||
vi.mock("@/lib/notifications/senders/ntfy", () => ({
|
||||
sendViaNtfy: (...args: unknown[]) => ntfySendMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const TEST_USER_ID = "user-global-channel-switch";
|
||||
|
||||
async function seedNtfyChannel(): Promise<void> {
|
||||
const { encrypt } = await import("@/lib/crypto");
|
||||
await getPrismaClient().notificationChannel.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
type: "NTFY",
|
||||
enabled: true,
|
||||
config: encrypt(
|
||||
JSON.stringify({ serverUrl: "https://ntfy.example", topic: "health" }),
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function setNtfyGlobal(ntfyGlobal: boolean): Promise<void> {
|
||||
await getPrismaClient().appSettings.upsert({
|
||||
where: { id: "singleton" },
|
||||
create: { id: "singleton", ntfyGlobal },
|
||||
update: { ntfyGlobal },
|
||||
});
|
||||
}
|
||||
|
||||
async function dispatch() {
|
||||
const { dispatchNotification } =
|
||||
await import("@/lib/notifications/dispatcher");
|
||||
return dispatchNotification({
|
||||
userId: TEST_USER_ID,
|
||||
eventType: "MEASUREMENT_REMINDER",
|
||||
title: "Time to weigh in",
|
||||
message: "Your morning reading is due",
|
||||
});
|
||||
}
|
||||
|
||||
/** The ledger write is fire-and-forget; let its microtask land. */
|
||||
async function settleLedger(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
await truncateAllTables(getPrismaClient());
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: TEST_USER_ID,
|
||||
username: "global-channel-switch",
|
||||
email: "global-channel-switch@example.test",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("instance-wide notification channel switch", () => {
|
||||
it("delivers nothing and records the skip when the operator switched ntfy off", async () => {
|
||||
await seedNtfyChannel();
|
||||
await setNtfyGlobal(false);
|
||||
|
||||
const outcome = await dispatch();
|
||||
await settleLedger();
|
||||
|
||||
expect(ntfySendMock).not.toHaveBeenCalled();
|
||||
expect(outcome).toEqual({
|
||||
dispatched: false,
|
||||
channelsAttempted: 0,
|
||||
channelsSucceeded: 0,
|
||||
});
|
||||
|
||||
const attempts = await getPrismaClient().pushAttempt.findMany({
|
||||
where: { userId: TEST_USER_ID },
|
||||
});
|
||||
expect(attempts).toHaveLength(1);
|
||||
expect(attempts[0]).toMatchObject({
|
||||
channel: "NTFY",
|
||||
eventType: "MEASUREMENT_REMINDER",
|
||||
result: "skipped",
|
||||
reason: "globally_disabled",
|
||||
});
|
||||
|
||||
// The switch is the operator's, not the user's: the account's own
|
||||
// channel row is left enabled so flipping the switch back restores
|
||||
// delivery without every user having to re-enable anything.
|
||||
const channel = await getPrismaClient().notificationChannel.findFirst({
|
||||
where: { userId: TEST_USER_ID, type: "NTFY" },
|
||||
});
|
||||
expect(channel?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("delivers on the same channel once the switch is back on", async () => {
|
||||
await seedNtfyChannel();
|
||||
await setNtfyGlobal(true);
|
||||
ntfySendMock.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const outcome = await dispatch();
|
||||
|
||||
expect(ntfySendMock).toHaveBeenCalledTimes(1);
|
||||
expect(outcome).toEqual({
|
||||
dispatched: true,
|
||||
channelsAttempted: 1,
|
||||
channelsSucceeded: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers when no settings row exists at all", async () => {
|
||||
await seedNtfyChannel();
|
||||
ntfySendMock.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const outcome = await dispatch();
|
||||
|
||||
expect(ntfySendMock).toHaveBeenCalledTimes(1);
|
||||
expect(outcome.dispatched).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user