@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 +176 -0
- package/api-surface.md +3609 -0
- package/package.json +3 -1
- package/src/application/Application.ts +174 -11
- package/src/command/builtin/DoctorCommand.ts +53 -6
- package/src/command/builtin/RouteTypesCommand.ts +1 -0
- package/src/dev/DevDeck.ts +144 -20
- package/src/dev/DevOrchestrator.ts +1 -1
- package/src/doctor/HeaderProbe.ts +164 -0
- package/src/events/Emitter.ts +24 -0
- package/src/events/FrameworkEvents.ts +42 -0
- package/src/helpers/index.ts +43 -28
- package/src/index.ts +2 -0
- package/src/middleware/BaseMiddleware.ts +12 -1
- package/src/middleware/SecureHeadersMiddleware.ts +54 -23
- package/src/provider/StorageProvider.ts +4 -1
- package/src/router/RouteHandler.ts +5 -0
- package/src/router/Router.ts +62 -3
- package/src/router/routeTypes.ts +52 -11
- package/src/router/routes.ts +115 -0
- package/src/security/index.ts +6 -0
- package/src/security/redactGraph.ts +107 -0
- package/src/support/deepMerge.ts +42 -2
- package/src/support/env.ts +48 -8
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
|