@zerotal/core 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.
Files changed (42) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/api-surface.md +148 -81
  3. package/package.json +2 -1
  4. package/src/application/Application.ts +4 -4
  5. package/src/build/codemod.ts +1 -1
  6. package/src/command/CommandRunner.ts +2 -0
  7. package/src/command/builtin/AssetsPruneCommand.ts +138 -0
  8. package/src/command/builtin/DeployCommand.ts +19 -0
  9. package/src/command/builtin/index.ts +1 -0
  10. package/src/command/startZerotal.ts +36 -9
  11. package/src/config/AppConfig.ts +10 -10
  12. package/src/conventions/ConventionLoader.ts +39 -2
  13. package/src/dev/BuildOutput.ts +58 -6
  14. package/src/dev/CssPlugins.ts +2 -2
  15. package/src/dev/DevDeck.ts +3 -3
  16. package/src/dev/DevProcess.ts +2 -2
  17. package/src/dev/DevSupervisor.ts +5 -5
  18. package/src/dev/index.ts +6 -1
  19. package/src/dev/startDevMode.ts +1 -1
  20. package/src/doctor/throttleIdentity.ts +6 -2
  21. package/src/health/Health.ts +5 -5
  22. package/src/helpers/index.ts +2 -1
  23. package/src/helpers/markdown.ts +7 -0
  24. package/src/http/UploadedFile.ts +2 -2
  25. package/src/index.ts +2 -1
  26. package/src/lock/LockManager.ts +4 -4
  27. package/src/logger/LoggerMiddleware.ts +1 -1
  28. package/src/logger/types.ts +4 -4
  29. package/src/middleware/CorsMiddleware.ts +6 -6
  30. package/src/middleware/RateLimiter.ts +105 -22
  31. package/src/middleware/SecureHeadersMiddleware.ts +8 -8
  32. package/src/middleware/ThrottleMiddleware.ts +57 -4
  33. package/src/middleware/WebhookMiddleware.ts +7 -7
  34. package/src/router/RouteHandler.ts +2 -2
  35. package/src/router/Router.ts +5 -5
  36. package/src/security/redactGraph.ts +1 -1
  37. package/src/shared/format.ts +8 -8
  38. package/src/storage/StorageFilesMiddleware.ts +2 -2
  39. package/src/storage/types.ts +10 -10
  40. package/src/support/cookie.ts +6 -6
  41. package/src/support/deepMerge.ts +49 -0
  42. package/src/support/runtime.ts +91 -1
@@ -17,13 +17,13 @@ export interface RefreshOptions {
17
17
  * question nobody can answer — and becomes "how long after a crash before
18
18
  * someone else may take over", which is a decision rather than a guess.
19
19
  */
20
- refresh?: boolean;
20
+ refresh?: boolean | undefined;
21
21
 
22
22
  /**
23
23
  * Seconds between refreshes. Defaults to a third of the TTL, so two
24
24
  * consecutive failures still leave a full attempt before the lock lapses.
25
25
  */
26
- refreshEvery?: number;
26
+ refreshEvery?: number | undefined;
27
27
  }
28
28
 
29
29
  /**
@@ -44,13 +44,13 @@ export interface BlockOptions extends RefreshOptions {
44
44
  * Maximum seconds to wait for the lock before throwing.
45
45
  * Defaults to the lock TTL.
46
46
  */
47
- timeout?: number;
47
+ timeout?: number | undefined;
48
48
 
49
49
  /**
50
50
  * Milliseconds between polling attempts while waiting.
51
51
  * Default: 100 ms.
52
52
  */
53
- retryDelay?: number;
53
+ retryDelay?: number | undefined;
54
54
  }
55
55
 
56
56
  /**
@@ -42,7 +42,7 @@ const LINE_WIDTH = 60;
42
42
 
43
43
  export interface LoggerOptions {
44
44
  /** Output format. Defaults to LOG_FORMAT env var, then 'text'. */
45
- format?: "text" | "json";
45
+ format?: "text" | "json" | undefined;
46
46
  }
47
47
 
48
48
  /**
@@ -186,11 +186,11 @@ export interface LoggingConfigShape {
186
186
  /**
187
187
  * The terminal sink, on unless `false`. See {@link ConsoleSinkConfig}.
188
188
  */
