The web leg of the OIDC callback already names the purge date when the
account is pending deletion; the native leg redirected with the bare
reason. It now adds the same purgeAt parameter to the custom-scheme
redirect, and only the date.
Refs #1341
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the
login, register, token and session paths, mirroring columns every
migration under prisma/migrations already creates. The entrypoint applies
those migrations before the server listens and stops the boot when one
fails, so the mirror never had work to do, and DDL on a request path
needs the table's owner: a connection that may only read and write rows
fails every login on it. Drop the module, its call sites and the mocks
that stubbed it out.
Every write to the per-sign-in Codex memory checked the account digest,
but the reads did not. A credential reconnected to another ChatGPT
account therefore tried the old account's working model first, sorted
by its refusals and trusted its model list, until the first write
rebound the entry, and the vision gate kept answering from the old
account in the meantime.
The client reads (working model, refusals, model list) now take the
credential and treat an entry learned under another account as empty.
The vision gate holds no account id, so the connect routes rebind the
entry and the disconnect routes drop it, and the gate reads the entry
as its process last bound it.
The MFA status read listed oidc among the proof methods for a Bearer
caller too, although the provider round trip stamps a browser session
and nothing else, and the API contract says a token is never offered
it. The status read now leaves it out on the Bearer path.
The createdAt field and the locale import grew files the size ratchet
holds. The account payload's insurance-number decryption moves into
its own helper, the import joins the zod one, and two comments lose a
wrapped line, so no baseline has to rise.
The header always said "welcome back", including to an account the
setup flow had created a few minutes earlier.
/api/auth/me now publishes the account's createdAt, and the greeting
welcomes an account in its first day ("Welcome, name.") and welcomes
back every other one, including an account whose older server does not
send the field. Until the account is known, during the SSR pass and the
hydration render, the line is the time-of-day greeting alone, so
nothing claims "back" before the client knows it is true.
An account created through OIDC holds no password, passkey or second
factor, so the recent-proof gate in front of token minting, MCP
connections, exports and the admin reset had nothing to ask it for and
the web could only say "sign in again" (#1296).
GET /api/auth/oidc/reproof sends the browser back to the configured
provider with prompt=login and max_age=0, its own state, nonce and PKCE
verifier, and the signed-in account and session sealed into the state
cookie. The shared callback branches on that intent: it refuses unless
the browser still carries the bound session (checked before the code is
redeemed), the ID token names the account's linked issuer and subject,
and auth_time lies inside the gate's five-minute window. It then stamps
Session.reproofAt exactly as a password re-proof does, creates no
session, and returns to the page it left with ?reproof=done|failed.
recentProofMethods offers "oidc" only to an account without a second
factor whose identity is linked while a provider is configured, and the
fresh-factor gate never lists it. GET /api/auth/me/mfa publishes the
list as proofMethods, so the passkey card offers what the account can
actually give, and register-options takes { method: "oidc" } on a fresh
provider re-proof, so such an account can add a passkey as a second way
to confirm. Both re-proof dialogs offer the provider and a toast reports
the outcome on return.
The callback's missing-state branch and the passkey card's enrollment
flow move into their own functions to keep both handlers under the size
ratchet.
A cookie named healthlog_session can be set by any host under the same
registrable domain, so a page on a sibling subdomain could plant a session of
its choosing in a visitor's browser. Wherever the session cookie is Secure it
is now issued as __Host-healthlog_session, which a browser accepts only from
this exact host. Plain-HTTP installs, which cannot hold a __Host- cookie,
keep the old name.
Nobody is signed out: the old name is still read after the new one, a
session found under it is re-issued under the new name and the old cookie
expired, and a browser holding the new cookie is never answered from the old
one. Every reader and writer goes through one module, which the proxy, the
native-caller check, the native handoff and the OIDC callback use; the
session-surface guard follows the new reader.
The translation files merge key by key (no key changed on both sides), the
backups list carries both the database dump and the pre-wipe copy window,
and the backup-restore doc keeps the shorter rewrite plus the dump in the
bucket and a lifecycle rule that covers database/.
The session cookie is SameSite=Lax, and SameSite compares the registrable
domain, so a page on a sibling subdomain of the instance could send a
CORS-simple POST with the person's cookie attached. safeJson accepted any
Content-Type containing "application/json", so a text/plain body that
mentions it in a parameter, which needs no preflight, was parsed as the
person's own request: readings added, entries deleted, integrations
disconnected, a delegate invited within the re-proof window.
apiHandler now refuses a POST, PUT, PATCH or DELETE before authentication
when the browser marks it Sec-Fetch-Site same-site or cross-site, or, from
a browser that sends no fetch metadata, when its Origin names a host other
than the one the request went to or APP_URL. A request with an
Authorization header, or with neither header (the phone app, scripts,
webhook senders), is unaffected, as are the webhook and URL-credential
routes that authenticate with their own secret. The JSON media type is
compared exactly, with parameters allowed, in safeJson and in every route
that branched on a substring of it.
The per-account password throttle skipped every attempt that came from an
address one of the account's sessions or device logins had used in the last
thirty days. An address is the easiest thing about a request to share or
claim: in a household or an office the guesser has the owner's address, and
a direct caller could name it in a forwarding header. Either way the
throttle switched off and guessing was bounded only by the hash cost.
A remembered browser (the trusted-device cookie) and a phone presenting the
device id of one of the account's logins still pass; everything else gets
the growing wait.
Deleting an account, a guardian deleting a managed profile, and "Delete
all data" were final the moment they were confirmed: the account row
cascaded into every table and every stored copy, and the off-host objects
were purged in the same breath. Nothing short of rolling the whole
instance back could return one account.
ACCOUNT_DELETION_GRACE_DAYS (14 by default, at most 30, 0 erases at once
as before) now sets a window:
- Account and managed-profile deletion lock the account (migration 0488:
deletion_requested_at, deletion_purge_at, deletion_requested_by_id).
Every session, refresh token, API token, connector, share link and
step-up elevation is revoked in the same transaction, and the record,
its stored copies and its off-host objects are left as they are.
- Sign-in is refused with 403 auth.account.deletion_pending and the
erasure date, after the credential was proved and before a second
factor is asked for. The session and Bearer resolvers and the acting-
account resolver refuse a pending account too, so a credential minted
in the moment before the lock is useless.
- POST /api/admin/users/{id}/undo-deletion (cookie-only, audit-logged)
clears the lock. The admin users list reports the pending state.
- The hourly account-deletion-purge job erases accounts whose window has
ended, exactly as the immediate deletion did, with the off-host purge
request committed alongside. The Guardian invariant is decided again;
a refused erasure stays pending and is reported.
- A wipe first stores the whole record as a PRE_WIPE_<ms> copy through
the writer the weekly copy uses, keeps it out of the wipe, and holds
its off-host purge back with a new not_before on the request. The
ordinary restore brings the record back; restoring that copy withdraws
the held purge. The purge job deletes the copy when its window ends.
Background work skips accounts pending deletion through one shared
predicate (src/lib/account-deletion/active-account.ts): every reminder,
digest, Coach, sync, backfill, rollup, score and backup enumeration
filters with it, and account-deletion-enumeration-guard.test.ts fails on
a query in background work that neither filters nor says why it need
not. Notification delivery and the AI capability loader refuse a pending
account as chokepoints behind it, and the Withings, WHOOP and Telegram
webhooks and the notification quick actions ignore one.
The privacy summary publishes the window. Two pieces of the reminder tick
and the off-host pass moved into modules of their own to keep the files
inside their size ratchet.
A save of a person's provider settings and of the operator's admin AI
settings forget the health ledger's record and close the status notes'
negative windows. The two Codex sign-ins, the operator's central one and a
person's own, forgot the ledger record only, so a fixed connection still
had every note wait out the window the refused one earned, up to two hours.
The central sign-in now closes every record's windows, since every account
may fall back on that connection, and a person's own sign-in closes theirs.
Proven against the real rows in the Codex sign-in integration suite.
Refs #1279
step-up-elevation-guard timed out under load in three separate gates
and passed every time on its own. One of its tests imported
@/lib/auth/step-up inside the test body to read the fresh-factor set,
and that module reaches the database client and the generated Prisma
client: the import alone ran against the test timeout, and how long it
took depended on the machine, not on the code under test.
The fresh-factor definitions (StepUpMethod, FRESH_FACTOR_METHODS,
isFreshFactorMethod) move to step-up-factors.ts, which imports nothing;
the guard imports it statically and the test is synchronous. Two more
guards did the same: next-image-optimizer-guard loaded next.config and
its plugins per test, and proxy-relative-redirect-guard loaded the
redirect helper with next/server. Both import statically now.
guard-dynamic-import-guard.test.ts parses every file of the guard
project with the TypeScript parser and fails on any import() call, so
a guard that needs a value imports it with the file or reads the
source; a test that must load late belongs to the isolated project.
Forty-two guard files carried their own comment stripper, block comments
first in one non-greedy pass, so a /* inside a line comment opened a
block that hid the code after it, and the stricter copies hid code
behind a /* inside a string, which every e2e route glob has. They all
read through the shared stripComments now, and it scans the way the
parser does: strings, template literals and regular expressions keep
every character, a comment inside a template expression goes, and line
numbers are kept. A parity test strips every source file both ways,
with the scanner and with the TypeScript parser, and fails on the first
that differs.
No guard reports anything new. Each one fails when the stripper returns
nothing; three of them passed on an empty file and assert that the code
they ban is read at all now.
The size baselines take the lower value of the two sides per entry, and every ratchet baseline is written back to what the tree measures now (file and function sizes, version stamps, source reads, import cycles, route body parsing).
132 route handlers spelled out safeJson, safeParse and the 422 refusal by
hand in one of six shapes. Each block becomes one parseJsonBody call with
the same size cap, wide-event action, issue count, error code, message and
status it had, so every response is the one it was. The comments inside
the replaced refusal blocks go with them.
A ratchet (route-body-parse-ratchet) counts the route files that still call
safeJson themselves; the count may only fall.
The module gate and the cycle gate imported each other for one pure
predicate. isCycleEnabled moves to lib/cycle/enabled.ts and both gates
import it from there.
The band minter read DOSE_WINDOW_DEFAULTS through the analytics/compliance
re-export barrel, which loads the compliance display and with it the
scheduling modules that import the minter. It now imports the constant from
the module that defines it. Runtime import cycles fall from 6 to 4.
lib/sharing/grants.ts held both the grant transitions and the predicates
every reader asks (state, level, scope, the live grant for a pair). The
transitions reach into the managed-profile lifecycle and the OAuth connect
target, and both of those read a live grant, so grants, lifecycle and
connect-target formed an import cycle.
The predicates, findActiveGrant and touchGrantUsage move verbatim to
lib/sharing/grant-access.ts, which imports only the database client and
the scope vocabulary. Importers and the mocks that replaced those
functions point at the new module. Runtime import cycles fall from 7 to 6.
api-handler.ts held two modules: the route wrapper and thirteen guards. The
guards now live in lib/auth/guards/, one module per family (the caller,
the acting-account carrier, the record modes, the cookie-only helpers, the
fresh-factor and recent-proof gates, the Bearer elevation, MFA management).
api-handler.ts keeps the wrapper and re-exports every guard, so it stays the
one public entry and no route changes its import.
The bodies move verbatim; only the version prefixes of moved comments are
dropped. The structural guards that read the auth layer now find the module
that declares a guard by name (helpers/auth-guards.ts) instead of a fixed
path, and a new guard holds the entry: nothing outside the folder but
api-handler.ts imports a guard module.
GET /api/topics answers one entry per topic the record holds data for:
a headline value, a rule-based sentence and a 30-day sparkline.
GET /api/topics/{topic} answers a topic's signals with their usual
ranges, side-by-side series for a chosen window, the relationships
between its signals, 90-day trends, what it holds nothing for yet, and
its blocks (training load, lab markers with their trend, screener
trends, the cycle day).
Every figure comes from reads the metric pages already make: the local
daily values, the reconstructed nights, the mood score, computeLabTrend,
the training load and the screener trends. No model is involved. A
relationship says nothing below 14 paired days and is only called a
pattern at p < 0.05; a lab trend needs three readings spanning a year.
The preview is the viewer's own opt-in: with it off both routes answer
404 topics.preview_off. Topics, signals and blocks follow the record's
module switches and the sections the viewer's grant opens; the routes
read across sections, so a scoped grant is refused like the timeline.
/api/auth/me publishes the caller's opt-ins as previewFeatures.
Fourteen route tests for the /api/auth/me preference family replaced Prisma
with a mock and asserted the calls it received. Their claims are now shown
through the real handler on the stored row and the response: validation
refusals with their codes, normalisation and defaults on read, optimistic
lock conflicts against a real second writer, rate limits driven to their
first refusal in the Postgres limiter, audit rows read back, cache entries
evicted, and the jobs a change queues observed on a real pg-boss queue.
Assertions that were only about the shape of a Prisma call are replaced by
the behaviour they stood for, and two that described a state only a mock
can reach (a missing column) by the stored value that state resolves to.
DELETE /api/auth/me/sessions/{id} takes the session's public handle, an HMAC
of the row id, and compared that handle with the caller's row id to decide
whether the caller signed out the session it was using. The two never match,
so every revoke was audited and annotated with self: false, including the
ones where a person ended their own session. The handle is compared with the
handle of the caller's session now.
The mocked route tests for the session list and revoke, the device list and
revoke, the security-activity feed and the FHIR surface move onto the kit.
Each claim is now shown on the stored rows and on the next request: a
revoked session, device login or token is refused when it is presented again,
another account's session or device answers 404 and stays signed in, the
current-session and current-device flags follow the credential that anchors
them, and no row id, full IP or push token reaches a response.
The FHIR read set, covered against a database only by the scope check until
now, is driven with seeded readings and medications: every resource carries
the seeded values, another account's rows never appear, a fhir:read token
reaches every data route and an unrelated narrow token is refused, the module
gate closes all eleven data routes, and $everything serves the same entries
as the document builder with references that resolve inside the response.
The module-gate inventory now names the new file as its behavioural proof.
Six Apple Health signals leave the deferred list and ride
POST /api/measurements/batch and the export.zip import end to end:
EXERCISE_MINUTES, STAND_MINUTES and STAND_HOURS (the exercise and stand
rings), MINDFUL_MINUTES, STATE_OF_MIND_VALENCE (iOS 18 State of Mind,
-1..1) and AFIB_BURDEN (AFib History, percent of the week). The ring
types and mindful minutes are cumulative, so the daily rollup is their
sum; AFib burden is scaled strictly from Apple's fraction and a value
above 1 is refused rather than guessed at. The archive import takes
mindful minutes from the session's dates and skips idle stand hours.
Every registry carries them: units, ranges, labels in all locales,
categories, chart tokens, report catalogue (mindful minutes and the
valence fenced as sensitive), signal registry with FHIR codings, and
MCP exposure for the three ring types only.
Workout GPS tracks are now an opt-in, off by default
(PATCH /api/auth/me/workout-routes, a switch on Data & Privacy). While
it is off, the workout batch route and the export import keep every
workout and drop its track with the same per-entry status. With it on,
the import reads the referenced GPX files from the archive one bounded
member at a time, thins each to the point cap and stores it sealed.
The climb a track carries is written to Workout.elevationM at write
time, so the workout insight no longer opens the track, and the AI
egress guard now freezes the set of files that read route geometry.
The setup flow asks for the name the greeting and the reports use, and
the profile route has always accepted it, but no settings surface offered
the field and /api/auth/me did not publish it. A name chosen on the
first day stayed for good.
The account payload now carries `displayName`, the profile card shows the
field beside the username and saves it with the rest of the profile, and
an emptied field clears the name.
The session lookup and the Bearer resolver loaded the whole users row on
every authenticated request, including the profile picture bytes, the
cached briefing text and every stored integration credential. Both now
select SESSION_USER_SELECT, a named allowlist of the small columns the
request path reads, and so does the owner load for a switched record.
The few routes that need a credential or a large column read it
themselves at the moment of use: the factor verifiers load the password
hash and TOTP secret, the profile response loads the insurance number,
and the snapshot read loads the cached briefing text. A structural guard
holds the lookups to the projection and keeps every Bytes and
secret-shaped column out of it.
A dead refresh token was asked again on every hop of every run: the
proactive refresh failed and was swallowed, the request 401'd, the on-401
refresh failed again, and the nightly warm repeated that for the briefing
and every card. A refusal that proves the grant dead now flips the
connection to the existing expired state (compare-and-set on the refused
token, one signal per transition), so the chain, the presence probe and
the AI capabilities stop counting it. A new device-code sign-in clears the
refused credential's ledger rows so the fresh one resumes in place.
listPage (src/lib/http/list-query.ts) reads one row past the limit and
cuts it back, on top of the existing list-cap signal rather than beside
it. Every capped list now gets its page there, and every read passed to
it orders by something ending in the row id, so rows that share a sort
value cannot repeat or vanish at a page boundary.
- practitioners, vaccinations and encounters were cut at their take
with no signal; they now answer meta.truncated and accept offset, and
their web readers walk the pages instead of stopping at the first
- symptoms/events reported hasMore on a page that was exactly full; it
is now true only when a row past the page exists
- the document list's hand-rolled limit + 1 goes through the helper
- security-activity answers an out-of-range limit with the 422 its
published schema already promised instead of clamping it
list-page-guard binds every apiListSuccess route to listPage, forbids
hand-cut lists and length-derived hasMore, and requires the id
tiebreaker in every listPage read.
medication_compliance_rollups lost its last reader when compliance moved
to the schedule-anchored computation, yet every dose write still upserted
it, a boot backfill refolded it, and the wipe and backup plans carried it.
The per-dose hook keeps the one derived row that still hangs off a dose
day, the supplement nutrients, under dose-day-refresh. The pending-row
mints (today projection, reminder) no longer call it, since an untaken
dose adds no nutrients. The import job finishes without its per-day
recompute phase; its progress keeps touchedDays and rollupProcessed on
the wire, inert and documented as deprecated. The backfill queue is
retired on boot and migration 0414 drops the table under a bounded lock
wait.
The session time-zone test stored sessions under readable secrets that
the cookie shape check now refuses before the query, so it tested the
refusal instead of the expiry. A shared helper mints hls_ plus 32 hex
bytes, and the two route tests that set a session cookie use it too.
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment
lines in 1,308 files lose the work-item identifier at their head. Release
versions and issue references stay; nothing outside comment text
changes, which the script checks per file against a comment-free
printout. healthlog/no-planning-tags is enabled at error level with this
commit, so the tree it lands on is clean.
users.insights_cached_text held the model's briefing, readings and
recommendations about the account in the clear. Migration 0411 adds
insights_cached_text_encrypted (AES-256-GCM, binary layout, purpose-bound
label) and a partial discovery index; the free-text encryption backfill
seals the existing rows one at a time, fail-closed, carrying updatedAt and
guarded on the value it read. Every reader, writer and clear goes through
cached-text-cipher.ts, and a guard keeps the column names there. The
column joins the encrypted-column registry and the key-rotation script,
stays out of backups as operational, and is reset by the data wipe.
A list that stops at its own take and says nothing lets the last row read
as the oldest there is. Capped lists now fetch one row past the limit and
answer meta.truncated and meta.limit on the success envelope, so data keeps
its shape. Applied to the capped lists without a total count: rhythm
events, ECG recordings, sharing grants and activity, illness episodes, side
effects, personal records, security activity, cycles, family history,
allergies and mental-health assessments. The activity feed no longer
reports a list that is exactly full as cut.
A Guardian signed in through single sign-on with a second factor enrolled
could not hand a managed profile over once five minutes had passed since
sign-in: the handover answered 401 auth.stepup.required, and the panel
printed "Confirm your second factor, then try again" with no way to do so.
The same held for every action behind requireFreshMfa: creating, editing
and ending a managed profile, adding and removing a Guardian, disabling
the second factor, rotating recovery codes, removing a security key or a
passkey, changing the password, the data wipe, account deletion and the
key rotation. The recent-proof routes had the dialog; these had text.
- requireFreshMfa names the proofs that clear a stale stamp in
meta.methods (the possession proofs the account holds, never a
password, which stamps reproofAt only).
- The re-proof flow treats that refusal like auth.reproof.required:
open the dialog, post the proof to /api/auth/reproof, send the request
once more. The flow moves into a React-free module so it can be driven
in tests; the hook binds it to the dialog.
- The managed-profile mutation hooks take a required proof guard, and
every caller passes recentProof.run. The other surfaces wrap their
requests in recentProof.run; disabling the second factor asks before
its confirmation opens, so the code typed there is not spent twice.
- An account with nothing to re-prove in place (single sign-on only, no
factor) is told to sign in again when adding a factor, instead of being
offered a password it does not have.
- step-up-web-caller-guard freezes the gated route methods and fails when
a web request to one of them runs outside the flow.
An account whose only second factor is the identity provider's is still
asked for an app factor on the managed-profile acts: the app cannot see
the provider's factor. Enrolling right after a sign-in needs no further
proof, and the confirmation stamps the session, so the act follows.
Refs #959
POST /api/auth/claim/sso checks the handover link and the username and seals
the link id, the username and a per-start binding into an hl_claim cookie.
GET /api/auth/oidc/login?intent=claim carries the binding into the encrypted
OIDC state, and the callback binds the provider subject to the profile in the
claim transaction, refusing a subject or verified email another account
holds, a link that is no longer usable, and a live session.
The handover status and the claim preview name the redemption methods; a
single-sign-on-only server hands over through the identity provider, and only
the password claim stays refused there.