Adds AuditLog.actorUserId (migration 0294) and threads it centrally, out
of the wide-event context the acting-account resolver already stamps. No
call site passes an actor, so no route can forget one.
NULL keeps meaning "acted for themselves" — the reading every existing
row carries. The threading fills the column only when a request resolved
an acting account AND the row is being filed under that account, so a
self-action, a refusal, and a delegate's row about themselves all stay
NULL rather than acquiring an id that would rewrite what the history says.
Delegated reads coalesce to one row per owner, delegate and day, through
a partial unique index and an insert that expects to lose the race. The
alternative is a row per request on the busiest table in the schema and
an activity view made of identical lines.
The column carries no foreign key, against the grain of user_id beside
it: SET NULL there erases an account from its own history, while here it
would turn "the delegate opened this record" into "the owner opened this
record". An id that no longer resolves reads as a deleted account; an id
that silently became NULL reads as a lie.
The unit file walks the arms with the grant module deliberately unmocked,
so the resolver consuming the one "is this grant active" predicate is a
property under test rather than a claim in a comment. Its Prisma fake
honours its own `where` clause for the same reason: a fake that answers
any query with any row turns every scoping assertion into a check that
cannot fail.
The integration file proves the two things a unit suite cannot, because
both are about which rows come back. Substitution: the owner's
measurements are returned and the delegate's own, seeded alongside, are
not, so a resolver that quietly served the caller their own record shows
up as the wrong values rather than as an empty list. Never cached: a
revocation lands between two requests inside one test and the second one
403s.
The do-no-harm arm runs against a real shipped route, and the cookie-only
helpers are asserted against a real session row carrying a real switch,
because the one edit that would break them is `getSession()` itself
substituting the owner and no mocked session can catch that.
The structural guard freezes the set of files allowed to touch the
carrier column and the selector header, and asserts a non-zero match
count before comparing against the allowlist. A matcher that finds
nothing agrees with an emptied list and reports success; this repository
has shipped several guards that did exactly that.
The nine-rung verdict ladder was written for the opt-in dashboard hero.
That hero was retired when the digest-driven Today rail became the single
promoted hero, and its i18n copy was dropped across every locale in the
same change, but the resolver stayed behind. Since then it has had no
caller outside its own tests: nothing renders it and no string exists for
any of its variants.
Every rung it computed already has a live owner. Dose states ride the
digest's dose_window item, an elevated-at-rest window rides tension_window,
the score delta rides the digest score block the Today hero paints its chip
from, weight drift and sleep deficit and a silent stretch are the coach
nudge cron's own triggers, and a crisis blood-pressure reading is the
safety-floor engine's. Wiring a renderer back on would rebuild the second
hero the retirement removed.
- Delete the resolver, its ladder suite, and the client-safety import walk
that pinned its bundle graph.
- Fold the two shared nudge thresholds back into the cron. The leaf module
existed only so a client component could read them without dragging the
server graph along; there is no such component now.
- Repoint the score-config guard off the verdict onto the digest fields the
Today hero actually reads, so it still fails on the assembly it was
written for.
- Repoint the clinical-floors notes off the retired surface.
It could never score. The schema records VO2max but cannot tell a
measured test apart from a device estimate, so every fitness row reached
the scorer unproven and was refused - the pillar has never produced a
value on any account. Listing it on the score settings page as a thing
you can choose, with a line admitting it does nothing, was chrome over a
switch that changed no number.
So it leaves the catalogue: no pillar id, no domain, no scorer, no row,
no wire field. A stored selection that still names it reads back fine -
the resolver has always filtered stored ids against the catalogue and
drops what it no longer knows, so nobody's saved recipe throws or is
discarded over it.
That also fixes the page claiming unsaved changes forever. The save
stripped the fitness pillar out of the selection on its way to the
server, so the answer that came back could never match the form that
sent it, and 'All pillars' left the save button lit no matter how often
you pressed it.
- refuse standard embedded-private IPv6 resolver answers pre-connect
- preserve public IPv6 to IPv4 fallback connectivity
- prove followed redirects repeat the pinned DNS verdict
The runtime has always read GEOLITE2_DIR, but the variable was absent
from the compose `environment:` whitelist, from both env examples, and
from the docs. A variable that is not on that whitelist never reaches
the container even when the operator's `.env` sets it, so the override
existed in code and was unreachable from the documented path.
Put it on the whitelist, document it in both examples and the manifest,
and add `docs/self-hosting/geolite2.md`: fetch the databases with a free
MaxMind account, mount the directory read-only, point the variable at
it. The compose file carries the bind-mount line commented out beside
the app service.
The admin notification told the reader to set MAXMIND_LICENSE_KEY in
this repository's Actions secrets — something nobody running the
published image can do, about a fallback that sends their sign-in
addresses to a third party. It now names the mount, names the online
provider it is actually querying, and links the runbook. Six locales.
It also no longer claims the carrier column stays empty; the online
tier has filled the carrier from the ISP field since v1.25.8.
A blank GEOLITE2_DIR now resolves to the default instead of the process
working directory: compose substitutes an unset variable to the empty
string, which is not nullish.
Refs #659
Five layers, because the defect had five places to hide.
The catalogue guard checks what the compiler cannot state: every leaf resolves
a label a human can read, every group resolves one, the fenced tier stays last,
and the shipped template intersects the fence at nothing.
The selection guard pins the three minting functions against each other. A
stored blob this build cannot read resolves to the empty selection, never to a
default set — that is what makes the revoked legacy links revocable rather than
silently re-scoped. A request naming an id the server does not know is refused
by name rather than dropped, because dropping it would narrow a scope the
caller did express.
The egress guard freezes the set of aggregator callers with the source of each
one's selection written down. The required brand-typed parameter already stops
a new path compiling without an answer; this stops one being added without a
human writing the answer down, which is the moment a reviewer asks the
question.
The behavioural sweep aggregates every leaf in and out against a fixture that
carries a row for each, and asserts the payload differs. It is parametrised
over the catalogue, so a leaf added later is covered the day it is added. Its
positive control asserts the all-on payload is genuinely populated first, so a
"no difference" result means the gate is inert and not that the account is
empty. Beside it: an unmapped measurement type is excluded rather than passed
through, no table behind an unchosen leaf is queried at all, and an empty
selection produces an empty record.
The artefact layer runs the real route and reads what comes out — the FHIR
bundle, the PDF text and the zip's contents — with a positive control beside
every exclusion.
Mutation-checked: making the gate admit everything reddens the sweep for every
leaf; passing an unmapped type through reddens the fail-closed case; dropping
the mood query's condition reddens the zero-read case; returning the BMI
unconditionally reddens the sweep naming BODY_MASS_INDEX; adding a fenced leaf
to the template reddens the purity guard; removing the panel's seeding block
reddens the first-run and repeat-run cases; dropping the insurance refusal
reddens the share-link case; adding a caller outside the frozen list reddens
the egress guard naming the file.
The panel suite grew past the practice name it was written for and is renamed
for what it now covers: the three states, the fenced tier having no group
control, and the scope line naming its sensitive inclusions.
Integration coverage moves to the leaf model, including the case this release
exists for: an account with data in every domain and a caller that named
nothing gets an empty record.
The OpenAPI contract catches up with the routes — the required selection, the
refused keys, the share summary's new fields, the moved `Patient/$everything`,
the Coverage search and the five typed reads.
The reverse-coverage guard asserts that every key in the bundle is reachable
from a call site. It had stopped doing that for most of the bundle. A template
whose only static anchor was a namespace root registered that root as a subtree
base, so a single per-provider OAuth key marked every key under `settings.*`
reachable, and the same shape did it for `measurementReminders.*`. A cache-key
string that happens to start with a namespace word had quietly done it for
`insights.*` too. The guard stayed green throughout, which is the part worth
noticing: a pair guard degrades into a one-end guard without any signal.
Extract the matcher into a helper with its own unit tests, and change what it
accepts. A template carrying both a literal prefix and a literal suffix now
records the pair and requires both ends to match, so the OAuth template covers
the provider keys and nothing else. A template with no suffix whose anchor is a
bare namespace no longer whitelists anything — it fails the guard by name, with
the two honest remedies in the message. The same door is used by the template,
assignment and concatenation arms, so none of them can reopen it. Template
bodies are read by scanning forward from a known opening backtick rather than
by pairing backticks across the tree, which was drifting and dropping whole
files out of the scan.
Three call sites build their keys whole instead of interpolating onto a
namespace root: the two measurement-reminder due helpers, the mental-health
option labels, and the OAuth error copy, which now names its per-provider base.
Two audit-log action names that merely look like keys carry a written
exemption; the API error catalog is a runtime lookup and is allowlisted with
its reason.
With the matcher honest, 149 keys had no call site left. They belong to an
explainer popover, a hypnogram, a passkey table and an insights hero that were
all rebuilt or removed, and they are deleted from all six locales. The
explainer guard stops pinning its title leaves, which no surface has rendered
since the popover went.
Mood has been a first-class module here for a long time, with entries,
structured tags, rated factors and notes. The bridge to the external moodLog
service had nothing left to add, and its Settings card was withdrawn in
a56e6fe61 on the promise of a dedicated removal. This delivers it.
Removed: the hourly `moodlog-sync` queue, cron and handler binding; the
inbound webhook and its shared-secret handling; the reverse push and its four
call sites; the pull-sync module; the per-user credential routes and the
instance-wide toggle; the entry on the `/api/integrations/status` envelope,
the `IntegrationKey` union, the cadence map and the display-name table; the
boot-time secret rotation pass; 22 message keys per locale.
Migration 0271 drops the five `users` credential columns and
`app_settings.mood_log_global`, clears the ledger row for the provider, and
deletes the retired pg-boss schedule so no cron keeps enqueuing work no
handler drains.
Mood data is untouched. `mood_entries` is not named anywhere in the migration
and cannot be reached from it: the dropped columns carry no index, constraint
or foreign key, and `mood_entries.user_id` references `users(id)`, not any of
them. Rows the bridge imported keep `source = 'MOODLOG'`, and every read path
- list filter, source badge, exports, backups - still resolves that value.
`src/lib/validations/moodlog.ts` held the native mood-entry schemas alongside
the bridge's own; it is now `validations/mood.ts` and carries only the former.
Merge the eight reviewed audit fixes that were cut a week ago and never
landed. Main has moved on since, so each fix was re-checked against the
current tree and each of the five conflicts resolved by understanding
both sides.
Kept, still open on main:
- Proxy early exits now carry the transport security header subset via a
shared helper. Reconciled with main's /api/health worker-only carve-out.
- Batch measurement ingest bounds the entry instant so a future-dated row
can no longer win "latest reading" or poison the rollup buckets.
- Web Push dials through the pinned safeFetch dispatcher with
requirePublicHost, closing the last user-controlled-host egress.
- Mood bulk upsert and its tag/factor links run in one transaction, so a
rejected rating rolls the mood row back instead of leaving a tagless row.
- Full backup carries NutrientIntakeDay with its unit and source. Folded
into main's portable-export / disaster-recovery split.
- Live daily-series fallback restores the min/max spread band the tiered
read already emitted.
- Coach stream aborts its in-flight reader on unmount and resets send state
on a conversation switch.
- Manual water writes share the day-bound helper, nutrient add clamps after
the increment, and the thiamin cap drops to a real supplement ceiling.
- Compliance point window widens symmetrically instead of wrapping overnight.
- Oura, Polar and Fitbit token helpers classify an empty or non-JSON 2xx
body as a handled transient error.
- WebAuthn relying-party fallback to localhost is gated to development.
- Chart-token strip list is derived from the allowlist and pinned by a test.
- Regression tests pin the snapshot-cache tenant key, the GlitchTip
path-secret redaction and the Withings credentials delete.
Superseded by main, tests re-pointed at the current shape:
- The intake CSV import was rewritten on main to run through a background
job with a single-active-job admission gate. The future-date bound is
re-applied at request validation and the per-user limiter is kept for
parity with the bulk twin; the old synchronous-write test is replaced.
The Settings page's instant score-ring toggle PUT the full dashboard
layout, built from the client's cached `remote` snapshot. That snapshot
can go stale mid-flight: if a normal tile/chart Save committed a newer
layout first, the ring request's explicit stale `widgets` (and
`comparisonBaseline` / `chartOverlayPrefs`) still won on write and
silently reverted the just-saved layout whenever it resolved last.
The ring mutation now PUTs only the two ring fields
(`buildRingMutationPayload`). `widgets` joins the existing
preserve-when-absent merge (`LAYOUT_FIELD_MERGE_DISPOSITION`) so an
omitted field is carried forward from whatever is currently stored
instead of a client-held copy, closing the race regardless of request
order.
`createTagLinks` validates `ratedFactors` after the mood row is written. On the
bulk path the two ran as separate awaits on the base client, so an out-of-scale
rating threw once the row had already committed: the per-entry catch reported
"skipped" — the one status that promises nothing was saved — while a mood entry
with no tags or factors sat on disk. The single-entry POST has always wrapped
the pair in one transaction; the bulk twin now matches.
The test client for the transaction is a distinct object from the base client
on purpose. Sharing one identity made the atomicity assertion vacuous: a write
that escaped the transaction looked exactly like one that stayed inside it.
Each of these was already correct in the tree and had nothing asserting it, so
a refactor would have regressed it silently.
The decrypted-snapshot LRU keys on userId, but only same-user memoisation and
scope-change recomputation were covered — dropping the userId segment would have
served one account's health snapshot to another with a green suite. The project
has shipped one cross-user cache-leak fix already; this is a repeat class.
The GlitchTip forwarder redacts path-segment secrets in the url it sends. The
query-string strip was tested; the path-segment pass was not, and that is the
one that reaches a durable external incident UI.
The Withings credentials DELETE narrows its connection-row delete to P2025 and
rethrows everything else, so a real failure can no longer null the client
credentials and report a successful disconnect while the encrypted OAuth tokens
survive. The route had no test file at all.
The layout PUT preserved chartOverlayPrefs, selectedScoreRings and
heroRingOrder when a client omitted them, but the top-level
comparisonBaseline was not in that merge. serializeDashboardLayout clamps
a missing baseline to "none", so a layout save from a client that does
not know the field — the native client treats it as web-only and never
sends it — silently reset the comparison the user had picked on the web.
No error, no audit row.
Replace the hand-maintained three-field merge with an explicit
disposition map over every top-level layout field. The map is declared
`satisfies Record<keyof DashboardLayout, ...>`, so adding a layout field
without deciding replace-or-preserve fails typecheck; the preserve set
and the merge are derived from it rather than restated. The stored-layout
read is skipped when the client sent the full set.
Also catalogue the four clinical signals the native client pins — grip
strength, pain NRS, waist circumference, waist-to-height. They reached
the route as unknown ids, were filtered out ahead of Zod, and vanished
from the persisted layout, so a tile placement was lost on every save.
They join the iOS-only set because the web dashboard has no render path
for them; their charts live on the insights sub-pages. Raise the widget
array cap to 50 to clear the now-42-id catalogue.
One suite per gate: the rich reads omit with an honest module_disabled
reason, the correlations reader omits and touches no channel, the
doctor-visit resource and prompt refuse without assembling the record,
and the clinician share collapses to its documents-only state. Each
asserts the operator availability path alongside the user toggle, and
that the gate short-circuits before the engine runs rather than building
the answer and withholding it.
The structural test pins that every metric the rich reads can resolve has
either an owning module or a written review decision, and is explicit in
its own comment about what it cannot prove — it does not show that any
read calls the gate. Writing it surfaced an unowned HRV_RMSSD, the
fallback type an RMSSD-only account resolves to, which would have served
recovery data the primary type refuses.
Three leaks of the same class - data leaving the process unprotected.
addMeta and addExternalCall wrote their values verbatim while setError
and setHttp scrubbed, so the builder's redaction contract held for two
entry points out of four. The finished event is serialised whole to
stdout and the log store, which made meta a trusted sink by accident: a
Telegram request timeout put the bot token there, and the AI provider
chain fed upstream error bodies through it. Both now scrub, at any depth.
Scrubbing cannot help with the second half. A gateway that echoes the
request it rejected echoes the prompt, and on that path the prompt
carries the health snapshot; no pattern separates that from any other
text. Upstream bodies are therefore classified rather than quoted - the
diagnostic question is which rejection occurred and whether it repeats,
which a classification answers without the content.
The idempotency replay cache stored response bodies unencrypted. The
create paths echo their own decrypted payload, so cycle notes, mood text
and allergy reactions sat in cleartext for 24 hours in a column that
lands in every backup; the existing guard missed it because it matches
secret shapes and health data is not secret-shaped. Bodies are encrypted
now, and when encryption is unavailable the response is not cached at
all rather than written in the clear.
chore(release): v1.30.18
resolveBearerToken took an optional permission, and an unset one admitted
any valid token. 324 of the 330 route files pass no permission, so a token
minted with a narrow scope reached the full-backup export, the labs and
measurement surfaces, the coach, and the bulk deletes — the settings copy
tells users to hand exactly that token to third-party automations.
Invert the default. The scope argument becomes a required ScopeRequirement
union: a route that names no scope now accepts cookie sessions and
cookie-equivalent ["*"] tokens only, and refuses a narrow token with 403 and
an undeclared_scope audit row. A route that means to accept a narrow token
names it, as the FHIR routes already do.
No route file changes. The absence of an argument becomes a positive
statement rather than an omission, so a newly added route denies a newly
minted narrow scope by default — the safe outcome is the one you get by
doing nothing.
/mcp keeps its own posture through an explicit any-valid-token requirement:
it authenticates here and authorises downstream on audience and write scope.
That is the single deliberate fail-open call site in the tree, and making the
parameter required is what keeps it greppable.
An MCP-audience token loses its REST read leg as a result, narrowing it to
/mcp only. The isMcpAudienceToken guard stays as defence in depth.
Sweeps every surface that renders a date from before this year without
a year suffix — illness episodes/flares/day-logs, lab and custom-metric
charts, the mood chart family, medication titration/side-effects/dose-
history/next-dose captions (incl. the Dec→Jan rollover), cycle
predictions and history, the BBT chart, the intraday-pulse day-nav, the
date-range filter chip, and the nutrient/compliance charts. Each now
routes through dateShortSmart / dateWithWeekdaySmart (format-locale.ts)
or the matching date-format.ts helper, which add the year only when it
differs from the current one — mirroring the workout list's existing
model instead of a bare "19.02." that reads as this year's.
Also fixes two sites that bypassed the app's locale entirely: the
trusted-devices expiry (bare toLocaleDateString, host locale) and the
admin token-name timestamp (hard-coded de-DE + Europe/Berlin) now both
render through the user's own locale, hour-cycle, and timezone
preference.
dateShortSmart / dateWithWeekdaySmart add the year only when a date
falls outside the current calendar year, so a chart axis or history
row never shows a bare "19.02." for a reading from last year. Also
adds monthYear for Dec-to-Jan rollover labels (next-dose captions).
Year comparison runs in the formatter's own timezone so UTC-pinned
bucket-label formatters agree with what they print.
Manual measurement, mood, and water writes never carried queryKeys.dailyDigest()
in their invalidation bundles — only medication intake did (v1.29.1). The digest
query also runs with refetchOnMount/WindowFocus off, so a write from the values
list, the mood list, or the hydration quick-add sheet left the Today hero's
score and rail items stale until a hard reload.
Adds dailyDigest() to measurementDependentKeys and moodDependentKeys, and adds
a shared refetchInactiveDailyReads() helper (mirrors the existing medication
seam) that forces the dashboard snapshot and digest to refetch even while
unmounted. Every measurement/mood/water write call site now runs it alongside
its existing *DependentKeys bundle.
sanitizeSameOriginPath returned pathname+search+hash, so a crafted relative next like /..//evil.com passed the origin check but left a //evil.com pathname that re-resolves to a foreign origin when a consumer re-feeds it to new URL(result, base) or router.push. Re-verify the reconstructed path resolves on-origin; reject to / otherwise. Covers the OIDC callback and the password/passkey login redirect.
Reuses the TrendCard strip-tile pattern (icon, neutral direction
sentiment, stale-days caption) for a new waterIntake widget, but
sources it from NutrientIntakeDay instead of introducing a parallel
MeasurementType. The dashboard snapshot builder reads the last 30
day-keys for nutrient="water" (summed across sources), folds them
into a synthetic NUTRIENT_WATER summary key alongside the real
MeasurementType summaries, and the existing module-gating machinery
(SUMMARY_TYPE_MODULE, WIDGET_MODULE_BY_ID) strips it when the opt-in
nutrients module is off — no new gating code path, just two new map
entries.
The tile links out to /insights/nutrients instead of a measurement
detail page. Widget catalogue grows 37 -> 38 ids.
The Today digest went stale after logging a dose: nothing invalidated the
`["daily","digest"]` query, and it reads meds from the same server snapshot
the intake routes already hard-evict — so the "dose due" rail item lingered
until a hard reload. Add the digest key to the medication-dependent bundle and
force the inactive digest (unmounted while the user is on the medication card)
to refetch alongside the dashboard snapshot; the server snapshot eviction seam
is reused, so the refetch returns post-write dose state immediately.
Remove the score-ring cluster from the Today hero — it read as uneven and
wasted tile space. The health-score ring is the hero's only ring now; the
score-ring selection contract stays server-side for the native client. Tighten
the hero's vertical rhythm so the worth-a-look rail sits close under the lead
instead of floating below a half-empty column.
Wire the remaining sleep transports into the morning-refresh trigger: Google
Health, Fitbit, Oura, and Polar now enqueue the debounced morning refresh on a
last-night segment landing, matching the Withings / WHOOP / Apple seams — those
sources had no trigger at all. Make the trigger observable: each early-return
gate now annotates a distinct `daily.morning_refresh.skipped` reason (the
not-last-night gate also carries the samples' resolved local dates, today's key
and the tz) so a real landing shows exactly which gate fired.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018V8V9qapgHi7fhgtMJ8SkM
The digest-driven Today hero is the single promoted hero at the top of
the dashboard, so the old opt-in hero band is redundant — a user who had
opted in and has Insights on saw two heroes. Remove the legacy hero and
its heroVisible layout flag.
- Drop the DashboardHero / DashboardHeroSkeleton mount from the
dashboard page; the header now renders the greeting unconditionally
(its showGreeting gate is gone with the hero that owned it).
- Retire the heroVisible field from the dashboard layout (type,
defaults, resolver, serializer) and the widgets PUT schema. The
resolver is a whitelist constructor, so a stored heroVisible is
dropped on read and never re-persisted — no stored layout breaks.
- Delete the now-orphaned hero components (band, skeleton, ring
carousel, briefing spotlight) and their tests; drop the retired
dashboard.hero copy across all locales.
- Remove the hero-visibility toggle from Settings → Dashboard. The
score-ring picker no longer hangs off that flag and stays available,
since the rings it configures still ride the server-computed
dashboard snapshot.
- Repoint the phone-width density guard at the Today hero and mock the
daily digest so it renders.
- Repoint the preserve-when-absent doc analogy off the removed field.
Several readings were synced and charted but had no place on the dashboard
strip: heart-rate variability, blood oxygen, breathing rate, wrist temperature,
and — for body-composition scale users — muscle mass, body water, and bone mass.
Each now has a tile.
The tiles are pure client additions: the dashboard snapshot already ships a
summary for every measurement type, so no backend, query, or migration changed.
Each tile is data-availability gated (renders only when the account has that
reading) and is toggleable and reorderable under Settings like the rest.
hrv and respiratoryRate graduate from iOS-only to web-writable widget ids, and
muscleMass is a new writable id (catalogue 36 to 37, within the 40-id PUT cap);
wristTemperature moves from pin-only to a web-rendered tile. All additive — the
widget enum only widens, which the iOS contract treats as non-breaking.
ECG recordings synced from a compatible watch were stored encrypted but
shown nowhere. They now have a surface in Insights: a list of recordings,
each opening the full single-lead waveform on a familiar ECG grid, with the
date, duration, average heart rate, and the result the device recorded.
The read routes narrow ownership in the query and decrypt the waveform
through the existing fail-closed codec; the overview is min/max-decimated so
the R-wave peaks survive the reduction, with a raw path for a calibrated
view. The result shown is the recording device's own classification,
attributed to the device — HealthLog does not read or interpret the trace
and produces no verdict of its own. A permanent note states this, and a
non-normal result carries a line to discuss the recording with a clinician.
Three CONFIRMED findings from the campaign's feature-wiring audit + structural
verification:
- Sleep rendered nothing in the doctor-report PDF: the sleep section is
default-on and the data layer reconstructs per-night SLEEP_DURATION rows, but
DOCTOR_REPORT_VITAL_TYPES omitted SLEEP_DURATION, so the vitals table showed
nothing (FHIR already emitted it). Added it to the allowlist with a
minutes-to-hours conversion; corrected the stale 'intentionally excluded'
comment.
- Glucose had no doctor-report section toggle — it rendered whenever the global
glucose module was on, with no report-level privacy control. The toggle
actually already existed in the UI (with i18n) but was dead because the prefs
fold dropped the key. Wired it end to end (glucose added to the prefs Zod
schema + defaults, default-on to preserve behaviour; gated the render loop +
the query). Also fixed a grouped-vs-flat prefs mis-detection: 'glucose' in
GROUPED_SECTION_KEYS would have flagged flat blobs (incl. EMPTY prefs) as
grouped and defaulted them all on. No migration (prefs is a JSON blob).
- environment fed the coach with no exclusion: MODULE_EXCLUDED_SOURCES omitted
it, so module-off still leaked audio_env/audio_headphone/audio_event/daylight/
skin_temp to the snapshot. Added, scoped exactly to the environment cluster's
five sources.
Regression test per fix.
Performance + correctness fixes from the campaign's verified perf-correctness
audit (the two server-query findings — comprehensive-generate + boot-discovery
distinct — are deferred to a careful separate perf release):
- format-locale built a fresh Intl.DateTimeFormat/NumberFormat per call
(main-thread stall across every history list). Routed through the existing
process-wide formatter-cache keyed on locale + options (timeZone + hour-cycle
in the key); output byte-identical.
- measurement-list rendered every row twice (desktop table + mobile cards, one
CSS-hidden), unvirtualized. Now renders one layout via the SSR-safe
useIsMobile(md) hook; row markup + every data-slot unchanged.
- moodDependentKeys omitted dashboardSnapshot(), so a mood write left the
dashboard tile/score stale ~120s (server cache was already correct). Added it.
- register first-user admin was a count-then-create race (two concurrent
first sign-ups could both become ADMIN). Now an advisory lock + in-transaction
re-count makes first-admin atomic; P2002 maps to 409 instead of 500.
Regression test per fix. No migration (0243 still free).
Client-rendered times fell back to the hardcoded display zone
(Europe/Berlin) because neither useFormatters() nor the legacy format.ts
helpers ever received users.timezone. A third localStorage mirror
(timezone-mirror.ts, following the time-format/date-format pattern)
carries the validated profile zone from /api/auth/me into both read
paths; the fallback chain is strictly profile → Europe/Berlin — never
the browser zone, so screen, PDF, and export cannot disagree.
makeFormatters validates its zone before Intl (a poison value falls back
instead of throwing inside every render).
Same pass, per the two-document audit: formatUpdatedLabel boundary and
clock now resolve one zone in every state (the undefined-tz browser hole
is closed); @db.Date calendar fields render UTC-pinned so profiles west
of UTC cannot shift a day; dose-history day headings and GLP-1 weekday
labels derive from the display zone; chart day/month labels are
encoding-derived and UTC-pinned (bucket math untouched, byte-identical
for Berlin profiles); the clinician share view renders in the patient's
timezone instead of the container's; Playwright pins Europe/Berlin.
Server day-key semantics, streak anchoring, insight cache keys, and
verbatim wall-clock strings are untouched by construction and pinned by
the new Auckland/New_York/Manila + DST test matrix.
Adds an OpenID Connect relying-party login path (PKCE authorization-code
flow) against a single env-configured identity provider — additive to
password/passkey login by default, with an operator opt-in (OIDC_ONLY,
enforced server-side on every auth route) to disable them entirely.
First-time sign-in auto-provisions or links an account by verified email.
Closes an open redirect in the post-login `next` parameter (both the new
OIDC path and the pre-existing password/passkey flow shared the same
backslash-normalization bypass) by resolving the target through a real
URL parse and comparing origins instead of a prefix check.
The clinician-facing export failed SOFT on an undecryptable allergy
reaction (→ null, rendered as a bare '—' indistinguishable from 'no
reaction recorded') while a sibling dose-change note fails CLOSED. Extract
a pure decryptAllergyReaction helper that keeps 'unreadable' distinct from
'unset', log the swallowed decrypt, and render an honest localized marker
in the PDF so a clinician is never shown a silent blank where an
unreadable reaction exists. Read-path only — the fail-closed write posture
is untouched.
Add resolveHeroRingOrder unit coverage (default health-score-first,
reconcile against selected set, drop/append, dedupe) plus resolver/serializer
round-trip; hero SSR tests for the default lead + a persisted custom order +
dropped-when-unrendered; Settings picker tests for the pinned health-score
anchor row, its locked-on switch, drag/arrow reorder, and a persisted
non-leading order. Extend the score-rings picker description across six
locales.
The policy module (src/lib/documents/upload-policy.ts) is the one source of
truth for what the vault accepts and how it serves: Class A (PDF/JPEG/PNG/
WebP/GIF) renders inline; Class B (Office OOXML+CFB, text/CSV/Markdown/RTF,
TIFF, HEIC/HEIF, XML/JSON) is stored verbatim and leaves the origin only as
an opaque attachment download; executables, HTML/XHTML/SVG, and generic
archives are denied. Detection is magic-bytes first — the wire Content-Type
is never trusted; containers (ZIP → OOXML, CFB → legacy Office) and
extension-only text formats gate on the filename plus a no-NUL heuristic.
resolveDocumentLimits() reads the admin-tunable per-file cap (clamped to
the hard 100 MiB ceiling — single-shot GCM and bounded in-memory reads are
load-bearing past that) and the quota (user override ?? instance default).
crypto.ts gains the binary2 codec: encryptBytes/decryptBytes do AES-256-GCM
over raw bytes with a versioned key-id header, skipping the base64 detour
(−33 % at rest, one fewer copy). Same fail-closed discipline as the string
codec. The store dispatches per row on the explicit contentCodec column:
new uploads write binary2, pre-existing base64v1 rows keep decoding
byte-identically, and an unknown codec throws instead of guessing.
Exhaustive unit coverage: every accept class from real magic-byte fixtures,
every deny case (incl. the PDF-with-HTML-head, SVG-as-image, and
ZIP-renamed-docx polyglots), header-matrix source of truth, limit clamping,
codec round-trips both ways, crossed-codec and unknown-codec fail-closed.
Tasks: W1-T3, W1-T4.
Two new self-report instruments join the mental-wellbeing engine on the
existing PHQ-9 / GAD-7 infrastructure:
- WHO-5 Well-Being Index: 5 items on the 6-point 5..0 scale over the last
two weeks; the reported total is raw-sum x 4 (0-100) per the official
WHO scoring (WHO/UCN/MSD/MHE/2024.1, CC BY-NC-SA 3.0 IGO). A total of
50 or below follows the WHO comment (below 50 = indication for further
assessment).
- Sleep Condition Indicator: 8 DSM-5-aligned items, 0-4 each with
item-specific anchors, total 0-32, higher = better sleep; a total of
16 or below maps to the probable-insomnia range of the source
publication (Espie et al., BMJ Open 2014;4:e004183, CC BY-NC,
(c) Sleepio Limited).
The instrument registry generalises rather than forking: per-item answer
ceilings, option order/label schemes, recall-stem captions, a reported-
score multiplier, score direction (higher-is-better inverts the follow-up
threshold), per-instrument attribution lines, and the validated-item
locale set are now instrument properties. Scoring stays server-
authoritative; the create route validates the per-instrument answer
ceiling and projects the total onto a COMPUTED WHO5_SCORE / SCI_SCORE
measurement row (never client-attributable), which also auto-satisfies a
planned screening reminder. Migration 0226 adds the enum values
(additive, idempotent). OpenAPI regenerated.
Neither instrument carries a self-harm item; the PHQ-9 item-9 crisis
routing is unchanged.
Add selectedScoreRings to the dashboard layout blob: a closed four-id
set (READINESS, RECOVERY_SCORE, SLEEP_SCORE, MED_COMPLIANCE) capped at
three rings, with a client-safe ring-to-module map alongside. The
resolver drops unknown ids, dedupes, and clamps; a legacy blob without
the field defaults to the single MED_COMPLIANCE ring while an explicit
empty array is respected. The widgets PUT validates the field against
the closed enum and preserves the stored value when a client omits it,
the same contract heroVisible already rides.
The admin system snapshot hardcoded "ipwho.is" for the offline-geo-off
state, so an operator who points IP_GEO_LOOKUP_URL at another provider
still read the wrong host. Resolve the actual provider host from the env
and surface it through /api/version, so the snapshot reads
"Online (ip-api.com)" — the host it really queries.
The network operator (carrier) used to resolve only from the optional
offline GeoLite2-ASN database. On a host without it the carrier stayed
empty even though the location resolved fine online.
Mine the operator from the same online provider response the location
lookup already fetches — ip-api's top-level `isp`/`org`/`as`, ipwho.is's
nested `connection.{isp,org,asn}` — so the carrier is populated out of the
box. The offline ASN database stays authoritative when configured.
A single `lookupIpGeo` now returns location + ASN + carrier in one pass;
the audit write and the backfill both go through it. The backfill also
revisits rows that already had a location but no carrier, so the column
fills in across existing history, not just for new sign-ins.
In the admin sign-in overview the carrier moves out of a chip stacked
under the login method into its own column, next to the location.
A self-hoster can set IP_GEO_ALLOW_INSECURE=true alongside an http:// URL to
use a free HTTP-only provider — the free ip-api.com endpoint is HTTP-only (its
HTTPS form needs a paid key) but its geolocation is often more accurate and it
carries the ISP. The default stays HTTPS-only with ipwho.is; plain HTTP is
refused unless the operator explicitly opts in, since the looked-up IP then
travels unencrypted over the server's own egress. Whitelisted in compose and
documented in the env example.
ipwho.is is free, needs no key, and resolves a city/country for the login
overview out of the box. It is the default online lookup again; the offline
GeoLite2 MMDB tier stays strictly optional (consulted only on an online miss,
skipped when the databases are absent). Operators whose egress ipwho.is
rejects can point IP_GEO_LOOKUP_URL at ip-api.com — the parser accepts both
response shapes, so the swap needs no code change.