@zerotal/core 1.6.3 → 1.7.2

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/CHANGELOG.md CHANGED
@@ -8,6 +8,182 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ### Added
12
+
13
+ - **`@zerotal/core/errors`** — a subpath for the error classes, so a module that can run in a
14
+ browser can import `ZerotalError` without reaching the root entry. The root re-exports
15
+ `CommandRunner`, which reaches the built-in CLI commands and `await import("bun")`, so a single
16
+ root import is enough to make a browser bundle fail at resolution. `@zerotal/core/helpers`
17
+ already covered `deepMerge` the same way.
18
+
19
+ The rule this makes workable: **core's root entry is server-only.** Anything that might be
20
+ bundled for a browser imports from a narrow subpath.
21
+
22
+
23
+ ## [1.7.1] — 2026-08-16
24
+
25
+ ### Changed
26
+
27
+ - **`APP_ENV` is the deployment name; the runtime mode moved to `APP_TYPE`.** They shared one
28
+ variable and the mode won: `setAppEnv()` overwrote `APP_ENV` with `web` / `worker` /
29
+ `console` at boot, so an app whose `.env` said `APP_ENV=development` read `"console"` back
30
+ from `env("APP_ENV")` inside every CLI command.
31
+
32
+ The dangerous direction is the one nobody hits in development. A guard written the obvious
33
+ way —
34
+
35
+ ```ts
36
+ if (env("APP_ENV") === "production") refuseToWipe();
37
+ ```
38
+
39
+ — was **inert in every console command**, which is exactly where destructive commands live.
40
+ 1.7.0 patched the framework's own gates by parking a copy that `deployEnv()` read back, but
41
+ application code reading the documented variable the documented way still got the mode.
42
+
43
+ Two questions, two variables. `setAppEnv()` no longer touches `APP_ENV` at all and writes
44
+ the mode to `APP_TYPE`; `runtimeMode()` reads it, and falls back to the legacy location so a
45
+ process started by an older launcher still boots the right providers. An explicit
46
+ `APP_TYPE` wins over the command, which is how `serve --dev` boots its supervised server as
47
+ `web`. `deployEnv()` and `config("app.env")` are unchanged and still correct.
48
+
49
+ No action needed in an app unless it sets `APP_ENV=web` by hand to force web mode — that
50
+ still works, and `APP_TYPE=web` is the spelling to move to.
51
+
52
+ Found seeding the first cookbook app, where a guard fired that should not have.
53
+
54
+ ### Fixed
55
+
56
+ - **`Router.raw()` did not answer `HEAD`.** The pipeline derives a `HEAD` handler from every
57
+ `GET` — its own docblock notes that not doing so gives "every uptime monitor,
58
+ load-balancer probe, CDN origin check and `curl -I`" a 404 — and the raw path was left out
59
+ of it. So `curl -I` against a raw route answered 404 while the `GET` beside it answered 200. This framework's own site serves `/docs/*` and `/blog` from raw routes, so every link
60
+ checker and uptime probe aimed at the documentation was told the page did not exist.
61
+
62
+ Derived from the wrapped handler rather than the bare one, so the security headers below
63
+ ride along and a `HEAD` cannot answer with fewer than the `GET` it mirrors. A `HEAD` the
64
+ app registered itself still wins. Third gap in the same family, after the headers and
65
+ static files: any path that answers a request without running the pipeline needs whatever
66
+ the pipeline was doing for it.
67
+
68
+ - **The dev deck would not scroll.** On the alternate screen a terminal has no scrollback of
69
+ its own, so the wheel and the scrollbar had nothing to move and the deck read as frozen —
70
+ from the moment tabs mode starts, every way of looking at an older line has to come from
71
+ the deck itself, and only Page Up/Down did.
72
+
73
+ It now asks the terminal to send the wheel as cursor keys (`?1007h`, released again on
74
+ exit) and handles `↑`/`↓` and Home/End. Deliberately not mouse tracking, which would give
75
+ real wheel events at the price of the terminal's own text selection.
76
+
77
+ Two things had to change underneath. A read from stdin is not one key: a wheel tick arrives
78
+ as the same arrow repeated once per line, all in one chunk, and two fast keystrokes arrive
79
+ together — so a chunk is split into keys and the frame painted once at the end. And a card
80
+ that has been scrolled up now holds its place: `scroll` counts up from the newest line, so a
81
+ busy process used to drag the window down by a line for every line it printed, sliding the
82
+ text somebody had stopped to read off the top while they read it. A card pinned to the
83
+ bottom still follows its output, which is the one that should.
84
+
85
+ Both of the next two were found by wiring DevTools into this repo's own `apps/docs` and
86
+ driving it in a browser.
87
+
88
+ - **`Router.raw()` responses carried no security headers.** A raw route opts out of the
89
+ _request_ pipeline — CSRF on a transport endpoint, session resolution on a relay — and was
90
+ silently opting its response out of `SecureHeadersMiddleware` too. This framework's own
91
+ documentation site serves every `/docs/*` page from a raw route, so every page of it went
92
+ out with no `X-Content-Type-Options: nosniff`, no `X-Frame-Options`, no
93
+ `Referrer-Policy` and no `Permissions-Policy`. In production the reverse proxy happened
94
+ to add two of them, which is why nothing had noticed.
95
+
96
+ The header set is now applied to raw responses at compile time, **add-if-absent** rather
97
+ than overwrite: a raw route is the one place a handler owns its whole response, and an
98
+ endpoint that deliberately allows framing has a reason the framework cannot see. The
99
+ response is only reconstructed when something is missing, so the hot path — Flow's action
100
+ endpoint is a raw route — pays nothing when it already has them.
101
+
102
+ This is the third surface in the same family, after the pipeline and static files. Any
103
+ path that answers a request without running middleware needs the same treatment.
104
+
105
+ - **`redactGraph` masked booleans.** Sensitivity is judged by key name, by substring, so
106
+ `cors.credentials` matched "credential" and the DevTools Config tab reported
107
+ `‹redacted›` where the answer was `false`. A boolean has two possible values: masking one
108
+ conceals nothing a reader could not guess, and hides the security setting they opened the
109
+ tab to check. Booleans now pass through; numbers still mask, since a number can be a PIN
110
+ or an account. The helper also gained the test file it shipped without.
111
+
112
+ ## [1.7.0] — 2026-08-16
113
+
114
+ ### Fixed
115
+
116
+ - **Security headers now cover static files.** Files under `public/` are handed to Bun as
117
+ pre-registered responses and served without entering JavaScript, so no middleware ran for
118
+ them — including `SecureHeadersMiddleware`, which the framework advertises as automatic.
119
+ Every asset went out with no `X-Content-Type-Options: nosniff`, which is precisely the
120
+ response class sniffing protection exists for. The header set is baked into the compiled
121
+ response at registration time, so Bun still serves the file natively; a header a mount
122
+ declares itself still wins.
123
+
124
+ - **`BaseMiddleware.with()` type-checks its options.** `Opts` has a default computed from the
125
+ middleware class, but a type parameter in an argument position is inferred from the
126
+ _argument_ and only falls back to its default when inference finds nothing — so
127
+ `with({ … })` inferred `Opts` from the object literal and type-checked the literal against
128
+ itself. Every callback parameter arrived implicitly `any`, and a misspelled option was
129
+ accepted in silence. `NoInfer` on the parameter makes the middleware's own option type the
130
+ one that governs. It caught a real defect on the first run: `StorageProvider` was passing
131
+ an `unknown` where a `StorageManager` was expected.
132
+
133
+ ### Added
134
+
135
+ - **`DeepPartial<T>`, and `deepMerge` accepts it.** `deepMerge` does a deep merge and
136
+ declared `override: Partial<T>`, which only makes the top level optional — so
137
+ `{ drivers: { anthropic: { apiKey } } }`, the commonest thing anyone writes in a config
138
+ file, was a type error against any shape whose nested block has other keys.
139
+ `@zerotal/ai` had already hit this and defined a private copy; that copy is now deleted and
140
+ the type is exported from the kernel. `BaseMiddleware.with()` takes it too, since it
141
+ deep-merges as well.
142
+
143
+ - **`zt doctor --url` reports duplicated security headers.** A header the app sets and the
144
+ proxy also sets is invisible from inside the process. Conflicting values fail — browsers do
145
+ not agree which copy applies, so the control is enforced inconsistently — and identical
146
+ duplicates warn. `Permissions-Policy` and `Referrer-Policy` are deliberately not checked:
147
+ a comma is legitimate syntax there, and a probe that cried wolf on a correct header would
148
+ be switched off before it caught a real one.
149
+
150
+ - **`RequestFailed` carries the error's class name and stack.** It had the message and the
151
+ status, which is enough to say a request failed and not enough to say anything about how.
152
+ A subscriber rendering a failure — the devtools Exception tab is the first — has nothing to
153
+ show without them, and by the time the event is emitted the error object is the only place
154
+ they exist. Both are optional trailing parameters, so nothing that constructs or reads the
155
+ event needs to change.
156
+
157
+ - **`Application.providerReport`** — what each provider cost to boot and what it put in the
158
+ container, in boot order. `bootDurationMs` said the app took 240ms and nothing said which
159
+ provider spent it; the container listed a hundred bindings and nothing said who bound
160
+ them. Boot order is itself the third answer, since it decides who wins a contested
161
+ binding.
162
+
163
+ Provenance comes from diffing the container registry around each provider's hooks rather
164
+ than from the container recording a registrar — it keeps the cost at boot instead of on
165
+ every binding, and adds no mutable state to the container for a question only a debugging
166
+ tool asks. Async hooks are timed across their `await`, not up to it.
167
+
168
+ - **`FrameworkEvents.subscriptions()` and `Emitter.registrations()`** — which events have
169
+ subscribers, and what reacts to them. The bus is the framework's nervous system and had
170
+ been entirely invisible: `handlerCount()` returned one number for the whole thing.
171
+
172
+ - **`redactGraph` on the `@zerotal/core/security` subpath** — the object-graph redaction walk
173
+ that every recorder needs and that three packages had each written for themselves. Copy a
174
+ value, replace what a key name says is a secret, come back with something
175
+ `JSON.stringify` survives.
176
+
177
+ Shared because the hard parts are the same everywhere and are easy to get subtly wrong:
178
+ cycles (a model with a back-reference to its parent is ordinary, and `JSON.stringify`
179
+ throws on it), a depth bound (recording is on the request path), and values that read
180
+ better flat than walked (`Object.entries` on a `Date` or a `File` produces something worse
181
+ than useless).
182
+
183
+ It is not a policy. Callers bring their own markers and their own sensitivity predicate,
184
+ because those are not interchangeable — a debug panel's `‹redacted›` is a display choice,
185
+ while an adapter implementing a published wire protocol has its markers specified for it.
186
+
11
187
  ## [1.6.3] — 2026-08-15
12
188
 
13
189
  ### Added