@zerotal/core 1.8.1 → 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.
Files changed (80) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/api-surface.md +192 -424
  3. package/package.json +3 -1
  4. package/src/application/Application.ts +10 -4
  5. package/src/application/currentApp.ts +7 -1
  6. package/src/build/codemod.ts +1 -1
  7. package/src/command/CommandRunner.ts +2 -0
  8. package/src/command/OutputWriter.ts +5 -1
  9. package/src/command/builtin/AssetsPruneCommand.ts +138 -0
  10. package/src/command/builtin/DeployCommand.ts +91 -1
  11. package/src/command/builtin/TestCommand.ts +9 -2
  12. package/src/command/builtin/index.ts +1 -0
  13. package/src/command/startZerotal.ts +62 -1
  14. package/src/config/AppConfig.ts +10 -10
  15. package/src/config/ConfigLoader.ts +5 -1
  16. package/src/config/DeployConfig.ts +33 -0
  17. package/src/config/index.ts +6 -1
  18. package/src/config/registry.ts +5 -1
  19. package/src/config/validation.ts +10 -2
  20. package/src/conventions/ConventionLoader.ts +44 -3
  21. package/src/dev/BuildOutput.ts +58 -6
  22. package/src/dev/CssPlugins.ts +13 -3
  23. package/src/dev/DevBuildHook.ts +10 -2
  24. package/src/dev/DevDeck.ts +9 -3
  25. package/src/dev/DevOrchestrator.ts +2 -0
  26. package/src/dev/DevProcess.ts +19 -5
  27. package/src/dev/DevReloadMiddleware.ts +7 -1
  28. package/src/dev/DevSupervisor.ts +27 -9
  29. package/src/dev/bootBuild.ts +9 -1
  30. package/src/dev/index.ts +6 -1
  31. package/src/dev/reloadClient.ts +2 -0
  32. package/src/dev/startDevMode.ts +8 -2
  33. package/src/doctor/AppDoctor.ts +43 -1
  34. package/src/doctor/throttleIdentity.ts +118 -0
  35. package/src/errors/RuntimeMismatchError.ts +21 -0
  36. package/src/errors/index.ts +1 -0
  37. package/src/events/Emitter.ts +5 -1
  38. package/src/health/Health.ts +5 -5
  39. package/src/helpers/html.ts +2 -0
  40. package/src/helpers/index.ts +2 -1
  41. package/src/helpers/markdown.ts +14 -1
  42. package/src/helpers/pageElements.ts +2 -0
  43. package/src/http/HttpClient.ts +5 -1
  44. package/src/http/UploadedFile.ts +2 -2
  45. package/src/http/Uri.ts +5 -1
  46. package/src/http/negotiate.ts +5 -1
  47. package/src/http/originGuard.ts +2 -0
  48. package/src/http/sniffContentType.ts +7 -1
  49. package/src/index.ts +2 -1
  50. package/src/lock/LockManager.ts +4 -4
  51. package/src/logger/LoggerMiddleware.ts +1 -1
  52. package/src/logger/types.ts +4 -4
  53. package/src/macros/config.macro.ts +2 -0
  54. package/src/metrics/HttpMetrics.ts +10 -2
  55. package/src/middleware/BaseMiddleware.ts +20 -0
  56. package/src/middleware/CorsMiddleware.ts +6 -6
  57. package/src/middleware/RateLimiter.ts +105 -22
  58. package/src/middleware/SecureHeadersMiddleware.ts +8 -8
  59. package/src/middleware/ThrottleMiddleware.ts +57 -4
  60. package/src/middleware/WebhookMiddleware.ts +7 -7
  61. package/src/pipeline/ContextRegistry.ts +12 -2
  62. package/src/pipeline/HttpContext.ts +4 -0
  63. package/src/pipeline/currentPage.ts +4 -0
  64. package/src/pipeline/types.ts +2 -0
  65. package/src/router/FileRouter.ts +20 -2
  66. package/src/router/Route.ts +3 -0
  67. package/src/router/RouteHandler.ts +2 -2
  68. package/src/router/Router.ts +7 -5
  69. package/src/router/domain.ts +1 -0
  70. package/src/router/registry.ts +12 -2
  71. package/src/security/redactGraph.ts +1 -1
  72. package/src/shared/format.ts +135 -0
  73. package/src/shared/index.ts +34 -0
  74. package/src/storage/StorageFilesMiddleware.ts +4 -2
  75. package/src/storage/types.ts +10 -10
  76. package/src/support/cookie.ts +13 -7
  77. package/src/support/deepMerge.ts +49 -0
  78. package/src/support/runtime.ts +226 -0
  79. package/src/view/FileRouteResolver.ts +2 -0
  80. package/src/view/jsx-runtime.ts +4 -0
