Migrate to zod v4 #163

Merged
lz merged 4 commits from chore/zod-v4 into main 2026-09-16 11:26:18 +02:00
Owner

Closes #148.

zod ^3.24.1^4.2.0, resolved to 4.6.5. No @modelcontextprotocol/* dependency is added — that is #154.

The issue's measurement was wrong, and so was mine

The issue recorded zero occurrences of all nine classic v3→v4 breaking patterns. Three of the nine do occur:

Pattern Issue said Actually
.default( 0 22
.url( 0 2
.email( 0 0
the other six 0 0

.default() is the one that matters, because v4 changed it from "parse the default through the schema" to "return it directly". Nine of the 22 are in config.ts, including PORT: z.coerce.number().int().positive().default(3001) — a coerce stacked on a default, exactly the combination the change is about. All 22 turned out to be safe (every default is already an output-type value), but that had to be checked rather than counted.

I then replayed the real config schema plus replicas of every other body through both versions — 64 cases — and concluded "every accept/reject decision and every parsed value is identical; only message wording changed." An independent review falsified that. The differential was real, but its inputs had blind spots. Four genuine behaviour changes:

1. z.url() trims. " http://172.30.0.1:3001\n" now parses to "http://172.30.0.1:3001"; zod 3 returned the padding verbatim. This is the bump, not the z.string().url()z.url() edit — v4's deprecated method form trims identically.

2. z.url() accepts unicode whitespace that zod 3 rejected outright — NBSP, U+1680, U+2000, U+2028, U+3000, U+FEFF — then strips it. A URL pasted with an NBSP used to 400; now it is repaired.

Both land on WORKER_CALLBACK_URL (injected into workers as NEXUS_URL, curl'd by notify-preview) and on connections.base_url (sealed under the DEK, used for every Forgejo call). Both are improvements in direction, and both now have a pinning test.

3. .int() requires a safe integer. 9007199254740992 and 1e21 are rejected where v3 accepted them. Reachable at tokens' expires_at, which now 400s at the schema instead of reaching createToken and failing its century guard. Fact #24's microseconds-as-milliseconds case is below 2^53 and still behaves exactly as documented.

4. z.number() rejects +Infinity (v3 accepted). Only reachable at the quota route, whose comment promised to "accept anything non-negative and clamp" — but JSON.parse cannot produce Infinity, so the code is right and only the comment was wrong. Corrected.

Everything else did hold: message wording changed everywhere, and nothing reads it. No test anywhere asserts on a zod-generated string, and no client code consumes the error payload — lib/api/client.ts reads only payload.error.

.flatten() is deliberately untouched

18 call sites do json({ error: 'invalid body', details: X.error.flatten() }, { status: 400 }). .flatten() is deprecated in v4 but present, and returns the same {formErrors, fieldErrors} shape as the z.flattenError() that supersedes it — verified byte-identical.

A trap for whoever finishes this: zod's own deprecation text points at z.treeifyError(), which produces a different shape ({errors, properties}). Following it would silently change the 400 body of 17 routes. Exactly one thing would notice — tokens/server.test.ts:112, which reads details?.fieldErrors?.scopes. The right fix is an extracted invalidBody(err) helper in lib/server/lib/http.ts, not 18 mechanical swaps, and it is not a dependency-bump change.

Source changes: four lines, all idiom

  • z.string().url()z.url() in config.ts and connections/+server.ts (the method form is deprecated). Accept/reject identical across a 51-case URL corpus.
  • { message } → a bare string in one .refine() and one z.custom(). The issue asked for { error }; the bare string drops a wrapper object that only exists for multi-key params, and it is the form workers/+server.ts and mounts/test/+server.ts already used — so this leaves the repo with one shape for refinement messages instead of two.

New: a test for config.ts

It is the only zod schema validated against the real process.env, and the one place a validation change fails at container boot rather than at typecheck. It had no test at all.

parseConfig(env) is now exported as the seam — loadConfig() stays the process.exit(1) wrapper its two callers use — so the test needs no process.env swap and no process.exit stub. reserved-volume-default.test.ts moves onto it too; it was the last loadConfig() in a test, and a bad ambient env there would have killed the vitest worker mid-run with no assertion output.

Nine cases: every default under an empty env, the two z.coerce.number() vars and their rejection matrix, the exact truthy set for FORCE_SECURE_COOKIE, unknown-key stripping, the NODE_ENV enum, and the three URL behaviours above. One of the nine cross-checks the defaults the settings registry declares a second time — drift there shows the operator a default in the Settings panel that the process never boots with.

Every assertion was proven to fail. Nine mutations of config.ts, one of the registry's PORT default, and one negative control that makes an unrelated field required. That last one caught a real defect the first draft shipped: the original rejects() helper asked only "did the whole env fail", so all ten rejection assertions stayed green while testing nothing. rejectsOn(env, field) now checks every issue points at the named var, and under that same mutation all nine cases fail.

Gates

Gate Result
pnpm typecheck 5042 files, 0 errors, 0 warnings
pnpm test 158 files, 1705 passed (main is 157 / 1696)
pnpm lint clean, exit 0

All three re-run on the final commit, not on a pre-review snapshot.

Beyond the three, because the issue's "done when" names a boot:

  • pnpm build succeeds — zod is packages: 'external' in build-server.mjs, so the bump costs 0 bytes in every shipped artifact (client included).
  • node build/server.js reaches agent-nexus ready; migrations ran, loadConfig accepted a real env.
  • Against that running bundle: a short passphrase to /api/auth/setup 400s; after unlock, /api/connections with {"kind":"nope","label":"","base_url":"not a url"} returns the flatten() shape intact, and /api/tokens with ["bogus"] returns {"scopes":["unknown scope"]} — the z.custom message surviving its rewrite.

A full docker compose up -d was not run (no docker daemon in this worktree). The booted production bundle is the closest substitute and covers the failure mode the issue was worried about.

Cost

zod's module graph grew 5.7× — 10 modules / 147 KiB → 95 modules / 832 KiB — which measures as +64 ms of blocking startup on a warm cache, once per container boot. zod/mini was measured and rejected: it saves 4 ms because both entry points unconditionally re-export 64 locale modules (341 KB, 40% of the graph), and it would cost a rewrite of every schema to .check() form.

Deliberately not done

z.url({ protocol: /^https?$/ }) on WORKER_CALLBACK_URL. v4 can express it, and it would reject the scheme-dropped typo this PR pins as accepted: z.url() needs only a scheme, so host.docker.internal:3001 parses the way mailto: does, boots green, and yields a NEXUS_URL that notify-preview can never reach. (The IP-shaped form is rejected already — a scheme cannot start with a digit.) It is a real gap, it is true on zod 3 as well, and it belongs in its own change: it alters what the container accepts at boot, and folding a deliberate decision change into a bump means a boot that breaks after this lands cannot be attributed to either. Pinned by a test meanwhile, so it stays a known gap.

Also noted but out of scope: a bare z.string().default(...) accepts '' and does not fall back to the default, so COOKIE_NAME= in an env file yields an empty cookie name and DATA_DIR= puts SQLite at ''. Identical on zod 3 — pre-existing, and a .min(1) decision rather than a migration one.

Merge note

No conflict with #160 (fix/unlock-rate-limit). That branch edits routes/api/auth/unlock/+server.ts, which this branch does not touch — its PassphraseSchema is z.string().min(8).max(1024), carrying none of the four idioms migrated here, so it needs no change under zod 4. The only shared file would be package.json, and only if that branch also moves a dependency.

Closes #148. zod `^3.24.1` → `^4.2.0`, resolved to **4.6.5**. No `@modelcontextprotocol/*` dependency is added — that is #154. ## The issue's measurement was wrong, and so was mine The issue recorded zero occurrences of all nine classic v3→v4 breaking patterns. Three of the nine do occur: | Pattern | Issue said | Actually | |---|---|---| | `.default(` | 0 | **22** | | `.url(` | 0 | **2** | | `.email(` | 0 | 0 | | the other six | 0 | 0 | `.default()` is the one that matters, because v4 changed it from "parse the default through the schema" to "return it directly". Nine of the 22 are in `config.ts`, including `PORT: z.coerce.number().int().positive().default(3001)` — a coerce stacked on a default, exactly the combination the change is about. All 22 turned out to be safe (every default is already an output-type value), but that had to be checked rather than counted. I then replayed the real config schema plus replicas of every other body through both versions — 64 cases — and concluded **"every accept/reject decision and every parsed value is identical; only message wording changed."** An independent review falsified that. The differential was real, but its inputs had blind spots. Four genuine behaviour changes: **1. `z.url()` trims.** `" http://172.30.0.1:3001\n"` now parses to `"http://172.30.0.1:3001"`; zod 3 returned the padding verbatim. This is the bump, **not** the `z.string().url()` → `z.url()` edit — v4's deprecated method form trims identically. **2. `z.url()` accepts unicode whitespace that zod 3 rejected outright** — NBSP, U+1680, U+2000, U+2028, U+3000, U+FEFF — then strips it. A URL pasted with an NBSP used to 400; now it is repaired. Both land on `WORKER_CALLBACK_URL` (injected into workers as `NEXUS_URL`, curl'd by `notify-preview`) and on `connections.base_url` (sealed under the DEK, used for every Forgejo call). Both are improvements in direction, and both now have a pinning test. **3. `.int()` requires a *safe* integer.** `9007199254740992` and `1e21` are rejected where v3 accepted them. Reachable at `tokens`' `expires_at`, which now 400s at the schema instead of reaching `createToken` and failing its century guard. Fact #24's microseconds-as-milliseconds case is below 2^53 and still behaves exactly as documented. **4. `z.number()` rejects `+Infinity`** (v3 accepted). Only reachable at the quota route, whose comment promised to "accept anything non-negative and clamp" — but `JSON.parse` cannot produce `Infinity`, so the code is right and only the comment was wrong. Corrected. Everything else did hold: message wording changed everywhere, and nothing reads it. No test anywhere asserts on a zod-generated string, and no client code consumes the error payload — `lib/api/client.ts` reads only `payload.error`. ## `.flatten()` is deliberately untouched 18 call sites do `json({ error: 'invalid body', details: X.error.flatten() }, { status: 400 })`. `.flatten()` is deprecated in v4 but present, and returns the same `{formErrors, fieldErrors}` shape as the `z.flattenError()` that supersedes it — verified byte-identical. **A trap for whoever finishes this:** zod's own deprecation text points at `z.treeifyError()`, which produces a **different shape** (`{errors, properties}`). Following it would silently change the 400 body of 17 routes. Exactly one thing would notice — `tokens/server.test.ts:112`, which reads `details?.fieldErrors?.scopes`. The right fix is an extracted `invalidBody(err)` helper in `lib/server/lib/http.ts`, not 18 mechanical swaps, and it is not a dependency-bump change. ## Source changes: four lines, all idiom - `z.string().url()` → `z.url()` in `config.ts` and `connections/+server.ts` (the method form is deprecated). Accept/reject identical across a 51-case URL corpus. - `{ message }` → a **bare string** in one `.refine()` and one `z.custom()`. The issue asked for `{ error }`; the bare string drops a wrapper object that only exists for multi-key params, and it is the form `workers/+server.ts` and `mounts/test/+server.ts` already used — so this leaves the repo with one shape for refinement messages instead of two. ## New: a test for `config.ts` It is the only zod schema validated against the real `process.env`, and the one place a validation change fails at container boot rather than at typecheck. It had no test at all. `parseConfig(env)` is now exported as the seam — `loadConfig()` stays the `process.exit(1)` wrapper its two callers use — so the test needs no `process.env` swap and no `process.exit` stub. `reserved-volume-default.test.ts` moves onto it too; it was the last `loadConfig()` in a test, and a bad ambient env there would have killed the vitest worker mid-run with no assertion output. Nine cases: every default under an empty env, the two `z.coerce.number()` vars and their rejection matrix, the exact truthy set for `FORCE_SECURE_COOKIE`, unknown-key stripping, the `NODE_ENV` enum, and the three URL behaviours above. One of the nine cross-checks the defaults the **settings registry** declares a second time — drift there shows the operator a default in the Settings panel that the process never boots with. **Every assertion was proven to fail.** Nine mutations of `config.ts`, one of the registry's `PORT` default, and one negative control that makes an unrelated field required. That last one caught a real defect the first draft shipped: the original `rejects()` helper asked only "did the whole env fail", so all ten rejection assertions stayed green while testing nothing. `rejectsOn(env, field)` now checks every issue points at the named var, and under that same mutation all nine cases fail. ## Gates | Gate | Result | |---|---| | `pnpm typecheck` | 5042 files, **0 errors, 0 warnings** | | `pnpm test` | 158 files, **1705 passed** (main is 157 / 1696) | | `pnpm lint` | clean, exit 0 | All three re-run on the final commit, not on a pre-review snapshot. Beyond the three, because the issue's "done when" names a boot: - `pnpm build` succeeds — zod is `packages: 'external'` in `build-server.mjs`, so the bump costs **0 bytes** in every shipped artifact (client included). - `node build/server.js` reaches `agent-nexus ready`; migrations ran, `loadConfig` accepted a real env. - Against that running bundle: a short passphrase to `/api/auth/setup` 400s; after unlock, `/api/connections` with `{"kind":"nope","label":"","base_url":"not a url"}` returns the `flatten()` shape intact, and `/api/tokens` with `["bogus"]` returns `{"scopes":["unknown scope"]}` — the `z.custom` message surviving its rewrite. A full `docker compose up -d` was **not** run (no docker daemon in this worktree). The booted production bundle is the closest substitute and covers the failure mode the issue was worried about. ## Cost zod's module graph grew 5.7× — 10 modules / 147 KiB → 95 modules / 832 KiB — which measures as **+64 ms of blocking startup** on a warm cache, once per container boot. `zod/mini` was measured and rejected: it saves 4 ms because both entry points unconditionally re-export 64 locale modules (341 KB, 40% of the graph), and it would cost a rewrite of every schema to `.check()` form. ## Deliberately not done `z.url({ protocol: /^https?$/ })` on `WORKER_CALLBACK_URL`. v4 can express it, and it would reject the scheme-dropped typo this PR pins as *accepted*: `z.url()` needs only a scheme, so `host.docker.internal:3001` parses the way `mailto:` does, boots green, and yields a `NEXUS_URL` that `notify-preview` can never reach. (The IP-shaped form is rejected already — a scheme cannot start with a digit.) It is a real gap, it is true on zod 3 as well, and it belongs in its own change: it alters what the container accepts at boot, and folding a deliberate decision change into a bump means a boot that breaks after this lands cannot be attributed to either. Pinned by a test meanwhile, so it stays a known gap. Also noted but out of scope: a bare `z.string().default(...)` accepts `''` and does **not** fall back to the default, so `COOKIE_NAME=` in an env file yields an empty cookie name and `DATA_DIR=` puts SQLite at `''`. Identical on zod 3 — pre-existing, and a `.min(1)` decision rather than a migration one. ## Merge note No conflict with #160 (`fix/unlock-rate-limit`). That branch edits `routes/api/auth/unlock/+server.ts`, which this branch does not touch — its `PassphraseSchema` is `z.string().min(8).max(1024)`, carrying none of the four idioms migrated here, so it needs no change under zod 4. The only shared file would be `package.json`, and only if that branch also moves a dependency.
lz added this to the MCP support (#140) milestone 2026-09-15 21:21:28 +02:00
lz added 4 commits 2026-09-15 21:21:28 +02:00
@modelcontextprotocol/server v2.0.0 needs zod ^4.2.0. Two majors of a
validation library in one process is a trap for whoever next imports the
wrong `z`, so Nexus moves ahead of the MCP work rather than alongside it.
Resolved to 4.6.5; no MCP dependency is added here.

The issue's measurement was that every classic v3->v4 breaking pattern
occurs zero times. Three of the nine do occur: `.default(` 22 times,
`.url(` twice, `.email(` never. `.default()` is the one whose semantics
actually changed — v4 returns the default directly instead of parsing it
through the schema — so it was the one worth checking rather than
counting.

Checked by replaying the real config schema (extracted from config.ts, not
retyped) plus replicas of the mount, token, quota, session, connection,
comment and preference bodies through both versions: 64 cases covering
absent/empty/garbage/realistic env, every URL shape the operator could
plausibly set, and the failure path of each schema. Every accept/reject
decision and every parsed value is identical. The only differences are the
wording of rejection messages, which reach API consumers through
`error.flatten()` but no UI — nothing in src/lib or src/routes reads
`fieldErrors`.

`error.flatten()` still exists in v4 and returns the same
`{formErrors, fieldErrors}` shape as the `z.flattenError()` that
supersedes it, so the 18 call sites are left alone.

The four source edits are v4 idiom, not fixes. `z.string().url()` is
deprecated in favour of the top-level `z.url()`; both were verified to
accept and reject identically across 13 URL shapes, including the
`host.docker.internal` and bridge-IP forms WORKER_CALLBACK_URL is
documented to take. `{ message }` in a `.refine()` and a `z.custom()`
becomes `{ error }`, which v4 prefers; the deprecated alias still works
and produces the same string.
Environment config is the one file where a validation-library change fails
at container boot instead of at typecheck: `loadConfig` runs once, against
the real `process.env`, before anything else — and it had no test at all,
so the zod 4 evidence lived in a throwaway script rather than in the gate.

Seven cases cover what a bump can silently move: every default when the
env is empty, the two `z.coerce.number()` vars, the exact truthy set for
FORCE_SECURE_COOKIE, unknown-key stripping (process.env is mostly noise),
and the three rejection paths that make the container exit 1.

Each assertion was proven to fail. Seven mutations of config.ts — dropping
the PORT default, dropping its coercion, dropping int/positive, loosening
the FORCE_SECURE_COOKIE transform, replacing the NODE_ENV enum with a bare
string, replacing z.url() with z.string(), and making the object loose —
each broke exactly one test and left the other six green.

One assertion records a surprise rather than a requirement: `nexus:3001`
is a valid URL to zod, scheme-only being enough, so it is accepted the way
`mailto:` is. It reads like the host:port an operator would type for
WORKER_CALLBACK_URL and it is not rejected. That is true of zod 3 as well
— it is pinned so the next reader does not have to rediscover it.
Quality pass over the zod bump. Four findings, three applied.

The config test bent around `loadConfig`'s shape instead of fixing it: it
swapped `process.env` wholesale, stubbed `process.exit` to throw and silenced
`console.error`, all to reach a `safeParse` six lines in. `parseConfig(env)`
is that call, exported; `loadConfig` stays the `process.exit(1)` wrapper its
two callers already use. The test keeps none of the scaffolding, and the
failure mode it carried goes with it — a spy that stopped throwing would have
run the real `process.exit(1)` and killed the vitest worker mid-run.

The "every default" case wrote the default table a third time, after the
schema and the settings registry, in a form that could never catch drift
between the first two. The literals stay — they are what a library upgrade
can move — and a second case now walks the nine `SETTINGS` rows whose
`envVar` names a config key and asserts each declares the same default.
Registry drift shows the operator a default in the Settings panel that the
process never boots with; the count is asserted too, so a row losing its
`envVar` cannot quietly empty the loop.

`z.custom` and `.refine` take a bare message string, so the `{ error: … }`
wrapper the bump introduced is gone from both sites. That is also the form
`workers/+server.ts` and `mounts/test/+server.ts` already use — the bump had
left four refinement messages in two different shapes.

Both mutation suites were re-run against the rewritten test: eight mutations
(seven of config.ts, one of the registry's PORT default) each break at least
one case, and dropping the PORT default now breaks two.

Not applied: constraining `WORKER_CALLBACK_URL` to `z.url({ protocol:
/^https?$/ })`. It is a real gap — v4 can express it, and it would reject the
scheme-dropped typo the new test pins as accepted — but it changes what the
container accepts at boot. The whole claim of this branch is that the bump
changed no decision anywhere; smuggling a deliberate decision change into it
means a boot that breaks after this lands cannot be attributed. It belongs in
its own change.
fix(config): correct the "no behaviour changed" claim, and a vacuous guard
Some checks failed
ci / nexus (pull_request) Successful in 20m57s
ci / images (pull_request) Has been cancelled
pr-image-cleanup / delete-pr-images (pull_request) Successful in 9s
66a7c2b9af
An independent review falsified the central claim of this branch. The 64-case
differential behind it was real but its inputs had two blind spots, and zod 4
does change behaviour in both:

- `z.url()` **trims** — `" http://host:3001\n"` now parses to
  `"http://host:3001"` where zod 3 returned the padding verbatim. Verified this
  is the bump and not the `z.string().url()` -> `z.url()` edit: v4's deprecated
  method form trims identically.
- `z.url()` **accepts unicode whitespace** that zod 3 rejected outright — NBSP,
  U+1680, U+2000, U+2028, U+3000, U+FEFF — and strips it. A pasted NBSP used to
  400; now it is repaired.
- `.int()` now requires a **safe** integer. `9007199254740992` and `1e21` are
  rejected where zod 3 accepted them. Reachable at `tokens`' `expires_at`,
  where such a value now 400s at the schema instead of reaching `createToken`
  and failing its century guard. Fact #24's microseconds case is below 2^53 and
  still behaves as documented.
- `z.number()` rejects `+Infinity`, which zod 3 accepted. Only reachable at the
  quota route, whose comment promised to accept anything non-negative — and
  `JSON.parse` cannot produce `Infinity`, so the code is right and only the
  comment was wrong. Corrected there.

Both URL changes are improvements on a value that reaches workers as NEXUS_URL,
and the callback URL now has a case pinning each direction.

Separately, `rejects()` was vacuous, and the review proved it: it asked only
whether the whole env failed, so making one unrelated field required left all
ten rejection assertions green while testing nothing. `rejectsOn(env, field)`
checks every issue points at the named var. Under the same mutation all nine
cases now fail.

Also from the review: `SESSION_TTL_HOURS` had no rejection coverage at all —
three mutations of its constraint survived the whole file, and at 0 it clamps
every session to `now`, which presents as a passphrase that unlocks and never
sticks. It shares PORT's matrix now. `toStrictEqual` replaces `toEqual` so the
"drops the rest of process.env" case cannot pass on an `undefined`-valued key.
The registry cross-check says why the other three envVars are out of scope
rather than leaving their absence to be inferred. The header's "fails before
anything else" was false — logger.ts and mounts/validate.ts both read
`process.env` at module scope first — and now claims only what was checked.

`reserved-volume-default.test.ts` moves to `parseConfig` as well; it was the
last `loadConfig()` in a test, and the previous commit's claim about removing
that failure mode was only true of the file it shipped.
lz merged commit d1430584ed into main 2026-09-16 11:26:18 +02:00
lz deleted branch chore/zod-v4 2026-09-16 11:26:19 +02:00
Sign in to join this conversation.
No reviewers
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lz/agent-nexus!163
No description provided.