bazis 0.96.6 → 0.97.1

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.
@@ -194,23 +194,24 @@ export class ${n.module}DbContext extends DbContext {
194
194
  }
195
195
 
196
196
  function serviceInterfaceFile(n: ModuleNaming): string {
197
- return `import { createToken } from "bazis/core/di";
198
- import type { PageResult } from "bazis/core/orm";
197
+ return `import type { PageResult } from "bazis/core/orm";
199
198
  import type { ListQuery } from "bazis/library/jsonapi";
200
199
  import type { Create${n.entity}Request, Update${n.entity}Request } from "../http/contracts/${n.module}.requests";
201
200
  import type { ${n.entity}Response, ${n.module}Summary } from "../http/contracts/${n.module}.responses";
202
201
 
203
- export interface I${n.module}Service {
204
- getAll(query: ListQuery): Promise<PageResult<${n.entity}Response>>;
205
- summary(): Promise<${n.module}Summary>;
206
- count(): Promise<number>;
207
- getById(id: string): Promise<${n.entity}Response | null>;
208
- create(body: Create${n.entity}Request): Promise<${n.entity}Response>;
209
- update(id: string, body: Update${n.entity}Request): Promise<${n.entity}Response | null>;
210
- delete(id: string): Promise<boolean>;
202
+ /**
203
+ * Service contract. An abstract class, not an interface: it exists at runtime,
204
+ * so it is the DI token itself: \`scoped(I${n.module}Service, ${n.module}Service)\`.
205
+ */
206
+ export abstract class I${n.module}Service {
207
+ abstract getAll(query: ListQuery): Promise<PageResult<${n.entity}Response>>;
208
+ abstract summary(): Promise<${n.module}Summary>;
209
+ abstract count(): Promise<number>;
210
+ abstract getById(id: string): Promise<${n.entity}Response | null>;
211
+ abstract create(body: Create${n.entity}Request): Promise<${n.entity}Response>;
212
+ abstract update(id: string, body: Update${n.entity}Request): Promise<${n.entity}Response | null>;
213
+ abstract delete(id: string): Promise<boolean>;
211
214
  }
212
-
213
- export const I${n.module}Service = createToken<I${n.module}Service>("I${n.module}Service");
214
215
  `;
215
216
  }
216
217
 
@@ -51,7 +51,7 @@ adapters that access it.
51
51
  | ${n.moduleClass} | [${n.module}.module.ts](${n.module}.module.ts) | host imports | ORM, DI, HTTP${profile === "full" ? ", background, AI" : ""} |
52
52
  | ${e} | [model/${m}.model.ts](model/${m}.model.ts) | Fields below | Table ${n.route} |
53
53
  | ${m}DbContext | [model/${m}.dbContext.ts](model/${m}.dbContext.ts) | Shared host ORM provider | DbSet ${n.collection} |
54
- | I${m}Service / ${m}Service | [services/${m}.service.ts](services/${m}.service.ts), [token](services/I${m}.service.ts) | ${m}DbContext${profile === "full" ? ", ICache" : ""} | CRUD, count, summary |
54
+ | I${m}Service / ${m}Service | [services/${m}.service.ts](services/${m}.service.ts), [contract](services/I${m}.service.ts) | ${m}DbContext${profile === "full" ? ", ICache" : ""} | CRUD, count, summary |
55
55
  | ${m}Controller | [http/${m}.controller.ts](http/${m}.controller.ts) | I${m}Service, HTTP request | HTTP operations below |
56
56
  | Create${e}Request / Update${e}Request | [requests](http/contracts/${m}.requests.ts) | JSON body | RequestModel + Validator |
57
57
  | ${e}Response / ${m}Summary / to${e}Response | [responses](http/contracts/${m}.responses.ts) | ORM entity | Public data projection |
package/core/di/README.md CHANGED
@@ -14,7 +14,7 @@ contracts are described in [MODULE.md](MODULE.md).
14
14
  - `ServiceCollection.ts`: service registration.
15
15
  - `ServiceScope.ts`: scoped resolve and scoped dispose.
16
16
  - `container.ts`: a compatible alias wrapper over `ServiceProvider`.
17
- - `token.ts`: DI tokens (`createToken`, open generic family).
17
+ - `token.ts`: DI tokens: classes, abstract classes as service contracts, `createToken`, open generic family.
18
18
 
19
19
  ## Internals (do not use directly)
20
20
 
package/core/di/index.ts CHANGED
@@ -75,6 +75,7 @@ export {
75
75
  export {
76
76
  createOpenGenericTokenFamily,
77
77
  createToken,
78
+ type AbstractClass,
78
79
  type Class,
79
80
  type InjectionToken,
80
81
  type OpenGenericTokenFamily,
package/core/di/token.ts CHANGED
@@ -3,6 +3,14 @@
3
3
  // constructor deps is still validated by the container.
4
4
  export type Class<T> = new (...args: any[]) => T;
5
5
 
6
+ /**
7
+ * A class usable only as a key: an abstract class declared as a contract
8
+ * (`export abstract class IClock { abstract now(): Date }`) and registered with
9
+ * `scoped(IClock, SystemClock)`. Unlike an interface it exists at runtime, so it
10
+ * needs no `createToken`. The container never constructs it.
11
+ */
12
+ export type AbstractClass<T> = abstract new (...args: any[]) => T;
13
+
6
14
  export interface InjectionToken<T> {
7
15
  readonly id: symbol;
8
16
  readonly description: string;
@@ -18,7 +26,7 @@ export function createToken<T>(description: string): InjectionToken<T> {
18
26
  };
19
27
  }
20
28
 
21
- export type Token<T> = Class<T> | InjectionToken<T>;
29
+ export type Token<T> = Class<T> | AbstractClass<T> | InjectionToken<T>;
22
30
 
23
31
  export interface OpenGenericTokenFamily<TArg, TResult> {
24
32
  readonly id: symbol;
@@ -17,7 +17,8 @@ type ClassOrMethodDecorator = (
17
17
  * Protects a controller or a single action: the route gets the
18
18
  * {@link AuthorizeCheck} function. The HTTP kernel does not know *what* it
19
19
  * checks: JWT, login/password, Telegram sign-in and so on. Several checks
20
- * can be passed; all of them must pass.
20
+ * can be passed; all of them must pass. On a method of a protected controller
21
+ * the checks are added to the controller's ones: the class checks run first.
21
22
  *
22
23
  * ```ts
23
24
  * @Authorize(requireTokenKind(TokenKind.Admin)) // access token kind
@@ -40,7 +41,7 @@ export function Authorize(check: AuthorizeCheck, ...rest: AuthorizeCheck[]): Cla
40
41
  };
41
42
  }
