@zerotal/arch 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/api-surface.md CHANGED
@@ -119,7 +119,7 @@ interface DoctorReport = {
119
119
  interface GuidelineOptions = {
120
120
  packages: string[]
121
121
  serverName: string
122
- shape?: ProjectShape
122
+ shape?: ProjectShape | undefined
123
123
  }
124
124
 
125
125
  interface InstalledPackage = {
@@ -177,8 +177,8 @@ interface SchemaReport = {
177
177
  }
178
178
 
179
179
  interface SpawnProbeOptions = {
180
- cwd?: string
181
- timeoutMs?: number
180
+ cwd?: string | undefined
181
+ timeoutMs?: number | undefined
182
182
  }
183
183
 
184
184
  interface ToolContext = {
@@ -262,10 +262,10 @@ interface ServerIdentity = {
262
262
  }
263
263
 
264
264
  interface StdioOptions = {
265
- input?: ReadableStream<Uint8Array<ArrayBufferLike>>
266
- log?: (message: string) => void
265
+ input?: ReadableStream<Uint8Array<ArrayBufferLike>> | undefined
266
+ log?: ((message: string) => void) | undefined
267
267
  server: McpServer
268
- write?: (frame: string) => void
268
+ write?: ((frame: string) => void) | undefined
269
269
  }
270
270
 
271
271
  interface ToolOutcome = {
package/docs/changelog.md CHANGED
@@ -27,6 +27,162 @@ the section for every version you cross and apply its migration notes, not only
27
27
  majors. [Releases and versioning](/docs/support-policy#releases-and-versioning) explains
28
28
  when that carve-out ends.
29
29
 
30
+ ## 1.10.0 — 2026-08-30
31
+
32
+ A second report from the team building on Zerotal, and the things it found. Most of this
33
+ release is failures that were silent by construction — mail delivered nowhere, a page
34
+ shared as a grey rectangle, a schedule that never fired, a rate limiter with one bucket
35
+ for everybody. None of them logged anything.
36
+
37
+ **Three things to know before upgrading.**
38
+
39
+ - **React apps using SSR now need `@inertiajs/react` installed.** It is the same adapter
40
+ your browser entry point already uses; the server renders through its `<App>` so
41
+ `<Head>` works. A missing one is a named error rather than a silent omission.
42
+ - **`scheduler.timezone` does something now.** It was documented as informational and read
43
+ by nothing. Its default moved from the literal `"UTC"` to **the system zone**, so an app
44
+ that never set the key keeps doing exactly what it did — but an app that set it now gets
45
+ what it asked for. If you set it to `"UTC"` on a server that is not on UTC, your
46
+ schedules will move. See [the upgrade guide](/docs/upgrade#1-9-to-1-10).
47
+ - **Named rate limiters need `.trustedProxies(n)` behind a proxy.** `.byIp()`, `.byUser()`
48
+ and `.byApiKey()` now ignore `X-Forwarded-For` unless told how many proxies sit in
49
+ front, which is the same rule `ThrottleMiddleware` already followed. `zt doctor` reports
50
+ any that need it.
51
+
52
+ ### Added
53
+
54
+ - **`zt assets:prune`** — removes the chunks an earlier release left behind, on the machine
55
+ that never ran a build. `assets:build --clean` cleans the directory it _builds into_,
56
+ which does nothing for the usual release shape: build here, tar the output, extract it
57
+ over `public/` there. Extracting merges, so every deploy adds another set of
58
+ content-hashed chunks and none are ever removed. One app reached 225 chunk files for the
59
+ 49 its entry point references. Ship `.zerotal/` with the release and this removes what
60
+ the build record does not claim. See [Deployment](/docs/deployment#assetsprune--clearing-up-after-the-extract-instead-of-before-it).
61
+
62
+ - **`zt deploy:<env> --check`** — the preflight gate on its own, for the point in a release
63
+ script where the new code is on disk and the service has not restarted. Exit 0 and
64
+ restart; exit non-zero and keep serving the previous release. Everything that can refuse
65
+ already runs by the end of preflight and none of it mutates, so stopping there is a
66
+ complete answer rather than half a deploy.
67
+
68
+ - **`RateLimiter.trustedProxies(n)`** on the fluent builder, and
69
+ **`res.assertInertiaRedirect(url)`** in `@zerotal/testing` — the assertion that checks
70
+ what actually breaks on an Inertia redirect, which is the `X-Inertia` marker rather than
71
+ the status and `Location` a normal redirect assertion already covers.
72
+
73
+ - **`@zerotal/core/runtime`** (`zerotal/runtime`) — the runtime checks as exports, so a
74
+ script or a test can make the same assertion `zt` makes: `runtimeBelowFloor`,
75
+ `declaredBunFloor`, `runtimeMismatch`, `bunBinary` and the messages that go with them.
76
+
77
+ - **`definedOnly()` and `Resolved<T>`** on `@zerotal/core/helpers`, for merging an options
78
+ bag over defaults without an explicit `undefined` overwriting one.
79
+
80
+ - **Scheduler timezone helpers** — `wallClockIn`, `isValidTimeZone`,
81
+ `CronExpression.matchesIn` and `CronExpression.nextRunAfterIn`, plus `SchedulerError` and
82
+ `UnknownTimeZoneError`.
83
+
84
+ - **A boot line when a convention is skipped in this environment.** An env-restricted
85
+ concern is skipped by _not looking_, which is correct and completely silent: an app ran
86
+ for weeks in production with `app/schedules` full and no worker process, and nothing
87
+ logged anything because from a web process's point of view nothing existed.
88
+
89
+ ### Changed
90
+
91
+ - **Optional properties in public option shapes are declared `?: T | undefined`.** The
92
+ generated `tsconfig.json` enables `exactOptionalPropertyTypes`, under which
93
+ `image?: string` refuses a key that is present and holds `undefined` — so
94
+ `{ image: candidate ?? undefined }`, the most ordinary thing there is, did not compile
95
+ and every conditionally-absent field had to be spelled `...(x ? { x } : {})`. 438
96
+ properties across 115 files. Nothing changes for a reader: an absent optional property
97
+ already read as `undefined`.
98
+
99
+ - **`scheduler.timezone` is honoured**, and its default is the system zone rather than the
100
+ literal `"UTC"`. See the note above.
101
+
102
+ - **`mail.driver: "log"` fails `zt doctor` in production** when `mail.from.address` has been
103
+ configured, and warns when it is still the placeholder. Mail written to a log file is
104
+ delivered to nobody and says so nowhere.
105
+
106
+ ### Fixed
107
+
108
+ - **React SSR emitted no `<Head>` tags at all.** The React branch rendered the page
109
+ component directly, and `<Head>` renders nothing — it reports its children to a head
110
+ manager it reads from context, and rendering the component alone puts none there. So
111
+ every page served the template's `<head>`: no title, no description, no card. Nothing
112
+ failed and nothing logged, because the page is perfect in a browser, where React has run.
113
+ Only the readers that do not run JavaScript saw it — which is every link-preview scraper
114
+ and every `curl`.
115
+
116
+ - **SMTP submission on port 587 sent nothing.** The STARTTLS handshake completed and then
117
+ the client's `EHLO` was dropped: `upgradeTLS()` returns the new socket while the
118
+ handshake is still in flight, and a write issued in that window is lost — not buffered,
119
+ not an error, gone. Port 465 was unaffected, so mail worked on the port nobody documents
120
+ and the 587 every provider _does_ document produced silence: no error, no bounce, no log
121
+ line, and password resets that never arrived.
122
+
123
+ - **TLS certificates were not actually verified, on either SMTP transport.**
124
+ `rejectUnauthorized` is not enforced by the runtime — it reports a self-signed
125
+ certificate as authorized and puts the real reason beside it — so the connection was
126
+ encrypted and would have accepted that encryption from anyone in the network path. The
127
+ driver reads the handshake result itself now and fails closed.
128
+
129
+ - **A scheduled task with a `timezone` took the whole scheduler down.** `Bun.cron`'s options
130
+ form throws, and it throws during registration, so the worker died on boot and
131
+ restart-looped: one task with a timezone stopped every task in the app. Zerotal evaluates
132
+ the zone itself now, and a task that cannot register takes only itself out.
133
+
134
+ - **Named rate limiters ignored `trustedProxies`, and `zt doctor` was told not to look.**
135
+ `.byIp()`, `.byUser()` and `.byApiKey()` used a resolver that read the socket address and
136
+ fell back to the leftmost `X-Forwarded-For` entry with no proxy count. Behind a reverse
137
+ proxy every visitor keyed on the proxy's own address and shared one bucket, so a `login`
138
+ limiter of five attempts a minute was five attempts a minute for the entire user base and
139
+ one attacker locked everybody out. The doctor check written to catch this exempted any
140
+ custom `keyResolver`, which is what all three are.
141
+
142
+ - **`ctx.session.intended()` could not read what `AuthMiddleware` stored.** It used the key
143
+ `intended` while the middleware and `redirect().intended()` used `intended_url`. Each pair
144
+ was internally consistent and separately tested, so every test passed — and an app that
145
+ mixed them, which the documentation invited, was silently sent to `/` after every sign-in.
146
+
147
+ - **`MonitorStore` overwrote its own defaults with `undefined`.** It applied `?? …` defaults
148
+ and then spread `...opts` after them, and spread copies own properties even when they
149
+ hold `undefined` — so an unset config put `undefined` back over the retention window and
150
+ `prune()` computed a `NaN` cutoff, pruning nothing and reporting nothing.
151
+
152
+ - **`engines.bun` is enforced.** Every generated app writes a floor and nothing read it.
153
+ `Intl` output moves between Bun releases, so a suite with currency or date assertions goes
154
+ red on a runtime that is otherwise fine and the failures name the code they touch rather
155
+ than the binary.
156
+
157
+ - **The asset build record is portable.** Its filename was hashed from the output
158
+ directory's _absolute_ path, so a record shipped with a release matched nothing at the
159
+ other end and moving a checkout silently orphaned it.
160
+
161
+ - **The React SSR root is marked `data-server-rendered`**, so the client hydrates the markup
162
+ instead of discarding it and rendering the page a second time. `POST /__ssr` returns the
163
+ same body shape as the Vue branch.
164
+
165
+ ### Documented
166
+
167
+ - **["What a crawler sees"](/docs/inertia/ssr#what-a-crawler-sees)** — `inertia()` does not
168
+ server-render the component, which is the normal Inertia arrangement and worth saying out
169
+ loud: the served document is a `<title>` and a JSON blob. Which readers run JavaScript,
170
+ which do not, and the three ways to give the second group something to read.
171
+
172
+ - **[Which Inertia redirects are covered](/docs/inertia/middleware#which-redirects-are-covered)**
173
+ — all of them, because `useOnce()` registers the middleware globally. Written down because
174
+ the opposite belief is what keeps an app's own workaround on every request forever.
175
+
176
+ - **[`bun test` vs `bun zt test`](/docs/testing#bun-test-vs-bun-zt-test)** — the 30-second
177
+ timeout (the `bunfig.toml` key is ignored by Bun and `setDefaultTimeout()` in a preload
178
+ reaches only the first file, so the flag is the only mechanism that works), the runtime
179
+ check, and the fact that configuration resolves once per process — so a test that mutates
180
+ the environment in `beforeAll` is testing whichever file booted first.
181
+
182
+ - **[Timezones](/docs/scheduler#timezones)** in the scheduler, **[the middleware names the
183
+ framework occupies](/docs/middleware#names-the-framework-already-occupies)**, and why
184
+ `X-Forwarded-For` is [counted from the right](/docs/rate-limiting#trustedproxies).
185
+
30
186
  ## 1.9.0 — 2026-08-29
31
187
 
32
188
  The gaps an app was filling in for itself: one Bun per project, a database backup that is not
@@ -38,6 +38,7 @@ deliberate: systemd, your container runtime or your deploy script owns process
38
38
  lifecycle, and this gives it a gate to restart behind.
39
39
 
40
40
  ```bash
41
+ bun zt deploy:production --check # run the gate only — see below
41
42
  bun zt deploy:production --dry-run # print the plan, run none of it
42
43
  bun zt deploy:production --skip-migrations # release without touching the schema
43
44
  bun zt deploy:production --probe=https://example.com # real handshake at the end
@@ -75,10 +76,11 @@ Omit the file entirely and you get `DEFAULT_DEPLOY_TARGETS`: `production` and
75
76
  ### Your own release gate
76
77
 
77
78
  The framework's preflight knows the things a framework can know: that this really is the
78
- environment you think it is, that the config validators pass, that `doctor` is happy. It
79
- cannot know that this workspace has no cancellation policy, that mail is still wired to the
80
- `log` driver so nothing is ever sent, or that the owner account is still on the password
81
- `admin:create` issued it. Those refusals are yours.
79
+ environment you think it is, that the config validators pass, that `doctor` is happy
80
+ which now includes refusing a production release whose `mail.driver` is still `log`,
81
+ because mail written to a log file is delivered to nobody and says so nowhere. It
82
+ cannot know that this workspace has no cancellation policy, or that the owner account
83
+ is still on the password `admin:create` issued it. Those refusals are yours.
82
84
 
83
85
  Write them as a command and name it `release:check` (exported as
84
86
  `CONVENTIONAL_PREFLIGHT_COMMAND`). The pipeline finds it by name — nothing to wire up:
@@ -124,6 +126,27 @@ Two things worth adding while you are there: `assets:build` and `inertia:build`
124
126
  `--clean`, which removes anything in the output directory the build did not write — see
125
127
  [Build assets](#build-assets).
126
128
 
129
+ ### `--check`: the gate on its own
130
+
131
+ A release script has a moment where the new code is on disk and the service has not
132
+ restarted yet. That is the moment to ask whether this release is fit to go live, and
133
+ `--check` is the whole preflight and nothing else:
134
+
135
+ ```bash
136
+ # on the box, after the new release is unpacked and before the restart
137
+ APP_ENV=production bun zt deploy:production --check || exit 1
138
+ systemctl restart app
139
+ ```
140
+
141
+ It runs the environment check, the config validators with production semantics,
142
+ `doctor`, and your own `release:check` — everything that can refuse — and builds
143
+ nothing, migrates nothing, restarts nothing. Exit 0 and restart; exit non-zero and
144
+ keep serving the previous release, which is the point. A workspace that has lost its
145
+ banking details, or had its mail driver knocked back to `log`, never goes live broken.
146
+
147
+ `--check` and `--dry-run` answer different questions. `--dry-run` prints the plan
148
+ without running any of it, including the gate. `--check` runs the gate for real.
149
+
127
150
  > **Note** — `deploy:<env>` runs **where the app runs**, with that environment's
128
151
  > variables. It does not reach another machine over SSH. Run it on the box, or in
129
152
  > the container build, as the step before the restart.
@@ -310,6 +333,31 @@ Ordering matters if the old release is still serving traffic: clearing the direc
310
333
  its bundles away, so do it as close to the swap as you can, or stage the release in a new
311
334
  directory and move it into place.
312
335
 
336
+ ### `assets:prune` — clearing up after the extract instead of before it
337
+
338
+ Clearing the directory first has a cost the ordering note above hints at: between the
339
+ `rm -rf` and the new files landing, the release still serving traffic has no bundles.
340
+ If you would rather extract over the top and tidy afterwards, `assets:build` records
341
+ what it wrote and `assets:prune` removes what that record does not claim:
342
+
343
+ ```bash
344
+ # on the build machine — the record is written under .zerotal/
345
+ bun zt assets:build
346
+ tar -czf release.tgz public .zerotal # ship the record with the release
347
+
348
+ # on the server, after extracting and before the restart
349
+ bun zt assets:prune --dry-run # list what would go
350
+ bun zt assets:prune
351
+ ```
352
+
353
+ **Ship `.zerotal/` with the release** — that is the part that makes it work. Without
354
+ the record, "which files belong to this release" has no answer on a server that never
355
+ ran a build, and `assets:prune` says so and removes nothing rather than guessing.
356
+
357
+ It is conservative by design: it deletes a file only when the record does not claim it
358
+ _and_ it is named the way the bundler names a code-split chunk. Your images and
359
+ favicon are never candidates, because an output directory is usually `public/`.
360
+
313
361
  ### `--clean` for a directory the build does not own outright
314
362
 
315
363
  The cleanup above recognises the filenames `Bun.build()` produces. An app that sets its own
package/docs/helpers.md CHANGED
@@ -393,6 +393,57 @@ matches how you want overrides to behave.
393
393
  > configured `driver`) are replaced by reference — they keep their prototype and are
394
394
  > never merged into.
395
395
 
396
+ ### `definedOnly` and `Resolved<T>` — merging a shallow options bag
397
+
398
+ Every public option shape in the framework declares its optional properties as
399
+ `?: T | undefined`, so that the most ordinary thing there is compiles under the
400
+ `exactOptionalPropertyTypes` the generated `tsconfig.json` turns on:
401
+
402
+ ```typescript
403
+ // in a controller
404
+ import { Media } from "zerotal/media";
405
+
406
+ await Media.store(file, { collection: request.input("collection") ?? undefined });
407
+ ```
408
+
409
+ That flexibility moves a problem to the merge. **Object spread copies own properties
410
+ even when their value is `undefined`**, so `{ ...DEFAULTS, ...options }` lets an
411
+ explicitly-`undefined` field overwrite a default rather than leave it standing —
412
+ which is how a `pingInterval` of `undefined` once became `setInterval(fn, 0)` and
413
+ ~830 pings a second. `definedOnly` is the fix: it drops the keys whose value is
414
+ `undefined`, so "supplied as undefined" reads as "not supplied".
415
+
416
+ ```typescript fragment
417
+ // in a class that takes options
418
+ const merged = { ...DEFAULTS, ...definedOnly(options) };
419
+ ```
420
+
421
+ `deepMerge` already does this for you — it skips `undefined` overrides at every
422
+ depth — so `definedOnly` is for the shallow case where a spread is all you need.
423
+
424
+ `Resolved<T>` is the type of what comes out. **`Required<T>` is not the same thing**,
425
+ and that is the trap: `-?` removes the optionality a `?` introduced, but it does not
426
+ remove an `undefined` written into the type. So `Required<MediaOptions>` still hands
427
+ back `string | undefined` for a `collection?: string | undefined`, and a "defaults
428
+ have been applied" type quietly stops meaning that.
429
+
430
+ ```typescript fragment
431
+ // in a class that takes options
432
+ interface Options {
433
+ retentionDays?: number | undefined;
434
+ storage?: string | undefined;
435
+ }
436
+
437
+ // Required<Pick<Options, "retentionDays">> → { retentionDays: number | undefined } ✗
438
+ // Resolved<Pick<Options, "retentionDays">> → { retentionDays: number } ✓
439
+ private readonly _opts: Resolved<Pick<Options, "retentionDays">> &
440
+ Omit<Options, "retentionDays">;
441
+ ```
442
+
443
+ `Omit` rather than `& Options` for the rest: intersecting with the whole shape puts
444
+ the optional declaration of each resolved field back alongside the required one, so
445
+ the field reads as possibly `undefined` in the very code that just gave it a default.
446
+
396
447
  ## Fluent wrappers
397
448
 
398
449
  ### fluent
@@ -50,6 +50,50 @@ would just register it twice (and `useOnce` guards against that anyway).
50
50
  > the authenticated user is always populated by the time props are built; you don't
51
51
  > need to hand-order `InertiaMiddleware` relative to auth.
52
52
 
53
+ ## Which redirects are covered
54
+
55
+ **All of them.** `useOnce()` registers `InertiaMiddleware` as _global_ middleware, so it
56
+ runs on every request the app serves — however that route declared its own middleware,
57
+ whether as an array, a map form (`{ ALL, POST }`), a group, or nothing at all. There is
58
+ no route that reaches a controller without passing through it, so there is no redirect
59
+ it does not mark.
60
+
61
+ This is worth stating plainly because the opposite belief is expensive. An app that
62
+ thinks some routes miss the middleware writes its own global `InertiaRedirectMiddleware`
63
+ to cover them, and then cannot tell whether it is still needed: removing it leaves every
64
+ test green either way, because the tests assert a status and a `Location` and those were
65
+ never the part that broke.
66
+
67
+ Three things have to be true for the Inertia client to follow a redirect, and the
68
+ middleware guarantees all three:
69
+
70
+ | | Set on |
71
+ | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
72
+ | A redirect status — `303` after a non-GET, so the browser follows with `GET` | every Inertia redirect |
73
+ | `Location` | your handler; carried through untouched, along with `Set-Cookie` |
74
+ | `X-Inertia: true` | **every** Inertia redirect, including one your handler already returned as a `303` |
75
+
76
+ That last row is the one that was wrong before 1.8.0: the marker was set inside the
77
+ 302→303 conversion, so a handler doing the protocol-correct thing already —
78
+ `http.redirect(to, 303)` — skipped the only line that marked the response as Inertia's.
79
+ The request succeeded, the row was written, and the form sat there with its fields still
80
+ filled in.
81
+
82
+ ### Pinning it from a test
83
+
84
+ `assertRedirect` checks the two headers that were never the problem. `assertInertiaRedirect`
85
+ checks all three:
86
+
87
+ ```typescript fragment
88
+ // tests/Feature/OrdersTest.ts
89
+ const res = await app.post("/orders", data, { headers: { "X-Inertia": "true" } });
90
+
91
+ res.assertInertiaRedirect("/orders/1");
92
+ ```
93
+
94
+ Send the request with the `X-Inertia` header, or there is nothing to assert — a redirect
95
+ to a browser that is not running Inertia is just a redirect.
96
+
53
97
  ## Asset versioning
54
98
 
55
99
  The asset version is a string sent as part of every page object. When it changes, the
@@ -75,27 +75,112 @@ export class PostController {
75
75
  ### Requirements
76
76
 
77
77
  - `react-dom/server` ≥ 18 (for `renderToReadableStream`)
78
+ - `@inertiajs/react` — the same adapter the browser entry point uses; the server
79
+ renders through its `<App>` so `<Head>` works (see below)
78
80
  - The HTML template must contain `<!-- @inertia -->`
79
81
  - The page component must exist under your pages directory (`resources/js/pages/<component>.tsx`)
80
82
 
81
- It throws if the template hasn't loaded, or if the component name contains path
82
- traversal (`..` or a leading `/`).
83
-
84
83
  ### inertia vs. inertiaStream
85
84
 
86
- | Criterion | `inertia()` | `inertiaStream()` |
87
- | -------------- | ---------------------- | ---------------------------------- |
88
- | Return type | `Promise<void>` | `Promise<void>` |
89
- | Rendering | Buffered HTML string | Streaming `renderToReadableStream` |
90
- | Response body | Fully buffered string | Streaming `ReadableStream` |
91
- | TTFB | After full render | After the prefix is flushed |
92
- | XHR navigation | JSON (the normal path) | N/A only the first-page document |
85
+ | Criterion | `inertia()` | `inertiaStream()` |
86
+ | -------------- | ------------------------ | ---------------------------------- |
87
+ | Return type | `Promise<void>` | `Promise<void>` |
88
+ | Rendering | None empty root + JSON | Streaming `renderToReadableStream` |
89
+ | Response body | Fully buffered string | Streaming `ReadableStream` |
90
+ | TTFB | Immediate | After the shell is ready |
91
+ | Page `<Head>` | Client only | Collected into the served `<head>` |
92
+ | XHR navigation | JSON (the normal path) | N/A — only the first-page document |
93
93
 
94
94
  For XHR navigations (`X-Inertia: true`), keep using `inertia()` — streaming only
95
95
  benefits the initial HTML document load.
96
96
 
97
97
  > **Tip** — Stream the heaviest landing pages and leave everything else on `inertia()`.
98
98
 
99
+ ## Page metadata: `<Head>` on the server
100
+
101
+ Both server-rendered paths — `inertiaStream()` and the `/__ssr` endpoint — collect
102
+ whatever your page's `<Head>` declares and splice it into the template's `<head>`
103
+ before the response goes out. A page writes its metadata once, in the component, and
104
+ gets it in the HTML as well as in the browser:
105
+
106
+ ```tsx fragment
107
+ // resources/js/pages/Trips/Show.tsx
108
+ import { Head } from "@inertiajs/react";
109
+
110
+ export default function Show({ trip }) {
111
+ return (
112
+ <>
113
+ <Head>
114
+ <title>{trip.name}</title>
115
+ <meta name="description" content={trip.summary} />
116
+ <meta property="og:title" content={trip.name} />
117
+ <meta property="og:image" content={trip.heroUrl} />
118
+ </Head>
119
+
120
+ </>
121
+ );
122
+ }
123
+ ```
124
+
125
+ An injected tag **replaces** the template's tag of the same identity rather than
126
+ being added after it — `<title>` by being a title, `<meta>` by its `name` or
127
+ `property`. That is not a detail: a document with two `<title>` tags is a document
128
+ with the _first_ one, so an appended title would be present, correct and ignored.
129
+ Anything the template does not already declare is appended before `</head>`.
130
+
131
+ Two things it does not do:
132
+
133
+ - **The title callback is client-side.** `createInertiaApp({ title })` in your
134
+ browser entry point is not visible to the server, so a page rendering
135
+ `<Head><title>Kruger</title></Head>` serves `Kruger` and the browser then shows
136
+ `Kruger — App`. Put the suffix in the `<Head>` itself if the served title matters
137
+ to you, which for a link preview it usually does.
138
+ - **`inertia()` does not render, so it does not collect.** A page returned through
139
+ plain `inertia()` sends the template's `<head>` as written. See
140
+ [What a crawler sees](#what-a-crawler-sees).
141
+
142
+ ## What a crawler sees
143
+
144
+ `inertia()` — the default — **does not server-render the component at all.** Its
145
+ response body is the template with an empty root and the page object beside it:
146
+
147
+ ```html
148
+ <body>
149
+ <div id="app"></div>
150
+ <script type="application/json" data-page="app">
151
+ { … }
152
+ </script>
153
+ </body>
154
+ ```
155
+
156
+ That is the normal Inertia arrangement and it is the right default: the page is
157
+ built by the client, and every navigation after the first is JSON. But it means the
158
+ served document contains **a title and a JSON blob**, and it is worth knowing which
159
+ readers of your site run JavaScript and which do not:
160
+
161
+ | Reader | Runs JavaScript | Sees your page |
162
+ | ---------------------------------------------------- | --------------------- | ----------------- |
163
+ | A browser | yes | yes |
164
+ | Googlebot, Bingbot | yes, on a second pass | yes, later |
165
+ | WhatsApp, Slack, iMessage, X, Facebook link previews | **no** | title + meta only |
166
+ | `curl`, uptime checks, most RSS and reader tools | **no** | title + meta only |
167
+
168
+ So the link preview a page produces is decided entirely by its `<head>` — which is
169
+ the template's, identically, on every page, unless you do one of these:
170
+
171
+ 1. **Switch the page to `inertiaStream()`.** The component is rendered, `<Head>` is
172
+ collected, and the served `<head>` is the page's own. This is the smallest change
173
+ and the one to reach for on pages that get shared.
174
+ 2. **Turn on endpoint SSR** (`ssr: true`) for the whole app.
175
+ 3. **Set the tags in middleware**, if the metadata is server-side data the component
176
+ does not otherwise need.
177
+
178
+ `curl` is also how most people first check whether a deploy worked. An empty
179
+ `<div id="app">` in that output is not a broken deploy.
180
+
181
+ It throws if the template hasn't loaded, or if the component name contains path
182
+ traversal (`..` or a leading `/`).
183
+
99
184
  ## Next steps
100
185
 
101
186
  - [Inertia overview](/docs/inertia) — the guide's front page and the rest of the sections.
@@ -206,6 +206,37 @@ The package ships several middleware you can drop straight into `app.use([...])`
206
206
  or a route's middleware array. Each extends `BaseMiddleware`, so `.with({ … })`
207
207
  bakes options into a zero-argument class.
208
208
 
209
+ ### Names the framework already occupies
210
+
211
+ Middleware live in a flat namespace: your `app/middleware/` classes are discovered by
212
+ class name, and so are the ones a package exports. Naming one of yours after one of
213
+ these is not caught as a conflict — it surfaces later as a type error somewhere that
214
+ does not mention either file, which is a confusing way to learn that
215
+ `TwoFactorMiddleware` was taken.
216
+
217
+ The full list, so you can check before you name:
218
+
219
+ | Middleware | Package |
220
+ | -------------------------------------------------------------------------------------- | -------------------------------- |
221
+ | `CorsMiddleware`, `SecureHeadersMiddleware`, `ThrottleMiddleware`, `WebhookMiddleware` | `@zerotal/core` |
222
+ | `AuthMiddleware`, `GuestMiddleware`, `PersistUserMiddleware`, `RememberMeMiddleware` | `@zerotal/auth` |
223
+ | `BasicAuthMiddleware`, `BearerTokenMiddleware`, `JwtGuardMiddleware` | `@zerotal/auth` |
224
+ | `RequireRoleMiddleware`, `RequirePermissionMiddleware`, `TwoFactorMiddleware` | `@zerotal/auth` |
225
+ | `ValidateSignatureMiddleware` | `@zerotal/auth` |
226
+ | `SessionMiddleware`, `CsrfMiddleware`, `AuthSessionMiddleware` | `@zerotal/session` |
227
+ | `InertiaMiddleware`, `PrecognitionMiddleware` | `@zerotal/inertia` |
228
+ | `AdminGuardMiddleware`, `AdminAbilityMiddleware` | `@zerotal/admin` |
229
+ | `MonitorAuthMiddleware`, `MonitorPayloadMiddleware` | `@zerotal/monitor` |
230
+ | `IdempotencyMiddleware` | `@zerotal/cache` |
231
+ | `LocaleMiddleware` | `@zerotal/i18n` |
232
+ | `EnsureTenancyMiddleware` | `@zerotal/tenancy` |
233
+ | `TelemetryMiddleware` | `@zerotal/telemetry` |
234
+ | `BaseMiddleware` | `@zerotal/core` (the base class) |
235
+
236
+ If yours does something different from the framework's, say so in the name rather
237
+ than shadowing it — `RequireTwoFactorMiddleware` for "fence the console until staff
238
+ have enrolled" reads better than `TwoFactorMiddleware` anyway, and cannot collide.
239
+
209
240
  ### CorsMiddleware
210
241
 
211
242
  ```ts fragment
@@ -70,6 +70,14 @@ ThrottleMiddleware.with({
70
70
  which entry is not attacker-controlled. Left `undefined` (or `0`), the unspoofable socket
71
71
  address is used.
72
72
 
73
+ **Counted from the right, and that is the whole of it.** Each proxy _appends_ the address
74
+ it received the request from, so the rightmost entries are the ones your own
75
+ infrastructure wrote and the leftmost is whatever the client sent. Reading the header
76
+ left-to-right — the obvious way, and how most hand-rolled versions do it — hands the
77
+ limiter's key to the attacker: they set `X-Forwarded-For: <your CFO's IP>`, spend the
78
+ budget, and the person whose address they borrowed is locked out of the form. A limiter
79
+ that can be aimed is worse than no limiter, because it looks like it is working.
80
+
73
81
  > **Danger** — That default is right, and it is the wrong answer the moment you deploy
74
82
  > behind a proxy. The socket address is then the _proxy's_ — `127.0.0.1` for every visitor
75
83
  > — so everyone shares one bucket per form and the limiter inverts into the thing it was
@@ -117,8 +125,9 @@ import { RateLimiter } from "zerotal";
117
125
  // 1000 req/hour per authenticated user (falls back to IP when unauthenticated)
118
126
  RateLimiter.for("api").limit(1000).every(3600).byUser().register();
119
127
 
120
- // 5 login attempts per minute, per IP
121
- RateLimiter.for("login").limit(5).every(60).byIp().register();
128
+ // 5 login attempts per minute, per IP. `.trustedProxies(1)` because this app is
129
+ // behind one reverse proxy — without it every visitor shares the proxy's address.
130
+ RateLimiter.for("login").limit(5).every(60).byIp().trustedProxies(1).register();
122
131
 
123
132
  // 500 req/min keyed by an API-key header (unknown key → per IP)
124
133
  RateLimiter.for("partner").limit(500).every(60).byApiKey("x-api-key").register();
@@ -140,12 +149,30 @@ RateLimiter.for("upload")
140
149
  Each `.by*()` call sets how requests are bucketed. The default (no `.by*()` call)
141
150
  is the client IP.
142
151
 
143
- | Method | Keys on | Falls back to |
144
- | -------------------- | ------------------------------------------- | ------------------------ |
145
- | `.byUser()` | `ctx.user.id` | IP when unauthenticated |
146
- | `.byApiKey(header?)` | `x-api-key` header (or a custom header) | IP when header is absent |
147
- | `.byIp()` | Socket IP `X-Forwarded-For` → `X-Real-IP` | `'unknown'` |
148
- | `.by(fn)` | Return value of your function | — |
152
+ | Method | Keys on | Falls back to |
153
+ | -------------------- | --------------------------------------- | ------------------------ |
154
+ | `.byUser()` | `ctx.user.id` | IP when unauthenticated |
155
+ | `.byApiKey(header?)` | `x-api-key` header (or a custom header) | IP when header is absent |
156
+ | `.byIp()` | Client IP (the explicit default) | `'unknown'` |
157
+ | `.by(fn)` | Return value of your function | — |
158
+
159
+ > **Danger** — **Every one of the built-in strategies can end up keying on an
160
+ > address**, including `.byUser()` and `.byApiKey()` — for a request with no user and
161
+ > no key, which on a login form is every request that matters. So a named limiter
162
+ > behind a reverse proxy needs `.trustedProxies(n)` for exactly the reason
163
+ > [`ThrottleMiddleware` does](#throttlemiddleware--inline): without it the address is
164
+ > the socket's, which is the _proxy's_, and every visitor shares one bucket. A
165
+ > `login` limiter of five attempts a minute becomes five attempts a minute for your
166
+ > whole user base, and one attacker locks everybody out.
167
+ >
168
+ > ```typescript fragment
169
+ > // config/limiters.ts — behind one reverse proxy
170
+ > RateLimiter.for("login").limit(5).every(60).byIp().trustedProxies(1).register();
171
+ > ```
172
+ >
173
+ > `zt doctor` reports a named limiter that keys on an address and was never told
174
+ > about a proxy. `.by(fn)` is yours — it is exempt, and resolving the address is on
175
+ > you if you use one.
149
176
 
150
177
  ### Applying a named limiter
151
178
 
@@ -263,15 +290,16 @@ await app.post("/login", { email: "a@b.c" }, { "X-Forwarded-For": "10.0.0.7" });
263
290
 
264
291
  ### `LimiterDefinition` (fluent)
265
292
 
266
- | Method | Signature | Description |
267
- | ---------- | -------------------------------------------- | ---------------------------------------------------------- |
268
- | `limit` | `limit(max: number): this` | Maximum requests in the window (default `60`). |
269
- | `every` | `every(seconds: number): this` | Window duration in seconds (default `60`). |
270
- | `byUser` | `byUser(): this` | Key by `ctx.user.id`; IP when unauthenticated. |
271
- | `byApiKey` | `byApiKey(header?: string): this` | Key by header value (default `x-api-key`); IP when absent. |
272
- | `byIp` | `byIp(): this` | Key by client IP (the explicit default). |
273
- | `by` | `by(fn: (ctx: HttpContext) => string): this` | Key by your own resolver. |
274
- | `register` | `register(): this` | Register the limiter with the global registry. |
293
+ | Method | Signature | Description |
294
+ | ---------------- | -------------------------------------------- | ---------------------------------------------------------- |
295
+ | `limit` | `limit(max: number): this` | Maximum requests in the window (default `60`). |
296
+ | `every` | `every(seconds: number): this` | Window duration in seconds (default `60`). |
297
+ | `byUser` | `byUser(): this` | Key by `ctx.user.id`; IP when unauthenticated. |
298
+ | `byApiKey` | `byApiKey(header?: string): this` | Key by header value (default `x-api-key`); IP when absent. |
299
+ | `byIp` | `byIp(): this` | Key by client IP (the explicit default). |
300
+ | `by` | `by(fn: (ctx: HttpContext) => string): this` | Key by your own resolver. |
301
+ | `trustedProxies` | `trustedProxies(count: number): this` | Proxies in front of the app — required behind one. |
302
+ | `register` | `register(): this` | Register the limiter with the global registry. |
275
303
 
276
304
  ### `ThrottleMiddleware`
277
305
 
package/docs/scheduler.md CHANGED
@@ -61,17 +61,43 @@ import { SchedulerConfig } from "@zerotal/scheduler";
61
61
  import { env } from "zerotal";
62
62
 
63
63
  export default SchedulerConfig({
64
- timezone: env("APP_TIMEZONE", "UTC"),
64
+ timezone: env("APP_TIMEZONE", "Africa/Johannesburg"),
65
65
  });
66
66
  ```
67
67
 
68
- | Field | Required | Default | Description |
69
- | ---------- | -------- | ------- | --------------------------------------------------------------------------------------- |
70
- | `timezone` | no | `"UTC"` | Informational only `Bun.cron` uses the system timezone. Set per task with `timezone`. |
68
+ | Field | Required | Default | Description |
69
+ | ---------- | -------- | --------------- | ---------------------------------------------------------------------------------- |
70
+ | `timezone` | no | the system zone | IANA zone every cron expression is read in, unless a task sets its own `timezone`. |
71
71
 
72
- > **Note** — The config `timezone` is informational. To evaluate a cron in a
73
- > specific zone, set `timezone` on the `Schedule` subclass or `.timezone(tz)` on a
74
- > facade task; that value is passed through to `Bun.cron`.
72
+ ## Timezones
73
+
74
+ A cron expression is a wall clock: `0 3 * * *` means three in the morning
75
+ _somewhere_. By default that somewhere is the server's zone. Set `scheduler.timezone`
76
+ to make it one zone for the whole app, or `timezone` on a single schedule to override
77
+ it:
78
+
79
+ ```typescript fragment
80
+ export class SendDailyReports extends Schedule {
81
+ cron = "0 8 * * *";
82
+ timezone = "Africa/Johannesburg"; // 08:00 there, whatever the server is on
83
+ }
84
+ ```
85
+
86
+ The zone is evaluated by Zerotal, not by `Bun.cron` — which reads the system zone and
87
+ has no option to change it. A zoned task ticks every minute and runs on the ticks
88
+ where its expression matches the clock in its own zone, so it stays correct across a
89
+ daylight-saving change rather than drifting by an hour twice a year. A minute is also
90
+ the finest granularity `Bun.cron` accepts, so nothing is given up.
91
+
92
+ Two consequences worth knowing:
93
+
94
+ - **A skipped hour skips the schedules inside it, and a repeated hour runs them
95
+ twice.** That is what every cron does. A task at `0 2 * * *` in a zone that springs
96
+ from 01:59 to 03:00 does not run that day.
97
+ - **An unknown zone name refuses at boot, loudly, and takes only its own task out.**
98
+ A registration failure used to propagate: the worker died during boot and
99
+ restart-looped, so one bad schedule stopped every schedule in the app. Now the
100
+ others start and the log names the one that did not.
75
101
 
76
102
  ## Defining schedules
77
103
 
@@ -134,7 +160,7 @@ Every setting is an optional property (or method) on your `Schedule` subclass:
134
160
  | `cron` | `string` | Cron expression (5- or 6-field). Set this **or** override `frequency()`. |
135
161
  | `frequency(every)` | method | Build the cadence fluently; return the task (see helpers below). |
136
162
  | `name` | `string` | Task name in `schedule:list` and logs. Defaults to the class name. |
137
- | `timezone` | `string` | IANA timezone the cron is evaluated in. |
163
+ | `timezone` | `string` | IANA timezone the cron is evaluated in — overrides `scheduler.timezone`. See [Timezones](#timezones). |
138
164
  | `withoutOverlapping` | `boolean \| OverlapLockOptions` | Skip a tick while a previous run is active; also takes a cross-process lock when a lock driver is configured. |
139
165
  | `environments` | `string[]` | Only run when `APP_ENV` is one of these. |
140
166
  | `inBackground` | `boolean` | Run the body without blocking the scheduler tick. |
@@ -489,6 +515,19 @@ override async onStarted(): Promise<void> {
489
515
  For production, run the worker as a separate process so it can be scaled, restarted,
490
516
  and monitored independently of the web server.
491
517
 
518
+ > **A web process says so when it is not running your schedules.** `app/schedules/`
519
+ > is only discovered in `worker` and `console`, so a web process skips it by not
520
+ > looking — which used to be completely silent, and is how an app runs for weeks in
521
+ > production with every schedule written and none of them ever firing. A boot line
522
+ > now names it:
523
+ >
524
+ > ```
525
+ > Skipping 3 file(s) in app/schedules — the "schedules" convention does not run in env=web (it runs in: worker, console).
526
+ > ```
527
+ >
528
+ > Seeing that on a web process is correct. Seeing it and having no worker running is
529
+ > the hole.
530
+
492
531
  ## References
493
532
 
494
533
  The `Scheduler` facade resolves the `scheduler` container binding — a
@@ -514,6 +553,30 @@ The `Scheduler` facade resolves the `scheduler` container binding — a
514
553
  | `stop` | `stop(): void` | Stop every running task. |
515
554
  | `tasks` | `get tasks(): ReadonlyMap<string, ScheduledTask>` | The registered tasks, keyed by name. |
516
555
 
556
+ ### Timezone helpers
557
+
558
+ The zone arithmetic the scheduler uses to evaluate a cron somewhere other than the
559
+ server, exported because an app doing its own time-window logic needs the same
560
+ answers.
561
+
562
+ | Export | Signature | Description |
563
+ | ------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
564
+ | `isValidTimeZone` | `isValidTimeZone(tz: string): boolean` | Whether this runtime knows the IANA zone. Check before storing one a user typed. |
565
+ | `wallClockIn` | `wallClockIn(date: Date, tz: string): Date` | The same instant, shifted so the Date's _local_ getters read that zone's clock face. |
566
+ | `CronExpression.matchesIn` | `matchesIn(date: Date, tz: string): boolean` | Whether the expression fires at `date`, read in `tz`. |
567
+ | `CronExpression.nextRunAfterIn` | `nextRunAfterIn(expr, from: Date, tz): Date \| null` | The next real instant the expression fires on that zone's clock — correct across a DST change. |
568
+
569
+ `wallClockIn` returns a Date that is a lie about the instant and true about the clock
570
+ face: its epoch value is off by the zone offset. Pass it to a field comparison, never
571
+ back to a caller.
572
+
573
+ ### Errors
574
+
575
+ | Error | Thrown when |
576
+ | ---------------------- | ------------------------------------------------------------------------------------------- |
577
+ | `SchedulerError` | Base class for everything this package throws. Catch it to catch them all. |
578
+ | `UnknownTimeZoneError` | A task declares a `timezone` this runtime does not know — at registration, naming the task. |
579
+
517
580
  ### ScheduledTask introspection
518
581
 
519
582
  | Member | Signature | Description |
@@ -209,6 +209,105 @@ observers, global scopes, and state-machine callbacks, plus framework event
209
209
  subscriptions. `createTestApp()` and `testApp.close()` call it for you, so suites
210
210
  using those helpers don't need the explicit `afterEach`.
211
211
 
212
+ ## `bun test` vs `bun zt test`
213
+
214
+ Both run the same files. `bun zt test` is a wrapper that sets up three things Bun's
215
+ runner does not, and each of them has cost somebody a day:
216
+
217
+ | | `bun test` | `bun zt test` |
218
+ | ---------------- | --------------------- | ---------------------------------------------------------- |
219
+ | Per-test timeout | Bun's default, 5000ms | 30000ms (`--timeout`, override with `--timeout=`) |
220
+ | Runtime check | none | refuses a Bun below the project's `engines.bun` |
221
+ | DB wiring | none | preloads `@zerotal/testing/preload` and passes `ZT_DB_URL` |
222
+
223
+ ### The timeout
224
+
225
+ Bun's default per-test timeout is 5000ms, and a suite that boots an app per file
226
+ exceeds it on a loaded machine — CI, or a laptop that has just run `tsc`. The
227
+ failures look like flakes, which is the expensive part: a flake gets re-run, and a
228
+ re-run passes.
229
+
230
+ `bun zt test` sets `--timeout=30000`. If you run `bun test` directly, pass it
231
+ yourself, because **the two documented-looking alternatives do not work**:
232
+
233
+ - `[test] timeout` in `bunfig.toml` — ignored.
234
+ - `setDefaultTimeout()` in a preload — applies to the first test file only. Bun
235
+ re-imports the preload per file, but the setting does not survive.
236
+
237
+ The command-line flag is the only mechanism that covers hooks as well as tests,
238
+ which matters because it is usually a `beforeAll` that boots the app.
239
+
240
+ ### The runtime
241
+
242
+ `engines.bun` in your `package.json` is a floor, and until you enforce it, it is a
243
+ comment. The shell's `bun` and the project's can differ, and the difference between
244
+ two Bun releases is real and narrow: `Intl` formatting, the SQLite bindings and
245
+ `node:` compatibility all move. So a handful of currency or date assertions go red
246
+ and the rest pass, and you go looking for a bug in the code they touch, because
247
+ nothing in the failure says "wrong binary".
248
+
249
+ `bun zt test` refuses to run below the declared floor. Direct `bun test` runs get the
250
+ same check as a warning if you load the preload:
251
+
252
+ ```toml
253
+ # bunfig.toml
254
+ [test]
255
+ preload = ["@zerotal/testing/preload"]
256
+ timeout = 30000 # note: currently ignored by Bun — pass --timeout on the command line
257
+ ```
258
+
259
+ Set `ZT_ALLOW_RUNTIME_MISMATCH=1` to downgrade the refusal to a warning while you
260
+ are mid-upgrade.
261
+
262
+ ### `@zerotal/core/runtime`
263
+
264
+ The checks behind the two paragraphs above, exported so a script or a test of your
265
+ own can make the same assertion. `zt` runs both at the top of every command; the test
266
+ preload runs the floor check as a warning.
267
+
268
+ | Export | Signature | What it answers |
269
+ | -------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
270
+ | `declaredBunFloor` | `declaredBunFloor(cwd): { range, manifest } \| null` | The nearest `engines.bun` up the tree from `cwd`. |
271
+ | `runtimeBelowFloor` | `runtimeBelowFloor(cwd?): RuntimeFloor \| null` | Is this process below that floor? `null` when it is met or none is declared. |
272
+ | `runtimeBelowFloorMessage` | `runtimeBelowFloorMessage(floor): string` | The explanation to print — both versions, the manifest, and the way out. |
273
+ | `installedBunVersion` | `installedBunVersion(cwd): { version, manifest } \| null` | The Bun in `node_modules`, if the project installs one as a package. |
274
+ | `runtimeMismatch` | `runtimeMismatch(cwd?): RuntimeMismatch \| null` | Does the running Bun differ from the installed one? Compared exactly — a patch is a binary. |
275
+ | `runtimeMismatchMessage` | `runtimeMismatchMessage(mismatch): string` | The explanation for that one. |
276
+ | `runtimeMismatchAllowed` | `runtimeMismatchAllowed(): boolean` | Whether `ZT_ALLOW_RUNTIME_MISMATCH` is set. |
277
+ | `bunBinary` | `bunBinary(): string` | The binary to spawn a child with — `process.execPath`, never the name PATH resolves. |
278
+ | `RUNTIME_MISMATCH_ESCAPE` | `"ZT_ALLOW_RUNTIME_MISMATCH"` | The env var name, so a script can set it without hardcoding the string. |
279
+
280
+ `RuntimeFloor` is `{ running, required, manifest }`; `RuntimeMismatch` is
281
+ `{ running, installed, manifest }`. Both name the file the second version came from,
282
+ because "which one is wrong" is the question you actually have.
283
+
284
+ ## Configuration is per-process, and `bun test` is one process
285
+
286
+ Zerotal resolves configuration once, at boot. `bun test` runs every file in the same
287
+ process, so **whichever file boots the app first fixes the configuration for all of
288
+ them.**
289
+
290
+ A test that sets an environment variable in its own `beforeAll` and then asserts on
291
+ the resulting behaviour passes alone and fails in the suite — or worse, passes in the
292
+ suite for a reason unrelated to what it claims to test:
293
+
294
+ ```typescript fragment
295
+ // Passes alone. In a suite, the app may already be booted with CSRF on, and the
296
+ // three "rejects without a token" assertions below pass on a 419 they would have
297
+ // got anyway — never reaching the guard they name.
298
+ beforeAll(() => {
299
+ Bun.env.CSRF_DISABLED = "1";
300
+ });
301
+ ```
302
+
303
+ Assert on the _relationship_ rather than on a literal — the published origin equals
304
+ the configured one, whatever it is — or boot a dedicated app for the case:
305
+
306
+ ```typescript fragment
307
+ const app = await createTestApp({ config: { app: { url: "https://example.test" } } });
308
+ expect(page.canonical).toBe(config("app.url"));
309
+ ```
310
+
212
311
  ## Running the suite from a script
213
312
 
214
313
  A script that gates on the tests has to read the tests' exit status, and a pipe hides it:
package/docs/upgrade.md CHANGED
@@ -169,6 +169,54 @@ these are the changes that need action. Full detail is in the
169
169
  interface so pages that read them do not look unpassed; see
170
170
  [Typed props](/docs/inertia/props#typed-props).
171
171
 
172
+ ## 1.9 to 1.10
173
+
174
+ Three settings changed meaning. Each is quiet if it does not apply to you, and each is
175
+ worth thirty seconds of checking if it does.
176
+
177
+ 1. **`scheduler.timezone` is honoured.** It was documented as informational and read by
178
+ nothing, so whatever you put there had no effect and your schedules ran in the
179
+ server's zone. It is now the zone every schedule is evaluated in unless the task sets
180
+ its own.
181
+
182
+ Its default moved from the literal `"UTC"` to **the system zone**, so an app that never
183
+ set the key keeps doing exactly what it did. The case to check is an app that _did_:
184
+
185
+ ```ts fragment
186
+ // config/scheduler.ts
187
+ export default SchedulerConfig({ timezone: env("APP_TIMEZONE", "UTC") });
188
+ ```
189
+
190
+ On a server that is not on UTC, that line used to do nothing and now moves every
191
+ schedule. Either set it to the zone you actually want your crons read in — which is
192
+ the point of the setting — or delete the key to keep the server's zone.
193
+
194
+ `bun zt schedule:list` prints each task's next run in its own zone, which is the
195
+ quickest way to see whether anything moved.
196
+
197
+ 2. **Named rate limiters need `.trustedProxies(n)` behind a proxy.** `RateLimiter`'s
198
+ `.byIp()`, `.byUser()` and `.byApiKey()` ignored the proxy count entirely and read
199
+ `X-Forwarded-For` unconditionally. They now follow the same rule `ThrottleMiddleware`
200
+ already did — the header is consulted only when you say how many proxies sit in front:
201
+
202
+ ```ts fragment
203
+ RateLimiter.for("login").limit(5).every(60).byIp().trustedProxies(1).register();
204
+ ```
205
+
206
+ Without it the address used is the socket's, which behind a proxy is the _proxy's_, and
207
+ every visitor shares one bucket. `bun zt doctor` reports any limiter that needs this —
208
+ it could not before, because its check exempted custom key resolvers and all three of
209
+ these are one.
210
+
211
+ 3. **React apps using SSR need `@inertiajs/react` installed.** The same adapter your
212
+ browser entry point already uses. Server-side rendering now goes through its `<App>`,
213
+ which is what makes `<Head>` produce a title and an og: card in the HTML your server
214
+ actually sends. If it is missing you get a named error at render time, not a silent
215
+ omission.
216
+
217
+ Nothing to change if you already have it as a dependency, which every React Inertia app
218
+ does.
219
+
172
220
  ## The managed zt.ts
173
221
 
174
222
  `zt.ts` is framework-managed — the header says _do not modify_. If a release
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/arch",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -35,11 +35,11 @@
35
35
  "typecheck": "tsc --noEmit"
36
36
  },
37
37
  "dependencies": {
38
- "@zerotal/core": "1.9.0"
38
+ "@zerotal/core": "1.10.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^5.8.0",
42
- "@zerotal/orm": "1.9.0"
42
+ "@zerotal/orm": "1.10.0"
43
43
  },
44
44
  "description": "The Zerotal agent surface — an MCP server that hands coding agents the framework's machine-readable truth: exact API signatures, live routes and schema, version-matched docs, and `zt doctor`.",
45
45
  "keywords": [
@@ -150,7 +150,7 @@ export interface GuidelineOptions {
150
150
  * How this project is configured, from {@link detectShape}. Omitted, the block
151
151
  * is what it always was — a function of the package list.
152
152
  */
153
- shape?: ProjectShape;
153
+ shape?: ProjectShape | undefined;
154
154
  }
155
155
 
156
156
  /**
package/src/mcp/stdio.ts CHANGED
@@ -27,11 +27,11 @@ import type { JsonRpcResponse } from "./types.ts";
27
27
  export interface StdioOptions {
28
28
  server: McpServer;
29
29
  /** Byte source. Defaults to this process's stdin. */
30
- input?: ReadableStream<Uint8Array>;
30
+ input?: ReadableStream<Uint8Array> | undefined;
31
31
  /** Frame sink. Defaults to this process's stdout. Injected in tests. */
32
- write?: (frame: string) => void;
32
+ write?: ((frame: string) => void) | undefined;
33
33
  /** Diagnostics sink. Defaults to stderr — never stdout. */
34
- log?: (message: string) => void;
34
+ log?: ((message: string) => void) | undefined;
35
35
  }
36
36
 
37
37
  /**
@@ -35,8 +35,8 @@ export interface ProbeRunner {
35
35
 
36
36
  export interface SpawnProbeOptions {
37
37
  /** Where to start looking for the app. Defaults to the server's working directory. */
38
- cwd?: string;
39
- timeoutMs?: number;
38
+ cwd?: string | undefined;
39
+ timeoutMs?: number | undefined;
40
40
  }
41
41
 
42
42
  /**