189
- console?: ConsoleSinkConfig;
189
+ console?: ConsoleSinkConfig | undefined;
190
190
  /**
191
191
  * The durable file trail, on unless `false`. See {@link FileSinkConfig}.
192
192
  */
193
- file?: FileSinkConfig;
193
+ file?: FileSinkConfig | undefined;
194
194
  /** Name of the channel used by unqualified `Log.*` calls. */
195
195
  default: string;
196
196
  /**
@@ -203,10 +203,10 @@ export interface LoggingConfigShape {
203
203
  */
204
204
  channels: Record<string, ChannelConfig>;
205
205
  /** Threshold, in ms, above which ORM queries are logged as slow (default 1000). */
206
- slowQueryMs?: number;
206
+ slowQueryMs?: number | undefined;
207
207
  /**
208
208
  * Log every HTTP request (method, path, status, duration) via `LoggerMiddleware`.
209
209
  * On by default; set `false` to silence the per-request access log.
210
210
  */
211
- requests?: boolean;
211
+ requests?: boolean | undefined;
212
212
  }
@@ -26,17 +26,17 @@ export interface CorsOptions {
26
26
  * `new URL(o).hostname.endsWith('.example.com') && o.startsWith('https://')`, or just
27
27
  * list the origins.
28
28
  */
29
- origin?: string | string[] | ((origin: string) => boolean);
29
+ origin?: string | string[] | ((origin: string) => boolean) | undefined;
30
30
  /** Allowed HTTP methods. Default: `['GET','POST','PUT','PATCH','DELETE','OPTIONS']`. */
31
- methods?: string[];
31
+ methods?: string[] | undefined;
32
32
  /** Allowed request headers. Default: `['Content-Type','Authorization','X-Requested-With']`. */
33
- allowedHeaders?: string[];
33
+ allowedHeaders?: string[] | undefined;
34
34
  /** Headers the browser may expose to JS. Default: `[]`. */
35
- exposedHeaders?: string[];
35
+ exposedHeaders?: string[] | undefined;
36
36
  /** Allow cookies / auth headers in cross-origin requests. Default: `false`. */
37
- credentials?: boolean;
37
+ credentials?: boolean | undefined;
38
38
  /** Preflight cache duration in seconds. Default: `600`. */
39
- maxAge?: number;
39
+ maxAge?: number | undefined;
40
40
  }
41
41
 
42
42
  /**
@@ -4,7 +4,7 @@
4
4
  * later attached to routes or checked manually as `ThrottleMiddleware`.
5
5
  */
6
6
  import type { HttpContext } from "../pipeline/HttpContext.ts";
7
- import { ThrottleMiddleware } from "./ThrottleMiddleware.ts";
7
+ import { ThrottleMiddleware, _clientIp, _markIpDerived } from "./ThrottleMiddleware.ts";
8
8
  import type { ThrottleOptions } from "./ThrottleMiddleware.ts";
9
9
 
10
10
  /**
@@ -14,7 +14,19 @@ import type { ThrottleOptions } from "./ThrottleMiddleware.ts";
14
14
  export class LimiterDefinition {
15
15
  private _max = 60;
16
16
  private _window = 60;
17
- private _keyFn?: (ctx: HttpContext) => string;
17
+ private _trustedProxies?: number;
18
+
19
+ /**
20
+ * How this limiter keys its buckets, recorded rather than resolved.
21
+ *
22
+ * The built-in strategies all fall back to the client address, and resolving that
23
+ * needs `trustedProxies` — which the fluent API may not have been told yet when
24
+ * `.byIp()` runs. Keeping the *decision* here and building the resolver in
25
+ * {@link toMiddlewareClass} means `.byIp().trustedProxies(1)` and
26
+ * `.trustedProxies(1).byIp()` mean the same thing, which is the only defensible
27
+ * answer for a builder.
28
+ */
29
+ private _key: KeyStrategy = { kind: "default" };
18
30
 
19
31
  constructor(private readonly _name: string) {}
20
32
 
@@ -30,9 +42,33 @@ export class LimiterDefinition {
30
42
  return this;
31
43
  }
32
44
 
