@zerotal/core 1.9.0 → 1.11.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/CHANGELOG.md +148 -0
- package/api-surface.md +151 -81
- package/package.json +2 -1
- package/src/application/Application.ts +4 -4
- package/src/build/codemod.ts +1 -1
- package/src/command/CommandRunner.ts +35 -4
- package/src/command/builtin/AssetsPruneCommand.ts +138 -0
- package/src/command/builtin/DeployCommand.ts +19 -0
- package/src/command/builtin/index.ts +1 -0
- package/src/command/startZerotal.ts +51 -8
- package/src/config/AppConfig.ts +10 -10
- package/src/conventions/ConventionLoader.ts +39 -2
- package/src/dev/BuildOutput.ts +58 -6
- package/src/dev/CssPlugins.ts +2 -2
- package/src/dev/DevDeck.ts +3 -3
- package/src/dev/DevProcess.ts +2 -2
- package/src/dev/DevSupervisor.ts +5 -5
- package/src/dev/index.ts +6 -1
- package/src/dev/startDevMode.ts +1 -1
- package/src/doctor/throttleIdentity.ts +6 -2
- package/src/health/Health.ts +5 -5
- package/src/helpers/index.ts +2 -1
- package/src/helpers/markdown.ts +7 -0
- package/src/http/UploadedFile.ts +2 -2
- package/src/index.ts +2 -1
- package/src/lock/LockManager.ts +4 -4
- package/src/logger/LoggerMiddleware.ts +1 -1
- package/src/logger/types.ts +4 -4
- package/src/middleware/CorsMiddleware.ts +6 -6
- package/src/middleware/RateLimiter.ts +105 -22
- package/src/middleware/SecureHeadersMiddleware.ts +8 -8
- package/src/middleware/ThrottleMiddleware.ts +57 -4
- package/src/middleware/WebhookMiddleware.ts +7 -7
- package/src/router/RouteHandler.ts +2 -2
- package/src/router/Router.ts +5 -5
- package/src/security/redactGraph.ts +1 -1
- package/src/shared/format.ts +8 -8
- package/src/storage/StorageFilesMiddleware.ts +2 -2
- package/src/storage/types.ts +10 -10
- package/src/support/cookie.ts +6 -6
- package/src/support/deepMerge.ts +49 -0
- package/src/support/runtime.ts +169 -4
package/src/health/Health.ts
CHANGED
|
@@ -149,20 +149,20 @@ export const Health = new HealthRegistry();
|
|
|
149
149
|
/** The health config, authored under the `health` key of `config/app.ts`. */
|
|
150
150
|
export interface HealthConfigShape {
|
|
151
151
|
/** Serve the endpoint. Default: on outside production, off in production. */
|
|
152
|
-
enabled?: boolean;
|
|
152
|
+
enabled?: boolean | undefined;
|
|
153
153
|
/** Route path. Default: `/health`. */
|
|
154
|
-
path?: string;
|
|
154
|
+
path?: string | undefined;
|
|
155
155
|
/** Shared secret. Required in production; supplied via `?key=` or `X-Health-Key`. */
|
|
156
|
-
secret?: string;
|
|
156
|
+
secret?: string | undefined;
|
|
157
157
|
/** Include per-check details. Set false for a bare `{ status }` body. Default: true. */
|
|
158
|
-
showDetails?: boolean;
|
|
158
|
+
showDetails?: boolean | undefined;
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
/** The health config after defaults and back-compat have been applied. */
|
|
162
162
|
export interface ResolvedHealthConfig {
|
|
163
163
|
enabled: boolean;
|
|
164
164
|
path: string;
|
|
165
|
-
secret?: string;
|
|
165
|
+
secret?: string | undefined;
|
|
166
166
|
showDetails: boolean;
|
|
167
167
|
}
|
|
168
168
|
|
package/src/helpers/index.ts
CHANGED
|
@@ -298,5 +298,6 @@ export { markdownPage, type BunMarkdownOptions } from "./markdown.ts";
|
|
|
298
298
|
// must not carry private copies.
|
|
299
299
|
export { escapeHtml } from "./html.ts";
|
|
300
300
|
export { pluralize, singularize, tableNameFor } from "../support/str.ts";
|
|
301
|
-
export { deepMerge } from "../support/deepMerge.ts";
|
|
301
|
+
export { deepMerge, definedOnly } from "../support/deepMerge.ts";
|
|
302
|
+
export type { Resolved } from "../support/deepMerge.ts";
|
|
302
303
|
export { parseCookieHeader } from "../support/cookie.ts";
|
package/src/helpers/markdown.ts
CHANGED
|
@@ -49,6 +49,13 @@ ${body}
|
|
|
49
49
|
* Options for `Bun.markdown.html()`. bun-types exposes the options type only inside
|
|
50
50
|
* its `markdown` namespace; this is the named, exported equivalent the framework uses.
|
|
51
51
|
*
|
|
52
|
+
* Its optional properties stay `?: T` rather than `?: T | undefined`, unlike the
|
|
53
|
+
* framework's own option shapes. This one is not an option shape, it is a *mirror* of
|
|
54
|
+
* somebody else's signature, and a mirror that is wider than what it reflects stops
|
|
55
|
+
* being assignable to it — the value built here is handed straight to
|
|
56
|
+
* `Bun.markdown.html()`.
|
|
57
|
+
*
|
|
58
|
+
* @exact-optional-ignore
|
|
52
59
|
* @internal
|
|
53
60
|
*/
|
|
54
61
|
export interface BunMarkdownOptions {
|
package/src/http/UploadedFile.ts
CHANGED
|
@@ -19,9 +19,9 @@ export type StorageDisk = Pick<StorageDriver, "url"> & {
|
|
|
19
19
|
*/
|
|
20
20
|
export interface FileValidationOptions {
|
|
21
21
|
/** Maximum allowed size in bytes. */
|
|
22
|
-
maxSize?: number;
|
|
22
|
+
maxSize?: number | undefined;
|
|
23
23
|
/** Allowed MIME types — e.g. `['image/jpeg', 'image/png', 'application/pdf']`. */
|
|
24
|
-
mimes?: string[];
|
|
24
|
+
mimes?: string[] | undefined;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/**
|
package/src/index.ts
CHANGED
|
@@ -162,7 +162,8 @@ export {
|
|
|
162
162
|
export { config } from "./helpers/config.ts";
|
|
163
163
|
export { pluralize, singularize, snakeCase, camelCase, tableNameFor } from "./support/str.ts";
|
|
164
164
|
export { deepMerge } from "./support/deepMerge.ts";
|
|
165
|
-
|
|
165
|
+
// `definedOnly` is a helper, not kernel surface — it lives on `@zerotal/core/helpers`.
|
|
166
|
+
export type { DeepPartial, Resolved } from "./support/deepMerge.ts";
|
|
166
167
|
// The type every class-keyed registry uses — a class rather than an instance.
|
|
167
168
|
export type { ClassRef } from "./support/classRef.ts";
|
|
168
169
|
export { safeEqual, sha256Hex, hmacHex } from "./support/crypto.ts";
|
package/src/lock/LockManager.ts
CHANGED
|
@@ -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
|
/**
|
package/src/logger/types.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/src/router/Router.ts
CHANGED
|
@@ -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
|
/**
|
package/src/shared/format.ts
CHANGED
|
@@ -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
|
/**
|