Three overrides had drifted behind the advisories they were written for,
and the scan found the packages at versions the pins were supposed to
exclude.
dompurify moves from <=3.4.10 to <3.4.13. GHSA-55q2-fjhq-7xh7 is fixed
in 3.4.13 and the tree carried 3.4.12. The copy is jspdf's optional HTML
renderer, an optionalDependency at ^3.3.1, so the raise stays inside the
range jspdf declares.
postcss moves from <8.5.10 to <8.5.23 for CVE-2026-69153. next declares
an exact postcss@8.4.31, so nothing but this bound moves it; the build
has run on the 8.5 line since the first bump and stays there.
valibot gets a floor it never had, <1.4.2, for CVE-2026-59952. Two
copies arrive and neither is ours to call: an optional peer of
@hookform/resolvers, which this app does not use because it validates
with zod, and prisma's local dev server. 1.4.2 is the same major, so no
consumer is moved onto a new one.
@hono/node-server moves from <1.19.13 to <1.19.15 for
GHSA-frvp-7c67-39w9. The app tree already resolved 1.19.17, so this
changes nothing here; it exists so the bound states the real floor and
so the Dockerfile mirror has something correct to match.
The floor test in ci-determinism asserted the exact key and replacement
string, which meant every legitimate raise failed it while a floor gone
stale against a newer advisory passed it unchanged. It now reduces each
key to a package name and asserts the pin is at or above the fixed
version, with a case proving the comparison can return false.
The scope-fence test asserts that a refused section leaves a
sharing.access.denied audit row naming the reason. That row is written
without being awaited, on purpose, so the audit never delays the 403 the
caller is waiting on, and it lands some time after the response returns.
The test bridged that gap with a fixed 150 ms sleep, which a slow runner
outran: the read found an empty table and the assertion reported no
refusal, which reads exactly like the section having reached the wire,
the one thing this file exists to catch.
The positive case now polls for the row and fails with a message naming
the caller when none arrives within five seconds. The absence case keeps
a fixed wait, longer than before, because an absence claim is only worth
something if it waited. Proven by dropping the audit write behind an env
flag: eleven cases fail with the new message, and pass again with the
write restored.
The account-deletion fix landed after v1.38.2 was tagged, so its entry
moves out of that section and into this one where it belongs. Also in
this release: every measurement source carries a display name in all
seven languages, with a test that reads the enums out of the schema.
The four raw values in the sources ladder shipped because nothing checks
the catalogues against the enum they render. The new guard reads
MeasurementSource and IntakeSource out of prisma/schema.prisma, not out
of a copy, and fails naming every member no catalogue labels; it also
asserts the key each entry names resolves in all seven bundles, since a
key that resolves nowhere renders no better than the enum value. Run red
against the tree before the labels landed, it reported seven unlabelled
measurement sources per catalogue and one unlabelled intake source.
The schema reader now carries each enum's declared members so the guard
can read them.
Settings, Sources listed GOOGLE_HEALTH, OURA, POLAR and STRAVA by their
internal name: the catalogue was written for seven sources and every
provider added since fell through to the raw value. The measurement list
carried the same fall-through in its source filter and its table column,
and the medication intake history read "via APPLE_HEALTH" for a dose
synced from Apple Health.
The three catalogues move into one module keyed by the enum, so an
omission is a type error rather than a raw value on screen, and the sleep
discrepancy tooltip drops the private brand-name map it had grown to work
around the gap.
The published contract still told a client that a Bearer token can never
clear the step-up on account deletion or the record wipe, which is what
an app generates its client from. Describe the elevation arm on both:
which factors qualify, that a password proof does not, that the proof
rides the X-Step-Up header, and that it is spent only when the erasure
runs. The 403 a narrow-scope token gets was never published either;
it is named now, ahead of the second-factor question it precedes.
The mint endpoint lists the surfaces an elevation unlocks, so both
routes join that list.
Also note on the record wipe that API tokens are on its own delete list:
a client that wipes over Bearer signs itself out, which is worth knowing
before the retry rather than after it.
Account deletion and the record wipe both demand a fresh second-factor
proof once a factor is enrolled, and that proof lived on the session row.
A native client has no session row, so an account with TOTP or a security
key could not delete itself from the app on any request it could build:
requireFreshMfaIfEnrolled fell through to requireFreshMfa, which resolves
via getSession and answers 401 to a perfectly valid token. App Store
guideline 5.1.1(v) asks for exactly that deletion.
Add requireFreshMfaOrElevationIfEnrolled beside the cookie-only sibling
rather than softening it. Its cookie arm is the sibling's, unchanged;
its Bearer arm takes the single-use elevation the second-factor
management routes already accept, with the fresh-factor rule pinned on,
so a password-proved elevation cannot erase an account any more than a
password login satisfies the gate on the web. No factor rides the request
body: one mint surface, one redemption path. Scope stays fail-closed, so
a narrow token is refused before the header is read.
The elevation is spent immediately before the erasure, after the typed
confirmation and the last-admin check, so a rejected request does not
burn a proof against a 5-per-15-minute mint ceiling. The Bearer branch
of requireMfaManagementAuth moves into a shared helper so both gates read
and claim in the same place.
The elevation guard grows a section naming the two routes, because the
set of surfaces an elevation can unlock only stays small if adding to it
is a visible edit.
No assistant prefill on Anthropic JSON calls, Claude 5 and Fable count as
vision-capable, the briefing cache carries its language and is never
served to a reader of another one, and job paths resolve a missing
account language to the instance default.
Every background path that resolved a user row's locale collapsed a
NULL column to English: the nightly briefing warm, the status crons,
the period-narrative warm, reaction lines, the morning refresh, the
briefing push, coach nudges, the reminder workers, and the document and
workout summaries each carried their own copy of
`locales.includes(x) ? x : defaultLocale` or called
`normalizeLocale(user.locale)`. None consulted the instance's configured
default language, so an account that never touched the language picker
got English prose and English pushes on a German-language instance.
Add resolveJobLocale (src/lib/i18n/job-locale.ts): stored user locale
when valid, else the operator's app_settings.default_locale when valid,
else English. The settings read lives beside the other app-settings
accessors. Every job and daily path resolves through it once, where it
has the row; the template helpers keep coerceLocale for a value that is
already resolved, and queue payloads keep normalizeLocale.
The briefing push hands its resolved locale down to the digest, and the
digest to the snapshot read, so the push copy and the digest lines are
in the same language instead of two different fallbacks.
A structural test walks src/lib/jobs and src/lib/daily and fails on a
hand-rolled coercion, a validator applied to a row column, or a row
reader that does not import the helper. It was red against the previous
tree on all three counts.
The comprehensive-briefing cache (User.insightsCachedText) is one slot
per account with no record of the language it was written in. Every
reader served whatever was there, so a German-language account whose
nightly warm had written English kept seeing English on the dashboard
lead and the Today briefing. The request path only replaced it once the
row was a day old and the provider answered, which with a provider at
its usage limit was never.
Add User.insightsCachedLocale (migration 0335). Both writers store the
locale they generated in; the timestamp-only refresh on an unchanged
snapshot tags the row too, since the fingerprint covers the generation
locale. Every reset that nulls the text nulls the tag.
The snapshot briefing lift and GET /api/insights/generate treat a tag
that differs from the reader's locale as an empty cache: no prose, no
timestamp, the preparing / no-provider state, and the out-of-band warm
enqueued in the reader's locale through the existing path. The
generator's own short-circuit and the POST short-circuit refuse to call
a foreign-language row fresh. A NULL tag (rows written before this
change) is served as before until the next write tags it. The Today
digest derives from the snapshot and follows.
Covered by unit tests on the lift, the route and the generator, and an
integration test that writes the row through Prisma and reads the
snapshot for a reader in another language.
The Anthropic vision check listed the Claude 3 and 4 families by prefix,
so a key pinned to claude-sonnet-5, claude-opus-5 or a Fable model was
told the provider cannot read images, and lab and document OCR refused
the upload even though the model handles image and PDF blocks.
Replace the per-release prefix list with a rule: any claude-<tier>-<major>
slug from the 4 family upward, plus every claude-fable-* model, counts as
vision-capable. Claude 3 handling is unchanged. The Settings preset list
gains claude-sonnet-5 and claude-opus-5 alongside the existing entries.
The Anthropic client steered JSON replies by appending an assistant turn
containing an opening brace and gluing the brace back onto the reply.
Current Claude models (the 4.6 / 4.7 / 4.8 family, Opus 5, Sonnet 5,
Fable 5 and 5.1) reject an assistant prefill with HTTP 400, so every
JSON surface on the provider (briefing, status cards, document and lab
extraction) failed for a self-hoster on a current model while plain chat
kept working.
Drop the prefill on every model. The request now carries a plain
instruction on the last user turn to reply with a single JSON object and
nothing else, and the reply is narrowed to the outermost object body via
a shared extractJsonObject helper, so a fenced or prefixed answer still
parses. The insights stripJsonFences helper delegates to the same
function rather than keeping a second copy. The tool-use path is
unchanged.
Reported independently by @sweidinger.
The v1.38.0 notice that warns when the configured OAuth callback origin
differs from the origin the browser is on never rendered in the published
image, and the copyable callback address in the redirect guide showed a
bare path. `integrationCallbackUrl()` read `process.env.NEXT_PUBLIC_APP_URL`
inside a "use client" module. Next.js inlines NEXT_PUBLIC_* into client
bundles at build time, and the GHCR image is built without that variable
(only the version, build-sha and built-at args reach the build), so the
base compiled to an empty string, `new URL("/api/<provider>/callback")`
threw, and the notice returned null on every card for every self-hoster.
The value now comes from the server at request time. A new map
(`src/lib/integrations/callback-urls.ts`) calls each provider's own
redirect-URI function (the four private `getRedirectUri()` helpers are
exported under provider-specific names; Polar, Oura and Strava already
exported theirs), so the page shows exactly what the handshake registers,
explicit `*_REDIRECT_URI` override included. The settings route resolves
the map and passes it as a prop through `IntegrationsSection` and
`ConnectionsPanel` to every card. Fitbit and Google Health validate their
URI and throw on a bad configuration; that surfaces as `null`, the notice
skips the comparison and the guide falls back to the fixed path.
Tests: a map test proving the explicit override wins and the derived form
is used otherwise, a component test rendering the notice from a passed
value (mismatch renders, match does not), and a structural guard that
fails when any "use client" file under src/components/settings/integrations
reads NEXT_PUBLIC_APP_URL.
The dependency audit went red on the release branch: mysql2 below 3.22.0
downgrades its auth plugin to mysql_clear_password and leaks the
credential. It reaches the tree only through prisma's MySQL driver, which
this app never opens, but the gate does not distinguish reachable from
present. Pinned to ^3.22.0 in the workspace overrides and mirrored with
npm's own override for the /opt/prisma-cli install, the pair the
lockstep guard holds together. The audit reads three moderates, below
the gate.
A measurement ingest token for readings from your own hardware, a
reference-range bar beside every lab reading, lab OCR working with a
GPT-5 model, an impossible reference window no longer written to the
record, and the image build kept intact across the Prisma adapter bump.
Dependency updates ride along.
Three of the last four pushes to main failed the integration workflow
without a single failing test. Shard 1 of 3 was still logging green
results when the 25-minute job timeout cancelled it; the same tests in
the same shard had run in 13 minutes the day before. A slow runner day
doubles every test's wall time, and a guard under twice the ordinary
run cannot absorb that.
Four shards keep each slice around fifty files, and the guard moves to
40 minutes, three times an ordinary run. A wedged test still fails
through vitest's own 60-second budget, so the job timeout only has to
catch a container that never boots. The fan-in job is unchanged; it
still refuses a matrix with any cancelled shard.
A floor at or above its ceiling fell through to the one-sided branch and
was read as "minimum only", which paints a high reading green. That is
the wrong direction for a transcription error to fail in: the colour
answers a question nobody could answer from that data.
Each one-sided branch now requires the OTHER bound to be absent, so a
present-but-impossible pair lands on the neutral dot instead. It matches
lab-reference-range-bar.tsx, which renders nothing for the same input —
the two components sitting next to each other should not disagree about
what a nonsense range means.
The file had no test. It has one now, covering all five window shapes
plus the zero-width band; with the two conditions relaxed again, exactly
the two new cases fail and the four existing ones stay green.
The production pass reports 22 files, and the comment said 21 — a small
drift, but the paragraph reads as a triage nobody has redone, so the
number is dated now and carries an instruction to re-measure.
Two things the block did not say:
The standing exception. `signals/adapters/metric-status.ts` is reported
unreachable because its only importer is its own registry-invariant test.
That is what the module is for: it projects the signal registry onto the
MetricStatusMeta shape so the test can assert the two agree field for
field. Deleting it to clear the line removes a check rather than ballast.
Someone triaging the list without that written down has to rediscover it,
and the obvious move is the wrong one.
The healed example. The false-positive slot cited a module reported
because scripts/ sits outside the tsconfig that defines the @/ alias.
It no longer appears — module and importing script both still exist, so
resolution improved under us. Replaced with the case that does hold
today, the service-worker policy that public/sw.js mirrors by hand, and
the old one kept as a note: an example that heals silently makes the
reasoning around it look wrong to whoever checks it next.
A lab report's reference range reaches the record by four paths. Three of
them validate that the floor does not exceed the ceiling; the document
path did not, and the catalog store writes what it is handed. An
integration run against a real Postgres confirmed the consequence: a
100/30 pair landed on the row and on the freshly minted biomarker, so
every later reading of that analyte inherited a window that cannot be
true and a high value came back reassuring.
Dropping beats swapping. A transposed pair says the transcription is
wrong, not which of the two numbers is, and inventing an order would
state something the report never printed. The verbatim string survives
either way, so nothing the document actually said is lost.
Three points, answering according to what each one is:
- the extraction schema drops the pair, because every field there is
catch-guarded so one bad value cannot sink a 120-fact document
- the review-screen edit refuses with the wording the lab schemas
already use, because a person can see both numbers and fix them
- the commit path drops it as the last gate, which is what covers facts
staged before this rule existed and confirmed afterwards
parseReferenceRange has answered a transposed PRINTED window this way
since it was written, and the commit path already counts a window it
could not read at all. This extends both to the case that was readable
and cannot be true.
That file stubs @/lib/api-handler wholesale, so it lists the exports it
knew about when it was written. /api/measurements now reaches for
isScopedCredential to decide source attribution and whether the sync
checkpoint may move, the mock does not provide it, and every test in the
file dies on import rather than on an assertion.
`false` is the honest stub: those cases resolve as a cookie session,
which is exactly what unscoped means, so the route takes its ordinary
path through both rules.
Checked the rest of the tree rather than fixing only the file that went
red — this is the only suite mocking that module for a route which
reaches the new export. The batch route's tests mock at a different
seam and are unaffected.
Not verified here: this environment cannot generate the Prisma client,
so that file is one of the suites which fail to import for an unrelated
reason and the fix could not be run.
Three failures in the integration run, all mine, none in the product.
The two delegation cases set the account selector as a header on the
NextRequest. Nothing reads it there. `readSelectorHeader()` resolves
through `next/headers`, which this suite mocks against the shared
`headerJar`, so the carrier came back `none` and both cases quietly
exercised the ordinary own-record path instead of the one they name. The
scoped case wrote to its own record and returned 201; the wildcard
control wrote to the caller rather than the owner. Neither was testing
delegation at all.
That is worse than a broken test. The refusal these two cover is the
regression this change could most easily have shipped in silence, and
the tests standing in front of it were not looking at it.
They now set the selector on the jar, through the exported
ACCOUNT_SELECTOR_HEADER rather than a literal, so a rename cannot
reintroduce the same gap.
The third failure is unrelated and smaller: the read-leg case read the
audit row once, racing a fire-and-forget write, and reported `undefined`
for a row about to exist. It now polls, matching B1, which carries a
comment about this exact hazard that I did not follow.
Not verified here: this environment has no Docker daemon, so the
integration suite cannot be run against a real Postgres. The diagnosis
is from the mock and the resolver, not from a green run.
The mint used requireAuth() with no scope, which refuses a narrow token
but still admits a cookie-equivalent one. The reach is not the problem —
a wildcard token can already do everything this endpoint does. The
lifetimes are: a native access token lives a day, and what it mints here
lives a year. So a credential that leaked for an afternoon could leave
behind one that outlives revoking it, and the revocation would look like
it had worked.
requireCookieAuth() closes that. It is the helper the passkey
registration and trusted-device routes already use for the same reason,
and the sharing guard recognises it as cookie-only.
The prose said more than the code did, in three places: the route header
and the OpenAPI description both claimed the endpoint "cannot mint
another token", which held only for narrow tokens, and the 403 arm
described a scope refusal that a Bearer never reaches. All three now say
what is true — no Bearer credential reaches this endpoint at any scope,
and a Bearer is refused 401 before the operator switch is read.
Tests follow the status change: the integration case asserting a scoped
token cannot mint now expects 401 rather than 403, since the refusal is
authentication and not scope, and a companion case pins the wildcard —
the credential class this change is actually about — and asserts no
token row is created.
The card read like API documentation sitting in a settings page: it named
the Authorization header, spelled out both endpoint paths, and listed the
scope's limits as four clauses. None of that is what someone deciding
whether to mint a token needs, and the endpoint paths were already in the
API endpoints card immediately above it.
Rewrite to the shape the MCP connector card next door uses — a short
title, one line on what the token is for, one line saying it is shown
once, and one sentence on what it can and cannot do. Drop the endpoints
note entirely rather than rephrase it, since the card above owns that.
Say the useful consequence instead of the mechanism: readings arrive as
if entered by hand, so they can be corrected. That is the thing a person
will actually notice.
Translations were revised across all seven locales — the previous German
in particular read as translated English rather than as the rest of the
app, which uses short informal sentences.
`stdResponses` already carries 401, 422 and 429. The three response
blocks added with the measurements:write scope each declared their own
422 (and, on the mint route, a 429) BEFORE spreading it, so the generic
wording overwrote the specific wording and TypeScript refused the
duplicate key. The build failed at auth.ts with "'422' is specified more
than once".
Move the explicit keys after the spread, which is what the MCP mint next
door already does and the only order in which a per-route description
survives.
Worth stating because the failure mode is quiet: had TypeScript allowed
it, the published contract would simply have carried "Request validation
failed" everywhere instead of naming
measurement.create.source_not_permitted and its siblings.
Since v1.30.17 there has been no self-service path to a token that can
write a measurement. That release made the Bearer scope default
fail-closed, which closed a real privilege-escalation hole, and removed
the generic mint at POST /api/tokens along with it. The generic mint had
been the only way to get a token that could reach /api/measurements, so
a local sensor pushed through Home Assistant or Node-RED lost its door
while the OAuth integrations kept theirs.
Add a narrow scope accepted by exactly two routes, both writes, both on
the holder's own record.
The scope constant is a leaf module beside the surface that consumes it,
matching FHIR_READ_SCOPE and the MCP scopes. requireRecordAuth grows an
options object rather than a sibling function, because the sharing
surface guard keys every leg on that one identifier and a second name
would mean two copies of the delegable declaration. AuthContext carries
the admitting scope as a single nullable string rather than the token's
permission array, so nothing downstream can re-derive an authorisation
decision from it.
A scoped credential is refused a record selector before any grant is
read. Placing the refusal above the sharing check rather than beside it
means no future widening of what a grant permits can reach one of these
tokens.
Rows are attributed MANUAL. An entry that explicitly names APPLE_HEALTH
is refused 422 rather than relabelled: that source is half the
(userId, type, source, externalId) dedup key the iOS client writes into,
it participates in the cross-source merge, and it gates the HealthKit
sync fields. For the same reason a scoped write no longer advances
User.lastSyncedAt, which the phone reads to size its undelivered window.
Also closes a narrower gap found while building this. The idempotency
wrapper resolved a replayed delegated response against a live grant
without considering which credential was presenting it, so a cached
response about a shared record could be returned to a token the handler
itself would refuse. No write was reachable that way; the response body
was.
Guards: T3 gains the mint site, T4's vocabulary gains the scope and
learns to read the requireRecordAuth form (without which this change
would open a way to widen a route that the closed vocabulary could not
see), and a new T5 freezes the set of delegable routes naming a scope.
The sharing surface guard's AST matcher reads only the first two
arguments, so it needed prose but no code.
Follow-up to #860, promised in review rather than asked of the
contributor. `not.toContain("left: 4%")" passes for a marker at 4.02%,
which is the same defect one rounding away, so the test now reads the
marker position and asserts it sits inside the padding at both ends.
EDGE_PADDING_PERCENT moves to module scope and is exported, so the
assertion uses the number the component clamps with instead of a copy
that can drift. It was being reallocated on every render besides.
Verified by mutation: dropping the outlier expansion puts the marker at
exactly 4 and turns this test red.