bazis 0.107.0 → 0.108.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.
@@ -1,10 +1,19 @@
1
- import { EN_HTTP_MESSAGES, RU_HTTP_MESSAGES, formatMessage, type HttpMessageKey, type Localize } from "../http/Errors/messages";
1
+ import { EN_HTTP_MESSAGES, RU_HTTP_MESSAGES, formatMessage, type HttpMessageKey, type LanguageSelection, type Localize, type SelectedLanguage } from "../http/Errors/messages";
2
2
  import { KernelError } from "../kernel";
3
3
  import { EN_VALIDATION_MESSAGES, MessageRegistry, RU_VALIDATION_MESSAGES, type ValidationCode } from "../../library/validation";
4
4
 
5
5
  /** Language of the texts the framework writes into responses. */
6
6
  export type AppLanguage = "en" | "ru";
7
7
 
8
+ /**
9
+ * Several response languages: each request gets the best match of its
10
+ * `Accept-Language` among `supported`, or `default`.
11
+ */
12
+ export interface AppLanguageOptions {
13
+ readonly default: AppLanguage;
14
+ readonly supported: readonly AppLanguage[];
15
+ }
16
+
8
17
  /**
9
18
  * Own texts for framework messages, by key: HTTP texts (`notFound`,
10
19
  * `malformedJson`, …), validation codes (`required`, `minLength`, …),
@@ -42,3 +51,49 @@ export function createLocalize(language: AppLanguage = "en", messages: AppMessag
42
51
  return text === undefined ? undefined : formatMessage(text, params, template);
43
52
  };
44
53
  }
54
+
55
+ /**
56
+ * The response language of every request: one language, or the best match of
57
+ * `Accept-Language` (`ru-RU,ru;q=0.9,en;q=0.8`) among `supported`.
58
+ */
59
+ export function createLanguages(option: AppLanguage | AppLanguageOptions = "en", messages: AppMessages = {}): LanguageSelection {
60
+ if (typeof option === "string") {
61
+ const single: SelectedLanguage = { language: option, localize: createLocalize(option, messages) };
62
+ return { negotiated: false, select: () => single };
63
+ }
64
+ const supported = [...new Set(option.supported)];
65
+ if (supported.length === 0) throw new KernelError("runApp: language.supported needs at least one language.");
66
+ if (!supported.includes(option.default)) {
67
+ throw new KernelError(`runApp: language.default "${String(option.default)}" must be one of language.supported (${supported.join(", ")}).`);
68
+ }
69
+ const selected = new Map<string, SelectedLanguage>(supported.map((language) => [language, { language, localize: createLocalize(language, messages) }]));
70
+ const fallback = selected.get(option.default)!;
71
+ return {
72
+ negotiated: supported.length > 1,
73
+ select(acceptLanguage) {
74
+ const language = negotiateLanguage(acceptLanguage, supported);
75
+ return language === undefined ? fallback : selected.get(language)!;
76
+ },
77
+ };
78
+ }
79
+
80
+ /** The supported language with the highest `q` in `Accept-Language`; ties keep the header order. */
81
+ export function negotiateLanguage(header: string | null | undefined, supported: readonly string[]): string | undefined {
82
+ if (header === null || header === undefined || header.length > 1024) return undefined;
83
+ let best: { language: string; q: number } | undefined;
84
+ for (const part of header.split(",").slice(0, 32)) {
85
+ const [range = "", ...parameters] = part.trim().split(";");
86
+ const tag = range.trim().toLowerCase();
87
+ if (tag.length === 0) continue;
88
+ let q = 1;
89
+ for (const parameter of parameters) {
90
+ const [name, value] = parameter.trim().split("=");
91
+ if (name?.trim() === "q") q = Number(value);
92
+ }
93
+ if (!Number.isFinite(q) || q <= 0 || q > 1) continue;
94
+ const primary = tag === "*" ? undefined : tag.split("-")[0];
95
+ const language = primary === undefined ? undefined : supported.find((candidate) => candidate === primary);
96
+ if (language !== undefined && (best === undefined || q > best.q)) best = { language, q };
97
+ }
98
+ return best?.language;
99
+ }
@@ -13,6 +13,7 @@ import { missingCacheModuleCheck } from "../cache/http/missingCacheModuleCheck";
13
13
  import { httpModule, useModelValidator, type HttpModuleOptions, type ModelValidator } from "../http";
14
14
  import { grpcModule, type GrpcModuleOptions } from "../grpc";
