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.
- package/cli/templates/module.ts +13 -12
- package/cli/templates/passport.ts +1 -1
- package/core/di/README.md +1 -1
- package/core/di/index.ts +1 -0
- package/core/di/token.ts +9 -1
- package/core/http/Authorization/Authorize.ts +3 -2
- package/core/http/Authorization/metadata.ts +17 -11
- package/core/http/HttpServer.ts +8 -1
- package/core/http/Middleware/errorHandler.ts +44 -10
- package/core/http/SPEC.md +11 -5
- package/core/http/middlewareTokens.ts +2 -2
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/package.json +1 -1
package/cli/templates/module.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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), [
|
|
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
|
|
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
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:
|
|
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
|
|
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
|
|
133
|
-
*
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
160
|
+
const checks = [...new Set([...classChecks, ...actionChecks])];
|
|
161
|
+
return checks.length === 0
|
|
162
|
+
? { allowAnonymous: false }
|
|
163
|
+
: { allowAnonymous: false, authorize: { checks } };
|
|
158
164
|
}
|
package/core/http/HttpServer.ts
CHANGED
|
@@ -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({
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
122
|
+
safelyReportUnexpected(options, log, ctx, error);
|
|
91
123
|
ctx.response = options.exposeDetails
|
|
92
124
|
? safeJsonResponse(500, {
|
|
93
125
|
error: "Internal Server Error",
|
|
94
|
-
|
|
95
|
-
|
|
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 |
|
|
410
|
-
| `logError` | `(error) => void` |
|
|
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`).
|
|
808
|
-
|
|
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
|
|
35
|
-
* error
|
|
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.
|
|
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