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.
@@ -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
- /** Remote peer address reported by Bun (not a forwarded header). */
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
+ }
@@ -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, clientIp?: string): Promise<Response> {
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
- response = await this.handle(request, clientIp);
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, clientIp?: string): Promise<Response> {
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, clientIp, () => this.healthResponse());
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, clientIp, () => docsResponse);
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, clientIp, () => preflightResponse(corsOptions, request));
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, clientIp, () =>
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, clientIp, () => this.errorJson(request, 400, "malformedPath"));
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, clientIp, () =>
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, clientIp, () => this.errorJson(request, 404, "notFound"));
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, clientIp, () =>
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, clientIp, () =>
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
- clientIp: string | undefined,
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: Bun's direct peer IP, falling back to a single shared
13
- * bucket when unavailable). Plug your own: user id, API key, etc.
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
- if (typeof options.trustProxy === "number" && (!Number.isSafeInteger(options.trustProxy) || options.trustProxy <= 0)) {
133
- throw new HttpSetupError("rateLimit trustProxy must be true, false or the number of trusted proxies (a positive integer).");
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` | the direct peer IP, else one shared bucket | the bucket key |
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 direct peer IP, when available |
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 |
@@ -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.119.0" }, capabilities: { experimentalApi: true },
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.119.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",