The step confirmed one key, the active application key. A restore needs
more: off-host copies are sealed under BACKUP_ENCRYPTION_KEY, a copy
written before a rotation needs the retired application key it was
written under, and every session, token and share link hangs off
API_TOKEN_HMAC_KEY. An operator could do everything the step asked and
still be unable to read a single backup.
The recovery kit lists every key a restore of this server needs, by id
and fingerprint, never key material:
- each id in ENCRYPTION_KEYS, plus any retired id a stored copy
(innerKeyIds) or an off-host object inside the bucket's retention
(OffhostBackupKeyUse) still lists, also once the server dropped it;
- BACKUP_ENCRYPTION_KEY and each BACKUP_ENCRYPTION_PREVIOUS_KEYS entry,
named by the id their objects record;
- API_TOKEN_HMAC_KEY.
Confirming records the set the admin was shown and refuses a stale one
with 409 recovery.kit.stale. The kit is due again as soon as a key it
lists is not in that set (a rotation, a new backup key, a re-keyed
install); a key that merely drops out does not make it due. "Check my
copy" now checks any key in the kit.
Migration 0487 carries a confirmation given under the old step forward
as a kit holding that key, so that part stays confirmed, and drops the
four app_settings columns. It also creates the two tables the readiness
evaluation uses (restore_drill_outcomes, recovery_readiness_notices).
The routes move to /api/admin/recovery-kit; the reader of the keys the
backups still need moves out of the encryption status route into
src/lib/recovery/backup-key-needs.ts so the kit and the status view
share one definition.
The keys followed each other without waiting for the step to move the focus,
so on a slow run Tab started from the previous answer and the number chose
nothing.
Each step focuses its question, but the first step's question only exists
once the language's wording chunk has arrived; the focus ran while the step
still showed its loading state and was never moved again, so a keyboard or
screen reader user started on the page body.
A long document read in parts can end with a part cut at the provider's
output limit, a part never read, or more facts than one review takes.
That was told once, as a notice when the run finished; a review opened
later looked complete.
InboundDocument.extractionIncomplete (migration 0483) is written with
the facts by both stagings, the extract route or run and lab
auto-staging. The document detail publishes it and the review shows the
same note whenever it is opened. The disaster-recovery backup carries
it; an older file restores it as false.
Refs #1257
"End course" now posts to `POST /api/medications/{id}/courses/end`, which
ends the running course at that moment: its last day becomes today on the
person's clock and the instant is stored as `endedAt`, so a dose later today
is not due, not reminded and never missed, while one taken earlier still
counts. Pending placeholders already minted for later slots are tombstoned.
A medication without dates gets the course its window describes, ended now.
End stays its own verb instead of `PUT { endsOn }`: an end date set through
the PUT or the course editor keeps meaning the whole day, and the PUT keeps
accepting it on any course, since moving an end is a legitimate edit.
The list and detail reads serve `canEndCourse`, true exactly while a course
is running, and the action refuses with 409 `medications.course.notRunning`
when it is false, so no client can reopen an ended course or invent a
one-day course from a planned one by ending it. The web controls render End
from the served flag; the contributed tests keep their cases against it.
Refs #1280
The document picker showed a document deleted in HealthLog as "Deleted
in HealthLog" and would not let it be ticked, so a person who deleted an
imported document by mistake met a dead end exactly where they would look
for it again.
Such a row can now be ticked, and the import sends revive: true for it.
A document still in Recently deleted is restored through the shared
restore, nothing is fetched, and the row reads "Restored from Recently
deleted". One the purge already removed is fetched and stored again, and
only once it is stored does the ledger forget its key, so a failed fetch
leaves the deletion standing. Without revive the route answers "deleted"
as before, and the token upload has no such switch: a Paperless workflow
or the import script re-sending a deleted document is still refused,
whether it is in Recently deleted or long purged.
The search names the document a deleted key still points at (documentId,
null once purged), so the row can say "In Recently deleted" rather than
"Deleted in HealthLog". The outcome gains "restored", sent only in answer
to revive. The row logic moves to picker-rows.ts.
Refs #1275
Deleting a document only stamps a tombstone and the purge removes it
after 30 days, but the only way back was the undo toast, gone a few
seconds after the delete. Recently deleted lists the deleted documents
of the record with type, filing date, where an import came from, when
each was deleted and how long it has left, and offers Restore, Restore
all and Delete now.
GET /api/documents/inbound/deleted lists them (owner-only, capped at 200
with the list-cap meta). POST /api/documents/inbound/{id}/purge removes
one deleted document for good through the same function the nightly
purge runs, so its import keys and aliases still move into the ledger
and an importer re-sending it is still answered "deleted"; a live
document answers 409 documents.inbound.notDeleted. The usage read gains
deletedCount, 0 under a grant. The restore route and bulk restore
share one restore, which now also clears ledger entries for the
document's own keys.
The vault header shows a quiet "Recently deleted (n)" link only while
something is in it, and the delete toasts and confirmations say that
deleted documents stay there for 30 days. The purge's queue facts and
grace window move to document-purge-queue.ts so the worker registry
still loads none of the work.
Refs #1275
LifecycleManageBody takes the medication (id, name, active, course
status) instead of one prop per field. The call site in
medication-detail-tabs.tsx shrinks to one line, so the course status
reaches the controls without growing a file the size ratchet holds.
End and its confirmation now live in EndCourseControl, which renders only
while the course runs. When End stops being offered, the open dialog and
its state unmount with it, so the state no longer has to be reset during
render.
The course-status prop uses the shared CourseStatus type instead of a
spelled-out union, and the End confirmation's open state is dropped when
End stops being offered, so a dialog closed by a refetch cannot pop up
by itself once a later course offers End again.
Ending sends today as the end of the medication's latest course. While
the PUT was refused this did nothing; now that it succeeds, pressing End
on a course that had already ended reopened it up to today (doses due
again, the days between counted as missed), and on a course that had not
started yet it became a one-day course today.
LifecycleManageBody now takes the server-resolved courseStatus and shows
End only while the course is CURRENT (a medication with no dates is
CURRENT). Pausing stays available whatever the course.
"End medication" sent new Date().toISOString(), a full instant, against
an endsOn that only accepts YYYY-MM-DD, so every press answered 422 and
the toast said the medication could not be ended. The instant's date was
also the UTC one, a day off around midnight for anyone not on UTC.
The button now sends the person's calendar day in their timezone
(userDayKey), the same day key the medication card uses.
The reasoning separation grew three files past their size ratchet. The Local
client's reasoning collector and the new separation helpers move to
local-reasoning.ts, the gateway client's thinking room and reply separation
become two private methods, and the Coach's error-code resolver moves out of
chat-bubble.tsx into coach-error-key.ts, still re-exported from
message-thread.tsx. Code motion apart from comments that said when a branch
was added rather than what it does. The baselines record the shorter files.
Refs #1276
When every provider in the chain answered a Coach round with reasoning and
no reply, even after being asked once more without reasoning, the turn ended
with "could not reach a provider, please try again in a moment". The
provider was reached, and trying again in a moment changes nothing for a
model that cannot stop reasoning.
Such a chain now ends with its own code, coach.provider.reasoning_only, and
the drawer says the model only reasoned and never wrote an answer, and
suggests another model. The choice between credential_expired, rate_limited,
reasoning_only and unavailable moved into one function beside the other turn
error rules, where it is tested on its own. An un-wrapped reasoning-only
error maps the same way. Clients that do not know the code keep showing the
generic provider message.
Refs #1276
Every briefing reader serves `dailyBriefing` only when the payload's
`briefingGeneratedAt` falls on the reader's today. The demo seed wrote the
comprehensive payload without that stamp and without the language tag, so
on a fresh seed the advisor read returned no briefing, the dashboard
snapshot stayed "preparing", and a warm was enqueued that can only fail
against the placeholder key. The /insights card sat empty while the hero
said "Updated today".
The seed now writes the cache the way a generation does: the payload
stamped through `withBriefingGeneratedAt`, the cache stamp from the same
instant, and the `en` tag. The baked paragraph drops its "Good morning"
opener, since the hero already greets by the hour.
The hero's freshness caption dates the subtitle, so it now shows only
when the subtitle is a briefing. The cache stamp also moves when a warm
keeps yesterday's text, which the read then withholds; the caption then
sat under the generic fallback sentence.
`seed()` is exported behind a main-module guard, and an integration test
runs the real seed and reads its output back through the advisor read,
the dashboard snapshot, the status cards and the period narratives.
With the day panel docked beside the blood-pressure page (1280 px and up),
each half of the stat card is a narrow column, and its four cells still
went side by side because the step followed the window's width. Every cell
came out narrower than "112 mmHg": the systolic values broke onto two
lines, the two-digit diastolic ones next to them did not.
The stat strip now joins a value to its unit with a no-break space, and
each series is a container whose cells go four-up only when the series
itself has the room: from 28rem for a half-card column, from 36rem for a
full-width strip (where the 640 px window step put it, so the longest unit
written, mL/(kg·min), keeps a cell it fits in). The loading shell follows
the same steps. Every page with a stat strip (pulse, weight, the
HealthKit, lab and custom-metric pages) shares the component.
A component test pins the no-break pair and the container steps; the
day-view spec measures every blood-pressure value beside the docked day
at 1280 px and fails on the old markup.
A least-squares slope per day over readings minutes apart explodes: three
blood-pressure readings in one sitting, the only ones of the week, fitted
thousands of mmHg per day, and the dashboard tile printed seven days of it
as the 7-day trend. The 30-day fallback had the same flaw.
A window now earns a slope only when its readings fall on at least three
UTC days whose first and last lie at least half the window apart (4 days
for the 7-day window, 15 for the 30-day one). The rule lives once in
window-confidence.ts and gates every path that publishes a windowed trend:
trendSlope (summarize and its callers), the rollup composition and the
live REGR_SLOPE fallback of the summaries slice and the comprehensive
aggregator, and the per-bucket slope of folded week, month and year
buckets. Slope, direction and confidence are present or absent together.
The fit itself stays over individual readings, as the window means do.
The parity snapshots change only where the fixture falls short of the
rule: a weekly body-fat series has two days in its 7-day window, and a
ten-day oxygen-saturation series spans nine days of its 30-day window.
An iPad in a Safari tab laid the app shell out 10 px in from the left edge
in portrait. The shell padded that inset, so a strip of page colour ran
beside the rail from the logo band to the account row, and the rail's band
borders stopped short of the edge.
The desktop shell no longer pads the sides. The rail runs to the window's
edge and takes the left inset inside its own background, widening by it so
its content keeps its 4rem / 16rem; the logo band, the footer group and the
account band reach back over the inset so their lines meet the edge. On the
right the top bar and main already run to the edge. The phone shell keeps
padding both sides. The side insets are read through --safe-area-left and
--safe-area-right, so a test can set one.
The account band takes the logo band's height (4rem) with the avatar
centred in it, instead of 61 to 69 px of padding with more room below the
avatar than above; the home-indicator inset stays under the band.
tablet-shell.spec.ts sets a 10 px side inset and a 20 px home indicator and
checks that the rail owns the window's left column of pixels, that its band
lines start at x = 0, that nothing in it sits inside the inset, and that the
account band mirrors the logo band. shell-viewport.test.ts holds the routing.
A status run (a metric card, the batched assessment, a derived score, a
workout line, a Coach memory pass) is one model call plus the one repeat
the Local and gateway clients send when a thinking model answered with
reasoning alone, each with the person's response timeout. The run's outer
bound was a single call, so the repeat was cut off, and the open card
stopped polling after 48 s, so a note from a model that needs minutes
never reached the page. The bound is now the per-call timeout times
STATUS_MODEL_CALLS, and the card's poll ceiling follows the published
response timeout (fast for the first 48 s, every 15 s after), only while
the route reports the note in flight; the fallback text stays meanwhile.
The status queue is exclusive, so the longer poll cannot queue a second
generation beside a running one.
The morning refresh is one comprehensive generation of up to five calls
in a row, with no outer bound and pg-boss's fifteen-minute expiry, so a
large setting had pg-boss fail and retry it while it was still running.
It now runs behind the same bound as the warms, and the job carries an
expiry derived from that worst case. The per-metric note, the workout line
and the Coach memory refresh get expiries derived the same way.
queue-runtime.ts gains a bounded class for these jobs: model time read off
model-time.ts, an expiry above it, carried on every send. A new guard
names every caller of runStatusCompletion and generateComprehensiveInsight
with the queues that reach it, and holds each such queue to bounded above
its runs or long under a run lock.
Refs #1274
The document review now names the two outcomes the extraction can report
since it reads a long document in parts. A read that stopped at the
provider's output limit before a single fact closed shows its own
sentence (try a model that allows longer answers) rather than the
generic "Couldn't read the document". A run that staged facts but had a
part cut or not reached opens the review as before and adds a warning
that some values may be missing, so the list is checked against the
document. Both strings are in every locale bundle.
Refs #1257
The ChatGPT Codex backend and the OpenAI API serve gpt-6-sol, gpt-6-luna,
gpt-6-astra and gpt-6.1-sol, but the reasoning, Chat Completions and vision
gates only knew the gpt-5 line. A gpt-6 model got no reasoning control and
no real "off", went out on the legacy wire with max_tokens and a
temperature that reasoning models refuse, and was denied image input.
The three gates now read one classifier, openai-model-family.ts, so a new
generation is taught in one place. Every GPT from 5.1 on, the gpt-6 line
included, is the family that takes effort "none"; every GPT from 5 on reads
images. The upstream catalog (codex-rs/models-manager/models.json) lists
image input for every gpt-6 slug and the same reasoning presets as the
gpt-5.6 line; the presets omit "none" for gpt-5.5 too, which accepts it.
The Codex slug ladder moves into codex-slug-chain.ts and gains gpt-6-sol,
gpt-6.1-sol and gpt-6-luna. gpt-5.6-terra stays first as the slug verified
against the backend; the catalog names gpt-6-sol as its upgrade, and a
rotation retires a whole line at once, so the gpt-6 rungs follow before
terra's own siblings. The ladder test now holds every rung to a recognised
reasoning family with a real off and image input. The OpenAI presets offer
gpt-6.1-sol and gpt-6-luna.
The browser's abort for a forced regenerate waits three calls at the
response timeout, fewer than the five the background generator may make.
That is correct: the route runs its own pipeline, refuses an unparseable
reply or a screened payload instead of retrying, and never reaches the
background generator, so its longest run is the generation, the
grounding retry and the one reasoning repeat.
The guard only read two of the pipeline's files, so a retry stage added
anywhere else on the route would have lengthened the run past the
browser's abort unnoticed. It now sweeps the route and the whole
briefing-request folder for stages and for any call into the background
generator, and the advisor states why its count is the shorter one.
Refs #1274
The briefing read polls while the warm it enqueued runs, and it stopped
after ten polls (225 s) whatever the response timeout. A briefing that
landed later never reached the open page, which kept the empty card, or
the previous attempt's "timed out" hint, on screen until a reload.
The read's poll ceiling now follows the published response timeout over
the generation's sequential calls, never less per call than the
briefing's 180 s default, plus one interval for the queue to pick the
warm up. A settling regenerate and the overview body keep their ten
polls. A test walks the poll schedule against the warm's own bound for
every setting, and the client-abort guard holds the read to the derived
ceiling.
Refs #1274
Every server render left its request reachable for ten minutes after the
response was sent. Providers builds its QueryClient once per render, and
the app's defaults give queries a ten-minute gcTime. On the server each
entry the render built, disabled observers included, started a timer of
that length, and a Node timer keeps the async context it was created in.
That context is the request's: the response, the React Flight stream and
the compressed body. A heap snapshot after a run of unauthenticated
probes showed one 600 s query-core timer per request and the retained
Flight chunks, ServerResponses and gzip buffers all hanging from them:
about 150 KB of heap and 90 KB of buffers per not-found render.
TanStack Query already defaults gcTime to Infinity on the server for this
reason; an explicit value from the defaults or a hook overrides it.
AppQueryClient folds Infinity back in at defaultQueryOptions, the merge
point every observer, cache build and hydration passes through, so the
server cache goes with the client at the end of the request. The browser
keeps its configured lifetimes.
The retention test reproduces the chain without a server: a response
object held only by an AsyncLocalStorage store must be collectable after
the render's client is dropped, with a positive control on a plain client
that must keep it alive. A guard keeps client modules from building a
QueryClient themselves and allows only option-less clients elsewhere.
A full backup carries the account's own mood tags, rated factors and
tag groups, and the JSON import left them out: a mood entry imported
into another account landed without every link to a tag the person had
made.
Before the entries are written, every key they or the file name is
resolved once. A catalogue tag or one the account owns is used as it
is; an own tag whose label the file states differently is kept, never
overwritten, and named in keptMoodTagKeys (keptMoodCategoryKeys for a
group). A tag the file defines and the account lacks is created in its
group, the group created the same way, within the account's caps of 50
tags and 12 groups. A key another account of the server holds comes
over under a key derived from the account and the file's key, so a
second import of the same file finds the copy instead of minting
another. The links are written through that resolution, own rated
factors included, inside the entry's transaction; anything unresolved
is still dropped and named. The import card names kept keys in all
nine locales.
The field-coverage guard now holds the backup's customMoodTags and
customMoodTagCategories to the import schema. The round trip moves a
backup to another server (the same seeded catalogue, none of the
account's rows), imports it into a fresh account and compares the
re-export, own tags, group and rated factor included; a second case
imports into another account of the same server, and a third keeps a
renamed own tag.
A restore never runs beside a backup pass or another large job: the
heavy lane puts its delivery back in the queue for a minute at a time.
The job's own row says queued for the whole wait, and the console said
the restore starts "as soon as the background worker picks it up",
which read as a stuck worker while a backup ran.
The status routes now ask the queue. A delivery the lane put back sits
in created with a start_after ahead of now, and the active row of a
heavy queue is what holds the lane. The job view carries
waiting: "backup" | "busy" | null, and the console says it waits for
the running backup, or for another large job, and since when it has
been queued, in all nine locales. A queue that cannot be asked answers
no wait and is reported at warn.
The job view and its reads move to backup-restore-view.ts, which keeps
the worker module under its size baseline.
The JSON import read six measurement fields and six mood fields while
the full backup and the JSON export carry twice that. A sleep night came
back as stageless rows, a range reading as a single value, an ECG alert
without its verdict, and a mood entry without its note, its level-A
values, its zone, its tag and factor links or its day context.
The import now reads valueMin/valueMax, sleepStage,
rhythmClassification and deviceType on a reading, and note, a1 to a5,
tz, factors, structuredTags and context on a mood entry. Each mood entry
is written with its links and context in one transaction, the way the
bulk mood ingest writes one. A link to a tag the importing account does
not have is dropped and named in droppedTagKeys / droppedFactorKeys, and
the import card says which. The schema moves to
src/lib/import/json/schema.ts so the guards can read it.
json-import-field-coverage.test.ts runs the JSON export and both backup
variants on an account with every column filled in: every field they
write is either read by the import or named with the reason it is not
(row ids, source, deletion records, ciphertext, sync bookkeeping), and a
reason for a field no exporter writes fails as stale. The round trip
imports each file into a fresh account and compares the re-export field
for field. The published contract is held to the runtime schema.
The value chart's axes and labels, as drawn on a 90-day blood pressure
view on a tablet:
- Y ticks were whatever Recharts split the padded domain into, so the
labels read 57, 83, 109. The domain is now widened to a 1/2/5 step
and every tick is handed to the axis, evenly spaced and all drawn.
- X ticks were every n-th point, so their dates drifted with the data.
They now fall on a calendar grid (days, Mondays, two weeks, months,
quarters, years), each on the point nearest the grid line, and the
step is the smallest whose labels fit the measured plot width.
- The "your average" label sat inside the plot over the readings, and a
target zone's label was clipped to its first letter. Both now sit
beside the plot, in a right margin sized to the longest label.
A browser test draws the axes with Recharts and pins the tick values,
the Monday grid, and both labels outside the plot clear of every dot.
On an index or band axis the rug placed every day that holds anything
by interpolating it between the two nearest points. A sparse series
beside a daily one squeezed a week of other days into one gap, and the
dots ran together into bars. A day now gets a dot on those axes only
when it is one of the chart's points; a time axis keeps every day of
its span.
The rail, main, the bottom bar and the Coach button each read
env(safe-area-inset-bottom) on their own. They now read
--safe-area-bottom, which is that env() value, so the four always agree
and a test can set an inset that no desktop browser reports.
The app shell's root also states the content colour itself instead of
leaving it to propagate from the body. A WKWebView browser on iPadOS
(Edge, Chrome, Firefox) can keep the strip over the home indicator out
of the page's layout and fill it with the colour it takes from the root;
that strip now meets main in the same colour.
Each measurement token row carries a switch, the mint form an opt-in, and
the card a notice when readings exist that no token reaches. Copy in all
nine locales. The endpoint catalogue moves to its own file and the
revoked list out of the token card.
touch-pan-x on the tab strip refused a vertical pan that began on it, so
a swipe up or down over a tab strip scrolled nothing. The strip already
pans sideways through its own overflow.
A touch tablet keeps the rail's 44 px rows, so in landscape three to
seven destinations sit below the fold, and the list has no scrollbar at
rest. The edge fade alone did not read as "swipe here", and a swipe that
reached an end chained to the document, which held the next one.
The list region moves into its own module. Under a coarse pointer it
contains its overscroll, keeps a focused row clear of a new scroll cue,
and shows that cue at an edge with more destinations; a tap scrolls the
list most of a screen on. The cue stays out of the focus order and the
accessibility tree: the keyboard and screen readers already reach every
row. The rail takes the home indicator's inset so the account row stays
clear of it. A mouse sees no change.
The authenticated shell was sized by h-dvh. On iPadOS Safari that value
and the area the browser lays out disagree after a rotation and under the
floating toolbars: a short value left a strip of page colour under the
shell with the last card cut off above it, a tall one let the document
scroll by the difference and take the swipes meant for main.
The shell now takes the window's edges (absolute inset-0 against the
initial containing block), and the document under it gets no scroll and
no rubber band (data-scroll="shell": overflow hidden, overscroll none,
height 100% so the box survives a dialog's scroll lock making the body
relative). Not fixed: a fixed box re-rasterises every page in a layer of
its own and traps the bottom bar's z-index.
The desktop shell's main now clears the home indicator of an installed
app, and the page frame clears the Coach button there with pb-24: the
button sits 5.5rem up, so pb-20 left the last 8 px under it. The
sub-shell grid floor follows the new padding.
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 reduced-motion backstop gives every element a near-zero transition,
and the default transition property is all, so the font size Recharts
writes into its measurement span started a transition and the read in
the same task returned the previous size. Labels were sized at whatever
the last one had, or at the inherited 16 px: the last date label moved
in from the edge and value labels that fit were dropped as colliding,
differently from one visit to the next. Nothing in the measurement host
is ever seen, so nothing in it transitions now. A browser test pins the
read under reduced motion and without it.
The backups section now hands its flow to the download, restore and
upload hooks. Each caller entry may name the file that sends the
request; the component still owns the flow and the dialog and must
import that hook and hand it the flow.
The HealthChart split moved its layers, tooltip and day footer into
health-chart/, so the guard read the composer as a date-axis chart with
no doors. A component and the folder of its name are now one entry, a
tooltip action handed whole to the tooltip counts as wired, and the mount
floor counts the dashboard's one builder mount for its seven charts.
Drop two return-type exports nothing imports.
The section becomes a composer over backups/: the restore dialog with its
preview, the refusal, skip and schedule notices, the restore jobs and
their request, the upload flow, the run and download actions, and the
two row layouts sharing one action pair. The upload total is a pure
function and tested as one. The read-failure guard's 250-character
window had stopped reaching the restore preview's own failure lines; the
preview now sits in a smaller component, the guard sees it, and it is
listed as a deliberate exception with its reason.
One question at a time in the source order, Back keeping the answer,
the number keys and arrows, the review with a way back to every answer,
the optional PHQ-9 follow-up, a failed submit, and the note over an
unvalidated translation staying inside the viewport on every step. The
switch to the English original is carried in the answers, and the full
path from the overview posts the instrument, the reader's language and
the item scores. Questionnaire text loads through the production chunk
loader.
The wizard's markup cases are removed from the surface suite; the
loading-frame case stays there, since a browser loads the chunk before
a test can look. The browser project inlines an empty process.env for
Next's client modules.