exisjs 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/build.d.ts +2 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/build.js +55 -3
- package/dist/cli/commands/build.js.map +1 -1
- package/dist/cli/commands/dev.d.ts +8 -0
- package/dist/cli/commands/dev.d.ts.map +1 -1
- package/dist/cli/commands/dev.js +96 -36
- package/dist/cli/commands/dev.js.map +1 -1
- package/dist/cli/index.js +1 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/manifest.d.ts.map +1 -1
- package/dist/cli/manifest.js +2 -1
- package/dist/cli/manifest.js.map +1 -1
- package/dist/cli/routes.d.ts +2 -0
- package/dist/cli/routes.d.ts.map +1 -1
- package/dist/cli/routes.js +14 -5
- package/dist/cli/routes.js.map +1 -1
- package/dist/config/config.d.ts +3 -1
- package/dist/config/config.d.ts.map +1 -1
- package/dist/config/config.js.map +1 -1
- package/dist/config/types.d.ts +11 -1
- package/dist/config/types.d.ts.map +1 -1
- package/dist/cron/job.d.ts +2 -0
- package/dist/cron/job.d.ts.map +1 -1
- package/dist/cron/job.js +33 -1
- package/dist/cron/job.js.map +1 -1
- package/dist/cron/manager.d.ts +12 -1
- package/dist/cron/manager.d.ts.map +1 -1
- package/dist/cron/manager.js +7 -1
- package/dist/cron/manager.js.map +1 -1
- package/dist/cron/types.d.ts +24 -0
- package/dist/cron/types.d.ts.map +1 -1
- package/dist/decorators/index.d.ts +1 -0
- package/dist/decorators/index.d.ts.map +1 -1
- package/dist/decorators/index.js +5 -0
- package/dist/decorators/index.js.map +1 -1
- package/dist/decorators/pipes.d.ts +2 -0
- package/dist/decorators/pipes.d.ts.map +1 -1
- package/dist/decorators/pipes.js +39 -18
- package/dist/decorators/pipes.js.map +1 -1
- package/dist/di/container.d.ts +6 -0
- package/dist/di/container.d.ts.map +1 -1
- package/dist/di/container.js +73 -57
- package/dist/di/container.js.map +1 -1
- package/dist/docs/content/cli/commands.mdx +5 -1
- package/dist/docs/content/config.mdx +5 -3
- package/dist/docs/content/cron.mdx +25 -1
- package/dist/docs/content/decorators.mdx +4 -4
- package/dist/docs/content/errors.mdx +37 -36
- package/dist/docs/content/installation.mdx +3 -4
- package/dist/docs/content/introduction.mdx +1 -1
- package/dist/docs/content/providers.mdx +2 -0
- package/dist/docs/content/requests.mdx +2 -2
- package/dist/docs/content/responses.mdx +18 -0
- package/dist/docs/content/security.mdx +93 -5
- package/dist/docs/content/testing.mdx +38 -0
- package/dist/docs/content/validation.mdx +21 -1
- package/dist/docs/reference/decorators.mdx +2 -4
- package/dist/docs/reference/middleware.mdx +2 -0
- package/dist/docs/reference/router.mdx +5 -7
- package/dist/error/errors.d.ts +9 -1
- package/dist/error/errors.d.ts.map +1 -1
- package/dist/error/errors.js +24 -15
- package/dist/error/errors.js.map +1 -1
- package/dist/index.d.ts +11 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/lib/inspect-routes.js +2 -0
- package/dist/lib/inspect-routes.js.map +1 -1
- package/dist/middleware/idempotency.d.ts +25 -23
- package/dist/middleware/idempotency.d.ts.map +1 -1
- package/dist/middleware/idempotency.js +70 -35
- package/dist/middleware/idempotency.js.map +1 -1
- package/dist/middleware/ip-filter.d.ts +4 -1
- package/dist/middleware/ip-filter.d.ts.map +1 -1
- package/dist/middleware/ip-filter.js +4 -13
- package/dist/middleware/ip-filter.js.map +1 -1
- package/dist/middleware/middleware.d.ts.map +1 -1
- package/dist/middleware/middleware.js +3 -1
- package/dist/middleware/middleware.js.map +1 -1
- package/dist/middleware/rate-limit.d.ts +36 -0
- package/dist/middleware/rate-limit.d.ts.map +1 -1
- package/dist/middleware/rate-limit.js +33 -19
- package/dist/middleware/rate-limit.js.map +1 -1
- package/dist/middleware/security.d.ts.map +1 -1
- package/dist/middleware/security.js +13 -32
- package/dist/middleware/security.js.map +1 -1
- package/dist/router/authorize.d.ts +43 -0
- package/dist/router/authorize.d.ts.map +1 -0
- package/dist/router/authorize.js +66 -0
- package/dist/router/authorize.js.map +1 -0
- package/dist/router/controller-registrar.d.ts.map +1 -1
- package/dist/router/controller-registrar.js +117 -149
- package/dist/router/controller-registrar.js.map +1 -1
- package/dist/router/route-scanner.d.ts +3 -0
- package/dist/router/route-scanner.d.ts.map +1 -1
- package/dist/router/route-scanner.js +32 -21
- package/dist/router/route-scanner.js.map +1 -1
- package/dist/router/router.d.ts +2 -0
- package/dist/router/router.d.ts.map +1 -1
- package/dist/router/router.js +49 -46
- package/dist/router/router.js.map +1 -1
- package/dist/router/scanner/boundary-loader.d.ts.map +1 -1
- package/dist/router/scanner/boundary-loader.js +16 -12
- package/dist/router/scanner/boundary-loader.js.map +1 -1
- package/dist/router/scanner/cron-mounter.d.ts.map +1 -1
- package/dist/router/scanner/cron-mounter.js +32 -18
- package/dist/router/scanner/cron-mounter.js.map +1 -1
- package/dist/router/scanner/functional-compiler.d.ts.map +1 -1
- package/dist/router/scanner/functional-compiler.js +27 -128
- package/dist/router/scanner/functional-compiler.js.map +1 -1
- package/dist/router/send-result.d.ts +24 -0
- package/dist/router/send-result.d.ts.map +1 -0
- package/dist/router/send-result.js +81 -0
- package/dist/router/send-result.js.map +1 -0
- package/dist/server/app.d.ts +5 -0
- package/dist/server/app.d.ts.map +1 -1
- package/dist/server/app.js +44 -5
- package/dist/server/app.js.map +1 -1
- package/dist/server/engines/node-engine.d.ts.map +1 -1
- package/dist/server/engines/node-engine.js +6 -1
- package/dist/server/engines/node-engine.js.map +1 -1
- package/dist/server/request-handler.d.ts +2 -0
- package/dist/server/request-handler.d.ts.map +1 -1
- package/dist/server/request-handler.js +11 -2
- package/dist/server/request-handler.js.map +1 -1
- package/dist/server/request.d.ts +8 -1
- package/dist/server/request.d.ts.map +1 -1
- package/dist/server/request.js +19 -1
- package/dist/server/request.js.map +1 -1
- package/dist/server/response.d.ts +2 -0
- package/dist/server/response.d.ts.map +1 -1
- package/dist/server/response.js +6 -0
- package/dist/server/response.js.map +1 -1
- package/dist/swagger/mounter.d.ts.map +1 -1
- package/dist/swagger/mounter.js +2 -0
- package/dist/swagger/mounter.js.map +1 -1
- package/dist/testing/client.d.ts +32 -0
- package/dist/testing/client.d.ts.map +1 -1
- package/dist/testing/client.js +69 -0
- package/dist/testing/client.js.map +1 -1
- package/dist/testing/index.d.ts +10 -2
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +7 -2
- package/dist/testing/index.js.map +1 -1
- package/dist/validator/builder.d.ts +33 -16
- package/dist/validator/builder.d.ts.map +1 -1
- package/dist/validator/builder.js.map +1 -1
- package/dist/validator/engine.d.ts +9 -0
- package/dist/validator/engine.d.ts.map +1 -1
- package/dist/validator/engine.js +148 -96
- package/dist/validator/engine.js.map +1 -1
- package/dist/validator/error.d.ts +4 -0
- package/dist/validator/error.d.ts.map +1 -1
- package/dist/validator/error.js +20 -4
- package/dist/validator/error.js.map +1 -1
- package/dist/validator/index.d.ts +12 -1
- package/dist/validator/index.d.ts.map +1 -1
- package/dist/validator/index.js.map +1 -1
- package/dist/validator/tex-types.d.ts +29 -2
- package/dist/validator/tex-types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -139,15 +139,13 @@ Every error response has the same core shape, whatever caused it:
|
|
|
139
139
|
| `statusCode` | Same as the HTTP status |
|
|
140
140
|
| `error.code` | Stable machine-readable code: `NOT_FOUND`, `UNAUTHORIZED`, `VALIDATION_ERROR`, `INTERNAL_ERROR`... |
|
|
141
141
|
| `error.message` | Human-readable message. For unexpected errors in production it is a generic message; internals are never exposed |
|
|
142
|
-
| `error.details` | Optional extra data (validation issues, or the `details` passed to an `HttpError`) |
|
|
142
|
+
| `error.details` | Optional extra data (validation issues, or the `details` passed to an `HttpError`). Never copied to the top level |
|
|
143
143
|
|
|
144
|
-
Clients can rely on `error.code` and `error.message` for every failed request.
|
|
144
|
+
Clients can rely on `error.code` and `error.message` for every failed request: thrown errors, validation, `@Roles` / `@Permissions` / guard rejections, `404` and `405` all use this shape. Codes the framework itself sends include `VALIDATION_ERROR`, `INVALID_JSON`, `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`, `METHOD_NOT_ALLOWED` (with an `Allow` header), `RATE_LIMIT_EXCEEDED`, `IDEMPOTENCY_KEY_REUSED`, `IDEMPOTENCY_IN_PROGRESS` and `INTERNAL_ERROR`. A handler that rejects with `null` or `undefined` is a `500`, never a `404`.
|
|
145
145
|
|
|
146
146
|
### Validation Error Responses
|
|
147
147
|
|
|
148
|
-
When schema validation fails (via `tex`, Zod, or
|
|
149
|
-
|
|
150
|
-
The top-level `message`, `validationErrors`, `errors` and `details` fields are kept for existing clients; new code should read `error.details`. Values of credential fields (`password`, `token`, `secret`...) are replaced with `[Redacted]` in responses and logs.
|
|
148
|
+
When schema validation fails (via `tex`, Zod, Yup or a DTO's `validate()`), the response is a `400` with **one entry per failing field** in `error.details` (up to 100):
|
|
151
149
|
|
|
152
150
|
```json filename="HTTP 400 Validation Error Response"
|
|
153
151
|
{
|
|
@@ -155,37 +153,40 @@ The top-level `message`, `validationErrors`, `errors` and `details` fields are k
|
|
|
155
153
|
"statusCode": 400,
|
|
156
154
|
"error": {
|
|
157
155
|
"code": "VALIDATION_ERROR",
|
|
158
|
-
"message": "Validation Error
|
|
159
|
-
"details": [
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
156
|
+
"message": "Validation Error",
|
|
157
|
+
"details": [
|
|
158
|
+
{
|
|
159
|
+
"field": "email",
|
|
160
|
+
"message": "Must be a valid email address",
|
|
161
|
+
"expected": "valid email address",
|
|
162
|
+
"received": "\"invalid-email\"",
|
|
163
|
+
"code": "INVALID_EMAIL",
|
|
164
|
+
"httpPart": "body"
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
"field": "age",
|
|
168
|
+
"message": "Must be at least 18",
|
|
169
|
+
"expected": "must be at least 18",
|
|
170
|
+
"received": "16",
|
|
171
|
+
"code": "VALUE_TOO_SMALL",
|
|
172
|
+
"httpPart": "body"
|
|
173
|
+
}
|
|
174
|
+
]
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`received` never shows secrets: credential fields (`password`, `token`, `secret`, `apiKey`...) and connection settings (`DATABASE_URL`, `REDIS_URI`, `SENTRY_DSN`, `*_CONNECTION`...) are `[Redacted]`, and the password of any `scheme://user:password@host` value is masked as `***`, in responses, logs and `tex.env` startup errors.
|
|
180
|
+
|
|
181
|
+
**Status codes.** Malformed input that cannot be parsed at all is `400` (`INVALID_JSON` for a broken JSON body); input that parses but breaks the schema is `400` `VALIDATION_ERROR`. Use `HttpError.unprocessable()` (`422`) for business rules that the schema cannot express, such as "this email is already registered".
|
|
182
|
+
|
|
183
|
+
#### Pre-0.9 duplicate fields
|
|
184
|
+
|
|
185
|
+
Before 0.9, error bodies also carried `message`, `validationErrors`, `errors`, `details` and `timestamp` at the top level. They are gone by default. Clients that still read them can get them back while they migrate:
|
|
186
|
+
|
|
187
|
+
```typescript filename="exis.config.ts"
|
|
188
|
+
export default {
|
|
189
|
+
errors: { legacyFields: true }, // deprecated, removed in 1.0
|
|
189
190
|
}
|
|
190
191
|
```
|
|
191
192
|
|
|
@@ -205,9 +205,8 @@ const config: ExisConfig = {
|
|
|
205
205
|
helmet: { enabled: true },
|
|
206
206
|
compression: true,
|
|
207
207
|
|
|
208
|
-
// Swagger UI at /docs
|
|
209
|
-
|
|
210
|
-
docs: { enabled: true },
|
|
208
|
+
// Swagger UI at /docs, development only
|
|
209
|
+
docs: { enabled: process.env.NODE_ENV !== 'production' },
|
|
211
210
|
}
|
|
212
211
|
|
|
213
212
|
export default config
|
|
@@ -283,7 +282,7 @@ npm run dev
|
|
|
283
282
|
Output:
|
|
284
283
|
|
|
285
284
|
```text filename="Terminal"
|
|
286
|
-
[exis] starting development server (v0.
|
|
285
|
+
[exis] starting development server (v0.9.0)...
|
|
287
286
|
EXIS ready in 125 ms
|
|
288
287
|
|
|
289
288
|
→ Local: http://localhost:4000/
|
|
@@ -50,7 +50,7 @@ ExisJS combines convention-based file routing, built-in standard modules, and a
|
|
|
50
50
|
The file-system routing engine scans the `src/http` directory and compiles route paths into a radix tree at startup. Purely static paths resolve through an exact-match table; parameterized paths walk the tree with no per-request parsing of the route definitions.
|
|
51
51
|
|
|
52
52
|
### 2. Dual-Paradigm Parity
|
|
53
|
-
ExisJS
|
|
53
|
+
ExisJS supports a functional and a class-based (OOP) paradigm. Both share the router, validation, boundaries, error handling, response handling and route authorization; a project uses **one** of them throughout (`exisjs dev` and `exisjs build` stop with the file names if they are mixed). Class-based controllers add decorator and dependency-injection work per request, so functional routes are somewhat faster.
|
|
54
54
|
- **Functional**: `controller()`, `route.get()`, `route.post()`, `defineBoundary()`, `inject()`.
|
|
55
55
|
- **Class-Based (OOP)**: `@Controller()`, `@Get()`, `@Post()`, `@Boundary()`, `@Injectable()`.
|
|
56
56
|
|
|
@@ -199,6 +199,8 @@ export class RequestContextService {
|
|
|
199
199
|
```
|
|
200
200
|
|
|
201
201
|
> [!WARNING]
|
|
202
|
+
> **Scopes propagate**: a controller or service that depends on a request-scoped provider is itself created per request, so it can never keep one request's instance for the next. `transient` providers get a new instance for every injection.
|
|
203
|
+
|
|
202
204
|
> **Request Scope Lifecycle**: Request-scoped providers can only be resolved within an active HTTP request context. Attempting to resolve a request-scoped provider during boot scripts, background cron tasks, or standalone worker processes will throw a container error.
|
|
203
205
|
|
|
204
206
|
---
|
|
@@ -33,9 +33,9 @@ ExisJS wraps the standard Node.js `IncomingMessage` in an `ExisRequest` instance
|
|
|
33
33
|
| `req.signal` | `AbortSignal` | Standard `AbortSignal` triggered when client cancels connection |
|
|
34
34
|
| `req.fresh` | `boolean` | `true` if client cache matches current `ETag` or `Last-Modified` |
|
|
35
35
|
| `req.stale` | `boolean` | Inverse of `req.fresh` |
|
|
36
|
-
| `req.user` | `ExisUser` | Authenticated user
|
|
36
|
+
| `req.user` | `ExisUser` | Authenticated user set by your auth middleware or guard. Type it once with `declare module 'exisjs' { interface ExisUser { id: string; roles: string[]; permissions: string[] } }` |
|
|
37
37
|
| `req.session` | `Record<string, any>` | Session data store when session middleware is enabled |
|
|
38
|
-
| `req.log` | `Logger` | Request-scoped Pino logger
|
|
38
|
+
| `req.log` | `Logger` | Request-scoped Pino logger; every line carries `requestId` |
|
|
39
39
|
| `req.requestId` | `string` | Unique request identifier header |
|
|
40
40
|
| `req.raw` | `IncomingMessage` | Underlying Node.js `IncomingMessage` stream |
|
|
41
41
|
|
|
@@ -51,6 +51,24 @@ export default class UsersController {
|
|
|
51
51
|
}
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
+
### What a Return Value Becomes
|
|
55
|
+
|
|
56
|
+
Both paradigms answer the same way:
|
|
57
|
+
|
|
58
|
+
| Handler returns | Response |
|
|
59
|
+
| :--- | :--- |
|
|
60
|
+
| Object, array, number, boolean, `null` | JSON (`null` is the JSON value `null`) |
|
|
61
|
+
| String | `text/plain` |
|
|
62
|
+
| `Buffer` / `Uint8Array` | `application/octet-stream` (set `Content-Type` first to change it) |
|
|
63
|
+
| Stream or async iterable | Streamed |
|
|
64
|
+
| `download(data, name)` | Attachment |
|
|
65
|
+
| `res`, or the result of `res.json()` / `res.send()` | Nothing more: the response was already sent |
|
|
66
|
+
| `undefined`, nothing sent | `204 No Content` (or the status set with `@HttpCode` / `res.status()`, with an empty body) |
|
|
67
|
+
|
|
68
|
+
Prefer returning a value. Call `res.json()` / `res.send()` yourself only when you need to, and do not do both. A handler that receives the response object (`@Res()`, or `(req, res)` with no decorators) may also answer later, for example from a callback; it is then responsible for sending exactly one response.
|
|
69
|
+
|
|
70
|
+
`HEAD` is answered by the matching `GET` route without a body. A path that exists under other methods answers `405 METHOD_NOT_ALLOWED` with an `Allow` header instead of `404`.
|
|
71
|
+
|
|
54
72
|
---
|
|
55
73
|
|
|
56
74
|
## Response Envelope Builders (`exisjs/response`)
|
|
@@ -32,6 +32,8 @@ The following security middlewares are exported from `exisjs/middleware` and can
|
|
|
32
32
|
|
|
33
33
|
## 1. Security Headers (`helmet`)
|
|
34
34
|
|
|
35
|
+
Helmet is on by default (`helmet: { enabled: true }` in `exis.config.ts`), including a same-origin `Content-Security-Policy`; see [Security Baseline](#11-security-baseline). Use the middleware directly to apply different settings to part of the app.
|
|
36
|
+
|
|
35
37
|
The `helmet` middleware sets secure response headers to harden the application against cross-site scripting (XSS), clickjacking, and content sniffing:
|
|
36
38
|
|
|
37
39
|
```typescript filename="src/http/server.ts"
|
|
@@ -69,7 +71,7 @@ export default exis({
|
|
|
69
71
|
async onStart(app) {
|
|
70
72
|
app.use(
|
|
71
73
|
csrf({
|
|
72
|
-
secret: process.env.
|
|
74
|
+
secret: process.env.CSRF_SECRET!, // Must be at least 32 characters
|
|
73
75
|
cookieName: 'csrf-token',
|
|
74
76
|
headerName: 'x-csrf-token',
|
|
75
77
|
cookieOptions: {
|
|
@@ -104,6 +106,30 @@ export const config = defineBoundary({
|
|
|
104
106
|
})
|
|
105
107
|
```
|
|
106
108
|
|
|
109
|
+
The default store counts per process: with two instances each allows `max`, and on serverless the count resets constantly. Pass a shared `store` to enforce one limit everywhere:
|
|
110
|
+
|
|
111
|
+
```typescript filename="src/http/auth/boundary.ts"
|
|
112
|
+
import { rateLimit, type RateLimitStore } from 'exisjs/middleware'
|
|
113
|
+
import { redis } from '../../lib/redis'
|
|
114
|
+
|
|
115
|
+
const redisStore: RateLimitStore = {
|
|
116
|
+
async increment(key, windowMs) {
|
|
117
|
+
const k = `rl:${key}`
|
|
118
|
+
const [[, count], [, ttl]] = (await redis
|
|
119
|
+
.multi()
|
|
120
|
+
.incr(k)
|
|
121
|
+
.pexpire(k, windowMs, 'NX')
|
|
122
|
+
.pttl(k)
|
|
123
|
+
.exec()) as [unknown, number][]
|
|
124
|
+
return { count, resetTime: Date.now() + Math.max(ttl, 0) }
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export const loginLimiter = rateLimit({ max: 10, windowMs: 15 * 60_000, store: redisStore })
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
If the store fails, requests are let through and a warning is logged, so a cache outage does not take the API down.
|
|
132
|
+
|
|
107
133
|
### Rate Limit Response Headers
|
|
108
134
|
|
|
109
135
|
`rateLimit` attaches standard IETF and legacy rate-limiting headers to every response:
|
|
@@ -136,7 +162,6 @@ export const adminIpGuard = ipFilter({
|
|
|
136
162
|
'10.0.0.99', // Block specific compromised host
|
|
137
163
|
],
|
|
138
164
|
message: 'Access Denied: IP address not authorized',
|
|
139
|
-
trustProxy: true,
|
|
140
165
|
})
|
|
141
166
|
|
|
142
167
|
export const config = defineBoundary({
|
|
@@ -150,6 +175,11 @@ export const config = defineBoundary({
|
|
|
150
175
|
|
|
151
176
|
Prevent duplicate mutations (e.g. double-charging a credit card or duplicate database inserts) by caching responses associated with an `Idempotency-Key` header:
|
|
152
177
|
|
|
178
|
+
- A repeat with the same key, user, method, path and body gets the first response again (header `Idempotent-Replayed: true`); the handler does not run twice.
|
|
179
|
+
- Keys belong to `req.user.id` (or `sub`), so one client can never receive another client's response. Change this with `scope: (req) => req.tenantId`.
|
|
180
|
+
- The same key with a **different body** is refused with `422 IDEMPOTENCY_KEY_REUSED`; a repeat that arrives while the first is still running gets `409 IDEMPOTENCY_IN_PROGRESS`.
|
|
181
|
+
- Only `2xx`/`3xx` responses are stored, so a failed request can be retried. Pass `store` to share responses between instances.
|
|
182
|
+
|
|
153
183
|
```typescript filename="src/http/checkout/route.ts"
|
|
154
184
|
import { controller, route } from 'exisjs/router'
|
|
155
185
|
import { idempotent } from 'exisjs/middleware'
|
|
@@ -274,9 +304,9 @@ export function apiKeyGuard(req: any) {
|
|
|
274
304
|
|
|
275
305
|
---
|
|
276
306
|
|
|
277
|
-
## 10.
|
|
307
|
+
## 10. Route Authorization (`@Roles`, `@Permissions`, `@Public`)
|
|
278
308
|
|
|
279
|
-
ExisJS
|
|
309
|
+
Declare who may call a route; ExisJS checks it against `req.user` before guards, pipes and the handler run. Your authentication middleware (in a boundary or `@Use`) verifies the token and sets `req.user`.
|
|
280
310
|
|
|
281
311
|
```typescript filename="src/http/users/controller.ts"
|
|
282
312
|
import { Controller, Get, Post, Roles, Permissions, Public } from 'exisjs/decorators'
|
|
@@ -291,9 +321,67 @@ export class UsersController {
|
|
|
291
321
|
}
|
|
292
322
|
|
|
293
323
|
@Post('/login')
|
|
294
|
-
@Public()
|
|
324
|
+
@Public()
|
|
295
325
|
async login() {
|
|
296
326
|
return { token: 'jwt' }
|
|
297
327
|
}
|
|
298
328
|
}
|
|
299
329
|
```
|
|
330
|
+
|
|
331
|
+
Functional routes declare the same with `roles`, `permissions` and `public` on the route (or on the whole `controller({...})`):
|
|
332
|
+
|
|
333
|
+
```typescript filename="src/http/reports/route.ts"
|
|
334
|
+
export default controller({
|
|
335
|
+
export: route.get('/export', {
|
|
336
|
+
permissions: ['report:read', 'report:export'],
|
|
337
|
+
handle: () => buildReport(),
|
|
338
|
+
}),
|
|
339
|
+
})
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
| Rule | Behaviour |
|
|
343
|
+
| :--- | :--- |
|
|
344
|
+
| No `req.user` | `401 UNAUTHORIZED` |
|
|
345
|
+
| `@Roles('a', 'b')` | The user needs **one** of the roles, from `user.roles` (array) or `user.role` |
|
|
346
|
+
| `@Permissions('x', 'y')` | The user needs **all** listed permissions in `user.permissions`. A role name never counts as a permission |
|
|
347
|
+
| Rule not met | `403 FORBIDDEN`, standard error envelope |
|
|
348
|
+
| `@Public()` | Wins over `@Roles` / `@Permissions` on the same route; a warning is logged at startup |
|
|
349
|
+
|
|
350
|
+
There is no built-in "super admin" field. To let some users through every rule, say so explicitly:
|
|
351
|
+
|
|
352
|
+
```typescript filename="exis.config.ts"
|
|
353
|
+
export default {
|
|
354
|
+
authorization: { superUser: (user) => user.role === 'owner' },
|
|
355
|
+
}
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
### Deny by Default
|
|
359
|
+
|
|
360
|
+
By default a controller route without `@Roles`, `@Permissions` or `@Public` is open, so a forgotten decorator exposes the route. Turn that around and require a signed-in user everywhere except routes marked `@Public()`:
|
|
361
|
+
|
|
362
|
+
```typescript filename="exis.config.ts"
|
|
363
|
+
export default {
|
|
364
|
+
authorization: { defaultPolicy: 'authenticated' },
|
|
365
|
+
}
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
It applies to class and functional controllers; plain `app.get()` routes such as health checks are not affected.
|
|
369
|
+
|
|
370
|
+
`exisjs routes` prints the rule of every route in its ACCESS column, which is the quickest way to review what is public.
|
|
371
|
+
|
|
372
|
+
---
|
|
373
|
+
|
|
374
|
+
## 11. Security Baseline
|
|
375
|
+
|
|
376
|
+
What a new app sends and hides without extra code:
|
|
377
|
+
|
|
378
|
+
| Area | Default |
|
|
379
|
+
| :--- | :--- |
|
|
380
|
+
| Headers (`helmet: { enabled: true }`) | `Content-Security-Policy: default-src 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'; form-action 'self'`, `Strict-Transport-Security`, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`, `X-XSS-Protection: 0`; no `X-Powered-By`. Set `contentSecurityPolicy` to a string to change the policy or to `false` to send none. A policy set earlier by your own middleware is kept |
|
|
381
|
+
| `/docs` | Sends its own policy allowing the Swagger UI assets from jsDelivr. The project template enables `/docs` outside production only |
|
|
382
|
+
| Errors | Unexpected errors answer `INTERNAL_ERROR` without message or stack outside development |
|
|
383
|
+
| Logs and validation errors | Credentials and connection strings are redacted (see [Errors](/docs/errors)) |
|
|
384
|
+
| Request bodies | Malformed JSON is `400 INVALID_JSON`; `__proto__` / `constructor` keys are stripped |
|
|
385
|
+
|
|
386
|
+
`tex.string({ preventSql: true, escapeHtml: true })` is input hygiene, not an injection defence: always use parameterised queries, and encode output for the context it is written to (HTML, SQL, shell).
|
|
387
|
+
|
|
@@ -117,6 +117,11 @@ const res = await api
|
|
|
117
117
|
.execute()
|
|
118
118
|
```
|
|
119
119
|
|
|
120
|
+
Two more builder methods:
|
|
121
|
+
|
|
122
|
+
- `.query({ page: 2, tags: ['a', 'b'] })` appends a query string.
|
|
123
|
+
- `.timeout(ms)` fails the request after `ms` milliseconds (default 5000, or `EXIS_TEST_TIMEOUT`; `0` waits forever). A handler that never answers fails its own test with the route in the message instead of hanging the whole run.
|
|
124
|
+
|
|
120
125
|
### Response Object Properties
|
|
121
126
|
|
|
122
127
|
The returned `TestResponse` contains:
|
|
@@ -132,6 +137,39 @@ export interface TestResponse {
|
|
|
132
137
|
|
|
133
138
|
---
|
|
134
139
|
|
|
140
|
+
## Testing Over Real Sockets (`startTestServer`)
|
|
141
|
+
|
|
142
|
+
In-memory requests cover most tests. For what only a real connection shows (CORS and security headers as a browser sees them, keep-alive, malformed HTTP), start the app on a free port:
|
|
143
|
+
|
|
144
|
+
```typescript filename="tests/headers.test.ts"
|
|
145
|
+
import { describe, it, expect, startTestServer } from 'exisjs/testing'
|
|
146
|
+
import app from '../src/http/server'
|
|
147
|
+
|
|
148
|
+
describe('headers', () => {
|
|
149
|
+
it('sends a Content-Security-Policy', async () => {
|
|
150
|
+
const server = await startTestServer(app)
|
|
151
|
+
try {
|
|
152
|
+
const res = await server.request('/health')
|
|
153
|
+
expect(res.headers.get('content-security-policy')).toBeDefined()
|
|
154
|
+
} finally {
|
|
155
|
+
await server.close()
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`exisjs test` runs your source files. To test the production build itself, run `exisjs build`, then start it with `exisjs start` (which always sets `NODE_ENV=production`) and send requests to it.
|
|
162
|
+
|
|
163
|
+
## Cron Jobs in Tests
|
|
164
|
+
|
|
165
|
+
Under `NODE_ENV=test` (what `exisjs test` sets) cron jobs and timeout tasks are registered but **never started**, so a test run cannot fire a real job. Run one on demand, or opt in for a test file:
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
await app.cron.trigger('daily-cleanup') // runs it once, now
|
|
169
|
+
|
|
170
|
+
const api = createTestContext(app, { cron: true }) // schedules jobs as in production
|
|
171
|
+
```
|
|
172
|
+
|
|
135
173
|
## Unit Testing Controllers & Middlewares
|
|
136
174
|
|
|
137
175
|
For fast unit tests that do not require booting a full HTTP server pipeline, use `createMockRequest` and `createMockResponse`:
|
|
@@ -78,7 +78,7 @@ All primitive builders accept modifier options directly in their configuration a
|
|
|
78
78
|
| **Optional** | `{ optional: true }` | `.optional()` | Allows `undefined`. |
|
|
79
79
|
| **Nullable** | `{ nullable: true }` | `.nullable()` | Allows `null`. |
|
|
80
80
|
| **Nullish** | `{ nullish: true }` | `.nullish()` | Allows both `null` and `undefined`. |
|
|
81
|
-
| **Default** | `{ default: value \| () => value }` | N/A |
|
|
81
|
+
| **Default** | `{ default: value \| () => value }` | N/A | Used when the field is missing. A client may omit the field, and after parsing it always has a value. |
|
|
82
82
|
|
|
83
83
|
```typescript filename="example.ts"
|
|
84
84
|
import { tex } from 'exisjs/validator'
|
|
@@ -92,6 +92,26 @@ const Schema = tex.object({
|
|
|
92
92
|
})
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
+
### Input and Output Types
|
|
96
|
+
|
|
97
|
+
A field with a `default` may be left out of a request but is always present after parsing, so the two sides have different types:
|
|
98
|
+
|
|
99
|
+
```typescript filename="src/http/reports/schema.ts"
|
|
100
|
+
import { tex, type ResolveInput, type ResolveOutput } from 'exisjs/validator'
|
|
101
|
+
|
|
102
|
+
export const MODULES = ['ALL', 'EV', 'PV'] as const // readonly arrays work in tex.enum
|
|
103
|
+
|
|
104
|
+
export const ReportQuery = tex.object({
|
|
105
|
+
module: tex.enum(MODULES, { default: 'ALL' }),
|
|
106
|
+
search: tex.string({ optional: true }),
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
type Query = ResolveOutput<typeof ReportQuery> // what the handler gets: module is 'ALL' | 'EV' | 'PV'
|
|
110
|
+
type QueryInput = ResolveInput<typeof ReportQuery> // what a client sends: module may be omitted
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`ResolveSchema` is the same as `ResolveOutput`. Validation reports every failing field, not only the first.
|
|
114
|
+
|
|
95
115
|
> [!NOTE]
|
|
96
116
|
> **Empty String Normalization**: When query parameters or form submissions supply empty strings `""` for fields marked `.optional()`, `.nullable()`, or `.nullish()`, ExisJS converts them to `undefined` or `null` before validation to avoid type conversion errors.
|
|
97
117
|
|
|
@@ -25,8 +25,6 @@ import {
|
|
|
25
25
|
Options,
|
|
26
26
|
Head,
|
|
27
27
|
All,
|
|
28
|
-
Ws,
|
|
29
|
-
Sse,
|
|
30
28
|
|
|
31
29
|
// Parameter Injection
|
|
32
30
|
Body,
|
|
@@ -152,8 +150,8 @@ Method decorators register class methods as HTTP route handlers and attach valid
|
|
|
152
150
|
| `@Options(path?, schema?)` | `OPTIONS` | `(path?: string, schema?: MethodSchemaInput) => MethodDecorator` |
|
|
153
151
|
| `@Head(path?, schema?)` | `HEAD` | `(path?: string, schema?: MethodSchemaInput) => MethodDecorator` |
|
|
154
152
|
| `@All(path?, schema?)` | Any Method | `(path?: string, schema?: MethodSchemaInput) => MethodDecorator` |
|
|
155
|
-
|
|
156
|
-
|
|
153
|
+
|
|
154
|
+
> Server-Sent Events use a normal `@Get` route that calls `res.sse()`; see [Responses](/docs/responses). WebSocket routes are not part of the class decorators.
|
|
157
155
|
|
|
158
156
|
---
|
|
159
157
|
|
|
@@ -217,6 +217,8 @@ function ipFilter(options: IpFilterOptions): Handler
|
|
|
217
217
|
| `allow` | `string[]` | Array of permitted IP addresses or CIDR blocks (e.g. `['10.0.0.0/8', '192.168.1.5']`). |
|
|
218
218
|
| `deny` | `string[]` | Array of blocked IP addresses or CIDR blocks. |
|
|
219
219
|
|
|
220
|
+
The address checked is `req.ip`. Behind a proxy or load balancer, set `trustProxy` in `exis.config.ts` so `req.ip` is the real client; `ipFilter` never reads `X-Forwarded-For` itself, because a client can send any value in that header.
|
|
221
|
+
|
|
220
222
|
---
|
|
221
223
|
|
|
222
224
|
### `backpressure(options?)`
|
|
@@ -232,8 +232,6 @@ import {
|
|
|
232
232
|
Options,
|
|
233
233
|
Head,
|
|
234
234
|
All,
|
|
235
|
-
Ws,
|
|
236
|
-
Sse,
|
|
237
235
|
Body,
|
|
238
236
|
Param,
|
|
239
237
|
Query,
|
|
@@ -311,8 +309,8 @@ Method decorators attach HTTP handler logic and validation schemas to controller
|
|
|
311
309
|
- `@Options(path?, schema?)`
|
|
312
310
|
- `@Head(path?, schema?)`
|
|
313
311
|
- `@All(path?, schema?)`
|
|
314
|
-
|
|
315
|
-
- `@
|
|
312
|
+
|
|
313
|
+
> Server-Sent Events use a normal `@Get` route that calls `res.sse()`; see [Responses](/docs/responses). WebSocket routes are not part of the class decorators.
|
|
316
314
|
|
|
317
315
|
---
|
|
318
316
|
|
|
@@ -361,9 +359,9 @@ getProfile(@CurrentUser() user: any, @CurrentUser('id') userId: string) {
|
|
|
361
359
|
| Decorator | Target | Description |
|
|
362
360
|
| :--- | :--- | :--- |
|
|
363
361
|
| `@Use(...middlewares)` | Class / Method | Applies middleware functions to the controller or route. |
|
|
364
|
-
| `@
|
|
365
|
-
| `@
|
|
366
|
-
| `@
|
|
362
|
+
| `@UseGuards(...guards)` | Class / Method | Attaches authorization guards. |
|
|
363
|
+
| `@UseFilters(...filters)` | Class / Method | Attaches exception filters. |
|
|
364
|
+
| `@UseInterceptors(...interceptors)` | Class / Method | Wraps the handler: each interceptor receives `next.handle()` and its return value becomes the response. |
|
|
367
365
|
| `@HttpCode(status)` | Method | Sets the default HTTP status code for successful responses. |
|
|
368
366
|
| `@Header(name, value)` | Method | Sets static HTTP response headers. |
|
|
369
367
|
| `@Redirect(url, status?)` | Method | Redirects responses to the target URL. |
|
package/dist/error/errors.d.ts
CHANGED
|
@@ -80,7 +80,15 @@ export declare function renderValidationTable(rows: {
|
|
|
80
80
|
received: string;
|
|
81
81
|
expected: string;
|
|
82
82
|
}[]): string;
|
|
83
|
-
export
|
|
83
|
+
export interface ErrorHandlerOptions {
|
|
84
|
+
/**
|
|
85
|
+
* Also send the pre-0.9 duplicate fields (`message`, `validationErrors`,
|
|
86
|
+
* `errors`, `details`, `timestamp`) next to `error`. Deprecated; for
|
|
87
|
+
* clients that have not moved to `error.code` / `error.message` / `error.details` yet.
|
|
88
|
+
*/
|
|
89
|
+
legacyFields?: boolean;
|
|
90
|
+
}
|
|
91
|
+
export declare function createErrorHandler(isDev?: boolean, options?: ErrorHandlerOptions): ErrorHandler;
|
|
84
92
|
/**
|
|
85
93
|
* Async handler wrapper — catches promise rejections.
|
|
86
94
|
* Note: Exis automatically handles async promise rejections internally.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/error/errors.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,OAAO,EAAE,MAAM,UAAU,CAAA;AAerD,qBAAa,SAAU,SAAQ,KAAK;IAClC,SAAgB,UAAU,EAAE,MAAM,CAAA;IAClC,SAAgB,IAAI,EAAE,MAAM,CAAA;IAC5B,SAAgB,aAAa,EAAE,OAAO,CAAA;IACtC,SAAgB,OAAO,CAAC,EAAE,OAAO,CAAA;gBAG/B,OAAO,EAAE,MAAM,EACf,UAAU,SAAM,EAChB,IAAI,SAAmB,EACvB,OAAO,CAAC,EAAE,OAAO;IAenB,MAAM,CAAC,UAAU,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,SAAS;IAIhE,MAAM,CAAC,YAAY,CAAC,OAAO,SAAiB,GAAG,SAAS;IAIxD,MAAM,CAAC,SAAS,CAAC,OAAO,SAAc,GAAG,SAAS;IAIlD,MAAM,CAAC,QAAQ,CAAC,QAAQ,SAAa,GAAG,SAAS;IAIjD,MAAM,CAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS;IAI3C,MAAM,CAAC,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,SAAS;IAInE,MAAM,CAAC,eAAe,CACpB,OAAO,SAAsB,EAC7B,OAAO,CAAC,EAAE,OAAO,GAChB,SAAS;IAIZ,MAAM,CAAC,eAAe,CAAC,OAAO,SAAsB,GAAG,SAAS;IAIhE,MAAM,CAAC,QAAQ,CAAC,OAAO,SAA0B,GAAG,SAAS;IAI7D,MAAM,CAAC,kBAAkB,CAAC,OAAO,SAAwB,GAAG,SAAS;IAIrE,MAAM,CAAC,cAAc,CAAC,OAAO,SAAoB,GAAG,SAAS;IAM7D,MAAM,IAAI,MAAM;
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/error/errors.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,OAAO,EAAE,MAAM,UAAU,CAAA;AAerD,qBAAa,SAAU,SAAQ,KAAK;IAClC,SAAgB,UAAU,EAAE,MAAM,CAAA;IAClC,SAAgB,IAAI,EAAE,MAAM,CAAA;IAC5B,SAAgB,aAAa,EAAE,OAAO,CAAA;IACtC,SAAgB,OAAO,CAAC,EAAE,OAAO,CAAA;gBAG/B,OAAO,EAAE,MAAM,EACf,UAAU,SAAM,EAChB,IAAI,SAAmB,EACvB,OAAO,CAAC,EAAE,OAAO;IAenB,MAAM,CAAC,UAAU,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,SAAS;IAIhE,MAAM,CAAC,YAAY,CAAC,OAAO,SAAiB,GAAG,SAAS;IAIxD,MAAM,CAAC,SAAS,CAAC,OAAO,SAAc,GAAG,SAAS;IAIlD,MAAM,CAAC,QAAQ,CAAC,QAAQ,SAAa,GAAG,SAAS;IAIjD,MAAM,CAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS;IAI3C,MAAM,CAAC,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,SAAS;IAInE,MAAM,CAAC,eAAe,CACpB,OAAO,SAAsB,EAC7B,OAAO,CAAC,EAAE,OAAO,GAChB,SAAS;IAIZ,MAAM,CAAC,eAAe,CAAC,OAAO,SAAsB,GAAG,SAAS;IAIhE,MAAM,CAAC,QAAQ,CAAC,OAAO,SAA0B,GAAG,SAAS;IAI7D,MAAM,CAAC,kBAAkB,CAAC,OAAO,SAAwB,GAAG,SAAS;IAIrE,MAAM,CAAC,cAAc,CAAC,OAAO,SAAoB,GAAG,SAAS;IAM7D,MAAM,IAAI,MAAM;CAWjB;AAED,qBAAa,eAAgB,SAAQ,SAAS;gBAChC,OAAO,SAAgB,EAAE,OAAO,CAAC,EAAE,OAAO;CAIvD;AAED,qBAAa,iBAAkB,SAAQ,SAAS;gBAClC,OAAO,SAAiB;CAIrC;AAED,qBAAa,cAAe,SAAQ,SAAS;gBAC/B,OAAO,SAAc;CAIlC;AAED,qBAAa,aAAc,SAAQ,SAAS;gBAC9B,QAAQ,SAAa;CAIlC;AAED,qBAAa,aAAc,SAAQ,SAAS;gBAC9B,OAAO,SAAa;CAIjC;AAED,qBAAa,kBAAmB,SAAQ,SAAS;gBACnC,OAAO,SAAyB,EAAE,OAAO,CAAC,EAAE,OAAO;CAIhE;AAED,qBAAa,oBAAqB,SAAQ,SAAS;gBACrC,OAAO,SAAsB,EAAE,OAAO,CAAC,EAAE,OAAO;CAI7D;AAED,qBAAa,cAAe,SAAQ,SAAS;gBAC/B,OAAO,SAAsB;CAI1C;AAED,qBAAa,aAAc,SAAQ,SAAS;gBAC9B,OAAO,SAA0B;CAI9C;AAED,qBAAa,uBAAwB,SAAQ,SAAS;gBACxC,OAAO,SAAwB;CAI5C;AAED,qBAAa,mBAAoB,SAAQ,SAAS;gBACpC,OAAO,SAAoB;CAIxC;AAGD,qBAAa,aAAc,SAAQ,SAAS;CAAG;AAC/C,qBAAa,mBAAoB,SAAQ,eAAe;CAAG;AAC3D,qBAAa,qBAAsB,SAAQ,iBAAiB;CAAG;AAC/D,qBAAa,kBAAmB,SAAQ,cAAc;CAAG;AACzD,qBAAa,iBAAkB,SAAQ,aAAa;CAAG;AACvD,qBAAa,iBAAkB,SAAQ,aAAa;CAAG;AACvD,qBAAa,wBAAyB,SAAQ,oBAAoB;CAAG;AACrE,qBAAa,sBAAuB,SAAQ,kBAAkB;CAAG;AACjE,qBAAa,kBAAmB,SAAQ,cAAc;CAAG;AACzD,qBAAa,iBAAkB,SAAQ,aAAa;CAAG;AACvD,qBAAa,2BAA4B,SAAQ,uBAAuB;CAAG;AAC3E,qBAAa,uBAAwB,SAAQ,mBAAmB;CAAG;AAiHnE,wBAAgB,qBAAqB,CACnC,IAAI,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,EAAE,GAC5D,MAAM,CA6BR;AAED,MAAM,WAAW,mBAAmB;IAClC;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;CACvB;AAED,wBAAgB,kBAAkB,CAChC,KAAK,UAAQ,EACb,OAAO,GAAE,mBAAwB,GAChC,YAAY,CAoUd;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,EAAE,EAAE,OAAO,GAAG,OAAO,CAIjD"}
|
package/dist/error/errors.js
CHANGED
|
@@ -75,7 +75,6 @@ class HttpError extends Error {
|
|
|
75
75
|
message: this.message,
|
|
76
76
|
...(this.details !== undefined && { details: this.details }),
|
|
77
77
|
},
|
|
78
|
-
...(this.details && typeof this.details === 'object' ? this.details : {}),
|
|
79
78
|
};
|
|
80
79
|
}
|
|
81
80
|
}
|
|
@@ -195,6 +194,8 @@ class GatewayTimeoutException extends GatewayTimeoutError {
|
|
|
195
194
|
}
|
|
196
195
|
exports.GatewayTimeoutException = GatewayTimeoutException;
|
|
197
196
|
// ─── Global Error Handler ─────────────────────────────────────────────────────
|
|
197
|
+
// The development error page is self-contained with inline styles
|
|
198
|
+
const DEV_PAGE_CSP = "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'";
|
|
198
199
|
function escapeHtml(str) {
|
|
199
200
|
if (typeof str !== 'string')
|
|
200
201
|
return String(str);
|
|
@@ -307,7 +308,8 @@ function renderValidationTable(rows) {
|
|
|
307
308
|
const bot = `└─${'─'.repeat(fieldWidth)}─┴─${'─'.repeat(receivedWidth)}─┴─${'─'.repeat(expectedWidth)}─┘`;
|
|
308
309
|
return [top, headerRow, sep, ...dataRows, bot].join('\n');
|
|
309
310
|
}
|
|
310
|
-
function createErrorHandler(isDev = false) {
|
|
311
|
+
function createErrorHandler(isDev = false, options = {}) {
|
|
312
|
+
const legacy = options.legacyFields === true;
|
|
311
313
|
return (err, req, res, _next) => {
|
|
312
314
|
if (res.headersSent)
|
|
313
315
|
return;
|
|
@@ -316,6 +318,7 @@ function createErrorHandler(isDev = false) {
|
|
|
316
318
|
if (isDev &&
|
|
317
319
|
req.headers.accept?.includes('text/html') &&
|
|
318
320
|
err.statusCode >= 500) {
|
|
321
|
+
res.setHeader('Content-Security-Policy', DEV_PAGE_CSP);
|
|
319
322
|
res.status(err.statusCode).html(renderErrorHtml(err, req));
|
|
320
323
|
return;
|
|
321
324
|
}
|
|
@@ -480,7 +483,7 @@ function createErrorHandler(isDev = false) {
|
|
|
480
483
|
tableRows.push({
|
|
481
484
|
field,
|
|
482
485
|
// Never echo credentials into logs or error responses
|
|
483
|
-
received: (0, error_1.
|
|
486
|
+
received: (0, error_1.redactReceived)(field, formattedReceived),
|
|
484
487
|
expected: itemDetail?.expected || expectedMsg,
|
|
485
488
|
code: itemDetail?.code,
|
|
486
489
|
});
|
|
@@ -527,12 +530,13 @@ function createErrorHandler(isDev = false) {
|
|
|
527
530
|
message: message || 'Validation Error',
|
|
528
531
|
details: responseDetails,
|
|
529
532
|
},
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
533
|
+
...(legacy && {
|
|
534
|
+
message: message || 'Validation Error',
|
|
535
|
+
validationErrors: errorsMap,
|
|
536
|
+
errors: errorsMap,
|
|
537
|
+
details: responseDetails,
|
|
538
|
+
timestamp: new Date().toISOString(),
|
|
539
|
+
}),
|
|
536
540
|
});
|
|
537
541
|
return;
|
|
538
542
|
}
|
|
@@ -545,9 +549,11 @@ function createErrorHandler(isDev = false) {
|
|
|
545
549
|
code: 'INVALID_JSON',
|
|
546
550
|
message: 'Invalid JSON in request body',
|
|
547
551
|
},
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
552
|
+
...(legacy && {
|
|
553
|
+
message: 'Invalid JSON in request body',
|
|
554
|
+
validationErrors: null,
|
|
555
|
+
timestamp: new Date().toISOString(),
|
|
556
|
+
}),
|
|
551
557
|
});
|
|
552
558
|
return;
|
|
553
559
|
}
|
|
@@ -561,6 +567,7 @@ function createErrorHandler(isDev = false) {
|
|
|
561
567
|
logger_1.logger.error({ err }, 'unhandled server error');
|
|
562
568
|
}
|
|
563
569
|
if (isDev && req.headers.accept?.includes('text/html')) {
|
|
570
|
+
res.setHeader('Content-Security-Policy', DEV_PAGE_CSP);
|
|
564
571
|
res
|
|
565
572
|
.status(500)
|
|
566
573
|
.html(renderErrorHtml(err instanceof Error ? err : new Error(String(err)), req));
|
|
@@ -575,9 +582,11 @@ function createErrorHandler(isDev = false) {
|
|
|
575
582
|
message: internalMessage,
|
|
576
583
|
...(isDev && { stack: err.stack }),
|
|
577
584
|
},
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
585
|
+
...(legacy && {
|
|
586
|
+
message: internalMessage,
|
|
587
|
+
validationErrors: null,
|
|
588
|
+
timestamp: new Date().toISOString(),
|
|
589
|
+
}),
|
|
581
590
|
});
|
|
582
591
|
};
|
|
583
592
|
}
|