package/CHANGELOG.md CHANGED
@@ -8,6 +8,210 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.10.0] — 2026-08-30
12
+
13
+ ### Changed
14
+
15
+ - **Optional properties in public option shapes are declared `?: T | undefined`.**
16
+ The generated `tsconfig.json` turns on `exactOptionalPropertyTypes`, under which
17
+ `image?: string` and `image?: string | undefined` are different types: only the
18
+ second accepts a key that is present and holds `undefined`. So an app with the
19
+ strictness the framework shipped it could not write the most ordinary thing there
20
+ is — `{ image: candidate ?? undefined }` — and had to spell every conditional field
21
+ `...(x ? { x } : {})` instead. An options bag exists to be spread into; one built by
22
+ spreading has conditionally-absent keys.
23
+
24
+ 438 properties across 115 files, applied by `scripts/exact-optional.ts` and checked
25
+ by `bun run exact:optional:check` so the convention holds for shapes written later.
26
+ Scoped to exported `*Options` / `*Config` shapes — what an app constructs — rather
27
+ than every optional property in the codebase. Nothing changes for a reader: an
28
+ absent optional property already read as `undefined`.
29
+
30
+ ### Added
31
+
32
+ - **`zt assets:prune`** — remove the chunks an earlier release left behind, on the
33
+ machine that never ran a build. `assets:build --clean` cleans the directory it
34
+ _builds into_, which does nothing for the common release shape: build here, tar the
35
+ output, extract it over `public/` there. Extracting merges — nothing removes a file
36
+ the new release does not contain — so every deploy adds another set of
37
+ content-hashed chunks. One app reached 225 chunk files for the 49 its entry point
38
+ references. Ship `.zerotal/` with the release and this removes what the build record
39
+ does not claim; without a record it says so and removes nothing rather than guessing.
40
+
41
+ - **`definedOnly` (`@zerotal/core/helpers`) and the `Resolved<T>` type.** Widening the
42
+ option shapes moves a problem to the merge, and both of these are that problem's
43
+ answer. Object spread copies own properties even when their value is `undefined`,
44
+ so `{ ...DEFAULTS, ...options }` now lets an explicit `undefined` overwrite a
45
+ default; `definedOnly` drops those keys, turning "supplied as undefined" back into
46
+ "not supplied". (`deepMerge` already did this at every depth.)
47
+
48
+ `Resolved<T>` is what a defaults-applied object is. **`Required<T>` is not**, which
49
+ is the trap: `-?` removes the optionality a `?` introduced but not an `undefined`
50
+ written into the type, so `Required<Options>` quietly stopped meaning "the defaults
51
+ have been applied".
52
+
53
+ - **`zt deploy:<env> --check`** — the preflight gate on its own. A release script has a
54
+ moment where the new code is on disk and the service has not restarted yet, and that
55
+ is where a gate belongs: exit 0 and restart, exit non-zero and keep serving the
56
+ previous release. Everything that can refuse already runs by the end of preflight and
57
+ none of it mutates, so stopping there is a complete answer rather than half a deploy.
58
+ A workspace that has lost its banking details, or had its mail driver knocked back to
59
+ `log`, never goes live broken.
60
+
61
+ - **`engines.bun` is enforced.** Every generated app writes a floor and nothing had
62
+ ever read it. `runtimeMismatch` did not cover this: it compares the running Bun
63
+ against an _installed_ one, and most projects do not install Bun as a package, so
64
+ it correctly says nothing about most of them. `startZerotal()` now refuses to run
65
+ below the declared floor, so every `zt` command is covered.
66
+
67
+ The failure it catches is narrow and expensive. `Intl` output moves between Bun
68
+ releases, so a suite with currency or date assertions goes red on a runtime that is
69
+ otherwise fine, the failures name the code they touch, and the version is the last
70
+ thing anyone checks. `ZT_ALLOW_RUNTIME_MISMATCH=1` downgrades it to a warning.
71
+
72
+ - **`@zerotal/core/runtime`** (`zerotal/runtime`) — a subpath exporting the checks
73
+ themselves: `runtimeBelowFloor`, `declaredBunFloor`, `runtimeMismatch`, `bunBinary`
74
+ and the messages that go with them, so a script or a test can make the same
75
+ assertion. A subpath rather than the main barrel, which is deliberately lean.
76
+
77
+ - **A boot line when a convention is skipped in this environment.** An env-restricted
78
+ concern is skipped by _not looking_, which is correct and completely silent: from a
79
+ web process's point of view nothing is wrong, because from a web process's point of
80
+ view nothing exists. An app ran for weeks in production with `app/schedules` full
81
+ and no worker process — no inventory hold released, no payment reminder sent, and
82
+ nothing logged, because there was nothing to log. It was found by going looking.
83
+
84
+ `runConventions` now says so once, at boot, and only when the directory actually
85
+ holds files:
86
+
87
+ Skipping 3 file(s) in app/schedules — the "schedules" convention does not run in env=web (it runs in: worker, console).
88
+
89
+ "Skipping 0 schedules" on every boot of every app would be noise, and noise is how
90
+ the line that matters gets scrolled past.
91
+
92
+ ### Fixed
93
+
94
+ - **Named rate limiters honour `trustedProxies`.** `ThrottleMiddleware` resolves the
95
+ client address from the socket unless told how many proxies sit in front — that was
96
+ fixed, documented and covered by a doctor check. `RateLimiter`'s `.byIp()`,
97
+ `.byUser()` and `.byApiKey()` did not: they used a private resolver that read the
98
+ socket address and fell back to the **leftmost** `X-Forwarded-For` entry, with no
99
+ proxy count anywhere in it. A `keyResolver` replaces the middleware's own
100
+ proxy-aware path entirely, so the sibling API kept the bug the middleware had shed.
101
+
102
+ Two consequences, in opposite directions. Where there was no socket address a client
103
+ picked its own bucket by writing the header, which is the part of it the client
104
+ controls. Behind a real reverse proxy — where there _is_ a socket address — it was
105
+ the proxy's, so **every visitor shared one bucket**: the documented
106
+ `RateLimiter.for("login").limit(5).every(60)` was five attempts a minute across the
107
+ entire user base, and one attacker locked everybody out.
108
+
109
+ All three now resolve through the same proxy-aware function, and
110
+ `.trustedProxies(n)` is on the builder. The strategy is recorded and the resolver
111
+ built at the end, so `.byIp().trustedProxies(1)` and `.trustedProxies(1).byIp()`
112
+ mean the same thing.
113
+
114
+ - **`zt doctor` audits named limiters.** Its `trustedProxies` check exempted any
115
+ throttle with a custom `keyResolver` — right for one keyed on a user id, where no
116
+ proxy can affect the answer, and exactly wrong for `RateLimiter`'s built-ins, which
117
+ _are_ custom resolvers and do key on an address. The check that exists to catch this
118
+ skipped the whole API that still had it. The built-in resolvers mark themselves now;
119
+ an app's own `.by(fn)` stays exempt, which is the point of the exemption.
120
+
121
+ - **The build record is portable.** Its filename was hashed from the output
122
+ directory's _absolute_ path, so the same project built at `/home/me/app` and
123
+ unpacked at `/opt/app` wrote and looked for two different files. A record shipped
124
+ with a release matched nothing at the other end — which is why a server could not
125
+ say which files belonged to the current release — and moving a checkout silently
126
+ orphaned it. It is keyed on the path relative to the project now.
127
+
128
+ ## [1.9.0] — 2026-08-29
129
+
130
+ ### Added
131
+
132
+ - **`@zerotal/core/env` is documented.** A whole subpath — a strict, fully typed environment
133
+ schema — had no page. `EnvSchema.define()` with the `t` field builders reports **every**
134
+ failing variable at once rather than one per restart, which is the difference between one fix
135
+ and three round trips through a deploy. See
136
+ [Configuration](/docs/config-system#declaring-the-whole-environment--envschema).
137
+
138
+ - **`negotiate()` is documented.** One route, three audiences: a browser, an API client and the
139
+ console, without three copies of the logic. It is what the framework's own error handler uses,
140
+ which is why a 422 is a redirect-with-errors for a form post and a JSON body for a fetch.
141
+
142
+ - **The hashing helpers get the paragraph `safeEqual` deserves.** `a === b` on a token returns as
143
+ soon as two bytes differ, and how long it took measures how much of the prefix was right —
144
+ enough, over many attempts, to recover a secret a character at a time.
145
+
146
+ - Routing, config, cookie, view, storage and Carbon types are named.
147
+
148
+ ### Changed
149
+
150
+ - **INTERNAL: 58 exports are marked `@internal`** — the file-routing internals, the router's
151
+ compiled state, the context registry, the metrics instrumentation hooks, the view component
152
+ symbols, and the config map/path types. Still exported, still working; none is something an
153
+ app constructs.
154
+
155
+ ### Changed
156
+
157
+ - **INTERNAL: the dev orchestrator's 26 exports are marked `@internal`.** `DevSupervisor`,
158
+ `DevChild`, `DevSpawnFn`, `StreamDeck`, `TabsDeck`, `createDeck`, `collectDevProcesses`,
159
+ `buildCssBundle`, `buildJsBundle`, `bootBuildDecision`, `detectCssPlugins`, `isWritableDir`,
160
+ `startDevMode`, `registerDevHtmlSnippet`, `DEV_RELOAD_CLIENT`, `SERVER_PROCESS_NAME` and their
161
+ option/result types.
162
+
163
+ **Nothing is removed and nothing breaks** — they are still exported and still work. What
164
+ changes is the promise: they leave the recorded API surface, because an app never constructs
165
+ any of them and the only callers outside `@zerotal/core` are sibling framework packages wiring
166
+ their own dev-time build. `DevProcessDefinition` — the one a package author actually writes —
167
+ is unaffected and stays documented.
168
+
169
+ ### Added
170
+
171
+ - **One project, one Bun.** `engines.bun` is a floor and nothing enforces it, so a project
172
+ can end up serving its app with one runtime and running its suite with another — the
173
+ shell's `bun` and a `node_modules/bun` put there by a transitive peer dependency nobody
174
+ declared. Nothing announces that, and a green suite is then evidence about a binary the app
175
+ is not served by. `startZerotal()` now compares `Bun.version` against the installed
176
+ manifest and refuses on a mismatch, naming both versions and the two ways out. It is not a
177
+ pin: the version to agree on is whichever one the project installed, so `bun update bun`
178
+ moves it. `ZT_ALLOW_RUNTIME_MISMATCH=1` downgrades the refusal to a warning.
179
+
180
+ - **`DeployTarget.preflight`** — a slot for the app's own release gate, run after the config
181
+ validators and `doctor` and before anything is built or migrated. A command named
182
+ `release:check` is found by convention with nothing to wire up. A declared name that is not
183
+ registered **fails** the deploy rather than being skipped: a missing `inertia:build` means
184
+ the app has no Inertia, but a missing gate means the gate is not running, which is the
185
+ state the feature exists to prevent.
186
+
187
+ - **`zerotal/shared`** — the helpers that are safe to import from a browser bundle:
188
+ `pluralize`, `singularize`, `snakeCase`, `camelCase`, `tableNameFor`, `Str`, and new
189
+ `formatMoney` / `formatNumber` / `formatDate`. Everything reachable from it is pure, and a
190
+ test bundles the entry point for the browser to keep it that way. Without it a page that
191
+ wants the framework's `pluralize` gets a second implementation written by hand, and the
192
+ second copy is always the worse one — `"supplier line"` pluralises to `"supplier lines"`,
193
+ and the naive rule gives `"suppliers line"`.
194
+
195
+ - **`doctor` check: rate-limit identity.** `ThrottleMiddleware` keys on the socket address
196
+ unless told how many proxies sit in front of the app. That default is right and it is the
197
+ wrong answer the moment you deploy behind one: the socket address is then the proxy's for
198
+ every request, everybody shares a bucket, and the limiter inverts into a way for one
199
+ attacker to lock out the rest. Nothing observable says so. A production-like deployment
200
+ with a registered throttle and no `trustedProxies` is now reported.
201
+
202
+ ### Changed
203
+
204
+ - **`zt test` spawns the binary running it**, not the name `bun` for the OS to resolve
205
+ against `PATH`. A command whose job is to run this app's tests was handing them to whatever
206
+ the shell happened to offer, so `node_modules/.bin/bun zt test` satisfied every check in
207
+ the parent process and still ran the suite on a different runtime.
208
+
209
+ - **`BaseMiddleware.with()`** now documents that each call creates a distinct class and any
210
+ per-class state goes with it — so re-using one `.with()` export on two routes shares that
211
+ state. For `ThrottleMiddleware` that state is the hit counter, and on a sign-in flow it
212
+ means a handful of fumbled passwords can spend the allowance a legitimate person needs to
213
+ answer their second factor.
214
+
11
215
  ## [1.8.0] — 2026-08-24
12
216
 
13
217
  ### Added