bazis 0.119.0 → 0.121.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/core/http/HttpContext/HttpContext.ts +6 -1
- package/core/http/HttpContext/clientAddress.ts +100 -0
- package/core/http/HttpServer.ts +27 -15
- package/core/http/Middleware/accessLog.ts +4 -0
- package/core/http/Middleware/rateLimit.ts +9 -29
- package/core/http/SPEC.md +9 -4
- package/core/http/options.ts +8 -0
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/library/validation/README.md +2 -1
- package/library/validation/RuleCompiler.ts +11 -2
- package/library/validation/RuleEngine.ts +29 -0
- package/library/validation/RuleSet.ts +59 -0
- package/library/validation/SPEC.md +28 -0
- package/library/validation/Validator.ts +13 -0
- package/library/validation/index.ts +1 -0
- package/package.json +1 -1
|
@@ -42,7 +42,10 @@ export class HttpContext {
|
|
|
42
42
|
readonly apiVersion?: string,
|
|
43
43
|
/** Optional maximum request body size enforced while reading body streams. */
|
|
44
44
|
private readonly maxBodyBytes?: number,
|
|
45
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* The client's address: the connection's, or with `http.trustedProxies`
|
|
47
|
+
* the address a trusted proxy forwarded in `X-Forwarded-For`.
|
|
48
|
+
*/
|
|
46
49
|
readonly clientIp?: string,
|
|
47
50
|
/** Validator captured by the owning HTTP server. */
|
|
48
51
|
readonly modelValidator?: ModelValidator,
|
|
@@ -52,6 +55,8 @@ export class HttpContext {
|
|
|
52
55
|
* `undefined` without `language`.
|
|
53
56
|
*/
|
|
54
57
|
readonly language?: string,
|
|
58
|
+
/** The address of the TCP connection itself (nginx behind a proxy), never a header. */
|
|
59
|
+
readonly peerIp?: string,
|
|
55
60
|
) {}
|
|
56
61
|
|
|
57
62
|
get method(): string {
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
import { HttpSetupError } from "../Errors/HttpError";
|
|
3
|
+
|
|
4
|
+
/** Addresses and subnets whose `X-Forwarded-For` is believed (`http.trustedProxies`). */
|
|
5
|
+
export interface TrustedProxies {
|
|
6
|
+
trusts(address: string): boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface Subnet {
|
|
10
|
+
readonly family: 4 | 6;
|
|
11
|
+
readonly base: bigint;
|
|
12
|
+
readonly mask: bigint;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Compiles `http.trustedProxies`: IP addresses (`127.0.0.1`, `::1`) and
|
|
17
|
+
* subnets (`10.0.0.0/8`, `fd00::/8`). An invalid entry stops the start.
|
|
18
|
+
*/
|
|
19
|
+
export function trustedProxies(entries: readonly string[]): TrustedProxies {
|
|
20
|
+
const subnets = entries.map((entry) => {
|
|
21
|
+
const subnet = parseSubnet(entry);
|
|
22
|
+
if (subnet === undefined) {
|
|
23
|
+
throw new HttpSetupError(`http.trustedProxies: ${JSON.stringify(entry)} is not an IP address or a subnet such as "10.0.0.0/8".`);
|
|
24
|
+
}
|
|
25
|
+
return subnet;
|
|
26
|
+
});
|
|
27
|
+
return Object.freeze({
|
|
28
|
+
trusts(address: string): boolean {
|
|
29
|
+
const parsed = parseAddress(address);
|
|
30
|
+
return parsed !== undefined && subnets.some((subnet) => subnet.family === parsed.family && (parsed.value & subnet.mask) === subnet.base);
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The client's address. A connection from a trusted proxy is resolved through
|
|
37
|
+
* `X-Forwarded-For` from the right: trusted hops are skipped and the first
|
|
38
|
+
* untrusted address is the client, so entries a client sent itself in front
|
|
39
|
+
* are never believed. A connection from any other address keeps that address:
|
|
40
|
+
* a client reaching the app directly cannot forge it. A malformed entry stops
|
|
41
|
+
* the walk at the last address known for sure.
|
|
42
|
+
*/
|
|
43
|
+
export function clientAddress(peer: string | undefined, forwardedFor: string | null | undefined, trusted: TrustedProxies | undefined): string | undefined {
|
|
44
|
+
if (peer === undefined) return undefined;
|
|
45
|
+
const direct = normalizeAddress(peer);
|
|
46
|
+
if (trusted === undefined || !trusted.trusts(direct) || !forwardedFor) return direct;
|
|
47
|
+
let current = direct;
|
|
48
|
+
const hops = forwardedFor.split(",").map((hop) => hop.trim());
|
|
49
|
+
for (let index = hops.length - 1; index >= 0; index -= 1) {
|
|
50
|
+
const hop = hops[index]!;
|
|
51
|
+
if (isIP(hop) === 0) return current;
|
|
52
|
+
current = normalizeAddress(hop);
|
|
53
|
+
if (!trusted.trusts(current)) return current;
|
|
54
|
+
}
|
|
55
|
+
return current;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** `::ffff:127.0.0.1` (IPv4 over a dual-stack socket) is `127.0.0.1`; IPv6 is lower-case. */
|
|
59
|
+
function normalizeAddress(address: string): string {
|
|
60
|
+
const mapped = address.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/iu);
|
|
61
|
+
return mapped ? mapped[1]! : address.toLowerCase();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseSubnet(entry: string): Subnet | undefined {
|
|
65
|
+
const [address, prefixText, extra] = entry.trim().split("/");
|
|
66
|
+
if (extra !== undefined || address === undefined) return undefined;
|
|
67
|
+
const parsed = parseAddress(address);
|
|
68
|
+
if (parsed === undefined) return undefined;
|
|
69
|
+
const bits = parsed.family === 4 ? 32 : 128;
|
|
70
|
+
const prefix = prefixText === undefined ? bits : /^\d+$/u.test(prefixText) ? Number(prefixText) : NaN;
|
|
71
|
+
if (!Number.isInteger(prefix) || prefix < 0 || prefix > bits) return undefined;
|
|
72
|
+
const mask = prefix === 0 ? 0n : ((1n << BigInt(prefix)) - 1n) << BigInt(bits - prefix);
|
|
73
|
+
return { family: parsed.family, base: parsed.value & mask, mask };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parseAddress(text: string): { readonly family: 4 | 6; readonly value: bigint } | undefined {
|
|
77
|
+
const address = normalizeAddress(text.trim());
|
|
78
|
+
const family = isIP(address);
|
|
79
|
+
if (family === 4) {
|
|
80
|
+
return { family: 4, value: address.split(".").reduce((value, part) => (value << 8n) | BigInt(Number(part)), 0n) };
|
|
81
|
+
}
|
|
82
|
+
if (family !== 6) return undefined;
|
|
83
|
+
const [head = "", tail] = address.split("::");
|
|
84
|
+
const groups = (part: string) => (part === "" ? [] : part.split(":"));
|
|
85
|
+
const left = groups(head);
|
|
86
|
+
const right = tail === undefined ? [] : groups(tail);
|
|
87
|
+
// An embedded IPv4 tail (::ffff:… already normalized away, but 64:ff9b::1.2.3.4 is valid).
|
|
88
|
+
const expand = (items: string[]) => items.flatMap((item) => (item.includes(".") ? ipv4Groups(item) : [item]));
|
|
89
|
+
const leftGroups = expand(left);
|
|
90
|
+
const rightGroups = expand(right);
|
|
91
|
+
const missing = 8 - leftGroups.length - rightGroups.length;
|
|
92
|
+
const all = tail === undefined ? leftGroups : [...leftGroups, ...Array.from({ length: missing }, () => "0"), ...rightGroups];
|
|
93
|
+
if (all.length !== 8) return undefined;
|
|
94
|
+
return { family: 6, value: all.reduce((value, group) => (value << 16n) | BigInt(parseInt(group, 16)), 0n) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function ipv4Groups(address: string): string[] {
|
|
98
|
+
const [a, b, c, d] = address.split(".").map(Number) as [number, number, number, number];
|
|
99
|
+
return [((a << 8) | b).toString(16), ((c << 8) | d).toString(16)];
|
|
100
|
+
}
|
package/core/http/HttpServer.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { HTTP_ERROR_HOOK, ROUTE_MIDDLEWARE_COMPOSER, SERVER_MIDDLEWARE, sortByOr
|
|
|
23
23
|
import type { HealthEndpointOptions, RouteMiddlewareComposer, HttpModuleOptions, HttpServerOptions } from "./options";
|
|
24
24
|
import { WEBSOCKET_UPGRADE, type WebSocketUpgrade } from "./WebSocketUpgrade";
|
|
25
25
|
import { HttpSetupError } from "./Errors/HttpError";
|
|
26
|
+
import { clientAddress, trustedProxies, type TrustedProxies } from "./HttpContext/clientAddress";
|
|
26
27
|
import { EN_HTTP_MESSAGES, formatMessage, languageHeaders, type HttpMessageKey, type MessageParams, type SelectedLanguage } from "./Errors/messages";
|
|
27
28
|
import { getModelValidator, type ModelValidator } from "./Binding/modelValidator";
|
|
28
29
|
|
|
@@ -43,6 +44,12 @@ function json(status: number, body: unknown, headers?: Record<string, string>):
|
|
|
43
44
|
});
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
/** The client's address (after trusted proxies) and the connection's own. */
|
|
48
|
+
interface ClientConnection {
|
|
49
|
+
readonly clientIp?: string;
|
|
50
|
+
readonly peerIp?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
46
53
|
/** HEAD responses must carry no body, only headers/status. */
|
|
47
54
|
function stripBody(response: Response): Response {
|
|
48
55
|
if (!response.body) return response;
|
|
@@ -82,6 +89,7 @@ export class HttpServer implements HostedService {
|
|
|
82
89
|
private globalCors: CorsOptions | undefined;
|
|
83
90
|
/** Body cap in bytes; `undefined` when the limit is disabled (`maxBodyBytes: 0`). */
|
|
84
91
|
private readonly maxBodyBytes?: number;
|
|
92
|
+
private readonly trustedProxies?: TrustedProxies;
|
|
85
93
|
private readonly requestScopeDisposeTimeoutMs?: number;
|
|
86
94
|
private readonly responseCompletions = new Set<Promise<void>>();
|
|
87
95
|
/** Absolute health-probe path, or `undefined` when disabled. */
|
|
@@ -112,6 +120,7 @@ export class HttpServer implements HostedService {
|
|
|
112
120
|
throw new HttpSetupError("inspectableRedirects must be a boolean.");
|
|
113
121
|
}
|
|
114
122
|
this.maxBodyBytes = options.maxBodyBytes === 0 ? undefined : (options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES);
|
|
123
|
+
this.trustedProxies = options.trustedProxies === undefined || options.trustedProxies.length === 0 ? undefined : trustedProxies(options.trustedProxies);
|
|
115
124
|
this.requestScopeDisposeTimeoutMs = options.requestScopeDisposeTimeoutMs === 0
|
|
116
125
|
? undefined
|
|
117
126
|
: options.requestScopeDisposeTimeoutMs ?? DEFAULT_REQUEST_SCOPE_DISPOSE_TIMEOUT_MS;
|
|
@@ -376,11 +385,12 @@ export class HttpServer implements HostedService {
|
|
|
376
385
|
}
|
|
377
386
|
}
|
|
378
387
|
|
|
379
|
-
private async dispatch(incoming: Request,
|
|
388
|
+
private async dispatch(incoming: Request, peerIp?: string): Promise<Response> {
|
|
380
389
|
const request = this.withAbsoluteUrl(incoming);
|
|
381
390
|
let response: Response;
|
|
382
391
|
try {
|
|
383
|
-
|
|
392
|
+
const client: ClientConnection = { clientIp: clientAddress(peerIp, request.headers.get("x-forwarded-for"), this.trustedProxies), peerIp };
|
|
393
|
+
response = await this.handle(request, client);
|
|
384
394
|
} catch (error) {
|
|
385
395
|
// Covers failures before a request context can be created. Normal route
|
|
386
396
|
// and short-circuit failures are handled by the configured error boundary.
|
|
@@ -440,34 +450,34 @@ export class HttpServer implements HostedService {
|
|
|
440
450
|
return new Request(new URL(request.url, `http://${host}:${server?.port ?? 80}`).href, request);
|
|
441
451
|
}
|
|
442
452
|
|
|
443
|
-
private async handle(request: Request,
|
|
453
|
+
private async handle(request: Request, client: ClientConnection): Promise<Response> {
|
|
444
454
|
const url = new URL(request.url);
|
|
445
455
|
|
|
446
456
|
if (this.healthPath !== undefined && url.pathname === this.healthPath && isReadMethod(request.method)) {
|
|
447
|
-
return this.runShortCircuit(request, url,
|
|
457
|
+
return this.runShortCircuit(request, url, client, () => this.healthResponse());
|
|
448
458
|
}
|
|
449
459
|
|
|
450
460
|
if (this.docs !== undefined) {
|
|
451
461
|
const docsResponse = serveOpenApiDocs(this.docs, url.pathname, request.method);
|
|
452
462
|
if (docsResponse !== undefined) {
|
|
453
|
-
return this.runShortCircuit(request, url,
|
|
463
|
+
return this.runShortCircuit(request, url, client, () => docsResponse);
|
|
454
464
|
}
|
|
455
465
|
}
|
|
456
466
|
|
|
457
467
|
if (isPreflight(request)) {
|
|
458
468
|
const corsOptions = this.globalCors ?? this.routeCors(request, url);
|
|
459
469
|
if (corsOptions !== undefined) {
|
|
460
|
-
return this.runShortCircuit(request, url,
|
|
470
|
+
return this.runShortCircuit(request, url, client, () => preflightResponse(corsOptions, request));
|
|
461
471
|
}
|
|
462
472
|
}
|
|
463
473
|
|
|
464
474
|
const segments = parseRequestPath(url.pathname);
|
|
465
475
|
if (!segments) {
|
|
466
476
|
if (this.maxBodyBytes !== undefined && exceedsBodyLimit(request, this.maxBodyBytes)) {
|
|
467
|
-
return this.runShortCircuit(request, url,
|
|
477
|
+
return this.runShortCircuit(request, url, client, () =>
|
|
468
478
|
this.errorJson(request, 413, "payloadTooLarge", { details: { maxBytes: this.maxBodyBytes } }));
|
|
469
479
|
}
|
|
470
|
-
return this.runShortCircuit(request, url,
|
|
480
|
+
return this.runShortCircuit(request, url, client, () => this.errorJson(request, 400, "malformedPath"));
|
|
471
481
|
}
|
|
472
482
|
|
|
473
483
|
const match = this.router!.match(request.method, segments, this.readVersion(request, url));
|
|
@@ -475,18 +485,18 @@ export class HttpServer implements HostedService {
|
|
|
475
485
|
? (match.action.maxBodyBytes ?? this.maxBodyBytes)
|
|
476
486
|
: this.maxBodyBytes;
|
|
477
487
|
if (effectiveMaxBodyBytes !== undefined && exceedsBodyLimit(request, effectiveMaxBodyBytes)) {
|
|
478
|
-
return this.runShortCircuit(request, url,
|
|
488
|
+
return this.runShortCircuit(request, url, client, () =>
|
|
479
489
|
this.errorJson(request, 413, "payloadTooLarge", { details: { maxBytes: effectiveMaxBodyBytes } }));
|
|
480
490
|
}
|
|
481
491
|
if (match.kind === "not-found") {
|
|
482
|
-
return this.runShortCircuit(request, url,
|
|
492
|
+
return this.runShortCircuit(request, url, client, () => this.errorJson(request, 404, "notFound"));
|
|
483
493
|
}
|
|
484
494
|
if (match.kind === "method-not-allowed") {
|
|
485
|
-
return this.runShortCircuit(request, url,
|
|
495
|
+
return this.runShortCircuit(request, url, client, () =>
|
|
486
496
|
this.errorJson(request, 405, "methodNotAllowed", {}, { allow: match.allow.join(", ") }));
|
|
487
497
|
}
|
|
488
498
|
if (match.kind === "unsupported-version") {
|
|
489
|
-
return this.runShortCircuit(request, url,
|
|
499
|
+
return this.runShortCircuit(request, url, client, () =>
|
|
490
500
|
this.errorJson(request, 400, "unsupportedApiVersion", { details: { supported: match.supported } }));
|
|
491
501
|
}
|
|
492
502
|
|
|
@@ -498,9 +508,10 @@ export class HttpServer implements HostedService {
|
|
|
498
508
|
scope,
|
|
499
509
|
match.action.version,
|
|
500
510
|
effectiveMaxBodyBytes,
|
|
501
|
-
clientIp,
|
|
511
|
+
client.clientIp,
|
|
502
512
|
this.modelValidator,
|
|
503
513
|
this.selected(request)?.language,
|
|
514
|
+
client.peerIp,
|
|
504
515
|
);
|
|
505
516
|
let scopeTransferred = false;
|
|
506
517
|
try {
|
|
@@ -532,7 +543,7 @@ export class HttpServer implements HostedService {
|
|
|
532
543
|
private async runShortCircuit(
|
|
533
544
|
request: Request,
|
|
534
545
|
url: URL,
|
|
535
|
-
|
|
546
|
+
client: ClientConnection,
|
|
536
547
|
produce: () => Response | Promise<Response>,
|
|
537
548
|
): Promise<Response> {
|
|
538
549
|
const scope = this.provider.createScope();
|
|
@@ -543,9 +554,10 @@ export class HttpServer implements HostedService {
|
|
|
543
554
|
scope,
|
|
544
555
|
undefined,
|
|
545
556
|
this.maxBodyBytes,
|
|
546
|
-
clientIp,
|
|
557
|
+
client.clientIp,
|
|
547
558
|
this.modelValidator,
|
|
548
559
|
this.selected(request)?.language,
|
|
560
|
+
client.peerIp,
|
|
549
561
|
);
|
|
550
562
|
const terminal: HttpMiddleware = async (context) => {
|
|
551
563
|
context.response = await produce();
|
|
@@ -11,6 +11,8 @@ export interface AccessLogEntry {
|
|
|
11
11
|
readonly durationMs: number;
|
|
12
12
|
/** Correlation id from {@link createCorrelationIdMiddleware}, if present. */
|
|
13
13
|
readonly requestId?: string;
|
|
14
|
+
/** `ctx.clientIp`: the client's address, through `http.trustedProxies`. */
|
|
15
|
+
readonly clientIp?: string;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
export interface AccessLogOptions {
|
|
@@ -42,6 +44,7 @@ function resolveSink(options: AccessLogOptions): (entry: AccessLogEntry) => void
|
|
|
42
44
|
status: entry.status,
|
|
43
45
|
durationMs: entry.durationMs,
|
|
44
46
|
...(entry.requestId !== undefined ? { requestId: entry.requestId } : {}),
|
|
47
|
+
...(entry.clientIp !== undefined ? { clientIp: entry.clientIp } : {}),
|
|
45
48
|
},
|
|
46
49
|
);
|
|
47
50
|
}
|
|
@@ -75,6 +78,7 @@ export function accessLog(options: AccessLogOptions = {}): HttpMiddleware {
|
|
|
75
78
|
status: ctx.response?.status ?? 0,
|
|
76
79
|
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
|
|
77
80
|
...(typeof requestId === "string" ? { requestId } : {}),
|
|
81
|
+
...(ctx.clientIp !== undefined ? { clientIp: ctx.clientIp } : {}),
|
|
78
82
|
});
|
|
79
83
|
}
|
|
80
84
|
} catch (error) {
|
|
@@ -9,19 +9,11 @@ export interface RateLimitOptions {
|
|
|
9
9
|
/** Max requests per key per window. */
|
|
10
10
|
max: number;
|
|
11
11
|
/**
|
|
12
|
-
* Request key (default:
|
|
13
|
-
*
|
|
12
|
+
* Request key (default: `ctx.clientIp`, the client's address behind
|
|
13
|
+
* `http.trustedProxies`, falling back to a single shared bucket when it is
|
|
14
|
+
* unavailable). Plug your own: user id, API key, etc.
|
|
14
15
|
*/
|
|
15
16
|
keyOf?: (ctx: HttpContext) => string;
|
|
16
|
-
/**
|
|
17
|
-
* Read the client address from `X-Forwarded-For` behind trusted proxies.
|
|
18
|
-
* `true` — one proxy: the last address, the one the proxy saw; a number —
|
|
19
|
-
* that many proxies: the address that many hops from the end. Earlier
|
|
20
|
-
* addresses are written by the client and are never used. Off by default.
|
|
21
|
-
*/
|
|
22
|
-
trustProxy?: boolean | number;
|
|
23
|
-
/** Forwarded client-address header used when `trustProxy` is set. */
|
|
24
|
-
proxyHeader?: string;
|
|
25
17
|
/** Hard cap for in-process buckets. New keys receive 429 when full. Default: 10,000. */
|
|
26
18
|
maxBuckets?: number;
|
|
27
19
|
/** Add `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` to responses. Default: true. */
|
|
@@ -46,18 +38,8 @@ export function rateLimit(options: RateLimitOptions): HttpMiddleware {
|
|
|
46
38
|
const buckets = new Map<string, Bucket>();
|
|
47
39
|
const maxBuckets = Math.floor(options.maxBuckets ?? 10_000);
|
|
48
40
|
let nextExpiry = Number.POSITIVE_INFINITY;
|
|
49
|
-
const proxyHeader = options.proxyHeader ?? "x-forwarded-for";
|
|
50
|
-
const trustedHops = options.trustProxy === true ? 1 : options.trustProxy || 0;
|
|
51
41
|
const withHeaders = options.headers !== false;
|
|
52
|
-
const keyOf = options.keyOf ?? ((ctx: HttpContext) =>
|
|
53
|
-
if (trustedHops > 0) {
|
|
54
|
-
const forwarded = forwardedClient(ctx.header(proxyHeader), trustedHops);
|
|
55
|
-
if (forwarded) {
|
|
56
|
-
return forwarded;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
return ctx.clientIp ?? "*";
|
|
60
|
-
});
|
|
42
|
+
const keyOf = options.keyOf ?? ((ctx: HttpContext) => ctx.clientIp ?? "*");
|
|
61
43
|
const limitHeaders = (remaining: number, resetSeconds: number): Record<string, string> => ({
|
|
62
44
|
"ratelimit-limit": String(options.max),
|
|
63
45
|
"ratelimit-remaining": String(remaining),
|
|
@@ -104,11 +86,6 @@ export function rateLimit(options: RateLimitOptions): HttpMiddleware {
|
|
|
104
86
|
* trusted proxy appends the address it saw, so entries before them are
|
|
105
87
|
* whatever the client sent.
|
|
106
88
|
*/
|
|
107
|
-
function forwardedClient(header: string | undefined, hops: number): string | undefined {
|
|
108
|
-
const addresses = header?.split(",").map((address) => address.trim()).filter((address) => address.length > 0) ?? [];
|
|
109
|
-
return addresses[Math.max(0, addresses.length - hops)];
|
|
110
|
-
}
|
|
111
|
-
|
|
112
89
|
function setIfAbsent(response: Response, headers: Record<string, string>): void {
|
|
113
90
|
try {
|
|
114
91
|
for (const [name, value] of Object.entries(headers)) {
|
|
@@ -129,8 +106,11 @@ function assertRateLimitOptions(options: RateLimitOptions): void {
|
|
|
129
106
|
if (options.maxBuckets !== undefined && (!Number.isSafeInteger(options.maxBuckets) || options.maxBuckets <= 0)) {
|
|
130
107
|
throw new HttpSetupError("rateLimit maxBuckets must be a positive integer.");
|
|
131
108
|
}
|
|
132
|
-
|
|
133
|
-
|
|
109
|
+
// Counting hops trusted a forged header whenever the app was reachable
|
|
110
|
+
// directly; the server now decides from the connection's own address.
|
|
111
|
+
const moved = (["trustProxy", "proxyHeader"] as const).find((name) => Object.hasOwn(options, name));
|
|
112
|
+
if (moved !== undefined) {
|
|
113
|
+
throw new HttpSetupError(`rateLimit ${moved} was removed: list the proxies in http.trustedProxies (for example ["127.0.0.1"] for nginx); rateLimit then keys by ctx.clientIp.`);
|
|
134
114
|
}
|
|
135
115
|
}
|
|
136
116
|
|
package/core/http/SPEC.md
CHANGED
|
@@ -444,6 +444,11 @@ line per request: `[http] GET /api/users 200 12.3ms <request-id>`.
|
|
|
444
444
|
| `logger` | `Logger` | the `LOGGER` from DI | Structured sink: `logger.info` per request |
|
|
445
445
|
| `skip` | `(ctx) => boolean` | — | Return `true` to skip a request (e.g. noisy probes) |
|
|
446
446
|
|
|
447
|
+
`AccessLogEntry`: `method`, `path`, `status`, `durationMs`, `requestId?` (from the
|
|
448
|
+
correlation middleware) and `clientIp?` (`ctx.clientIp`, the client's address behind
|
|
449
|
+
`http.trustedProxies`). The structured logger gets the same fields; the plain stdout
|
|
450
|
+
line does not print the address.
|
|
451
|
+
|
|
447
452
|
### `createCorrelationIdMiddleware(options?)`
|
|
448
453
|
|
|
449
454
|
Reads `x-request-id` or generates a UUID, stores it in `ctx.state`, echoes it on the
|
|
@@ -476,9 +481,7 @@ server fails at startup so it never opens credentialed allow-any-origin.
|
|
|
476
481
|
| --- | --- | --- | --- |
|
|
477
482
|
| `windowMs` | `number` | — | window size (ms) |
|
|
478
483
|
| `max` | `number` | — | max requests per key per window |
|
|
479
|
-
| `keyOf` | `(ctx) => string` |
|
|
480
|
-
| `trustProxy` | `boolean` | `false` | trust the first forwarded hop |
|
|
481
|
-
| `proxyHeader` | `string` | `x-forwarded-for` | the forwarded address header, only with `trustProxy` |
|
|
484
|
+
| `keyOf` | `(ctx) => string` | `ctx.clientIp`, else one shared bucket | the bucket key |
|
|
482
485
|
| `maxBuckets` | `number` | `10000` | hard cap of in-process buckets; new keys get 429 when full |
|
|
483
486
|
|
|
484
487
|
Exceeding the limit → `TooManyRequestsError` (429 + `Retry-After`).
|
|
@@ -505,7 +508,8 @@ Exceeding the limit → `TooManyRequestsError` (429 + `Retry-After`).
|
|
|
505
508
|
| `ctx.json()` | the JSON body (cached per request) |
|
|
506
509
|
| `ctx.text()`, `ctx.formData()` | alternative bodies |
|
|
507
510
|
| `ctx.apiVersion` | the API version when versioning is on |
|
|
508
|
-
| `ctx.clientIp` | the
|
|
511
|
+
| `ctx.clientIp` | the client's address: the connection's, or behind `http.trustedProxies` the one a trusted proxy forwarded in `X-Forwarded-For` |
|
|
512
|
+
| `ctx.peerIp` | the address of the connection itself (the proxy), never a header |
|
|
509
513
|
|
|
510
514
|
### Passing data between middleware
|
|
511
515
|
|
|
@@ -996,6 +1000,7 @@ inline is handy when the route options are already one object `{ code: 201, midd
|
|
|
996
1000
|
| `Middleware/securityHeaders.ts` | default security headers |
|
|
997
1001
|
| `Middleware/cors.ts` | CORS + preflight helpers |
|
|
998
1002
|
| `Middleware/rateLimit.ts` | the in-memory rate limiter |
|
|
1003
|
+
| `HttpContext/clientAddress.ts` | `http.trustedProxies`: the client's address from `X-Forwarded-For` of a trusted proxy |
|
|
999
1004
|
| `correlation/` | `createCorrelationIdMiddleware` |
|
|
1000
1005
|
| `middlewareTokens.ts` | `SERVER_MIDDLEWARE`, `ROUTE_MIDDLEWARE_COMPOSER`, `HTTP_ERROR_HOOK` |
|
|
1001
1006
|
| `Routing/RouterBuilder.ts` | builds the chain at startup |
|
package/core/http/options.ts
CHANGED
|
@@ -110,6 +110,14 @@ export interface HttpModuleOptions {
|
|
|
110
110
|
* Default: 1 MiB. `0` disables the framework check.
|
|
111
111
|
*/
|
|
112
112
|
readonly maxBodyBytes?: number;
|
|
113
|
+
/**
|
|
114
|
+
* Proxies whose `X-Forwarded-For` names the client: IP addresses and subnets,
|
|
115
|
+
* for example `["127.0.0.1", "10.0.0.0/8"]` for nginx. Then `ctx.clientIp`
|
|
116
|
+
* is the client's address; a connection from any other address keeps its
|
|
117
|
+
* own (the header cannot be forged by reaching the app directly), and
|
|
118
|
+
* `ctx.peerIp` is always the connection's address. Default: none.
|
|
119
|
+
*/
|
|
120
|
+
readonly trustedProxies?: readonly string[];
|
|
113
121
|
/** Per-connection idle timeout in seconds (Bun.serve, 0–255). Default: Bun's. */
|
|
114
122
|
readonly idleTimeoutSeconds?: number;
|
|
115
123
|
/** Maximum wait for request-scope disposal. Default 5000ms; `0` restores an unbounded wait. */
|
|
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
|
|
|
232
232
|
if (method === "account/updated") this.modelCache = undefined;
|
|
233
233
|
}, () => { this.clearLogin(); this.modelCache = undefined; });
|
|
234
234
|
const initialized = await client.request("initialize", {
|
|
235
|
-
clientInfo: { name: "bazis", title: "Bazis", version: "0.
|
|
235
|
+
clientInfo: { name: "bazis", title: "Bazis", version: "0.121.0" }, capabilities: { experimentalApi: true },
|
|
236
236
|
}, 15_000, signal);
|
|
237
237
|
if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
|
|
238
238
|
client.notify("initialized");
|
|
@@ -42,11 +42,12 @@ More scenarios (complex conditions, async, nested objects) are in
|
|
|
42
42
|
|
|
43
43
|
| File | Purpose |
|
|
44
44
|
|---|---|
|
|
45
|
-
| `Validator.ts` | public facade: the decorator + `validate`/`validateAsync`/`setDefaultMessages` |
|
|
45
|
+
| `Validator.ts` | public facade: the decorator + `validate`/`validateAsync`/`ruleSet`/`setDefaultMessages` |
|
|
46
46
|
| `ValidationError.ts` | one error: `property`, `value`, `message`, `code` |
|
|
47
47
|
| `ValidationResult.ts` | the outcome: `isValid`, `errors`, `getErrorsFor`, `hasErrorsFor` |
|
|
48
48
|
| `RuleEngine.ts` | the engine: one pass over the plan, sync/async, nested, cycle protection |
|
|
49
49
|
| `RuleCompiler.ts` | compiles class rules into a plan + WeakMap cache (RegExp, enum Set) |
|
|
50
|
+
| `RuleSet.ts` | rules as data without a class: `Validator.ruleSet({ field: options })` |
|
|
50
51
|
| `MessageRegistry.ts` | message templates, placeholders, priority local > global > built-in; built-in texts are English, `RU_VALIDATION_MESSAGES` is a Russian set for `setDefaults` and `runApp({ language: "ru" })` |
|
|
51
52
|
| `metadata.ts` | stores rules through `context.metadata` / `Symbol.metadata` |
|
|
52
53
|
| `types/ValidatorOptions.ts` | all decorator options |
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { rulesOf } from "./metadata";
|
|
1
|
+
import { rulesOf, type FieldRule } from "./metadata";
|
|
2
2
|
import type { ValidatorOptions } from "./types/ValidatorOptions";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -69,9 +69,18 @@ export class RuleCompiler {
|
|
|
69
69
|
if (!rules) {
|
|
70
70
|
return undefined;
|
|
71
71
|
}
|
|
72
|
+
const plan = RuleCompiler.compileRules(rules);
|
|
73
|
+
RuleCompiler.cache.set(ctor, plan);
|
|
74
|
+
return plan;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Compiles a rule list that is not tied to a class (see `RuleSet`). Not
|
|
79
|
+
* cached: the caller keeps the result for as long as the rules are valid.
|
|
80
|
+
*/
|
|
81
|
+
static compileRules(rules: readonly FieldRule[]): CompiledRule[] {
|
|
72
82
|
const plan = rules.map((rule) => RuleCompiler.compile(rule.property, rule.options));
|
|
73
83
|
RuleCompiler.assignNestedCarriers(plan);
|
|
74
|
-
RuleCompiler.cache.set(ctor, plan);
|
|
75
84
|
return plan;
|
|
76
85
|
}
|
|
77
86
|
|
|
@@ -49,6 +49,24 @@ export class RuleEngine {
|
|
|
49
49
|
return new ValidationResult(errors);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/** Synchronous validation of any object by a precompiled plan (see `RuleSet`). */
|
|
53
|
+
static validatePlan(plan: readonly CompiledRule[], value: object): ValidationResult {
|
|
54
|
+
const errors: ValidationError[] = [];
|
|
55
|
+
RuleEngine.run(plan, value, "", errors, undefined, undefined);
|
|
56
|
+
return new ValidationResult(errors);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Asynchronous validation of any object by a precompiled plan (see `RuleSet`). */
|
|
60
|
+
static async validatePlanAsync(plan: readonly CompiledRule[], value: object): Promise<ValidationResult> {
|
|
61
|
+
const errors: ValidationError[] = [];
|
|
62
|
+
const jobs: AsyncJob[] = [];
|
|
63
|
+
RuleEngine.run(plan, value, "", errors, jobs, undefined);
|
|
64
|
+
for (const job of jobs) {
|
|
65
|
+
await job();
|
|
66
|
+
}
|
|
67
|
+
return new ValidationResult(errors);
|
|
68
|
+
}
|
|
69
|
+
|
|
52
70
|
/**
|
|
53
71
|
* Asynchronous validation: all synchronous rules run immediately, async
|
|
54
72
|
* `custom` functions run sequentially after them (deterministic error order).
|
|
@@ -75,6 +93,17 @@ export class RuleEngine {
|
|
|
75
93
|
if (!plan) {
|
|
76
94
|
return;
|
|
77
95
|
}
|
|
96
|
+
RuleEngine.run(plan, instance, prefix, errors, jobs, seen);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private static run(
|
|
100
|
+
plan: readonly CompiledRule[],
|
|
101
|
+
instance: object,
|
|
102
|
+
prefix: string,
|
|
103
|
+
errors: ValidationError[],
|
|
104
|
+
jobs: AsyncJob[] | undefined,
|
|
105
|
+
seen: Set<object> | undefined,
|
|
106
|
+
): void {
|
|
78
107
|
for (const rule of plan) {
|
|
79
108
|
RuleEngine.applyRule(rule, instance, prefix, errors, jobs, seen);
|
|
80
109
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { RuleCompiler, type CompiledRule } from "./RuleCompiler";
|
|
2
|
+
import { RuleEngine } from "./RuleEngine";
|
|
3
|
+
import type { ValidationResult } from "./ValidationResult";
|
|
4
|
+
import type { FieldRule } from "./metadata";
|
|
5
|
+
import type { ValidatorOptions } from "./types/ValidatorOptions";
|
|
6
|
+
|
|
7
|
+
/** Field rules of a {@link RuleSet}: one options object or several, as several decorators on one field. */
|
|
8
|
+
export type FieldRules = Readonly<Record<string, ValidatorOptions | readonly ValidatorOptions[]>>;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Validation rules that are data, not a decorated class: for objects whose
|
|
12
|
+
* shape is known only at run time (rows of tables created by users, rules
|
|
13
|
+
* stored in a database). The same engine, options, codes and messages as
|
|
14
|
+
* `@Validator`; the rules are compiled once in the constructor.
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* const rules = Validator.ruleSet({
|
|
18
|
+
* title: { required: true, maxLength: 200 },
|
|
19
|
+
* amount: [{ type: "number", min: 0 }, { integer: true }],
|
|
20
|
+
* });
|
|
21
|
+
* rules.validate({ title: "", amount: -1.5 }).errors;
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* Only the named fields are read. A field name that exists on
|
|
25
|
+
* `Object.prototype` (`__proto__`, `constructor`, `toString`…) is rejected,
|
|
26
|
+
* so a plain object never yields an inherited value.
|
|
27
|
+
*/
|
|
28
|
+
export class RuleSet {
|
|
29
|
+
private readonly plan: readonly CompiledRule[];
|
|
30
|
+
|
|
31
|
+
constructor(rules: FieldRules) {
|
|
32
|
+
const list: FieldRule[] = [];
|
|
33
|
+
for (const property of Object.keys(rules)) {
|
|
34
|
+
if (property in Object.prototype) {
|
|
35
|
+
throw new TypeError(`RuleSet: field "${property}" is not allowed: it is a property of Object.prototype.`);
|
|
36
|
+
}
|
|
37
|
+
const options = rules[property]!;
|
|
38
|
+
for (const item of Array.isArray(options) ? options as readonly ValidatorOptions[] : [options as ValidatorOptions]) {
|
|
39
|
+
list.push({ property, options: item });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
this.plan = RuleCompiler.compileRules(list);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Field names that have rules. */
|
|
46
|
+
get fields(): readonly string[] {
|
|
47
|
+
return [...new Set(this.plan.map((rule) => rule.property))];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Synchronous validation; an async `custom` adds an `asyncCustomInSyncCall` error. */
|
|
51
|
+
validate(value: object): ValidationResult {
|
|
52
|
+
return RuleEngine.validatePlan(this.plan, value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Asynchronous validation: awaits async `custom` functions. */
|
|
56
|
+
validateAsync(value: object): Promise<ValidationResult> {
|
|
57
|
+
return RuleEngine.validatePlanAsync(this.plan, value);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -412,6 +412,34 @@ Validator.validate(new ChildDto()); // checks both id and extra
|
|
|
412
412
|
Validator.validate(new BaseDto()); // only id
|
|
413
413
|
```
|
|
414
414
|
|
|
415
|
+
### 4.13 Rules as data: `Validator.ruleSet`
|
|
416
|
+
|
|
417
|
+
When the fields are known only at run time (a table created by a user, rules
|
|
418
|
+
stored in a database), there is no class to decorate. `Validator.ruleSet`
|
|
419
|
+
takes the same options per field name and compiles them once:
|
|
420
|
+
|
|
421
|
+
```ts
|
|
422
|
+
const rules = Validator.ruleSet({
|
|
423
|
+
title: { required: true, maxLength: 200 },
|
|
424
|
+
amount: [{ type: "number", min: 0 }, { integer: true }], // like two decorators
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
rules.validate({ title: "", amount: -1.5 }).errors;
|
|
428
|
+
// [amount: min, amount: integer] — "" is a value, so required passes
|
|
429
|
+
await rules.validateAsync(row); // when a rule has an async custom
|
|
430
|
+
rules.fields; // ["title", "amount"]
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
- Codes, paths, `params` and messages are the same as with `@Validator`.
|
|
434
|
+
- The plan is compiled in the constructor (RegExp, enum Set) and not cached
|
|
435
|
+
globally: keep the `RuleSet` while the rules are valid and build a new one
|
|
436
|
+
when they change.
|
|
437
|
+
- Only the named fields are read. A field name that exists on
|
|
438
|
+
`Object.prototype` (`__proto__`, `constructor`, `toString`…) throws
|
|
439
|
+
`TypeError`, so a plain object never yields an inherited value.
|
|
440
|
+
- `nested: true` validates a nested value only if it is an instance of a
|
|
441
|
+
decorated class, as with decorators.
|
|
442
|
+
|
|
415
443
|
---
|
|
416
444
|
|
|
417
445
|
## 5. Result: `ValidationResult` and `ValidationError`
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { MessageRegistry } from "./MessageRegistry";
|
|
2
2
|
import { RuleEngine } from "./RuleEngine";
|
|
3
|
+
import { RuleSet, type FieldRules } from "./RuleSet";
|
|
3
4
|
import type { ValidationResult } from "./ValidationResult";
|
|
4
5
|
import { registerFieldRule } from "./metadata";
|
|
5
6
|
import type { ValidatorOptions } from "./types/ValidatorOptions";
|
|
@@ -74,6 +75,18 @@ export const Validator = Object.assign(
|
|
|
74
75
|
validateAsync(instance: object): Promise<ValidationResult> {
|
|
75
76
|
return RuleEngine.validateAsync(instance);
|
|
76
77
|
},
|
|
78
|
+
/**
|
|
79
|
+
* Rules as data, without a class: for objects whose fields are known only
|
|
80
|
+
* at run time. Compiled once; keep the result and reuse it.
|
|
81
|
+
*
|
|
82
|
+
* ```ts
|
|
83
|
+
* const rules = Validator.ruleSet({ title: { required: true, maxLength: 200 } });
|
|
84
|
+
* rules.validate(row).isValid;
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
ruleSet(rules: FieldRules): RuleSet {
|
|
88
|
+
return new RuleSet(rules);
|
|
89
|
+
},
|
|
77
90
|
|
|
78
91
|
/**
|
|
79
92
|
* Globally overrides the standard error messages.
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* methods `validate` / `validateAsync` / `setDefaultMessages`.
|
|
9
9
|
*/
|
|
10
10
|
export { Validator } from "./Validator";
|
|
11
|
+
export { RuleSet, type FieldRules } from "./RuleSet";
|
|
11
12
|
export { modelValidatorAdapter } from "./modelValidatorAdapter";
|
|
12
13
|
export { ValidationError } from "./ValidationError";
|
|
13
14
|
export { ValidationResult } from "./ValidationResult";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bazis",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.121.0",
|
|
4
4
|
"description": "Modular backend framework for Bun in TypeScript: DI wired by codegen, HTTP, a PostgreSQL ORM, JWT, WebSocket, agents and a CLI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Alexander Alenin",
|