The dashboard and capture-picker intake form filtered the medication list
on `active && trackIntake`, so a course that had ended (still active,
nobody paused it) was offered for a dose while the cards, the table and
"take all" already followed the server's `intakeActionable`. The picker
now renders from that one answer as well.
When medications exist but none offers a dose today, the empty state no
longer claims there are no medications; it says nothing is due and
points to the medication history for a past dose.
The restore dialog asked GET /api/admin/backups/{id}/summary for the
contents of a copy, and the route read the whole stored copy to answer:
every piece opened, every measurement parsed, the rest validated. On an
account of 1.8 million readings on slow hardware that took about two
minutes. The dialog stopped waiting after 15 s and said the contents
could not be read, and its automatic retry made the server read the
whole copy a second time.
The copy passes through the process once anyway while it is written, so
the preview is worked out there and kept on the row (DataBackup.preview,
migration 0364): the summarizeBackup counts, the schema version, and
the inner key ids with the sections they sit in and one sample value
each. Every creation path goes through storeBackupBlob (the weekly and
manual pass, and an upload), so every new copy carries one. The column
is plain JSON: counts and dates only, and the samples are ciphertext
that stands for what is inside the copy, which a key rotation must not
re-seal.
The route answers from it without opening the copy. Both verdicts are
taken again on every read, from what the preview keeps: whether this
release restores the schema version, and whether this server holds the
keys the copy needs. The pieces are still counted against the row, so
a copy that lost one is refused as the restore and the download refuse
it. A preview names the copy it describes and is ignored once the copy
changes; converting a single stored value into pieces carries it over.
A copy written before this has no preview. It is read whole once, as
before, and the result is kept on the row, so it is slow only the first
time; concurrent requests for the same copy share the one read.
Only a file that fails the schema answers 422 "Backup payload failed
schema validation". Read refusals keep their codes; anything else is
the server failing and answers 500.
The dialog no longer retries the preview, waits up to 55 s, and when it
stops waiting on an older copy it says the copy is still being read and
to open the dialog again in a few minutes.
Refs #1031
A bridge that pushes workouts from a watch vendor had no credential that
reached POST /api/workouts/batch: the route called requireAuth() with no
scope, so under the fail-closed default only a cookie session or a
wildcard token got through, and a measurements:write token was refused
403. The route's header still described the pre-fail-closed behaviour.
Add a separate workouts:write scope rather than widening
measurements:write, so a token already handed to a scale does not start
accepting workouts.
- POST /api/tokens/workouts mints it, mirroring the measurement and
document mints: cookie session plus a fresh proof, a literal permission
array, 10 mints a minute, at most 10 live tokens (409
tokens.workouts.ceiling_reached), a one-year default lifetime, audited.
- POST /api/workouts/batch names the scope. Sessions and wildcard tokens
behave as before. A scoped caller's workouts are stored as EXTERNAL,
resolved from the credential, and a batch in which any entry names a
source is refused 422 (workout.batch.source_not_permitted), the same
rule the measurement batch applies. EXTERNAL is on no source ladder, so
when the phone recorded the same session the phone's copy is shown.
- Settings > API offers a workout token card next to the measurement one,
and the endpoint list shows the batch route.
- Guards: the mint site, the declared scope vocabulary, the recent-proof
routes, and a new check that only the batch declares the scope.
- OpenAPI documents the mint, the batch's narrow-token behaviour and both
error codes; docs/api/README.md lists the workout and document mints.
Refs #1054
The review dialog silently dropped any selected reading that could not be
written (no date, no unit, no value) and saved the rest. When nothing was
left it showed "Select at least one reading to save", which described a
different situation and said nothing about what was missing.
A save is now all-or-nothing over the selected readings. When one is
incomplete, nothing is written; the dialog states how many readings need
attention, marks each missing field (aria-invalid, inline message, red
border, required marker on the label) and moves focus to the first one.
Marks appear after a save attempt and follow the edits. The "nothing
selected" message is kept for that case only.
Validation is a pure function next to the payload mapping, so the rule that
blocks a save and the rule that builds the payload cannot drift apart.
Adds EN and DE strings; the other locales are left to the maintainers.
Refs #1055
A week or month point of a cumulative metric is the average daily total,
which keeps the axis comparable with the 7, 30 and 90 day tabs. The
caption said "Weekly avg", which reads as an average week's total. Steps,
energy, distance, flights and daylight now say "Daily avg per week" or
"per month" on the caption and in the tooltip, in all seven languages;
level metrics keep the plain average.
The add and edit wizard had no row for the thyroid, digestive, skin and
sleep-aid categories, so such a medication opened on "Other" and saving any
change, even a new dose time, rewrote its category to OTHER. The wizard
now offers a row for every category the app knows, translated in all seven
languages.
An edit also sends the category and treatment class only when the category
row was changed. A stored value the wizard cannot show, such as one a newer
server or another client wrote, now survives an unrelated edit.
Antidepressants, anxiolytics, mood stabilisers and ADHD medication had no
category of their own and ended up under "Other". MENTAL_HEALTH joins the
clinical category list: the add and edit wizard offers it as its own row
with a brain icon, the card badge names it, and it is translated in all
seven languages.
The category is a text value, so no migration is needed. Clients that
read the category as a string show the new value; an app that does not
know it yet falls back to its own label for it.
A medication whose end date had passed still showed taken and skip in the
table and on its card, because the action row only looked at whether the
medication was active. The schedule engine already stops minting doses
after the end date, so reminders, the doses card and the today list were
quiet, but the medication itself kept inviting a dose that is no longer
part of the course.
The server now resolves one rule and publishes it on the list and detail
reads: `courseStatus` (UPCOMING, CURRENT or ENDED, in calendar days on the
user's clock, both ends inclusive) and `intakeActionable` (active, intake
tracked, course includes today). The table and both card variants render
their actions from it, the take-all flow skips such a medication, the card
and table name an ended course with an "Ended" badge, and no overdue
escalation is raised for it. A course that ends today keeps its actions
for the whole local day.
The detail header's "Ended" status now follows the same field. It compared
the end date's UTC midnight with the clock and so called the whole last
course day ended. Logging a past dose from the history is unchanged.
A withheld table now leaves a line saying why (module off, or the stored
tables could not be read) and a failed read shows an error row with a
retry, so the Data used count matches what is listed. A table copied from
an earlier answer names that answer's date under its title. The preview
shows the latest twelve periods of a time series, and the truncation note
says the latest rows are kept, which is what the server keeps. The title,
copy and view controls sit above the scrolling table with the title kept
as a hidden caption, and a reply with tables takes the full column width.
The step headers and the related-metric chip read as label: value in all
seven locales, so a metric name no longer sits capitalised mid-sentence.
The method line merged a repeated read by keeping the larger count. With
show_result that could let an earlier answer's count stand over the read
this turn just made. A fresh read of the same source, window and period
now always wins, whichever order the steps ran in, and the two are never
added. An entry that rests only on an earlier table says so ("from an
earlier answer"), including a reused table no step accounts for.
The new catalog string is in all seven locales.
The small chevron above the first nav entry becomes a full footer row that
heads the utility group: directly above Admin for an administrator, directly
above Settings for everyone else. PanelLeftClose / PanelLeftOpen mark the two
states; the label reads Collapse while expanded and stays for screen readers
only in the icon rail, where the tooltip names the action. aria-label switches
between collapse and expand; the stored choice keeps its storage key.
The width transition now honours reduced motion. Adds the expand and
sidebar-action labels in all seven locales.
A restore of an account with 1.9 million readings, most of them deleted
by the nightly consolidation and still awaiting the purge, was cancelled
in its clearing step: `DELETE FROM measurements` ran past the 60 s
statement timeout every pooled connection carries, and the whole restore
rolled back with a message that named no cause (#1031).
Most of that statement was not the delete. Two columns point into
`measurements` with ON DELETE SET NULL and had no index, so Postgres
scanned `personal_records` and `ecg_recordings` once per deleted reading:
38 of 40 s on a seeded account with 300 personal records. Migration 0362
indexes both, which also speeds up the tombstone purge and account
deletion.
The restore transaction now sets its own statement and idle-in-transaction
limits to the transaction budget with SET LOCAL, and the budget counts
the rows the account holds as well as the rows the file carries. A
rolled-back restore names what the database refused it over (timeout,
lock, constraint, storage, connection, other) on the job row, the
console, the audit row and the wide event.
Adds every string the dialog needs up front: step labels, table titles,
columns and controls, the method line, the follow-up chips and their
setting, the clarification card, and the caption of an answer reused from
a stored table. A key catalog shared by server and client names each one;
a test holds the catalog and the bundles equal in both directions.
An admin page stacks independent cards, and a card that threw while
rendering replaced the whole page, so a field missing from the off-host
card's payload hid the snapshot table beside it. Every card in an admin
section now sits in its own boundary and fails as an error card with a
retry that remounts it; the rest of the page keeps working.
The address is what single sign-on links an existing account by, and
changing it took the password or a session younger than five minutes on
every account. A remembered browser skips the second factor at sign-in and
still makes a fresh session, so the password alone could move the address
of an account the second factor was meant to protect.
On an account with a second factor only a second factor or passkey
completed in the last five minutes counts now; the password is not
checked. The refusal names it (second_factor_required in rejectedFields,
or 401 profile.update.emailSecondFactorRequired when the address was all
that changed), and the profile form opens the re-proof dialog with the
factors the account holds, then saves again, no longer offering the
password field. Accounts without a second factor keep the password or a
recent sign-in. A token cannot change the address of such an account;
the app does not change it.
Allowing an AI assistant hands it a standing credential to the record
that outlives the session, the same class of act as minting a token, but
the consent took a live session alone. A stolen session could connect
its own assistant and keep reading after the owner signed everything out.
The consent page now shows the Allow form only when the session signed in
or re-proved within five minutes (with the second factor on an account
that has one). Otherwise it links to a new confirm page that runs the
same re-proof dialog as the rest of the app and returns to the exact
consent request. The allow decision checks again and mints nothing
without it; deny needs no proof. The confirm page only ever returns to
the consent endpoint.
Offering access to the record took a live session alone, and an
invitation outlives the session that sent it: whoever held a stolen
session could invite their own account and keep a way into the record
after the owner signed everything out. POST /api/account/grants now takes
the recent-proof gate at every level (a sign-in or re-proof within five
minutes on the browser, with the second factor on an account that has one;
a step-up elevation on a token, which no shipped client uses here), and
the invite form opens the re-proof dialog and sends again.
Sign out everywhere now withdraws every invitation the account offered
that nobody has accepted. Accepted grants are left standing on purpose:
each is a person the owner chose, often a carer who still needs the
record, and a sign-out button must not quietly cut them off. The response
lists them as grantsKept and the sessions card shows each with a one-click
End access.
Since the supply runway stopped counting an as-needed schedule as a daily
dose, a medication taken only as needed had no runway and never raised a
low-supply notice. It now warns once when two doses or fewer are left,
with its own line naming what is left, and re-arms after a refill like any
other medication.
Making `=true` grant nothing would stop a single-user self-host's local
model on a patch update. It is deprecated for one release cycle instead,
in a narrowed and safe form: it still grants a private host, but only to
the operator's own configurations (the instance-wide admin provider and
AI settings saved on an admin account), and only through the pinned
operator-approved dispatcher, so redirects stay forbidden and metadata,
link-local and unspecified addresses stay refused. A non-admin account's
own base URL needs an exact origin in AI_PRIVATE_ORIGINS.
Provider resolution passes the owning account's role to the client
(`operatorTrusted`); the save route and the connection test apply the
same rule to the account being configured. The boot summary now says the
setting is deprecated and names the replacement, and the admin AI
settings page shows the exact AI_PRIVATE_ORIGINS line for the private
origins saved on the instance (every account's Local and gateway URL and
the admin base URL, scheme, host and port only), so the operator can copy
it and drop the old setting.
The admin wipe of every account is the operator clearing their own host,
and the bucket's copies are the documented way back from a wipe pressed
by mistake, so it writes no off-host deletion request. Deleting an
account, deleting a managed profile and "Delete all data" still do.
The backup runbook no longer says the worker never deletes an object: it
lists the grant the worker needs (DeleteObject included), when objects
are deleted, the version-4 envelope, the lifecycle check and how to
rotate the off-host key. The rotation runbook no longer says backups are
covered: rotation reaches a backup's envelope, not the content a restore
writes back, and a retired key stays needed until no backup lists it.
The privacy notice beside account deletion says the same about off-host
copies.
A failed import records its failure in a plain try block, and the
extraction test that inflates past the 64 MiB floor gets the time it
needs on a loaded runner.
"Sign out everywhere" ended web sessions and device logins but left every
credential that works without signing in: a connected AI assistant kept its
OAuth connection and minted the next access token from it, a connector or
ingest token kept working, and a clinician share link kept opening the
record. A password change revoked the tokens but not the assistant's
connection or the share links. Anything a stolen session had minted
outlived the remedy.
- Sign out everywhere now ends every other sign-in, every AI-assistant
connection, every programmatic token except the caller's own, and every
clinician share link. Share links are revoked by default; the web offers
a checkbox to keep them (keepShareLinks=1), and a client that sends
nothing, such as the shipped app, gets the safe default.
- A password change and an admin password reset also end AI-assistant
connections, share links and step-up elevations.
- Turning on the first second factor (TOTP or a security key) ends every
other sign-in, as a password change does. Adding a further factor does
not.
destroyOtherSessions takes an explicit reach ("sign-ins" or "everything")
so each caller says how far it goes.
Deleting an account, deleting a managed profile, "Delete all data" and the
admin wipe now write an off-host deletion request in the same transaction
as the deletion. A new job (offhost-backup-purge, kicked straight away and
run nightly as a backstop) lists the bucket once, deletes every
<date>/user-<id>.json.enc object of each pending account and removes the
request only when nothing of the account is left. A refused delete keeps
the request with the bucket's answer and is retried; the off-host card
shows how many deletions are outstanding and why. A copy the nightly run
was writing while the account was deleted is taken back after the upload.
The off-host card also reads the bucket's lifecycle rule and says plainly
when there is none (every nightly copy kept forever) or when the
credential may not read it.
Every stored backup now records the key ids its content was written
under, and every off-host upload records the same per key. The admin
encryption view lists, per key, the stored copies that need it with the
oldest date, and when the last off-host copy needing it expires. The
"safe to drop" badge now requires that no backup still needs a retired
key, instead of only that every row was rotated.
Migration 0358 adds data_backups.inner_key_ids, the key-use table and the
deletion requests.
Exporting the whole record, making a clinician share link, minting a token,
and the admin backup download, upload and restore, data wipe and password
reset ran on a live session alone. The plaintext full backup did, while the
encrypted one beside it asked for a second factor. Whoever held a stolen
session could take the record, or leave behind a credential that outlives
the session.
These now go through one gate, requireRecentProof (assertRecentCookieProof
for the routes that resolve their caller themselves). A browser session
passes after a sign-in or re-proof within five minutes: a second factor on
an account that has one, otherwise the password or a passkey. The refusal is
401 auth.reproof.required with meta.methods, and the web opens the re-proof
dialog, posts the proof to the new POST /api/auth/reproof and retries. A
password proof stamps the new Session.reproofAt (migration 0359), never
mfaVerifiedAt, so it cannot stand in for a second factor anywhere.
On Bearer, the full backup asks for an X-Step-Up elevation; the encrypted
export asks for a second-factor elevation on an account that has one. Share
links and connector tokens keep taking the token alone, because the shipped
app calls them without an elevation and reads a 401 there as a dead
session; the guard test names them.
Key rotation keeps its stricter second-factor gate.
A backup carries every encrypted column as the stored ciphertext, and a
restore writes it back verbatim. Key rotation re-seals the stored copy's
envelope but cannot reach inside it, so a copy taken before a rotation
still needs the retired key. Without it the restore went through and
left rows no reader can open.
The restore, the restore preview, the admin upload and the monthly
restore drill now collect the key ids of every inner ciphertext value,
hold them against ENCRYPTION_KEYS and decrypt one value per key. A
missing key, or an id configured with different key material, is
refused before anything is deleted, with the key id named. The restore
status and the preview say which key to put back.
The restore also checks, inside its transaction, that it added no
reference from the account's rows to rows another account owns. The
check is derived from the schema's foreign keys rather than written per
section, so the tag and category upserts keyed on a global key are
covered along with every link table. The illness parent update is
scoped to the owner as well.
The off-host listing now follows every page: the first page held only
the oldest dates, so the drill could read an old object and report the
chain as stale.
The SSO callback bound a verified provider email to the local account with
the same address on the spot. The local address is not verified: anyone
could register with it, or type it into their profile, and the SSO user
then landed in an account the other person still held the password to.
The callback now links nothing. It records the link it would make in a
sealed, ten-minute, HttpOnly cookie and sends the browser to the sign-in
page, and the link is made only when that browser then signs in to that
same account with its password (and second factor) or passkey. Under an
SSO-only policy the password form opens for exactly that account. The
native sign-in answers oidc_link_required; the link is confirmed once on
the web.
Moving an account to a new email address now also needs a fresh proof: a
sign-in or second factor in the last five minutes, or the current password
sent alongside the change. Without it the address is left as it was and
reported in rejectedFields; the rest of the save still lands.
A browser session on its own could start TOTP enrollment or register a
security key. Combined with a pending secret counting as a factor, that let
a stolen session give itself a second factor, register a passkey with it,
and keep access after the owner changed the password.
TOTP setup and security-key registration now pass on the cookie path only
when the session signed in or completed a second factor in the last five
minutes, or when the body carries a proof in the step-up shape (password,
a confirmed authenticator code, or a passkey or security-key assertion).
The settings cards ask for that proof when the server answers
auth.reproof.required and retry the same call with it. The Bearer path is
unchanged: its elevation already is the proof.
Every route that checks a password or a factor as a re-proof now uses one
verifier and one per-account budget of five attempts per fifteen minutes,
shared with the step-up mint, and records each refused proof as
auth.mfa.failed. Passkey enrollment and password change had neither the
limit (passkey enrollment) nor the audit row (both). A structural test
freezes the set of credential-verifying routes and requires a throttle and
an audit call in each.
Two requests left the server without anything being connected: the
HaveIBeenPwned range query on every password set (registration, change,
admin reset) and the daily GitHub releases request behind the About page
and the admin version tile. Neither could be turned off, which made
"nothing leaves" untrue for an instance that wanted it to be.
PASSWORD_BREACH_CHECK_DISABLED and UPDATE_CHECK_DISABLED (1, true, yes or
on) stop each request. Both stay on by default. The breach check keeps
its fail-open contract: switched off, a password is simply not looked up.
The update route answers status "unknown" with reason "disabled" and
the admin tile says the check is off. The password routes reach the
check only through the switch, and a test holds that import line.
Both are on the compose whitelist, in the env manifest under a new
"Outbound requests" group with IP_GEO_LOOKUP_DISABLED, and documented in
both env examples and the getting-started guide.
The "Send raw data" switch promised anonymised single readings from the
last 30 days. It sends bucket averages from the rollup table: per day for
the last 90 days, per week for the year before, per month back to five
years, each with its date and reading count. The label, both
descriptions and the warning now say that, and the "off" text no longer
claims the summaries carry no individual value (they include the latest
one).
The data-posture line and the Coach's own description of the app said
all health data is encrypted at rest and nothing leaves the instance
beyond the AI provider. Free text, documents, questionnaire answers,
AI-written text and tokens are encrypted; measurement values, lab
values, medication names and mood scores are not. Both now say so, and
the Coach is told not to claim otherwise. Two narrower absolutes are
scoped to what they cover: the local-model onboarding title and the
off-host backup card.
All seven locales.
The password-reset runbook tells an operator to run
`docker compose exec app node scripts/reset-password.mjs`, and the
intake-repair runbook runs `healthlog-tsx scripts/repair-intake-anomalies.ts`
inside the container. The runner stage copied nothing from scripts/, so
both failed with "Cannot find module", the reset at the moment an admin
is locked out.
The runner stage now copies both scripts and the Argon2 parameter module
the reset imports, and proves at build time that each reaches its own
first check with every import resolved. A structural test holds every
in-container command in docs/ and the Dockerfile copies in step.
Key rotation is documented the way it works on the published image: the
Rotate now button under Admin > Encryption is the primary path. The
rotation script imports the application graph and runs from a source
checkout of the same release; the runbook, the admin hint in all seven
languages and the decrypt error now say so.
Papra organization ids are checked against org_ plus 24 characters
before anything is sent. A 403 user.not_in_organization and a 400
server.invalid_request.params read as no such organization instead of a
refused key; the error body is read for its code and never passed on.
The connection test also reads the tags, so a key without tags:read
fails at save (permissionMissing) instead of on the first filter. Name
searches quote each word, so a leading dash is not a negation, and the
date range goes to Papra as date:>= / date:<= with the per-row check
kept as a net.
The settings card stays visible, read-only, while a connection is
stored but the operator list is gone or Documents is off, so the token
can still be removed; a new address asks for the token again. The
picker clears a selection when the search or a filter changes, says
when the 25-document limit is reached, steps back the rows a finished
run did not touch and offers Pick more, shows an empty state, and
announces each row's result to screen readers. Both system switches
are radio groups with one tab stop and arrow-key movement. Strings in
all seven languages.
A settings card under Integrations connects either system and tests it
before saving. An import button beside Upload in Documents, on a
condition's documents card, and beside Add in the visit and vaccination
document pickers opens a sheet that searches by name with tag and date
filters, shows what HealthLog already holds, and imports the ticked
documents one at a time with a result per row. The buttons appear only
for a person in their own record with a connection; strings in all
seven languages.
The start page's short list left out things a reminder had just told
the person about. A check-up counted only once its due hour had
passed, a booked visit was read one at a time and dropped out at its
start time, which is when its reminder fires, and both sat below doses,
sync problems and the Coach check-in under a three-item cap, so a busy
morning pushed them off.
The check-up item now covers every check-up due today or overdue and
names up to three of them. Visits are read for the whole local day,
also once started, and for the next 48 hours: everything booked today
shares one item, the next day with a visit gets a second, and the
today / tomorrow / day-after wording follows the calendar rather than
an hour count. A due check-up and today's visits always keep a place
under the cap; the displayed order is unchanged. A Coach cadence label
is translated here as it is on the checkups page.
The digest's wire shape is unchanged: visits reach it only as
`upcoming_visit` items, which may now appear twice.
The due phrase on the check-up card and the checkups page counted a
check-up one day past its date as "Overdue by 1 days", and the same
counted form read wrongly in German. One day over now has its own
phrase in all seven languages. Both overdue phrases keep the warning
colour through a shared `isOverdueDue` helper, so a surface no longer
compares against a single key.