33
- /** Custom key resolver — defaults to client IP. */
45
+ /**
46
+ * Number of trusted reverse proxies in front of this server.
47
+ *
48
+ * Needed by every strategy that can fall back to the client address, which is all
49
+ * the built-in ones. Without it the address used is the socket's — the *proxy's*,
50
+ * behind a reverse proxy, so every visitor shares one bucket and the limiter
51
+ * inverts into the thing it was installed to prevent.
52
+ *
53
+ * @param count - How many proxies sit in front; see {@link ThrottleOptions.trustedProxies}.
54
+ *
55
+ * @example
56
+ * RateLimiter.for("login").limit(5).every(60).trustedProxies(1).register();
57
+ */
58
+ trustedProxies(count: number): this {
59
+ this._trustedProxies = count;
60
+ return this;
61
+ }
62
+
63
+ /**
64
+ * Custom key resolver — defaults to client IP.
65
+ *
66
+ * Whatever this returns is the bucket. If it derives from the client address,
67
+ * resolve that with `ctx.ip()` plus your own proxy handling, or prefer
68
+ * {@link byIp}, which honours {@link trustedProxies} for you.
69
+ */
34
70
  by(fn: (ctx: HttpContext) => string): this {
35
- this._keyFn = fn;
71
+ this._key = { kind: "custom", fn };
36
72
  return this;
37
73
  }
38
74
 