42
43
 
43
- /** Public route: overrides `@Authorize` declared on the controller. */
44
+ /** Public route: on a method, removes every check, including the controller's `@Authorize`. */
44
45
  export function AllowAnonymous(): ClassOrMethodDecorator {
45
46
  return (_value, context) => {
46
47
  if (context.kind === "class") {
@@ -122,15 +122,22 @@ export function setActionAllowAnonymous(metadata: object, methodName: string | s
122
122
  delete action.authorize;
123
123
  }
124
124
 
125
- /** Effective route requirement (the method overrides the controller). */
125
+ /** Effective route requirement: the class and method checks combined. */
126
126
  export interface ResolvedAuthorizeMeta {
127
127
  readonly allowAnonymous: boolean;
128
128
  readonly authorize?: AuthorizeOptions;
129
129
  }
130
130
 
131
131
  /**
132
- * Resolves the authorization requirement of a specific action. Precedence:
133
- * `@AllowAnonymous`/`@Authorize` on the method override those declared on the class.
132
+ * Resolves the authorization requirement of a specific action:
133
+ *
134
+ * - `@AllowAnonymous` on the method opens it, whatever the class declares;
135
+ * - `@Authorize` on the class and on the method combine: the class checks run
136
+ * first, then the method checks (a check repeated on both runs once);
137
+ * - `@AllowAnonymous` on the class opens the methods without their own `@Authorize`.
138
+ *
139
+ * Inheritance is different: a subclass's own class-level declaration replaces
140
+ * the base class's, and an overriding method's declaration replaces the base method's.
134
141
  */
135
142
  export function resolveAuthorizeMeta(ctor: object, methodName: string | symbol): ResolvedAuthorizeMeta {
136
143
  const metadata = (ctor as { [key: symbol]: unknown })[Symbol.metadata as unknown as symbol] as
@@ -145,14 +152,13 @@ export function resolveAuthorizeMeta(ctor: object, methodName: string | symbol):
145
152
  if (action?.allowAnonymous) {
146
153
  return { allowAnonymous: true };
147
154
  }
148
- if (action?.authorize) {
149
- return { allowAnonymous: false, authorize: action.authorize };
150
- }
151
- if (meta.allowAnonymous) {
155
+ const classChecks = meta.authorize?.checks ?? [];
156
+ const actionChecks = action?.authorize?.checks ?? [];
157
+ if (actionChecks.length === 0 && meta.allowAnonymous) {
152
158
  return { allowAnonymous: true };
153
159
  }
154
- if (meta.authorize) {
155
- return { allowAnonymous: false, authorize: meta.authorize };
156
- }
157
- return { allowAnonymous: false };
160
+ const checks = [...new Set([...classChecks, ...actionChecks])];
161
+ return checks.length === 0
162
+ ? { allowAnonymous: false }
163
+ : { allowAnonymous: false, authorize: { checks } };
158
164
  }
@@ -150,8 +150,15 @@ export class HttpServer implements HostedService {
150
150
  if (this.options.securityHeaders !== false) {
151
151
  serverChain.push(securityHeaders(this.options.securityHeaders ?? {}));
152
152
  }
153
+ // Unexpected errors go to the application logger, like the access log.
154
+ const errorLogger = this.resolver.tryResolve(LOGGER);
153
155
  serverChain.push(
154
- errorHandler({ exposeDetails, ...this.options.errorHandler, onUnexpectedError: this.collectErrorHook() }),
156
+ errorHandler({
157
+ exposeDetails,
158
+ ...(errorLogger ? { logger: errorLogger } : {}),
159
+ ...this.options.errorHandler,
160
+ onUnexpectedError: this.collectErrorHook(),
161
+ }),
155
162
  );
156
163
  this.shortCircuitChain = [...serverChain];
157
164
  serverChain.push(...this.collectServerMiddleware());
@@ -1,4 +1,5 @@
1
- import { redactSensitive } from "../../../library/redaction";
1
+ import { REQUEST_ID_STATE_KEY, type Logger } from "../../kernel";
2
+ import { redactSensitive, redactSensitiveText } from "../../../library/redaction";
2
3
  import type { HttpContext } from "../HttpContext/HttpContext";
3
4
  import { HttpError } from "../Errors/HttpError";
4
5
  import type { HttpMiddleware } from "./types";
@@ -9,10 +10,20 @@ export interface ErrorHandlerOptions {
9
10
  * never enable in production).
10
11
  */
11
12
  exposeDetails?: boolean;
12
- /** Called for non-{@link HttpError} failures (self-wired via `HTTP_ERROR_HOOK`, e.g. the logging module). */
13
+ /**
14
+ * Notified of every non-{@link HttpError} failure, for example to send it to
15
+ * an error tracker; `HTTP_ERROR_HOOK` registrations are merged in. It does not
16
+ * replace logging: the error is logged as well.
17
+ */
13
18
  onUnexpectedError?: (ctx: HttpContext, error: unknown) => void;
14
- /** Fallback when {@link onUnexpectedError} is omitted or does not handle the error. */
19
+ /** Replaces the built-in logging of unexpected errors. */
15
20
  logError?: (error: unknown) => void;
21
+ /**
22
+ * Logger for unexpected errors when {@link logError} is absent: one `error`
23
+ * line with the method, path, request id and the redacted error. The server
24
+ * passes the application `LOGGER`; without either, `console.error` is used.
25
+ */
26
+ logger?: Logger;
16
27
  }
17
28
 
18
29
  function jsonResponse(status: number, body: unknown, extraHeaders?: readonly [string, string][]): Response {
@@ -33,9 +44,30 @@ function safeJsonResponse(status: number, body: unknown, extraHeaders?: readonly
33
44
  }
34
45
  }
35
46
 
47
+ type ErrorLog = (ctx: HttpContext, error: unknown) => void;
48
+
49
+ function resolveErrorLog(options: ErrorHandlerOptions): ErrorLog {
50
+ const { logError, logger } = options;
51
+ if (logError) {
52
+ return (_ctx, error) => logError(error);
53
+ }
54
+ if (logger) {
55
+ return (ctx, error) => {
56
+ const requestId = ctx.state.get(REQUEST_ID_STATE_KEY);
57
+ logger.error(`${ctx.method} ${ctx.path} failed`, {
58
+ method: ctx.method,
59
+ path: ctx.path,
60
+ ...(typeof requestId === "string" ? { requestId } : {}),
61
+ error: redactSensitive(error),
62
+ });
63
+ };
64
+ }
65
+ return (_ctx, error) => console.error("[http] unhandled error:", redactSensitive(error));
66
+ }
67
+
36
68
  function safelyReportUnexpected(
37
69
  options: ErrorHandlerOptions,
38
- fallback: (error: unknown) => void,
70
+ log: ErrorLog,
39
71
  ctx: HttpContext,
40
72
  error: unknown,
41
73
  ): void {
@@ -43,13 +75,13 @@ function safelyReportUnexpected(
43
75
  options.onUnexpectedError?.(ctx, error);
44
76
  } catch (hookError) {
45
77
  try {
46
- fallback(hookError);
78
+ log(ctx, hookError);
47
79
  } catch {
48
80
  // Logging hooks are best-effort; response safety is primary.
49
81
  }
50
82
  }
51
83
  try {
52
- fallback(error);
84
+ log(ctx, error);
53
85
  } catch {
54
86
  // A broken sink must not escape the HTTP error boundary.
55
87
  }
@@ -61,7 +93,7 @@ function safelyReportUnexpected(
61
93
  * else becomes 500 Internal Server Error.
62
94
  */
63
95
  export function errorHandler(options: ErrorHandlerOptions = {}): HttpMiddleware {
64
- const fallback = options.logError ?? ((error: unknown) => console.error("[http] unhandled error:", redactSensitive(error)));
96
+ const log = resolveErrorLog(options);
65
97
  return async (ctx, next) => {
66
98
  try {
67
99
  await next();
@@ -87,12 +119,14 @@ export function errorHandler(options: ErrorHandlerOptions = {}): HttpMiddleware
87
119
  );
88
120
  return;
89
121
  }
90
- safelyReportUnexpected(options, fallback, ctx, error);
122
+ safelyReportUnexpected(options, log, ctx, error);
91
123
  ctx.response = options.exposeDetails
92
124
  ? safeJsonResponse(500, {
93
125
  error: "Internal Server Error",
94
- message: error instanceof Error ? error.message : String(error),
95
- stack: error instanceof Error ? error.stack : undefined,
126
+ // Development only, but redacted like the log: a secret in an error
127
+ // message must not reach a browser or a shared screenshot.
128
+ message: redactSensitiveText(error instanceof Error ? error.message : String(error)),
129
+ stack: error instanceof Error && error.stack !== undefined ? redactSensitiveText(error.stack) : undefined,
96
130
  })
97
131
  : safeJsonResponse(500, { error: "Internal Server Error" });
98
132
  }
package/core/http/SPEC.md CHANGED
@@ -405,9 +405,10 @@ Exported from `bazis/core/http`. They are connected through `@Middleware(...)`,
405
405
 
406
406
  | `ErrorHandlerOptions` field | Type | Default | Description |
407
407
  | --- | --- | --- | --- |
408
- | `exposeDetails` | `boolean` | `httpModule({ exposeErrorDetails })` / environment | message/stack in 500 responses; never enable in production |
409
- | `onUnexpectedError` | `(ctx, error) => void` | DI `HTTP_ERROR_HOOK` hooks | called for non-`HttpError` failures |
410
- | `logError` | `(error) => void` | `console.error` | fallback when no hook handles the error |
408
+ | `exposeDetails` | `boolean` | `httpModule({ exposeErrorDetails })` / environment | redacted message/stack in 500 responses; never enable in production |
409
+ | `onUnexpectedError` | `(ctx, error) => void` | DI `HTTP_ERROR_HOOK` hooks | notification for non-`HttpError` failures (e.g. an error tracker); the error is still logged |
410
+ | `logError` | `(error) => void` | — | replaces the built-in logging of unexpected errors |
411
+ | `logger` | `Logger` | the application `LOGGER` | one `error` line: `"<METHOD> <path> failed"` with `method`, `path`, `requestId` and the redacted `error`; `console.error` when there is no logger |
411
412
 
412
413
  `HttpError` → JSON with its status; anything else → 500.
413
414
 
@@ -804,8 +805,13 @@ Do not create a **new** middleware function per request, only at load/startup.
804
805
  `@Authorize(check, ...checks)` on a controller or a method compiles into the built-in
805
806
  authorization link [6]. A check is `(ctx) => boolean | Promise<boolean>`: `true` lets the
806
807
  request through, `false` gives `403`, and a thrown `HttpError` defines the response
807
- itself (for example `UnauthorizedError` → `401`). A method-level `@Authorize` or
808
- `@AllowAnonymous` overrides the controller's.
808
+ itself (for example `UnauthorizedError` → `401`). Checks of the controller and of the
809
+ method combine: the controller's run first, then the method's; a check repeated on both
810
+ runs once. A method-level `@AllowAnonymous` removes every check, including the
811
+ controller's. On an `@AllowAnonymous` controller only methods with their own
812
+ `@Authorize` are protected. Inheritance replaces instead: a subclass's own class-level
813
+ declaration replaces the base class's (since 0.97.0; before it a method-level
814
+ `@Authorize` replaced the controller's checks).
809
815
 
810
816
  ```ts
811
817
  const isSignedIn: AuthorizeCheck = (ctx) => {
@@ -31,8 +31,8 @@ export const ROUTE_MIDDLEWARE_COMPOSER = createToken<RouteMiddlewareComposerRegi
31
31
 
32
32
  /**
33
33
  * Hook invoked by the built-in error boundary on a non-{@link HttpError}
34
- * failure. Self-wiring: e.g. the logging module registers one to record the
35
- * error, keeping `@/core/http` free of logging imports. Collected via `resolveAll`.
34
+ * failure, for example to report it to an error tracker. It is a notification:
35
+ * the error is still logged and answered with 500. Collected via `resolveAll`.
36
36
  */
37
37
  export type HttpErrorHook = (ctx: HttpContext, error: unknown) => void;
38
38
 
@@ -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.96.6" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.97.1" }, 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.96.6",
3
+ "version": "0.97.1",
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",