15
15
  import { buildHttpOpenApiDocument } from "../http/OpenApi/openApiDocument";
16
+ import { defaultAuthorizeChecks } from "../http/Authorization/metadata";
16
17
  import { KernelBuilder, Bazis, KernelError, type KernelOptions, type ValidatableConfig } from "../kernel";
17
18
  import { websocketModule, type WebSocketOptions } from "../websocket";
18
19
  import {
@@ -20,7 +21,7 @@ import {
20
21
  type UiProfileV1,
21
22
  } from "../../library/ui";
22
23
  import { modelValidatorAdapter } from "../../library/validation";
23
- import { createLocalize, type AppLanguage, type AppMessages } from "./messages";
24
+ import { createLanguages, type AppLanguage, type AppLanguageOptions, type AppMessages } from "./messages";
24
25
  import { useCodexLanguage } from "../infra/connectors/codex/contracts";
25
26
  import {
26
27
  type RunAppUiOptions,
@@ -65,8 +66,16 @@ export interface RunAppOptions {
65
66
  * ```ts
66
67
  * await runApp(AppModule, { language: "ru", http: { port: 3000 } });
67
68
  * ```
69
+ *
70
+ * Several languages — each request gets the best match of its
71
+ * `Accept-Language`, or `default`; such responses carry `Content-Language`
72
+ * and `Vary: Accept-Language`, and `ctx.language` names the choice:
73
+ *
74
+ * ```ts
75
+ * await runApp(AppModule, { language: { default: "ru", supported: ["ru", "en"] } });
76
+ * ```
68
77
  */
69
- readonly language?: AppLanguage;
78
+ readonly language?: AppLanguage | AppLanguageOptions;
70
79
  /**
71
80
  * Own texts for single framework messages, on top of `language`:
72
81
  * `{ notFound: "Нет такой страницы", required: "Заполните {property}" }`.
@@ -204,6 +213,7 @@ function buildUiOpenApiDocument(
204
213
  controllers: collectModuleControllers([root]),
205
214
  globalPrefix: http.prefix,
206
215
  versioning: http.versioning,
216
+ authorize: defaultAuthorizeChecks(http.authorize),
207
217
  title: docs?.title ?? "Bazis UI profiles",
208
218
  version: docs?.version ?? "1.0.0",
209
219
  });
@@ -290,8 +300,8 @@ export async function runApp(root: BazisModuleRef, options: RunAppOptions = {}):
290
300
  */
291
301
  export function composeApp(root: BazisModuleRef, options: RunAppOptions = {}): BazisModuleRef {
292
302
  // Checked even without HTTP: an unknown language is a startup error.
293
- const localize = createLocalize(options.language, options.messages);
294
- useCodexLanguage(options.language ?? "en");
303
+ const languages = createLanguages(options.language, options.messages);
304
+ useCodexLanguage(typeof options.language === "object" ? options.language.default : options.language ?? "en");
295
305
  // Infrastructure and cache are global modules; add them to the graph next to the feature root.
296
306
  const globals: BazisModuleRef[] = [];
297
307
  if (options.infra !== undefined) {
@@ -329,7 +339,7 @@ export function composeApp(root: BazisModuleRef, options: RunAppOptions = {}): B
329
339
  options.http,
330
340
  );
331
341
  const httpRoot = httpModule({
332
- localize,
342
+ languages,
333
343
  ...options.http,
334
344
  validator,
335
345
  imports: [httpAppRoot],
@@ -2,7 +2,7 @@ import { JwtError } from "../../../library/jwt/errors";
2
2
  import { ForbiddenError, UnauthorizedError } from "../Errors/HttpError";
3
3
  import type { HttpMiddleware } from "../Middleware/types";
4
4
  import type { RouteMiddlewareComposer } from "../options";
5
- import { resolveAuthorizeMeta } from "./metadata";
5
+ import { routeAuthorizeChecks, type AuthorizeCheck } from "./metadata";
6
6
 
7
7
  /**
8
8
  * Built-in composer for `@Authorize`. The HTTP server puts it first among
@@ -12,14 +12,13 @@ import { resolveAuthorizeMeta } from "./metadata";
12
12
  * `UnauthorizedError` → `401`). A `JwtError` (missing, malformed, expired or
13
13
  * forged token) escaping a check is a 401 as well, not a server error.
14
14
  */
15
- export function createAuthorizeComposer(): RouteMiddlewareComposer {
15
+ export function createAuthorizeComposer(defaults: readonly AuthorizeCheck[] = []): RouteMiddlewareComposer {
16
16
  return (controllerClass, methodName) => {
17
- const meta = resolveAuthorizeMeta(controllerClass, methodName);
18
- if (meta.allowAnonymous || meta.authorize === undefined) {
17
+ const checks = routeAuthorizeChecks(controllerClass, methodName, defaults);
18
+ if (checks === undefined) {
19
19
  return [];
20
20
  }
21
21
 
22
- const { checks } = meta.authorize;
23
22
  const middleware: HttpMiddleware = async (ctx, next) => {
24
23
  for (const check of checks) {
25
24
  let allowed: boolean;
@@ -177,3 +177,20 @@ export function resolveAuthorizeMeta(ctor: object, methodName: string | symbol):
177
177
  ? { allowAnonymous: false }
178
178
  : { allowAnonymous: false, authorize: { checks } };
179
179
  }
180
+
181
+ /**
182
+ * The checks a route runs: the server's default checks (`runApp({ http:
183
+ * { authorize } })`) first, then the route's own `@Authorize`; each function
184
+ * once. `undefined` — the route is open: `@AllowAnonymous`, or no checks at all.
185
+ */
186
+ export function routeAuthorizeChecks(ctor: object, methodName: string | symbol, defaults: readonly AuthorizeCheck[] = []): readonly AuthorizeCheck[] | undefined {
187
+ const meta = resolveAuthorizeMeta(ctor, methodName);
188
+ if (meta.allowAnonymous) return undefined;
189
+ const checks = [...new Set([...defaults, ...(meta.authorize?.checks ?? [])])];
190
+ return checks.length === 0 ? undefined : checks;
191
+ }
192
+
193
+ /** `authorize` of the HTTP options as a list. */
194
+ export function defaultAuthorizeChecks(option: AuthorizeCheck | readonly AuthorizeCheck[] | undefined): readonly AuthorizeCheck[] {
195
+ return option === undefined ? [] : typeof option === "function" ? [option] : option;
196
+ }
@@ -8,6 +8,27 @@ export type MessageParams = Readonly<Record<string, unknown>>;
8
8
  */
9
9
  export type Localize = (key: string, params: MessageParams) => string | undefined;
10
10
 
11
+ /** The response language of one request and its texts. */
12
+ export interface SelectedLanguage {
13
+ readonly language: string;
14
+ readonly localize: Localize;
15
+ }
16
+
17
+ /**
18
+ * Picks the response language of a request. `negotiated`: several languages,
19
+ * chosen by `Accept-Language`; responses whose text the framework wrote then
20
+ * carry `Content-Language` and `Vary: Accept-Language`.
21
+ */
22
+ export interface LanguageSelection {
23
+ readonly negotiated: boolean;
24
+ select(acceptLanguage: string | null | undefined): SelectedLanguage;
25
+ }
26
+
27
+ /** Headers of a framework-written response in a negotiated language. */
28
+ export function languageHeaders(selection: LanguageSelection | undefined, selected: SelectedLanguage | undefined): [string, string][] {
29
+ return selection?.negotiated === true && selected !== undefined ? [["content-language", selected.language], ["vary", "Accept-Language"]] : [];
30
+ }
31
+
11
32
  /** A message key and parameters carried by an error, rendered at the response. */
12
33
  export interface LocalizedMessage {
13
34
  readonly key: string;
@@ -46,6 +46,12 @@ export class HttpContext {
46
46
  readonly clientIp?: string,
47
47
  /** Validator captured by the owning HTTP server. */
48
48
  readonly modelValidator?: ModelValidator,
49
+ /**
50
+ * Response language of this request: `runApp({ language })`, or the best
51
+ * match of `Accept-Language` when several languages are supported.
52
+ * `undefined` without `language`.
53
+ */
54
+ readonly language?: string,
49
55
  ) {}
50
56
 
51
57
  get method(): string {
@@ -15,6 +15,7 @@ import type { HttpMiddleware } from "./Middleware/types";
15
15
  import { prepareOpenApiDocs, serveOpenApiDocs } from "./OpenApi/openApiDocs";
16
16
  import type { PreparedOpenApiDocs } from "../../library/openapi";
17
17
  import { RouterBuilder } from "./Routing/RouterBuilder";
18
+ import { defaultAuthorizeChecks } from "./Authorization/metadata";
18
19
  import type { Router } from "./Routing/Router";
19
20
  import { parseRequestPath } from "./Routing/template";
20
21
  import { assertVersioningOptions, createVersionReader, type VersionReader } from "./Versioning/VersionReader";
@@ -22,7 +23,7 @@ import { HTTP_ERROR_HOOK, ROUTE_MIDDLEWARE_COMPOSER, SERVER_MIDDLEWARE, sortByOr
22
23
  import type { HealthEndpointOptions, RouteMiddlewareComposer, HttpModuleOptions, HttpServerOptions } from "./options";
23
24
  import { WEBSOCKET_UPGRADE, type WebSocketUpgrade } from "./WebSocketUpgrade";
24
25
  import { HttpSetupError } from "./Errors/HttpError";
25
- import { EN_HTTP_MESSAGES, formatMessage, type HttpMessageKey, type MessageParams } from "./Errors/messages";
26
+ import { EN_HTTP_MESSAGES, formatMessage, languageHeaders, type HttpMessageKey, type MessageParams, type SelectedLanguage } from "./Errors/messages";
26
27
  import { getModelValidator, type ModelValidator } from "./Binding/modelValidator";
27
28
 
28
29
  const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
@@ -167,7 +168,7 @@ export class HttpServer implements HostedService {
167
168
  errorHandler({
168
169
  exposeDetails,
169
170
  ...(errorLogger ? { logger: errorLogger } : {}),
170
- ...(this.options.localize ? { localize: this.options.localize } : {}),
171
+ ...(this.options.languages ? { languages: this.options.languages } : {}),
171
172
  ...this.options.errorHandler,
172
173
  onUnexpectedError: this.collectErrorHook(),
173
174
  }),
@@ -177,11 +178,13 @@ export class HttpServer implements HostedService {
177
178
  this.globalCors = this.options.cors || serverMiddleware.map(corsOptionsOf).find((options) => options !== undefined);
178
179
  serverChain.push(...serverMiddleware.filter((middleware) => !isCorrelationIdMiddleware(middleware)));
179
180
 
181
+ const defaultChecks = defaultAuthorizeChecks(this.options.authorize);
180
182
  const builder = new RouterBuilder(
181
183
  serverChain,
182
184
  this.options.prefix,
183
185
  this.options.versioning,
184
186
  this.collectRouteComposer(),
187
+ defaultChecks,
185
188
  );
186
189
  this.router = builder.build(this.options.controllers);
187
190
  const routes = builder.routes;
@@ -192,6 +195,7 @@ export class HttpServer implements HostedService {
192
195
  globalPrefix: this.options.prefix,
193
196
  versioning: this.options.versioning,
194
197
  environment: this.resolver.tryResolve(Environment),
198
+ authorize: defaultChecks,
195
199
  });
196
200
 
197
201
  this.webSocket = this.resolver.tryResolve(WEBSOCKET_UPGRADE);
@@ -204,7 +208,7 @@ export class HttpServer implements HostedService {
204
208
  await ws.initialize();
205
209
  this.server = await this.startBunServer((listen) => Bun.serve({
206
210
  ...listen,
207
- error: (error) => this.transportError(error),
211
+ error: (error) => this.transportError(error, undefined),
208
212
  websocket: ws.createBunHandler(),
209
213
  fetch: async (request, server) => {
210
214
  // null -> not for the WS runtime, fall through to HTTP.
@@ -230,7 +234,7 @@ export class HttpServer implements HostedService {
230
234
  Bun.serve({
231
235
  ...listen,
232
236
  fetch: (request, server) => this.dispatch(request, server.requestIP(request)?.address),
233
- error: (error) => this.transportError(error),
237
+ error: (error) => this.transportError(error, undefined),
234
238
  }),
235
239
  );
236
240
  }
@@ -380,29 +384,44 @@ export class HttpServer implements HostedService {
380
384
  } catch (error) {
381
385
  // Covers failures before a request context can be created. Normal route
382
386
  // and short-circuit failures are handled by the configured error boundary.
383
- response = this.prepareClientResponse(this.transportError(error), request);
387
+ response = this.prepareClientResponse(this.transportError(error, request), request);
384
388
  }
385
389
  // HEAD must echo the GET response's status/headers without a body.
386
390
  return request.method === "HEAD" ? stripBody(response) : response;
387
391
  }
388
392
 
389
- /** A response text the server writes itself, in the application language. */
390
- private text(key: HttpMessageKey, params: MessageParams = {}): string {
393
+ /** The response language of a request; `undefined` without `runApp({ language })`. */
394
+ private selected(request: Request | undefined): SelectedLanguage | undefined {
395
+ try {
396
+ return this.options.languages?.select(request?.headers.get("accept-language"));
397
+ } catch {
398
+ return undefined;
399
+ }
400
+ }
401
+
402
+ /** A response text the server writes itself, in the language of the request. */
403
+ private text(key: HttpMessageKey, request: Request | undefined, params: MessageParams = {}): string {
391
404
  const english = formatMessage(EN_HTTP_MESSAGES[key], params);
392
405
  try {
393
- return this.options.localize?.(key, params) ?? english;
406
+ return this.selected(request)?.localize(key, params) ?? english;
394
407
  } catch {
395
408
  return english;
396
409
  }
397
410
  }
398
411
 
399
- private transportError(error: unknown): Response {
412
+ /** `{ error, …extra }` the server writes itself, with the language headers when negotiated. */
413
+ private errorJson(request: Request | undefined, status: number, key: HttpMessageKey, extra: Record<string, unknown> = {}, headers: Record<string, string> = {}): Response {
414
+ const language = Object.fromEntries(languageHeaders(this.options.languages, this.selected(request)));
415
+ return json(status, { error: this.text(key, request), ...extra }, { ...headers, ...language });
416
+ }
417
+
418
+ private transportError(error: unknown, request: Request | undefined): Response {
400
419
  try {
401
420
  console.error("[http] transport failed:", redactSensitive(error));
402
421
  } catch {
403
422
  // Best-effort diagnostics only.
404
423
  }
405
- const response = json(500, { error: this.text("internalServerError") });
424
+ const response = this.errorJson(request, 500, "internalServerError");
406
425
  if (this.options.securityHeaders !== false) {
407
426
  applySecurityHeaders(response, this.options.securityHeaders ?? {});
408
427
  }
@@ -446,9 +465,9 @@ export class HttpServer implements HostedService {
446
465
  if (!segments) {
447
466
  if (this.maxBodyBytes !== undefined && exceedsBodyLimit(request, this.maxBodyBytes)) {
448
467
  return this.runShortCircuit(request, url, clientIp, () =>
449
- json(413, { error: this.text("payloadTooLarge"), details: { maxBytes: this.maxBodyBytes } }));
468
+ this.errorJson(request, 413, "payloadTooLarge", { details: { maxBytes: this.maxBodyBytes } }));
450
469
  }
451
- return this.runShortCircuit(request, url, clientIp, () => json(400, { error: this.text("malformedPath") }));
470
+ return this.runShortCircuit(request, url, clientIp, () => this.errorJson(request, 400, "malformedPath"));
452
471
  }
453
472
 
454
473
  const match = this.router!.match(request.method, segments, this.readVersion(request, url));
@@ -457,18 +476,18 @@ export class HttpServer implements HostedService {
457
476
  : this.maxBodyBytes;
458
477
  if (effectiveMaxBodyBytes !== undefined && exceedsBodyLimit(request, effectiveMaxBodyBytes)) {
459
478
  return this.runShortCircuit(request, url, clientIp, () =>
460
- json(413, { error: this.text("payloadTooLarge"), details: { maxBytes: effectiveMaxBodyBytes } }));
479
+ this.errorJson(request, 413, "payloadTooLarge", { details: { maxBytes: effectiveMaxBodyBytes } }));
461
480
  }
462
481
  if (match.kind === "not-found") {
463
- return this.runShortCircuit(request, url, clientIp, () => json(404, { error: this.text("notFound") }));
482
+ return this.runShortCircuit(request, url, clientIp, () => this.errorJson(request, 404, "notFound"));
464
483
  }
465
484
  if (match.kind === "method-not-allowed") {
466
485
  return this.runShortCircuit(request, url, clientIp, () =>
467
- json(405, { error: this.text("methodNotAllowed") }, { allow: match.allow.join(", ") }));
486
+ this.errorJson(request, 405, "methodNotAllowed", {}, { allow: match.allow.join(", ") }));
468
487
  }
469
488
  if (match.kind === "unsupported-version") {
470
489
  return this.runShortCircuit(request, url, clientIp, () =>
471
- json(400, { error: this.text("unsupportedApiVersion"), details: { supported: match.supported } }));
490
+ this.errorJson(request, 400, "unsupportedApiVersion", { details: { supported: match.supported } }));
472
491
  }
473
492
 
474
493
  const scope = this.provider.createScope();
@@ -481,13 +500,14 @@ export class HttpServer implements HostedService {
481
500
  effectiveMaxBodyBytes,
482
501
  clientIp,
483
502
  this.modelValidator,
503
+ this.selected(request)?.language,
484
504
  );
485
505
  let scopeTransferred = false;
486
506
  try {
487
507
  await runPipeline(match.action.chain, ctx);
488
508
  // The error boundary always sets a response on failure; an empty
489
509
  // response here means no middleware produced one.
490
- const response = this.prepareClientResponse(ctx.response ?? json(404, { error: this.text("notFound") }), request);
510
+ const response = this.prepareClientResponse(ctx.response ?? this.errorJson(request, 404, "notFound"), request);
491
511
  if (request.method === "HEAD") return stripBody(response);
492
512
  if (!response.body) return response;
493
513
  this.trackResponse(holdResponseScope(response, request.signal, () =>
@@ -525,6 +545,7 @@ export class HttpServer implements HostedService {
525
545
  this.maxBodyBytes,
526
546
  clientIp,
527
547
  this.modelValidator,
548
+ this.selected(request)?.language,
528
549
  );
529
550
  const terminal: HttpMiddleware = async (context) => {
530
551
  context.response = await produce();
@@ -532,7 +553,7 @@ export class HttpServer implements HostedService {
532
553
  let scopeTransferred = false;
533
554
  try {
534
555
  await runPipeline([...this.shortCircuitChain, terminal], ctx);
535
- const response = this.prepareClientResponse(ctx.response ?? json(500, { error: this.text("internalServerError") }), request);
556
+ const response = this.prepareClientResponse(ctx.response ?? this.errorJson(request, 500, "internalServerError"), request);
536
557
  if (request.method === "HEAD") return stripBody(response);
537
558
  if (!response.body) return response;
538
559
  this.trackResponse(holdResponseScope(response, request.signal, () =>
@@ -3,7 +3,7 @@ import { redactSensitive, redactSensitiveText } from "../../../library/redaction
3
3
  import { HttpClientError, HttpErrorCode } from "../../../library/http-client/errors";
4
4
  import type { HttpContext } from "../HttpContext/HttpContext";
5
5
  import { HttpError, ModelValidationError } from "../Errors/HttpError";
6
- import { EN_HTTP_MESSAGES, STATUS_MESSAGE_KEYS, type HttpMessageKey, type Localize } from "../Errors/messages";
6
+ import { EN_HTTP_MESSAGES, STATUS_MESSAGE_KEYS, type HttpMessageKey, languageHeaders, type LanguageSelection, type Localize } from "../Errors/messages";
7
7
  import { upstreamFailure } from "../Errors/upstreamFailure";
8
8
  import type { HttpMiddleware } from "./types";
9
9
 
@@ -36,11 +36,11 @@ export interface ErrorHandlerOptions {
36
36
  logger?: Logger;
37
37
  /**
38
38
  * Renders framework texts (`Not Found`, validation messages, …) in the
39
- * application language; `runApp` builds it from `language` and `messages`.
40
- * Without it the texts stay English. Messages an application passes to an
41
- * error itself are never changed.
39
+ * response language of each request; `runApp` builds it from `language` and
40
+ * `messages`. Without it the texts stay English. Messages an application
41
+ * passes to an error itself are never changed.
42
42
  */
43
- localize?: Localize;
43
+ languages?: LanguageSelection;
44
44
  }
45
45
 
46
46
  function jsonResponse(status: number, body: unknown, extraHeaders?: readonly [string, string][]): Response {
@@ -111,13 +111,15 @@ function safelyReportUnexpected(
111
111
  */
112
112
  export function errorHandler(options: ErrorHandlerOptions = {}): HttpMiddleware {
113
113
  const log = resolveErrorLog(options);
114
- const text = textRenderer(options.localize);
115
114
  return async (ctx, next) => {
116
115
  try {
117
116
  await next();
118
117
  } catch (error) {
118
+ const selected = options.languages?.select(ctx.header("accept-language"));
119
+ const text = textRenderer(selected?.localize);
120
+ const language = languageHeaders(options.languages, selected);
119
121
  if (error instanceof HttpError) {
120
- const extra: [string, string][] = [];
122
+ const extra: [string, string][] = [...language];
121
123
  const allow = (error as { allow?: readonly string[] }).allow;
122
124
  if (allow) {
123
125
  extra.push(["allow", allow.join(", ")]);
@@ -159,8 +161,8 @@ export function errorHandler(options: ErrorHandlerOptions = {}): HttpMiddleware
159
161
  // message must not reach a browser or a shared screenshot.
160
162
  message: redactSensitiveText(error instanceof Error ? error.message : String(error)),
161
163
  stack: error instanceof Error && error.stack !== undefined ? redactSensitiveText(error.stack) : undefined,
162
- })
163
- : safeJsonResponse(status, { error: title });
164
+ }, language)
165
+ : safeJsonResponse(status, { error: title }, language);
164
166
  }
165
167
  };
166
168
  }
@@ -1,3 +1,4 @@
1
+ import type { AuthorizeCheck } from "../Authorization/metadata";
1
2
  import type { Class } from "../../di";
2
3
  import { Environment } from "../../kernel";
3
4
  import {
@@ -15,6 +16,7 @@ interface PrepareOpenApiDocsInput {
15
16
  readonly globalPrefix?: string;
16
17
  readonly versioning?: ApiVersioningOptions;
17
18
  readonly environment?: Environment;
19
+ readonly authorize?: readonly AuthorizeCheck[];
18
20
  }
19
21
 
20
22
  const DEFAULT_UI_PATH = "/docs";
@@ -38,6 +40,7 @@ export function prepareOpenApiDocs(input: PrepareOpenApiDocsInput): PreparedOpen
38
40
  versioning: input.versioning,
39
41
  title,
40
42
  version,
43
+ ...(input.authorize === undefined ? {} : { authorize: input.authorize }),
41
44
  });
42
45
  const json = JSON.stringify(document, null, 2);
43
46
  return {
@@ -8,7 +8,7 @@ import {
8
8
  type OpenApiSchema,
9
9
  } from "../../../library/openapi";
10
10
  import { optionsFromSchema } from "../../../library/jsonapi";
11
- import { resolveAuthorizeMeta } from "../Authorization/metadata";
11
+ import { resolveAuthorizeMeta, routeAuthorizeChecks, type AuthorizeCheck } from "../Authorization/metadata";
12
12
  import type { ParameterBinding } from "../Binding/bindings";
13
13
  import { resolveGeneratedBindings } from "../Binding/autoBindings";
14
14
  import { controllerMetaOf, type ActionMeta, type ControllerMeta } from "../Decorators/metadata";
@@ -23,6 +23,8 @@ export interface HttpOpenApiDocumentBuildInput {
23
23
  readonly versioning?: ApiVersioningOptions;
24
24
  readonly title: string;
25
25
  readonly version: string;
26
+ /** Default checks of `runApp({ http: { authorize } })`: such routes are secured too. */
27
+ readonly authorize?: readonly AuthorizeCheck[];
26
28
  }
27
29
 
28
30
  /** Body of every error response: `{ "error": "...", "details": ... }` (see Middleware/errorHandler). */
@@ -80,6 +82,7 @@ function collectOpenApiOperations(
80
82
  routeTemplate: joinPaths(meta.prefix, route.template),
81
83
  versioning: input.versioning,
82
84
  generated,
85
+ defaultChecks: input.authorize ?? [],
83
86
  }));
84
87
  }
85
88
  }
@@ -98,6 +101,7 @@ interface CreateOperationInput {
98
101
  readonly routeTemplate: string;
99
102
  readonly versioning?: ApiVersioningOptions;
100
103
  readonly generated: GeneratedOpenApiMetadata;
104
+ readonly defaultChecks: readonly AuthorizeCheck[];
101
105
  }
102
106
 
103
107
  function createOperation(input: CreateOperationInput): OpenApiCatalogOperation {
@@ -108,7 +112,7 @@ function createOperation(input: CreateOperationInput): OpenApiCatalogOperation {
108
112
  const status = String(successStatus(input.httpMethod, input.action, generatedOperation?.status));
109
113
  const responseSchema = generatedOperation?.response;
110
114
  const contentType = input.action.produces ?? "application/json";
111
- const authorized = !auth.allowAnonymous && auth.authorize !== undefined;
115
+ const authorized = routeAuthorizeChecks(input.controllerClass, input.methodName, input.defaultChecks) !== undefined;
112
116
 
113
117
  return {
114
118
  path: input.path,
@@ -10,7 +10,7 @@ import { corsOptionsOf } from "../Middleware/cors";
10
10
  import { normalizeResult } from "../Results/normalize";
11
11
  import type { ApiVersioningOptions, RouteMiddlewareComposer } from "../options";
12
12
  import { createAuthorizeComposer } from "../Authorization/authorizeComposer";
13
- import { resolveAuthorizeMeta } from "../Authorization/metadata";
13
+ import { resolveAuthorizeMeta, routeAuthorizeChecks, type AuthorizeCheck } from "../Authorization/metadata";
14
14
 
15
15
  /** A registered route as the startup screen lists it. */
16
16
  export interface RegisteredRoute {
@@ -39,7 +39,7 @@ interface ActionPipeline {
39
39
  * precompiled per route — request dispatch performs no metadata work at all.
40
40
  */
41
41
  export class RouterBuilder {
42
- private readonly authorizeComposer = createAuthorizeComposer();
42
+ private readonly authorizeComposer: RouteMiddlewareComposer;
43
43
  /** Every route of the last {@link build}, in registration order. */
44
44
  readonly routes: RegisteredRoute[] = [];
45
45
 
@@ -49,7 +49,11 @@ export class RouterBuilder {
49
49
  private readonly globalPrefix: string | undefined,
50
50
  private readonly versioning: ApiVersioningOptions | undefined,
51
51
  private readonly routeMiddlewareComposer?: RouteMiddlewareComposer,
52
- ) {}
52
+ /** Checks for every route that is not `@AllowAnonymous`. */
53
+ private readonly defaultChecks: readonly AuthorizeCheck[] = [],
54
+ ) {
55
+ this.authorizeComposer = createAuthorizeComposer(defaultChecks);
56
+ }
53
57
 
54
58
  build(controllers: readonly Class<object>[]): Router {
55
59
  const router = new Router();
@@ -110,7 +114,7 @@ export class RouterBuilder {
110
114
  method: declaration.httpMethod,
111
115
  path: fullPath,
112
116
  handler: routeAction.name,
113
- open: access.allowAnonymous === true || access.authorize === undefined,
117
+ open: routeAuthorizeChecks(controllerClass, methodName, this.defaultChecks) === undefined,
114
118
  anonymous: access.allowAnonymous === true,
115
119
  }));
116
120
  }
@@ -2,12 +2,13 @@ import type { Class } from "../di";
2
2
  import type { AccessLogOptions } from "./Middleware/accessLog";
3
3
  import type { CorsOptions } from "./Middleware/cors";
4
4
  import type { ErrorHandlerOptions } from "./Middleware/errorHandler";
5
- import type { Localize } from "./Errors/messages";
5
+ import type { LanguageSelection } from "./Errors/messages";
6
6
  import type { SecurityHeadersOptions } from "./Middleware/securityHeaders";
7
7
  import type { HttpMiddleware } from "./Middleware/types";
8
8
  import type { ActionMeta, ControllerMeta } from "./Decorators/metadata";
9
9
  import type { OpenApiDocsOptions } from "../../library/openapi";
10
10
  import type { ModelValidator } from "./Binding/modelValidator";
11
+ import type { AuthorizeCheck } from "./Authorization/metadata";
11
12
 
12
13
  /** Built-in health endpoint (liveness/readiness probe). */
13
14
  export interface HealthEndpointOptions {
@@ -60,11 +61,11 @@ export interface HttpModuleOptions {
60
61
  /** Request-model validator captured by this HTTP server instance. */
61
62
  readonly validator?: ModelValidator;
62
63
  /**
63
- * Renders framework response texts in the application language. `runApp`
64
+ * Response language of each request and its framework texts. `runApp`
64
65
  * sets it from its `language` and `messages` options; without it the
65
66
  * texts are English.
66
67
  */
67
- readonly localize?: Localize;
68
+ readonly languages?: LanguageSelection;
68
69
  /**
69
70
  * Modules providing the controllers' dependencies. Controllers live inside
70
71
  * the returned HttpModule, so their dependencies must be visible to it:
@@ -129,6 +130,16 @@ export interface HttpModuleOptions {
129
130
  * title/version.
130
131
  */
131
132
  readonly docs?: OpenApiDocsOptions | boolean;
133
+ /**
134
+ * Closed by default: these checks run on every route that is not
135
+ * `@AllowAnonymous`, before the route's own `@Authorize` checks
136
+ * (like ASP.NET Core's fallback policy). `/health` and the docs stay open.
137
+ *
138
+ * ```ts
139
+ * runApp(AppModule, { http: { authorize: signedIn } });
140
+ * ```
141
+ */
142
+ readonly authorize?: AuthorizeCheck | readonly AuthorizeCheck[];
132
143
  /**
133
144
  * Access log for every request (method, path, status, duration, correlation
134
145
  * id). `true` enables the default single-line sink; pass an object to plug a
@@ -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.107.0" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.108.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");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.107.0",
3
+ "version": "0.108.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",