@@ -45,10 +81,7 @@ export class LimiterDefinition {
45
81
  * RateLimiter.for('api').limit(1000).every(3600).byUser().register();
46
82
  */
47
83
  byUser(): this {
48
- this._keyFn = (ctx) => {
49
- const user = (ctx as unknown as Record<string, unknown>).user as { id?: number } | undefined;
50
- return user?.id !== undefined ? `user:${user.id}` : `ip:${_resolveIp(ctx)}`;
51
- };
84
+ this._key = { kind: "user" };
52
85
  return this;
53
86
  }
54
87
 
@@ -60,10 +93,7 @@ export class LimiterDefinition {
60
93
  * RateLimiter.for('api').limit(500).every(60).byApiKey('x-api-key').register();
61
94
  */
62
95
  byApiKey(header = "x-api-key"): this {
63
- this._keyFn = (ctx) => {
64
- const key = ctx.header(header);
65
- return key ? `apikey:${key}` : `ip:${_resolveIp(ctx)}`;
66
- };
96
+ this._key = { kind: "apiKey", header };
67
97
  return this;
68
98
  }
69
99
 
@@ -73,7 +103,7 @@ export class LimiterDefinition {
73
103
  * through the fluent API.
74
104
  */
75
105
  byIp(): this {
76
- this._keyFn = (ctx) => `ip:${_resolveIp(ctx)}`;
106
+ this._key = { kind: "ip" };
77
107
  return this;
78
108
  }
79
109
 
@@ -97,7 +127,12 @@ export class LimiterDefinition {
97
127
  maxAttempts: this._max,
98
128
  windowSeconds: this._window,
99
129
  };
100
- if (this._keyFn) options.keyResolver = this._keyFn;
130
+ if (this._trustedProxies !== undefined) options.trustedProxies = this._trustedProxies;
131
+
132
+ // `default` deliberately sets no resolver: ThrottleMiddleware's own is already
133
+ // proxy-aware, and every resolver set here has to be too.
134
+ const resolver = _resolverFor(this._key, this._trustedProxies);
135
+ if (resolver) options.keyResolver = resolver;
101
136
  return ThrottleMiddleware.with(options);
102
137
  }
103
138
 
@@ -114,14 +149,62 @@ export class LimiterDefinition {
114
149
  }
115
150
  }
116
151
 
117
- /** Resolve the best available client IP from the context. */
118
- function _resolveIp(ctx: HttpContext): string {
119
- return (
120
- ctx.ip() ??
121
- ctx.header("x-forwarded-for")?.split(",")[0]?.trim() ??
122
- ctx.header("x-real-ip") ??
123
- "unknown"
124
- );
152
+ /** How a limiter decides which bucket a request belongs to. */
153
+ type KeyStrategy =
154
+ | { kind: "default" }
155
+ | { kind: "ip" }
156
+ | { kind: "user" }
157
+ | { kind: "apiKey"; header: string }
158
+ | { kind: "custom"; fn: (ctx: HttpContext) => string };
159
+
160
+ /**
161
+ * Build the key resolver for a strategy, or `undefined` to let `ThrottleMiddleware`
162
+ * use its own.
163
+ *
164
+ * Every branch that can end up keying on an address goes through `_clientIp` with the
165
+ * proxy count, and is marked so `zt doctor` still audits it. The previous versions
166
+ * used a private helper that read the socket address and fell back to the *leftmost*
167
+ * `X-Forwarded-For` entry with no proxy count at all — so behind a reverse proxy
168
+ * every visitor keyed on the proxy's own address and shared a single bucket. A named
169
+ * `login` limiter of five attempts a minute was five attempts a minute across the
170
+ * entire user base, and one attacker locked everybody out.
171
+ *
172
+ * @param strategy - What the builder recorded.
173
+ * @param trustedProxies - How many proxies sit in front, if the builder was told.
174
+ */
175
+ function _resolverFor(
176
+ strategy: KeyStrategy,
177
+ trustedProxies: number | undefined,
178
+ ): ((ctx: HttpContext) => string) | undefined {
179
+ switch (strategy.kind) {
180
+ case "default":
181
+ return undefined;
182
+
183
+ case "ip":
184
+ return _markIpDerived((ctx) => `ip:${_clientIp(ctx, trustedProxies)}`);
185
+
186
+ case "user":
187
+ // Marked, because the *unauthenticated* half of this keys on an address —
188
+ // which is the half a rate limiter on a login form exists for.
189
+ return _markIpDerived((ctx) => {
190
+ const user = (ctx as unknown as Record<string, unknown>).user as
191
+ { id?: number } | undefined;
192
+ return user?.id !== undefined ? `user:${user.id}` : `ip:${_clientIp(ctx, trustedProxies)}`;
193
+ });
194
+
195
+ case "apiKey": {
196
+ const { header } = strategy;
197
+ return _markIpDerived((ctx) => {
198
+ const key = ctx.header(header);
199
+ return key ? `apikey:${key}` : `ip:${_clientIp(ctx, trustedProxies)}`;
200
+ });
201
+ }
202
+
203
+ case "custom":
204
+ // Unmarked on purpose: an app-supplied resolver is the app's to reason about,
205
+ // and most of them key on something no proxy can affect.
206
+ return strategy.fn;
207
+ }
125
208
  }
126
209
 
127
210
  /**
@@ -14,48 +14,48 @@ export interface SecureHeadersOptions {
14
14
  * `Content-Security-Policy` header value.
15
15
  * Omitted by default — set this to a policy appropriate for your application.
16
16
  */
17
- contentSecurityPolicy?: string;
17
+ contentSecurityPolicy?: string | undefined;
18
18
 
19
19
  /**
20
20
  * `Strict-Transport-Security` max-age in seconds.
21
21
  * Defaults to 1 year (31 536 000 s). Set to `0` to disable HSTS entirely.
22
22
  * Only emitted when `secure: true` to avoid HSTS issues in plain-HTTP dev environments.
23
23
  */
24
- hstsMaxAge?: number;
24
+ hstsMaxAge?: number | undefined;
25
25
 
26
26
  /**
27
27
  * Include `includeSubDomains` in the HSTS header. Defaults to `true`.
28
28
  */
29
- hstsIncludeSubDomains?: boolean;
29
+ hstsIncludeSubDomains?: boolean | undefined;
30
30
 
31
31
  /**
32
32
  * Set the HSTS `preload` directive. Defaults to `false`.
33
33
  * Only set this if you have registered the domain with the HSTS preload list.
34
34
  */
35
- hstsPreload?: boolean;
35
+ hstsPreload?: boolean | undefined;
36
36
 
37
37
  /**
38
38
  * Enable HSTS and the `Secure` flag on the XSRF-TOKEN cookie.
39
39
  * Default `false` — set to `true` in production when serving over HTTPS.
40
40
  */
41
- secure?: boolean;
41
+ secure?: boolean | undefined;
42
42
 
43
43
  /**
44
44
  * `X-Frame-Options` value. Defaults to `'SAMEORIGIN'`.
45
45
  * Set to `'DENY'` for maximum protection, or `false` to omit the header.
46
46
  */
47
- frameOptions?: "DENY" | "SAMEORIGIN" | false;
47
+ frameOptions?: "DENY" | "SAMEORIGIN" | false | undefined;
48
48
 
49
49
  /**
50
50
  * `Referrer-Policy` value. Defaults to `'strict-origin-when-cross-origin'`.
51
51
  */
52
- referrerPolicy?: string;
52
+ referrerPolicy?: string | undefined;
53
53
 
54
54
  /**
55
55
  * `Permissions-Policy` header value.
56
56
  * Defaults to a conservative policy disabling sensitive APIs.
57
57
  */
58
- permissionsPolicy?: string | false;
58
+ permissionsPolicy?: string | false | undefined;
59
59
  }
60
60
 
61
61
  const DEFAULT_PERMISSIONS_POLICY = "camera=(), microphone=(), geolocation=(), payment=()";
@@ -14,12 +14,12 @@ export interface ThrottleOptions {
14
14
  /** Maximum number of requests allowed within the window. */
15
15
  maxAttempts: number;
16
16
  /** Time window in seconds. Defaults to 60. */
17
- windowSeconds?: number;
17
+ windowSeconds?: number | undefined;
18
18
  /**
19
19
  * Custom key resolver — defaults to client IP address.
20
20
  * Use this to rate-limit by user ID, API key, route, etc.
21
21
  */
22
- keyResolver?: (ctx: HttpContext) => string;
22
+ keyResolver?: ((ctx: HttpContext) => string) | undefined;
23
23
  /**
24
24
  * Number of trusted reverse proxies in front of this server.
25
25
  *
@@ -35,7 +35,7 @@ export interface ThrottleOptions {
35
35
  * // Behind one load balancer:
36
36
  * ThrottleMiddleware.with({ maxAttempts: 60, trustedProxies: 1 })
37
37
  */
38
- trustedProxies?: number;
38
+ trustedProxies?: number | undefined;
39
39
  }
40
40
 
41
41
  interface HitRecord {
@@ -226,7 +226,60 @@ export function _pruneStore(store: Map<string, HitRecord>, now: number): void {
226
226
  for (let i = 0; i < live.length - _MAX_KEYS; i++) store.delete(live[i]![0]);
227
227
  }
228
228
 
229
- function _clientIp(ctx: HttpContext, trustedProxies?: number): string {
229
+ /**
230
+ * A `keyResolver` that derives its key from the client's address, tagged so the
231
+ * doctor can tell it apart from one that does not.
232
+ *
233
+ * `zt doctor` exempts a throttle with a custom `keyResolver` from the
234
+ * `trustedProxies` check, and that is right for a limiter keyed on a user id or a
235
+ * tenant — no proxy can affect it. It is exactly wrong for one keyed on an IP,
236
+ * which is what `RateLimiter`'s `.byIp()`, `.byUser()` and `.byApiKey()` build.
237
+ * Unmarked, those slipped past the one check that exists to catch them.
238
+ *
239
+ * @internal
240
+ */
241
+ export const _IP_DERIVED_KEY = Symbol.for("zerotal.throttle.ipDerivedKey");
242
+
243
+ /** Shape of a resolver carrying the marker. @internal */
244
+ type MaybeMarked = { [_IP_DERIVED_KEY]?: boolean };
245
+
246
+ /**
247
+ * Tag `resolver` as deriving its key from the client address.
248
+ *
249
+ * @param resolver - The key resolver to mark.
250
+ * @returns The same function, marked.
251
+ * @internal
252
+ */
253
+ export function _markIpDerived<T extends (ctx: HttpContext) => string>(resolver: T): T {
254
+ (resolver as T & MaybeMarked)[_IP_DERIVED_KEY] = true;
255
+ return resolver;
256
+ }
257
+
258
+ /**
259
+ * Whether `resolver` keys on the client address, and so still needs `trustedProxies`.
260
+ *
261
+ * @param resolver - The key resolver to test, if there is one.
262
+ * @internal
263
+ */
264
+ export function _isIpDerived(resolver: unknown): boolean {
265
+ return typeof resolver === "function" && (resolver as MaybeMarked)[_IP_DERIVED_KEY] === true;
266
+ }
267
+
268
+ /**
269
+ * The client's address, honouring `trustedProxies`.
270
+ *
271
+ * Exported so `RateLimiter`'s built-in key resolvers use the same rule rather than
272
+ * their own. Theirs read the socket address and fell back to the *leftmost*
273
+ * `X-Forwarded-For` entry with no proxy count at all — so behind a reverse proxy
274
+ * every visitor keyed on the proxy's address and shared one bucket, and a named
275
+ * `login` limiter of five attempts a minute was five attempts a minute for the whole
276
+ * user base. One attacker locked everybody out.
277
+ *
278
+ * @param ctx - The request to resolve an address for.
279
+ * @param trustedProxies - How many proxies sit in front; see {@link ThrottleOptions.trustedProxies}.
280
+ * @internal
281
+ */
282
+ export function _clientIp(ctx: HttpContext, trustedProxies?: number): string {
230
283
  // Default (undefined) and an explicit 0 both mean "not behind a trusted proxy": read the raw
231
284
  // socket address, which cannot be spoofed.
232
285
  //
@@ -13,32 +13,32 @@ export interface WebhookOptions {
13
13
  /** HMAC secret used to verify the signature. */
14
14
  secret: string;
15
15
  /** Request header that carries the signature. Default: 'x-webhook-signature'. */
16
- header?: string;
16
+ header?: string | undefined;
17
17
  /** HMAC algorithm. Default: 'sha256'. */
18
- algorithm?: "sha256" | "sha1" | "sha512";
18
+ algorithm?: "sha256" | "sha1" | "sha512" | undefined;
19
19
  /**
20
20
  * Expected signature prefix that will be stripped before comparison
21
21
  * (e.g. `'sha256='` for GitHub). Default: `''`.
22
22
  */
23
- prefix?: string;
23
+ prefix?: string | undefined;
24
24
  /**
25
25
  * Max age in seconds for replay-attack protection.
26
26
  * Requires `timestampHeader` (or `format: 'stripe'`). Disabled when `undefined`.
27
27
  */
28
- tolerance?: number;
28
+ tolerance?: number | undefined;
29
29
  /**
30
30
  * Header that carries the Unix timestamp of the request.
31
31
  * When present the signed payload becomes `{timestamp}{timestampSeparator}{body}`.
32
32
  */
33
- timestampHeader?: string;
33
+ timestampHeader?: string | undefined;
34
34
  /** Separator between timestamp and body in the signed payload. Default: `'.'`. */
35
- timestampSeparator?: string;
35
+ timestampSeparator?: string | undefined;
36
36
  /**
37
37
  * Header format.
38
38
  * - `'raw'` (default) — signature is the full (or prefix-stripped) header value.
39
39
  * - `'stripe'` — header is parsed as `t=<ts>,v1=<sig>` (Stripe-Signature style).
40
40
  */
41
- format?: "raw" | "stripe";
41
+ format?: "raw" | "stripe" | undefined;
42
42
  }
43
43
 
44
44
  /**
@@ -85,7 +85,7 @@ export interface DispatchOptions {
85
85
  exceptionHandler?: ExceptionHandler | undefined;
86
86
  providerHooks?: ProviderHooks | undefined;
87
87
  /** Configure the freshly created context (params, route info) before dispatch. */
88
- configure?: (ctx: HttpContext) => void;
88
+ configure?: ((ctx: HttpContext) => void) | undefined;
89
89
  /**
90
90
  * Path-specific work: run the pipeline and produce the response (or leave it
91
91
  * on `ctx.response`). Errors thrown here are rendered by the exception handler.
@@ -95,7 +95,7 @@ export interface DispatchOptions {
95
95
  * Render an unhandled error to a Response. Defaults to reporting + rendering
96
96
  * through `exceptionHandler` when present, else `ExceptionHandler.defaultRender`.
97
97
  */
98
- renderError?: (error: unknown, ctx: HttpContext) => Promise<Response>;
98
+ renderError?: ((error: unknown, ctx: HttpContext) => Promise<Response>) | undefined;
99
99
  }
100
100
 
101
101
  /**
@@ -124,13 +124,13 @@ type CompiledRoutes = Record<string, Record<string, RouteHandlerFn> | Response>;
124
124
  /** Options for {@link Router.static}. */
125
125
  export interface StaticOptions {
126
126
  /** Extra response headers applied to every served file (e.g. Cache-Control). */
127
- headers?: Record<string, string>;
127
+ headers?: Record<string, string> | undefined;
128
128
  /**
129
129
  * When true (default) every file in the directory is pre-registered at
130
130
  * compile() time as a static `Response`, so Bun serves it without invoking
131
131
  * JS. Set false to fall back to the per-request lookup in Application.fetch.
132
132
  */
133
- eager?: boolean;
133
+ eager?: boolean | undefined;
134
134
  }
135
135
 
136
136
  type StaticDir = { prefix: string; rootDir: string; options?: StaticOptions | undefined };
@@ -143,7 +143,7 @@ type MarkdownDir = {
143
143
  /** Options for Router.group(). All fields are optional. */
144
144
  export interface GroupOptions {
145
145
  /** URL prefix applied to every route registered inside the group. */
146
- prefix?: string;
146
+ prefix?: string | undefined;
147
147
  /**
148
148
  * Middleware to prepend to every route inside the group.
149
149
  * Accepts a named group (string), an array of names/classes, or middleware class(es) directly.
@@ -152,9 +152,9 @@ export interface GroupOptions {
152
152
  * Router.group({ middleware: 'api' }, () => { ... });
153
153
  * Router.group({ middleware: ['web', AuthMiddleware] }, () => { ... });
154
154
  */
155
- middleware?: string | string[] | MiddlewareClass | MiddlewareClass[];
155
+ middleware?: string | string[] | MiddlewareClass | MiddlewareClass[] | undefined;
156
156
  /** Host pattern; routes inside the group only match this host (e.g. ':tenant.app.com'). */
157
- domain?: string;
157
+ domain?: string | undefined;
158
158
  }
159
159
 
160
160
  // ── Internal helpers ──────────────────────────────────────────────────────────
@@ -48,7 +48,7 @@ export interface RedactGraphOptions {
48
48
  * `JSON.stringify` drops the key it sits under, and a debugging tool that
49
49
  * silently omits a field is worse than one that says `‹fn›`.
50
50
  */
51
- flatten?: (value: unknown) => string | undefined;
51
+ flatten?: ((value: unknown) => string | undefined) | undefined;
52
52
  }
53
53
 
54
54
  /**
@@ -25,7 +25,7 @@ export interface FormatOptions {
25
25
  * and in a browser is the reader's, and those are not the same. Pass one
26
26
  * explicitly wherever the two sides must match, and they usually must.
27
27
  */
28
- locale?: string;
28
+ locale?: string | undefined;
29
29
  }
30
30
 
31
31
  /** How money should be written. */
@@ -37,9 +37,9 @@ export interface MoneyOptions extends FormatOptions {
37
37
  * database column that must not lose a cent stores it. Default `true`, because
38
38
  * an app that stores money in a float has a different problem.
39
39
  */
40
- minorUnits?: boolean;
40
+ minorUnits?: boolean | undefined;
41
41
  /** Digits after the decimal point. Defaults to whatever the currency uses. */
42
- fractionDigits?: number;
42
+ fractionDigits?: number | undefined;
43
43
  }
44
44
 
45
45
  /**
@@ -69,9 +69,9 @@ export function formatMoney(amount: number, options: MoneyOptions): string {
69
69
  /** How a plain number should be written. */
70
70
  export interface NumberOptions extends FormatOptions {
71
71
  /** Smallest number of digits after the decimal point. */
72
- minimumFractionDigits?: number;
72
+ minimumFractionDigits?: number | undefined;
73
73
  /** Largest number of digits after the decimal point. */
74
- maximumFractionDigits?: number;
74
+ maximumFractionDigits?: number | undefined;
75
75
  }
76
76
 
77
77
  /**
@@ -90,9 +90,9 @@ export function formatNumber(value: number, options: NumberOptions = {}): string
90
90
  /** How a date should be written. */
91
91
  export interface DateOptions extends FormatOptions {
92
92
  /** Length of the date part. Omit for none. */
93
- dateStyle?: "full" | "long" | "medium" | "short";
93
+ dateStyle?: "full" | "long" | "medium" | "short" | undefined;
94
94
  /** Length of the time part. Omit for none. */
95
- timeStyle?: "full" | "long" | "medium" | "short";
95
+ timeStyle?: "full" | "long" | "medium" | "short" | undefined;
96
96
  /**
97
97
  * IANA zone — `Africa/Johannesburg`, `UTC`.
98
98
  *
@@ -100,7 +100,7 @@ export interface DateOptions extends FormatOptions {
100
100
  * disagree about which day an 11pm booking happened on, and that is the shape
101
101
  * the bug takes: not a wrong time, a wrong date.
102
102
  */
103
- timeZone?: string;
103
+ timeZone?: string | undefined;
104
104
  }
105
105
 
106
106
  /**
@@ -16,9 +16,9 @@ interface Mount {
16
16
 
17
17
  export interface StorageFilesOptions {
18
18
  /** Disks to serve, in match order. Built from config by {@link StorageProvider}. */
19
- mounts?: Mount[];
19
+ mounts?: Mount[] | undefined;
20
20
  /** The manager the files are read through. */
21
- storage?: StorageManager;
21
+ storage?: StorageManager | undefined;
22
22
  }
23
23
 
24
24
  /**
@@ -1,6 +1,6 @@
1
1
  export interface PutOptions {
2
- contentType?: string;
3
- visibility?: "public" | "private";
2
+ contentType?: string | undefined;
3
+ visibility?: "public" | "private" | undefined;
4
4
  }
5
5
 
6
6
  export interface StorageDriver {
@@ -61,14 +61,14 @@ export interface DiskServeConfig {
61
61
  * only someone holding a link you signed can read a file, and only until it
62
62
  * expires.
63
63
  */
64
- signed?: boolean;
64
+ signed?: boolean | undefined;
65
65
  /**
66
66
  * How long a signed link stays valid, in seconds. Only meaningful with
67
67
  * `signed`. Defaults to 900 (15 minutes).
68
68
  */
69
- expiresIn?: number;
69
+ expiresIn?: number | undefined;
70
70
  /** Extra response headers, e.g. `Cache-Control`. */
71
- headers?: Record<string, string>;
71
+ headers?: Record<string, string> | undefined;
72
72
  }
73
73
 
74
74
  export interface LocalDiskConfig {
@@ -76,9 +76,9 @@ export interface LocalDiskConfig {
76
76
  /** Absolute or relative root directory for file storage. */
77
77
  root: string;
78
78
  /** Optional base URL for url() — e.g. '/storage' or 'https://cdn.example.com'. */
79
- url?: string;
79
+ url?: string | undefined;
80
80
  /** Expose this disk over HTTP. Omit to keep it unreachable. */
81
- serve?: DiskServeConfig;
81
+ serve?: DiskServeConfig | undefined;
82
82
  }
83
83
 
84
84
  export interface S3DiskConfig {
@@ -88,11 +88,11 @@ export interface S3DiskConfig {
88
88
  region: string;
89
89
  bucket: string;
90
90
  /** Override endpoint for R2, MinIO, etc. E.g. 'https://<account>.r2.cloudflarestorage.com' */
91
- endpoint?: string;
91
+ endpoint?: string | undefined;
92
92
  /** Custom public URL base (e.g. CDN or R2 public domain). */
93
- url?: string;
93
+ url?: string | undefined;
94
94
  /** Expose this disk over HTTP, proxied through the app. Omit to keep it unreachable. */
95
- serve?: DiskServeConfig;
95
+ serve?: DiskServeConfig | undefined;
96
96
  }
97
97
 
98
98
  export type DiskConfig = LocalDiskConfig | S3DiskConfig;
@@ -16,17 +16,17 @@ export interface CookieOptions {
16
16
  /** Cookie value (caller is responsible for encoding if needed). */
17
17
  value: string;
18
18
  /** `Path` attribute. Defaults to `/`. */
19
- path?: string;
19
+ path?: string | undefined;
20
20
  /** `Max-Age` in seconds. Omit for a session cookie. `0` clears the cookie. */
21
- maxAge?: number;
21
+ maxAge?: number | undefined;
22
22
  /** `SameSite` attribute. Defaults to `Lax`. */
23
- sameSite?: SameSite;
23
+ sameSite?: SameSite | undefined;
24
24
  /** Add the `HttpOnly` attribute. Defaults to `true` — pass `false` for cookies JS must read (e.g. XSRF-TOKEN). */
25
- httpOnly?: boolean;
25
+ httpOnly?: boolean | undefined;
26
26
  /** Add the `Secure` attribute. Defaults to `false`. */
27
- secure?: boolean;
27
+ secure?: boolean | undefined;
28
28
  /** `Domain` attribute. Omitted when absent. */
29
- domain?: string;
29
+ domain?: string | undefined;
30
30
  }
31
31
 
32
32
  /**