`User.locale` is the only signal a cookie-blind path has. The nightly
briefing warm, the Coach nudge, the reminder senders and the native aggregate
all resolve from that column; the screen in front of the user resolves from
the `healthlog-locale` cookie first, then the column, then Accept-Language. So
a browser can read the whole app in German while the column says English, and
while it does, nothing on screen looks wrong: the divergence surfaces as one
generated paragraph in the wrong language inside an otherwise correct page.
Keeping the two in step was a fire-and-forget PUT from a mount effect. A 401
on the paint before sign-in, an offline blip, a tab closed mid-flight — each
loses the write, and the next mount repeats the same unchecked attempt rather
than noticing the column is still wrong, so a stale column can stand for the
life of the account.
`getSession` now corrects the column when the request carries a valid locale
cookie the column disagrees with. It is ordered by the request pipeline,
needs nothing from the client, and is self-limiting: once the two agree the
condition is false. Only a cookie counts, and a cookie is only ever written
alongside a committed UI locale, so Accept-Language never stamps a guess onto
a column that generated prose depends on. The mount PUT stays for the
server-set cookie it gets in reply, which Safari's 7-day cap on script-written
cookies makes worth having; it is no longer the mechanism.
The obvious suspect was a first-paint race between two unordered writes. It is
not that: the provider takes the locale the server already resolved and
nothing on the mount path changes it, so the effect fires once with the right
value. `locale-first-paint-single-write` pins both properties so a future
mount-time flip has to face them.
The salutation and the streak label are the two strings this route still
translates server-side, and the native client is its only caller: a Bearer
request carries no language cookie, so `User.locale` is the sole per-user
signal. The coercion here stopped at English when that column was NULL, so an
account that never opened the language picker was greeted in English on an
instance configured for German, while every string beside it was German.
`resolveJobLocale` is where the fallback order lives — stored preference, then
the operator default, then English — and every background writer was moved
onto it earlier. This was the last copy of the old coercion outside that tree.
The structural guard covers `lib/jobs` and `lib/daily` only, so it never saw
this one; the two tests added here pin the behaviour directly instead.
Three pieces of code answered "what hour is it" for the salutation. The
snapshot builder computed a `greetingHour` in the user's zone and put it on
the payload so clients would not have to; no client ever read it. The
insights hero derived one through the shared `hourInTz`. The dashboard header
derived one through a local helper that fell back to the DEVICE clock
whenever the runtime could not resolve the configured zone, which is what
happens to an IANA entry the browser's data does not yet carry. At one
instant, for one account, those three returned 20, 21 and 05.
Keep the client end and drop the server one. The hour is a clock reading, not
a fact about the record, and the snapshot body serves stale for up to an hour
past its fresh TTL, so a stored hour can name a salutation the clock has left
behind. What the server IS authoritative for is the zone, and that already
travels on the same payload and is already what both surfaces read.
Both greetings now share `hourInTz`, so an unresolvable zone falls back to the
same default the rest of the app uses instead of to the device. The header's
private helper is gone; it had no other caller.
Two tests pinned the old shape by asserting the payload carried an hour.
Inverted rather than deleted: the payload must now be shown NOT to carry one,
which is the property that was missing when the field went unread for months.
`greetingHour` leaves the response contract, so a native client decoding it as
required needs the field made optional or dropped.
Three advisories stood open, all development-scope. Two are closed with
version floors in the workspace overrides, following the existing pattern:
@humanfs/node for GHSA-p498-v437-472g and esbuild for GHSA-g7r4-m6w7-qqqr.
Both patched lines stay inside the same minor, so nothing downstream moves.
The third, GHSA-jmr9-qjv8-65gv in extract-zip, had no patched release to
float to. It arrived through @openai/codex-security, which nothing in the
tree imports: the package was referenced only by its own declaration, a
knip ignore entry, and a release-age exclusion. Removing it closes the
advisory and takes about 340 MB of node_modules with it. The knip ignore
and the release-age exclusion go with it rather than being left to rot.
pnpm audit now reports no known vulnerabilities.
The column-reader sweep read every `field:` key as a write, so
`where: { ticketHash }` counted as writing `ticketHash`. All twelve columns it
reported were a lookup key or a cron's discovery predicate being filtered on,
and a review tool that has cried wolf twelve times is not consulted on the
thirteenth. It also reported only columns that already had a write, which made
a column with no write at all the one thing it could not see.
Mask each file, bracket-match every Prisma argument, and tag its contents as
write, projection or query so nesting resolves innermost-wins. Follow a payload
assembled before the call: a const handed to `update:`, a spread merged into a
`create:`, a `rows.push({...})` builder filling a typed array. Credit raw SQL
against the `@map` column name. Report a column nothing touches as its own
category, and split the output so a write with no reader reads differently from
a query predicate, a relation's foreign key and an annotated column.
Twelve false alarms become nineteen findings that hold up. Four are worth
acting on and are left for a decision rather than annotated away: the glucose
unit preference and an import job's export date have readers and no writer, and
the provider-health status code and the known-device first-seen stamp have the
opposite.
The matcher underneath is a test now, checked by breaking the fix three ways.
Carries the night's audit work: the weekly backup ran out of memory
rather than out of time and now completes, two dashboard reads stopped
scanning the largest tables, six webhook verbs compare a secret behind a
rate limit, three global channel switches actually stop delivery,
thirty-five settings reach the container, four guarded helpers are used
where their naive twins were, and three guards now see what they claim.
The export was its only user, and the export now reads through a helper that
marks an undecryptable note instead of blanking it. Nothing else wanted the
silent null: the list DTO never carries the note at all, only `hasNote`, and
the single-resource GET has always used the throwing decrypt so a key gap
surfaces there.
Deleted rather than kept behind a dead-code exemption. A fail-soft twin
sitting unused next to a strict one is the exact shape this branch has been
clearing out — the next person writing an export reaches for whatever is
nearest, and the whole point was that the nearest thing should not silently
swallow a row. It had no test of its own, so none was removed; the three
comments that pointed at it are rewritten to say what is true now.
The weekly pass can stop and leave no trace where anyone looks. The
handler's own error never runs when the job is killed rather than thrown;
`recordError()` increments an unnamed counter; and the admin status page's
failing-queue panel 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 an ordinary row with an ordinary timestamp, because a copy
made six weeks ago reads exactly like Sunday's until somebody subtracts
the dates.
The listing now carries two facts that fail at different times: the age of
the newest scheduled copy, which never leaves any retention window, and
the terminal state of the last scheduled run, which is the only place the
reason is written down. `readLastQueueRun` takes the newest run rather
than a window, so a weekly queue's outcome stays readable until the next
one replaces it, and it unwraps the value pg-boss stores a thrown error
under, so the page says "job timed out" instead of raw JSON. The
failing-queue reader gets the same unwrap.
Manual uploads are excluded from the cadence judgement by the shape of the
input, so an ad-hoc upload cannot make a dead cron look alive, and a fresh
instance is not called overdue for having no copy yet.
The listing also stops reading every blob into the web process to measure
it; `octet_length` answers that in the database, which on an instance with
large records was its own way of running out of memory.
The weekly pass built the whole account in memory, serialised it whole,
encrypted the string whole, and handed the result to the database whole.
That is four full copies of the record at the same moment — the object
graph, the JSON, the base64 ciphertext at 1.33x the JSON, and the copy the
driver makes of that parameter. Measured against a seeded account of
445 000 readings, the JSON alone is 242 MB and the run dies of heap
exhaustion; under a modest heap it never reaches the insert. The pass was
not slow, it could not finish.
A health record's JSON is extremely repetitive, so gzip takes that 242 MB
to 14 MB and every copy after it shrinks with it. The same run completes
in about twenty seconds with a peak heap of 434 MB and a stored row of
25 MB instead of 322 MB — which also matters because the copy lives inside
the database it would be needed to restore. Both backup jobs now hand the
serialised form back from a frame of its own, so the payload graph is
released before the compress step allocates anything.
The marker sits inside the ciphertext, so the stored column keeps the one
shape `decrypt()` already understands and rows written before this still
restore. The off-host object gets the same treatment through the version
byte its envelope always carried; version 1 objects still read back, which
both the restore drill and the restore script depend on.
The obvious saving — dropping soft-deleted readings, four rows in five on
a real record — is not taken, and the round-trip test now seeds a
tombstone and asserts it makes the journey. A tombstone inside the
retention window is what tells a synced client the row is gone and what
makes a re-sent sample a no-op; a restore without them brings back every
measurement the account ever deleted on the next device sync.
The `data-backup` queue was registered without send options, so every job
it minted took pg-boss's queue defaults: a fifteen-minute expiration and
two redeliveries. A snapshot that serialises every account's whole record
does not fit in fifteen minutes on a large one, so the pass was killed
part-way through, redelivered twice against the same record, and reached
`failed` with "job timed out" about three quarters of an hour after it
started — having never once completed.
Two hours, on the schedule rather than on the queue: `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. The
admin run-now route sends with the same options, or an ad-hoc snapshot
would silently keep the default the cron was rescued from.
The default two retries stay. The old redeliveries were pointless because
the first attempt could not succeed either; a transient failure genuinely
benefits from a second go, and the alternative is losing the week.
Derives both sets mechanically — the whitelist from docker-compose.yml, the
read set from the source — and fails when a variable is read without being
forwarded. Written red against the tree before the fix, where it named 35.
A search for process.env. alone is not the read set: four modules resolve the
name through a helper and index process.env dynamically, which hides every
OIDC_* variable and ALLOW_LOCAL_AI_PRIVATE_HOSTS. Each is registered with its
own call-shape matcher and its own non-zero assertion, so a rename fails the
file loudly instead of quietly shrinking the set. The walk and the matchers
both assert a floor, because an empty match set agrees with any allowlist.
Thirteen deliberate exceptions each carry a written reason, and the list is
kept honest from both ends: an entry that stops being read, or that later
appears in the whitelist, fails too.
The environment: block is a whitelist. Compose substitutes ${VAR} from .env
at compose-up, so a value set there looks configured, but a variable the block
does not name never reaches the Node process and the default stands silently.
Twenty-eight variables were in that state: DEMO_MODE, the four LOG_* knobs and
the three LOKI_* shipping vars, DATABASE_STATEMENT_TIMEOUT_MS, four retention
and alerting windows, TELEGRAM_WEBHOOK_SECRET, NIGHTSCOUT_PRIVATE_ORIGINS,
three OAuth callback overrides, both Open-Meteo endpoints, four AI knobs,
three APNs key-source vars, and DASHBOARD_SSR_PREFETCH.
APNS_KEY and APNS_KEY_FILE are the sharpest of them: .env.production.example
and the env manifest both document a precedence chain of APNS_KEY_B64 before
APNS_KEY before APNS_KEY_FILE, and only the first arrived.
Each is documented in .env.production.example in its neighbours' style. The
VAPID section's note claiming those three names are absent from the whitelist
is corrected; they have been on it since v1.17.1.
Six WEB_PUSH_* names and NEXT_PUBLIC_VAPID_PUBLIC_KEY sat behind the three
real VAPID_* variables in the env fallback. None was ever on the compose
environment: whitelist and none appeared in .env.example, .env.production.example
or the env manifest, so under the bundled stack a value set under any of them
could not reach the process. Remove them and the self-hosting note that
advertised them.
`adherenceRatePercent` returns null exactly when nothing was expected, so
callers render a placeholder instead of a misleading 0 or 100. The FHIR
exporter coalesced that null to 0, which would put a clinical claim of zero
adherence into an exported bundle. The guard above it means the branch
cannot be reached today; skipping rather than coalescing keeps it
unreachable if that guard ever moves.
A deleted reading is kept with a `deletedAt` stamp, and the schema says in
as many words that normal reads filter it. Three did not.
The custom-metric correlation channel filtered the metric's own tombstone
but not its entries', so a reading the person deleted kept shaping the
correlation they were shown. The status input hash — which decides whether a
cached AI assessment is still current — filtered its measurement arm but not
its mood aggregate and not its custom-metric count or newest-entry probe. A
deletion left that fingerprint unchanged, so the gate re-dated yesterday's
text and served it as today's over data that was gone.
Pinned in the existing soft-delete guard, whose matchers were checked by
removing each filter again.
A lab note or a biomarker context written under a key this instance no
longer holds came out of a portable export as `null` — byte-identical to a
column that was never written. The restore importer then wrote nothing, so
the text was gone and nothing said it had ever been there. The whole loss
fit inside a fail-soft `catch` written for a list view, where returning null
is the right answer.
Both now export a short marker naming what happened, and the marker survives
a restore. The Coach transcript already did this with a placeholder sentence
and the doctor report with a rendered flag; the marker is now one constant
the transcript shares, and its comment says why an empty string will not do.
The unit test that required `note: null` here pinned the loss. It keeps the
half that matters — one bad row must never abort the export — and asserts
the marker instead. The round trip is proven end to end against a real
restore.
The blood-pressure medication continuity series joined every day that had a
systolic reading against `takenByDay.get(day) ?? 0`, so a day with no intake
record at all entered the scatter and the coefficient as a measured 0 %
continuity. A week away from the app read as a week off the medication.
Only days carrying an intake record are paired now. A logged skip is a real
zero and stays; a day with no record is absent, and absence is not zero.
The timezone-parity fixture in the route test recorded three logged days out
of five and relied on the other two arriving as zeros, which pinned the
defect rather than the timezone question it asks. Its two remaining days are
recorded as skips, which keeps five paired days and keeps every one of them
something the person actually logged.
`calculateCompliance` answers `rate: 100, totalExpected: 0` for a medication
that expects no dose. Arithmetically honest, and a lie on a surface that
reads the number as adherence — it also inflated the averages the AI prompts
are built from. The doctor report has excluded both the PRN arm and the
empty-schedule arm since it was written, with the reason beside it. The
insights envelope, the blood-pressure gate and the medication-compliance
snapshot filtered only PRN, and the empty schedule is reachable:
`POST /api/medications` accepts `schedules ?? []` on a scheduled medication.
Move both arms into one predicate, `expectsDoses`, and route all four
surfaces through it so they cannot drift apart again.
Four advisories landed against the URI parser that arrives under ajv,
including the copy the connector SDK pulls, all of them host confusion
or request forgery through percent-encoded schemes, skipped IDN
canonicalisation and malformed authorities. The floor set yesterday was
one patch below the fix. A query-string parser needs 6.16.0 for an
array-limit bypass and a denial of service.
The audit reads no known vulnerabilities after the bump.
The mood page called the plain `pearsonCorrelation` — strength banded from
|r| alone above a floor of five pairs, no significance test — so five days
of noise sloping the right way arrived at the reader as a "Strong" badge,
with n and r behind an info icon. The guarded twin was written for exactly
that failure and names it in its own comment, and the weight, blood-pressure
and mood-status surfaces had already moved onto it. The mood page had not.
Route the page's five cross-metric channels through
`significantPearsonCorrelation` (n >= 20 AND p < 0.05) and carry the reason
a pairing was refused, so the card can distinguish "still building" from
"twenty days and nothing in them" instead of showing the same blank for
both. New copy in all seven locales.
Add a structural guard that fails when a new caller of the unguarded helper
appears outside a written allowlist. Two callers remain on it, each named as
open work rather than as a blessing: the /insights envelope and the AI
feature payload both still band at n >= 5, and moving them changes payload
shapes their own tests pin.
Dismissing a correlation pattern stores `dismissedEvidenceHash` alongside
the effect size and sample size, and the gate that decides whether to raise
the finding again read the two numbers and never the hash. The hash is the
only part of the snapshot that covers the whole evidence tuple, and a
snapshot that carries it without the two numbers — what a partial restore
leaves behind — could not be released by anything: the gate had no value to
compare and fell through to `false` on every future run.
Move the decision into `shouldReleaseDismissal`, which reads the hash twice
and for two different reasons. Evidence byte-identical to what was
dismissed settles immediately, without evaluating a threshold. Evidence
whose hash no longer matches goes to the existing thresholds when the
numbers are there, and releases the dismissal when they are not, because a
changed hash is still proof the evidence moved.
The thresholds themselves are untouched. A dismissal still has to survive
ordinary sampling noise, which is what they are for; a hash that changes on
every recomputation would otherwise undo a deliberate dismissal on the next
nightly run.
`adminSettingsSchema` validates five `assistant*` booleans and the strict
schema refuses anything else, but the route's `booleanFields` list never
carried them. Sending one alone answered 422 "No valid fields"; sending one
alongside a real field answered 200 and dropped it. The schema comment says
the entry exists so an operator scripting their settings over the single
route can carry the values, so the route is brought up to the contract
rather than the contract cut down to the route.
The flags are echoed in the GET and PUT payloads as raw column values — the
master-over-sub resolution stays with `/settings/assistant-flags`, which
owns that shape and was never affected.
With the switches now enforced at dispatch, a user could still flip a
channel on, see the toggle stick, and get nothing. Refuse the enable
instead, with 403 and a message that names the reason: the ntfy and
Telegram config endpoints on both accepted body shapes, and the Web Push
subscribe endpoint, which is the enable step for that channel because it
creates the channel row.
Telegram is checked before the webhook registration, so a refused enable
makes no outbound call and stores nothing. Disabling a channel keeps
working while the switch is off — a user must always be able to turn a
channel off, whatever the operator has done.
The three refusals are published in the contract; `/api/settings/global-services`
already exposes the switches so a client can hide the control instead of
provoking the refusal.
`AppSettings.telegramGlobal`, `.ntfyGlobal` and `.webPushGlobal` were read
in two places, and neither of them was the one that matters: the setup-card
visibility on Settings -> Integrations and an echo in the preferences
payload. The dispatcher never looked at them. An operator who switched a
channel off instance-wide lost the setup card and kept every existing
delivery on that channel, indefinitely.
Gate each of the three in the dispatcher, in the shape
`isApiGloballyEnabled()` already uses for the twelve API routes. The read
sits above the per-user channel row and below the per-event preference, it
happens at most once per dispatch, and only when a switchable channel is
actually in the cascade. A skip writes a `globally_disabled` row to the
push-attempt ledger so the admin diagnostics name the cause, and does not
count as an attempt, so `dispatched` keeps telling the truth. The account's
own channel row is left enabled: the switch is the operator's, and flipping
it back has to restore delivery without every user re-enabling anything.
APNs, the generic webhook and email carry no instance-wide switch and are
not gated here; they are turned off at their source instead.
The unit test drives the real dispatcher over a stubbed settings row; the
integration test writes the actual singleton and reads the ledger back out
of Postgres, which is the layer that proves the column the admin route
writes is the column the dispatcher reads.
Two spellings the egress guard could not see, both probed.
src/proxy.ts, src/instrumentation.ts and src/cli/ sat outside the enforced
roots (src/lib/ and src/app/), and the companion api-fetch-required guards
the client surface, so it did not reach them either. A raw fetch dropped
into instrumentation.ts linted clean. All three are egress-free today, but
instrumentation is exactly where an exporter pointed at an operator-supplied
URL would land.
The callee match knew the identifier `fetch` and the globalThis / window /
self member forms only, so an aliased import escaped it in every file:
`import { fetch as f } from "undici"` then `f(url)` linted clean under
src/lib. That spelling is what src/lib/safe-fetch.ts itself writes. Local
names bound to an imported `fetch`, and namespace-imported `ns.fetch(…)`,
are now callees; the wrapper internals and test files stay exempt as
before, and the wrapper's own aliased import is pinned as a valid case.
Adds the rule's missing unit-test file. The header records what the
binding scan still cannot see: require(), dynamic import, a fetch passed
as a parameter, and egress that never spells fetch at all.