@webpieces/http-server 0.4.694 → 0.4.696

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webpieces/http-server",
3
- "version": "0.4.694",
3
+ "version": "0.4.696",
4
4
  "description": "WebPieces server with filter chain and dependency injection",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -22,10 +22,10 @@
22
22
  "access": "public"
23
23
  },
24
24
  "dependencies": {
25
- "@webpieces/core-context": "0.4.694",
26
- "@webpieces/core-util": "0.4.694",
27
- "@webpieces/gcp-identity": "0.4.694",
28
- "@webpieces/http-routing": "0.4.694",
25
+ "@webpieces/core-context": "0.4.696",
26
+ "@webpieces/core-util": "0.4.696",
27
+ "@webpieces/gcp-identity": "0.4.696",
28
+ "@webpieces/http-routing": "0.4.696",
29
29
  "cors": "2.8.5",
30
30
  "express": "5.1.0",
31
31
  "inversify": "7.10.4"
@@ -48,6 +48,12 @@ export declare class ExpressWrapper {
48
48
  * `createExpressWrapper` rather than reaching around the middleware to construct a wrapper.
49
49
  */
50
50
  private maxBodyBytes;
51
+ /**
52
+ * Decides what an outside caller is allowed to see of a thrown {@link HttpError}. Stateless —
53
+ * one instance per wrapper is fine, and the class doc there is where the "only HttpUserError's
54
+ * message goes on the wire" rule is stated.
55
+ */
56
+ private readonly errorWireMapper;
51
57
  constructor(clientMethod: (requestDto: unknown) => Promise<unknown>, path: string,
52
58
  /** Owns the wire<->context transfer, both directions. Stateless framework singleton. */
53
59
  headers: RequestContextHeaders,
@@ -126,17 +132,23 @@ export declare class ExpressWrapper {
126
132
  * Maps HttpError subclasses to appropriate HTTP status codes and ProtocolError response.
127
133
  *
128
134
  * Maps all HttpError types (must match ClientErrorTranslator's built-in status mapping):
129
- * - HttpUserError → 266 (with errorCode)
130
- * - HttpBadRequestError → 400 (with field, guiAlertMessage)
131
- * - HttpUnauthorizedError → 401
132
- * - HttpForbiddenError → 403
133
- * - HttpNotFoundError → 404
134
- * - HttpTimeoutError → 408
135
- * - HttpInternalServerError500
136
- * - HttpBadGatewayError502
137
- * - HttpServiceUnavailableError503 (generic branch: res.status(error.code))
138
- * - HttpGatewayTimeoutError504
139
- * - HttpVendorError598 (with waitSeconds)
135
+ * - HttpUserError → 266 (message + subType + errorCode)
136
+ * - HttpBadRequestError → 400 (generic message + field, guiAlertMessage)
137
+ * - HttpUnauthorizedError → 401 (generic message + subType)
138
+ * - HttpForbiddenError → 403 (generic message)
139
+ * - HttpNotFoundError → 404 (generic message)
140
+ * - HttpTimeoutError → 408 (generic message)
141
+ * - HttpTooManyRequestsError429 (generic message)
142
+ * - HttpInternalServerError500 (generic message)
143
+ * - HttpBadGatewayError502 (generic message)
144
+ * - HttpServiceUnavailableError503 (generic message)
145
+ * - HttpGatewayTimeoutError504 (generic message)
146
+ * - HttpVendorError → 598 (generic message + waitSeconds)
147
+ *
148
+ * "generic message" is the point of {@link HttpErrorWireMapper}: ONLY `HttpUserError`'s message
149
+ * was written for a human to read, so only it is copied to the response body. Every other type
150
+ * sends the standard reason phrase for its status and logs the real message. Read that class's
151
+ * doc for the full rule, including why `subType` is kept and `name` is not.
140
152
  */
141
153
  handleError(res: Response, error: unknown): void;
142
154
  }
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ExpressWrapper = exports.MAX_BODY_BYTES = void 0;
4
4
  const core_util_1 = require("@webpieces/core-util");
5
5
  const core_context_1 = require("@webpieces/core-context");
6
+ const HttpErrorWireMapper_1 = require("./HttpErrorWireMapper");
6
7
  // The logging backend prepends this logger name to every line, so messages below carry NO
7
8
  // "[ExpressWrapper]" literal of their own — that would print the name twice.
8
9
  const log = core_util_1.LogManager.getLogger('ExpressWrapper');
@@ -32,6 +33,12 @@ class ExpressWrapper {
32
33
  formPost;
33
34
  rawBody;
34
35
  maxBodyBytes;
36
+ /**
37
+ * Decides what an outside caller is allowed to see of a thrown {@link HttpError}. Stateless —
38
+ * one instance per wrapper is fine, and the class doc there is where the "only HttpUserError's
39
+ * message goes on the wire" rule is stated.
40
+ */
41
+ errorWireMapper = new HttpErrorWireMapper_1.HttpErrorWireMapper();
35
42
  constructor(
36
43
  // webpieces-disable no-any-unknown -- request/response DTOs are erased at the routing boundary
37
44
  clientMethod, path,
@@ -239,17 +246,23 @@ class ExpressWrapper {
239
246
  * Maps HttpError subclasses to appropriate HTTP status codes and ProtocolError response.
240
247
  *
241
248
  * Maps all HttpError types (must match ClientErrorTranslator's built-in status mapping):
242
- * - HttpUserError → 266 (with errorCode)
243
- * - HttpBadRequestError → 400 (with field, guiAlertMessage)
244
- * - HttpUnauthorizedError → 401
245
- * - HttpForbiddenError → 403
246
- * - HttpNotFoundError → 404
247
- * - HttpTimeoutError → 408
248
- * - HttpInternalServerError500
249
- * - HttpBadGatewayError502
250
- * - HttpServiceUnavailableError503 (generic branch: res.status(error.code))
251
- * - HttpGatewayTimeoutError504
252
- * - HttpVendorError598 (with waitSeconds)
249
+ * - HttpUserError → 266 (message + subType + errorCode)
250
+ * - HttpBadRequestError → 400 (generic message + field, guiAlertMessage)
251
+ * - HttpUnauthorizedError → 401 (generic message + subType)
252
+ * - HttpForbiddenError → 403 (generic message)
253
+ * - HttpNotFoundError → 404 (generic message)
254
+ * - HttpTimeoutError → 408 (generic message)
255
+ * - HttpTooManyRequestsError429 (generic message)
256
+ * - HttpInternalServerError500 (generic message)
257
+ * - HttpBadGatewayError502 (generic message)
258
+ * - HttpServiceUnavailableError503 (generic message)
259
+ * - HttpGatewayTimeoutError504 (generic message)
260
+ * - HttpVendorError → 598 (generic message + waitSeconds)
261
+ *
262
+ * "generic message" is the point of {@link HttpErrorWireMapper}: ONLY `HttpUserError`'s message
263
+ * was written for a human to read, so only it is copied to the response body. Every other type
264
+ * sends the standard reason phrase for its status and logs the real message. Read that class's
265
+ * doc for the full rule, including why `subType` is kept and `name` is not.
253
266
  */
254
267
  // webpieces-disable no-any-unknown -- a thrown error is genuinely unknown until narrowed below
255
268
  handleError(res, error) {
@@ -269,57 +282,20 @@ class ExpressWrapper {
269
282
  return;
270
283
  }
271
284
  }
272
- const protocolError = new core_util_1.ProtocolError();
273
285
  if (error instanceof core_util_1.HttpError) {
274
- // Set common fields for all HttpError types
275
- protocolError.message = error.message;
276
- protocolError.subType = error.subType;
277
- protocolError.name = error.name;
278
- // Set type-specific fields (MUST match ClientErrorTranslator's built-in status mapping)
279
- if (error instanceof core_util_1.HttpUserError) {
280
- log.info(`User Error: ${error.message}`);
281
- protocolError.errorCode = error.errorCode;
282
- }
283
- else if (error instanceof core_util_1.HttpBadRequestError) {
284
- log.info(`Bad Request: ${error.message}`);
285
- protocolError.field = error.field;
286
- protocolError.guiAlertMessage = error.guiMessage;
287
- }
288
- else if (error instanceof core_util_1.HttpNotFoundError) {
289
- log.info(`Not Found: ${error.message}`);
290
- }
291
- else if (error instanceof core_util_1.HttpTimeoutError) {
292
- log.error(`Timeout Error: ${error.message}`);
293
- }
294
- else if (error instanceof core_util_1.HttpVendorError) {
295
- log.error(`Vendor Error: ${error.message}`);
296
- protocolError.waitSeconds = error.waitSeconds;
297
- }
298
- else if (error instanceof core_util_1.HttpUnauthorizedError) {
299
- log.info(`Unauthorized: ${error.message}`);
300
- }
301
- else if (error instanceof core_util_1.HttpForbiddenError) {
302
- log.info(`Forbidden: ${error.message}`);
303
- }
304
- else if (error instanceof core_util_1.HttpInternalServerError) {
305
- log.error(`Internal Server Error: ${error.message}`);
306
- }
307
- else if (error instanceof core_util_1.HttpBadGatewayError) {
308
- log.error(`Bad Gateway: ${error.message}`);
309
- }
310
- else if (error instanceof core_util_1.HttpGatewayTimeoutError) {
311
- log.error(`Gateway Timeout: ${error.message}`);
312
- }
313
- else {
314
- log.info(`Generic HttpError: ${error.message}`);
315
- }
286
+ // The mapper decides what the caller may see AND logs everything it withholds — see
287
+ // HttpErrorWireMapper. Nothing type-specific is decided here any more.
288
+ const protocolError = this.errorWireMapper.toWire(error);
316
289
  // Serialize ProtocolError to JSON (SYMMETRIC with client)
317
290
  const responseJson = JSON.stringify(protocolError);
318
291
  res.status(error.code).setHeader('Content-Type', 'application/json').send(responseJson);
319
292
  }
320
293
  else {
321
- // Unknown error - 500
294
+ // Unknown error - 500. This branch was ALREADY generic, which is what made the old
295
+ // HttpError branch's verbatim `error.message` so obviously backwards: an unexpected crash
296
+ // leaked nothing while a deliberate HttpInternalServerError leaked everything.
322
297
  const err = (0, core_util_1.toError)(error);
298
+ const protocolError = new core_util_1.ProtocolError();
323
299
  protocolError.message = 'Internal Server Error';
324
300
  log.error('Unexpected error:', err);
325
301
  const responseJson = JSON.stringify(protocolError);
@@ -1 +1 @@
1
- {"version":3,"file":"ExpressWrapper.js","sourceRoot":"","sources":["../../../../../packages/http/http-server/src/ExpressWrapper.ts"],"names":[],"mappings":"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","sourcesContent":["import { Request, Response, NextFunction } from 'express';\nimport {\n ProtocolError,\n ClientRegistry,\n HttpError,\n HttpBadRequestError,\n HttpVendorError,\n HttpUserError,\n HttpNotFoundError,\n HttpTimeoutError,\n HttpUnauthorizedError,\n HttpForbiddenError,\n HttpInternalServerError,\n HttpBadGatewayError,\n HttpGatewayTimeoutError,\n toError,\n LogManager,\n} from '@webpieces/core-util';\nimport { RequestContext, HttpRequest, RawRequest, RequestContextHeaders } from '@webpieces/core-context';\n\n// The logging backend prepends this logger name to every line, so messages below carry NO\n// \"[ExpressWrapper]\" literal of their own — that would print the name twice.\nconst log = LogManager.getLogger('ExpressWrapper');\n\n/**\n * The cap on an inbound body, in bytes. Reading stops and the request is refused the moment a body\n * crosses it — the bytes already read are dropped, nothing further is buffered.\n *\n * There was NO limit at all before, which was a latent memory DoS on every route and an outright one\n * on a webhook route: `{ rawBody: true }` retains what it reads, and a webhook url is public by\n * construction, so the endpoint most likely to be flooded was also the one that held on to the flood.\n * 10 MiB is comfortably above any api DTO and well under Cloud Run's own 32 MiB request limit.\n *\n * Read this as FRAMEWORK-FIXED, because today an app has no knob for it. The only seam that accepts a\n * different number is the {@link ExpressWrapper} constructor parameter, and nothing production reaches\n * it: `WebpiecesMiddleware.createExpressWrapper` forwards no such argument, and neither\n * `ExpressWrapper` nor this constant is exported from this package's barrel (`src/index.ts`), so the\n * only caller that can vary the cap is a spec inside this package. An app that legitimately needs to\n * accept a larger body therefore cannot unblock itself and has to open an issue. Making it tunable is a\n * code change, not a config one — a follow-up has to thread a value through `createExpressWrapper` and\n * decide where an app declares it (per-route, most likely, since that is the granularity the need has).\n */\nexport const MAX_BODY_BYTES = 10 * 1024 * 1024;\n\nexport class ExpressWrapper {\n constructor(\n // webpieces-disable no-any-unknown -- request/response DTOs are erased at the routing boundary\n private clientMethod: (requestDto: unknown) => Promise<unknown>,\n private path: string,\n /** Owns the wire<->context transfer, both directions. Stateless framework singleton. */\n private headers: RequestContextHeaders,\n /**\n * True for an @Endpoint(..., { formPost: true }) route: parse the body as\n * application/x-www-form-urlencoded (flat) instead of JSON. Driven by the ANNOTATION, not\n * the request Content-Type header — the annotation is the single source of truth.\n */\n private formPost: boolean = false,\n /**\n * True for an @Endpoint(..., { rawBody: true }) route: RETAIN the verbatim bytes + the\n * absolute url on the published {@link HttpRequest}, so an @AuthWebhook hook can verify a\n * vendor signature over what the sender actually transmitted. Also switches the JSON parse\n * failure from \"throw now\" to \"hold it for AuthFilter\" — see {@link RawRequest.bodyParseError}.\n */\n private rawBody: boolean = false,\n /**\n * The inbound body cap for this route. See {@link MAX_BODY_BYTES}.\n *\n * No production caller passes this — `WebpiecesMiddleware.createExpressWrapper` builds every\n * wrapper without it, so every live route runs on the default. The parameter exists so the\n * refusal path can be tested against a small cap instead of a 10 MiB fixture, and the specs in\n * this package are its only callers; it is not an app-facing tuning point, since the class is\n * not exported from `src/index.ts`. If a route ever needs a different cap, thread it through\n * `createExpressWrapper` rather than reaching around the middleware to construct a wrapper.\n */\n private maxBodyBytes: number = MAX_BODY_BYTES,\n ) {\n }\n\n public async execute(req: Request, res: Response, next: NextFunction): Promise<void> {\n // MOVED: Wrap entire request in RequestContext.run()\n // This establishes AsyncLocalStorage context for the request\n await RequestContext.run(async () => {\n await this.executeTryCatch(req, res, next);\n });\n }\n\n public async executeTryCatch(req: Request, res: Response, next: NextFunction): Promise<void> {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- ExpressWrapper catches errors to translate to HTTP responses\n try {\n await this.executeImpl(req, res, next);\n } catch (err: unknown) {\n const error = toError(err);\n // 5. Handle errors\n this.handleError(res, error);\n }\n }\n\n public async executeImpl(req: Request, res: Response, next: NextFunction): Promise<void> {\n // 1. Parse the request body. The PARSER is chosen by the @Endpoint annotation (this.formPost),\n // NOT the request Content-Type header — the annotation is the single source of truth.\n // webpieces-disable no-any-unknown -- request/response DTOs are erased at the routing boundary\n let requestDto: unknown = {};\n let raw: RawRequest | undefined;\n if (['POST', 'PUT', 'PATCH'].includes(req.method)) {\n // Read BYTES, not text. Concatenating per-chunk toString() corrupted any multi-byte\n // character that straddled a chunk boundary — invisible on small bodies, and fatal for a\n // signature computed over the bytes.\n const bodyBytes = await this.readRequestBody(req);\n const bodyText = bodyBytes.toString('utf8');\n let parseError: Error | undefined;\n if (this.formPost) {\n // application/x-www-form-urlencoded → flat key→value. URLSearchParams is lenient\n // (never throws) — right for EXTERNAL webhooks (e.g. Twilio) that post form-encoded.\n requestDto = Object.fromEntries(new URLSearchParams(bodyText));\n } else {\n // JSON (default, SYMMETRIC with the client's JSON.stringify). A non-JSON body is a\n // CLIENT error → 400, not the raw 500 an unguarded JSON.parse would throw.\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- translate parse failure to a 400 HttpError\n try {\n requestDto = bodyText ? JSON.parse(bodyText) : {};\n } catch (err: unknown) {\n const error = toError(err);\n // On a raw-body (webhook) route the failure is HELD, not thrown: AuthFilter must\n // answer 401 to an unauthenticated caller rather than 400, because \"your JSON was\n // bad\" tells that caller it got past auth. Everywhere else, fail now as before.\n if (!this.rawBody) {\n throw new HttpBadRequestError('Request body is not valid JSON', undefined, undefined, error);\n }\n parseError = error;\n }\n }\n if (this.rawBody) {\n raw = new RawRequest(this.absoluteUrl(req), bodyBytes, req.socket?.remoteAddress, parseError);\n }\n }\n\n // 2. Translate express's request into the transport-neutral HttpRequest webpieces speaks.\n const httpRequest = this.toWebpiecesRequest(req, raw);\n\n // 3. Publish the transport-neutral HttpRequest, then move its headers into the context and\n // mint a request id if the caller sent none. BOTH happen above the api boundary, because\n // http-routing requires an already-established, already-filled request scope — it never\n // builds one for you. This is the \"translation layer\" every transport must provide.\n this.headers.fillFromRequest(httpRequest);\n\n // 4. Invoke the api CLIENT method — the SAME proxy tests use. Its filter chain + controller\n // run here, reading the context filled above; the chain never touches express `req`.\n const result = await this.clientMethod(requestDto);\n\n // 5. Serialize the response DTO to JSON (SYMMETRIC with client's response.json())\n const responseJson = JSON.stringify(result);\n res.status(200).setHeader('Content-Type', 'application/json').send(responseJson);\n }\n\n /**\n * Read HTTP headers from Express request.\n * Returns Map of header name (lowercase) -> array of values.\n *\n * HTTP spec allows multiple values for same header name.\n */\n /**\n * express Request -> webpieces {@link HttpRequest}. THE translation layer: below this line the\n * filter chain and controllers never see express, which is what lets the same chain run\n * in-process with no transport at all.\n */\n private toWebpiecesRequest(req: Request, raw?: RawRequest): HttpRequest {\n return new HttpRequest(req.method, this.path, this.readExpressHeaders(req), raw);\n }\n\n /**\n * The absolute url AS THE SENDER ADDRESSED IT — the string a vendor like Twilio signed.\n *\n * `x-forwarded-proto` / `x-forwarded-host` WIN when present, because behind a TLS-terminating\n * proxy (Cloud Run, any load balancer) express's own view is wrong in both halves: `req.protocol`\n * reads `http` and the Host header is the internal one, while the vendor signed the public\n * `https://...` url the customer configured. Reconstructing naively therefore fails 100% of the\n * time in production and works 100% of the time locally — the worst possible pairing, so this is\n * stated here and pinned by a test rather than left to each app.\n *\n * These headers are attacker-controllable when nothing strips them, and that is ACCEPTABLE here\n * precisely because of what the value is used for: a forged url produces a signature that does not\n * verify, i.e. a 401. It grants nothing. (It is used for verification only — never for a redirect.)\n */\n private absoluteUrl(req: Request): string {\n const forwardedProto = req.headers['x-forwarded-proto'];\n const forwardedHost = req.headers['x-forwarded-host'];\n // A proxy chain sends a comma-separated list; the FIRST entry is the original client's hop.\n const proto = this.firstForwarded(forwardedProto) ?? req.protocol;\n const host = this.firstForwarded(forwardedHost) ?? req.get('host') ?? '';\n return `${proto}://${host}${req.originalUrl ?? req.url ?? this.path}`;\n }\n\n private firstForwarded(value: string | string[] | undefined): string | undefined {\n const raw = Array.isArray(value) ? value[0] : value;\n const first = raw?.split(',')[0]?.trim();\n return first === undefined || first === '' ? undefined : first;\n }\n\n private readExpressHeaders(req: Request): Map<string, string[]> {\n const headers = new Map<string, string[]>();\n\n // Express stores headers in req.headers as Record<string, string | string[]>\n for (const [name, value] of Object.entries(req.headers)) {\n const lowerName = name.toLowerCase();\n\n if (typeof value === 'string') {\n headers.set(lowerName, [value]);\n } else if (Array.isArray(value)) {\n headers.set(lowerName, value);\n }\n }\n\n return headers;\n }\n\n /**\n * Read the raw request body as BYTES (we parse manually rather than mounting express.json()).\n *\n * Bytes, not a growing string: a per-chunk `toString()` splits any multi-byte character that\n * straddles a chunk boundary into two replacement characters, so the body a webhook hook verified\n * would not be the body the vendor signed.\n *\n * REFUSES a body over {@link maxBodyBytes} the moment it crosses the line — the chunks read so far\n * are dropped and the stream is destroyed, so an oversize body is never fully buffered. It answers\n * 400 rather than 401 even on a webhook route, unavoidably: there is no way to authenticate a\n * caller whose request we are refusing to finish reading, and that ordering is the point.\n */\n private async readRequestBody(req: Request): Promise<Buffer> {\n return new Promise((resolve: (body: Buffer) => void, reject: (err: Error) => void) => {\n let chunks: Buffer[] = [];\n let size = 0;\n // A socket emits Buffers; a stream someone put in string mode (or a test's Readable.from)\n // emits strings. Normalize to bytes ONCE, here, so everything downstream counts and\n // concatenates the same units.\n req.on('data', (data: Buffer | string) => {\n const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');\n size += chunk.length;\n if (size > this.maxBodyBytes) {\n chunks = [];\n req.destroy();\n reject(new HttpBadRequestError(\n `Request body exceeds the ${this.maxBodyBytes} byte limit`,\n ));\n return;\n }\n chunks.push(chunk);\n });\n req.on('end', () => {\n resolve(Buffer.concat(chunks));\n });\n req.on('error', (err: Error) => {\n reject(err);\n });\n });\n }\n\n /**\n * Handle errors - translate to JSON ProtocolError (SYMMETRIC with ClientErrorTranslator).\n * PUBLIC so wrapExpress can call it for symmetric error handling.\n * Maps HttpError subclasses to appropriate HTTP status codes and ProtocolError response.\n *\n * Maps all HttpError types (must match ClientErrorTranslator's built-in status mapping):\n * - HttpUserError → 266 (with errorCode)\n * - HttpBadRequestError → 400 (with field, guiAlertMessage)\n * - HttpUnauthorizedError → 401\n * - HttpForbiddenError → 403\n * - HttpNotFoundError → 404\n * - HttpTimeoutError → 408\n * - HttpInternalServerError → 500\n * - HttpBadGatewayError → 502\n * - HttpServiceUnavailableError → 503 (generic branch: res.status(error.code))\n * - HttpGatewayTimeoutError → 504\n * - HttpVendorError → 598 (with waitSeconds)\n */\n // webpieces-disable no-any-unknown -- a thrown error is genuinely unknown until narrowed below\n public handleError(res: Response, error: unknown): void {\n if (res.headersSent) {\n return;\n }\n\n // App-registered translations win, so an app can serialize its OWN error types (e.g. a\n // custom 460) AND override built-ins. `undefined` means \"not mine\" — fall through to the\n // built-in instanceof-HttpError ladder below, which stays the generic default. Symmetric\n // with the client's ClientErrorTranslator, which consults tryTranslateFromWire() first.\n if (error instanceof Error) {\n const wire = ClientRegistry.tryTranslateToWire(error);\n if (wire !== undefined) {\n res.status(wire.statusCode)\n .setHeader('Content-Type', 'application/json')\n .send(JSON.stringify(wire.protocolError));\n return;\n }\n }\n\n const protocolError = new ProtocolError();\n\n if (error instanceof HttpError) {\n // Set common fields for all HttpError types\n protocolError.message = error.message;\n protocolError.subType = error.subType;\n protocolError.name = error.name;\n\n // Set type-specific fields (MUST match ClientErrorTranslator's built-in status mapping)\n if (error instanceof HttpUserError) {\n log.info(`User Error: ${error.message}`);\n protocolError.errorCode = error.errorCode;\n } else if (error instanceof HttpBadRequestError) {\n log.info(`Bad Request: ${error.message}`);\n protocolError.field = error.field;\n protocolError.guiAlertMessage = error.guiMessage;\n } else if (error instanceof HttpNotFoundError) {\n log.info(`Not Found: ${error.message}`);\n } else if (error instanceof HttpTimeoutError) {\n log.error(`Timeout Error: ${error.message}`);\n } else if (error instanceof HttpVendorError) {\n log.error(`Vendor Error: ${error.message}`);\n protocolError.waitSeconds = error.waitSeconds;\n } else if (error instanceof HttpUnauthorizedError) {\n log.info(`Unauthorized: ${error.message}`);\n } else if (error instanceof HttpForbiddenError) {\n log.info(`Forbidden: ${error.message}`);\n } else if (error instanceof HttpInternalServerError) {\n log.error(`Internal Server Error: ${error.message}`);\n } else if (error instanceof HttpBadGatewayError) {\n log.error(`Bad Gateway: ${error.message}`);\n } else if (error instanceof HttpGatewayTimeoutError) {\n log.error(`Gateway Timeout: ${error.message}`);\n } else {\n log.info(`Generic HttpError: ${error.message}`);\n }\n\n // Serialize ProtocolError to JSON (SYMMETRIC with client)\n const responseJson = JSON.stringify(protocolError);\n res.status(error.code).setHeader('Content-Type', 'application/json').send(responseJson);\n } else {\n // Unknown error - 500\n const err = toError(error);\n protocolError.message = 'Internal Server Error';\n log.error('Unexpected error:', err);\n const responseJson = JSON.stringify(protocolError);\n res.status(500).setHeader('Content-Type', 'application/json').send(responseJson);\n }\n }\n}\n"]}
1
+ {"version":3,"file":"ExpressWrapper.js","sourceRoot":"","sources":["../../../../../packages/http/http-server/src/ExpressWrapper.ts"],"names":[],"mappings":"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","sourcesContent":["import { Request, Response, NextFunction } from 'express';\nimport {\n ProtocolError,\n ClientRegistry,\n HttpError,\n HttpBadRequestError,\n toError,\n LogManager,\n} from '@webpieces/core-util';\nimport { RequestContext, HttpRequest, RawRequest, RequestContextHeaders } from '@webpieces/core-context';\nimport { HttpErrorWireMapper } from './HttpErrorWireMapper';\n\n// The logging backend prepends this logger name to every line, so messages below carry NO\n// \"[ExpressWrapper]\" literal of their own — that would print the name twice.\nconst log = LogManager.getLogger('ExpressWrapper');\n\n/**\n * The cap on an inbound body, in bytes. Reading stops and the request is refused the moment a body\n * crosses it — the bytes already read are dropped, nothing further is buffered.\n *\n * There was NO limit at all before, which was a latent memory DoS on every route and an outright one\n * on a webhook route: `{ rawBody: true }` retains what it reads, and a webhook url is public by\n * construction, so the endpoint most likely to be flooded was also the one that held on to the flood.\n * 10 MiB is comfortably above any api DTO and well under Cloud Run's own 32 MiB request limit.\n *\n * Read this as FRAMEWORK-FIXED, because today an app has no knob for it. The only seam that accepts a\n * different number is the {@link ExpressWrapper} constructor parameter, and nothing production reaches\n * it: `WebpiecesMiddleware.createExpressWrapper` forwards no such argument, and neither\n * `ExpressWrapper` nor this constant is exported from this package's barrel (`src/index.ts`), so the\n * only caller that can vary the cap is a spec inside this package. An app that legitimately needs to\n * accept a larger body therefore cannot unblock itself and has to open an issue. Making it tunable is a\n * code change, not a config one — a follow-up has to thread a value through `createExpressWrapper` and\n * decide where an app declares it (per-route, most likely, since that is the granularity the need has).\n */\nexport const MAX_BODY_BYTES = 10 * 1024 * 1024;\n\nexport class ExpressWrapper {\n /**\n * Decides what an outside caller is allowed to see of a thrown {@link HttpError}. Stateless —\n * one instance per wrapper is fine, and the class doc there is where the \"only HttpUserError's\n * message goes on the wire\" rule is stated.\n */\n private readonly errorWireMapper = new HttpErrorWireMapper();\n\n constructor(\n // webpieces-disable no-any-unknown -- request/response DTOs are erased at the routing boundary\n private clientMethod: (requestDto: unknown) => Promise<unknown>,\n private path: string,\n /** Owns the wire<->context transfer, both directions. Stateless framework singleton. */\n private headers: RequestContextHeaders,\n /**\n * True for an @Endpoint(..., { formPost: true }) route: parse the body as\n * application/x-www-form-urlencoded (flat) instead of JSON. Driven by the ANNOTATION, not\n * the request Content-Type header — the annotation is the single source of truth.\n */\n private formPost: boolean = false,\n /**\n * True for an @Endpoint(..., { rawBody: true }) route: RETAIN the verbatim bytes + the\n * absolute url on the published {@link HttpRequest}, so an @AuthWebhook hook can verify a\n * vendor signature over what the sender actually transmitted. Also switches the JSON parse\n * failure from \"throw now\" to \"hold it for AuthFilter\" — see {@link RawRequest.bodyParseError}.\n */\n private rawBody: boolean = false,\n /**\n * The inbound body cap for this route. See {@link MAX_BODY_BYTES}.\n *\n * No production caller passes this — `WebpiecesMiddleware.createExpressWrapper` builds every\n * wrapper without it, so every live route runs on the default. The parameter exists so the\n * refusal path can be tested against a small cap instead of a 10 MiB fixture, and the specs in\n * this package are its only callers; it is not an app-facing tuning point, since the class is\n * not exported from `src/index.ts`. If a route ever needs a different cap, thread it through\n * `createExpressWrapper` rather than reaching around the middleware to construct a wrapper.\n */\n private maxBodyBytes: number = MAX_BODY_BYTES,\n ) {\n }\n\n public async execute(req: Request, res: Response, next: NextFunction): Promise<void> {\n // MOVED: Wrap entire request in RequestContext.run()\n // This establishes AsyncLocalStorage context for the request\n await RequestContext.run(async () => {\n await this.executeTryCatch(req, res, next);\n });\n }\n\n public async executeTryCatch(req: Request, res: Response, next: NextFunction): Promise<void> {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- ExpressWrapper catches errors to translate to HTTP responses\n try {\n await this.executeImpl(req, res, next);\n } catch (err: unknown) {\n const error = toError(err);\n // 5. Handle errors\n this.handleError(res, error);\n }\n }\n\n public async executeImpl(req: Request, res: Response, next: NextFunction): Promise<void> {\n // 1. Parse the request body. The PARSER is chosen by the @Endpoint annotation (this.formPost),\n // NOT the request Content-Type header — the annotation is the single source of truth.\n // webpieces-disable no-any-unknown -- request/response DTOs are erased at the routing boundary\n let requestDto: unknown = {};\n let raw: RawRequest | undefined;\n if (['POST', 'PUT', 'PATCH'].includes(req.method)) {\n // Read BYTES, not text. Concatenating per-chunk toString() corrupted any multi-byte\n // character that straddled a chunk boundary — invisible on small bodies, and fatal for a\n // signature computed over the bytes.\n const bodyBytes = await this.readRequestBody(req);\n const bodyText = bodyBytes.toString('utf8');\n let parseError: Error | undefined;\n if (this.formPost) {\n // application/x-www-form-urlencoded → flat key→value. URLSearchParams is lenient\n // (never throws) — right for EXTERNAL webhooks (e.g. Twilio) that post form-encoded.\n requestDto = Object.fromEntries(new URLSearchParams(bodyText));\n } else {\n // JSON (default, SYMMETRIC with the client's JSON.stringify). A non-JSON body is a\n // CLIENT error → 400, not the raw 500 an unguarded JSON.parse would throw.\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- translate parse failure to a 400 HttpError\n try {\n requestDto = bodyText ? JSON.parse(bodyText) : {};\n } catch (err: unknown) {\n const error = toError(err);\n // On a raw-body (webhook) route the failure is HELD, not thrown: AuthFilter must\n // answer 401 to an unauthenticated caller rather than 400, because \"your JSON was\n // bad\" tells that caller it got past auth. Everywhere else, fail now as before.\n if (!this.rawBody) {\n throw new HttpBadRequestError('Request body is not valid JSON', undefined, undefined, error);\n }\n parseError = error;\n }\n }\n if (this.rawBody) {\n raw = new RawRequest(this.absoluteUrl(req), bodyBytes, req.socket?.remoteAddress, parseError);\n }\n }\n\n // 2. Translate express's request into the transport-neutral HttpRequest webpieces speaks.\n const httpRequest = this.toWebpiecesRequest(req, raw);\n\n // 3. Publish the transport-neutral HttpRequest, then move its headers into the context and\n // mint a request id if the caller sent none. BOTH happen above the api boundary, because\n // http-routing requires an already-established, already-filled request scope — it never\n // builds one for you. This is the \"translation layer\" every transport must provide.\n this.headers.fillFromRequest(httpRequest);\n\n // 4. Invoke the api CLIENT method — the SAME proxy tests use. Its filter chain + controller\n // run here, reading the context filled above; the chain never touches express `req`.\n const result = await this.clientMethod(requestDto);\n\n // 5. Serialize the response DTO to JSON (SYMMETRIC with client's response.json())\n const responseJson = JSON.stringify(result);\n res.status(200).setHeader('Content-Type', 'application/json').send(responseJson);\n }\n\n /**\n * Read HTTP headers from Express request.\n * Returns Map of header name (lowercase) -> array of values.\n *\n * HTTP spec allows multiple values for same header name.\n */\n /**\n * express Request -> webpieces {@link HttpRequest}. THE translation layer: below this line the\n * filter chain and controllers never see express, which is what lets the same chain run\n * in-process with no transport at all.\n */\n private toWebpiecesRequest(req: Request, raw?: RawRequest): HttpRequest {\n return new HttpRequest(req.method, this.path, this.readExpressHeaders(req), raw);\n }\n\n /**\n * The absolute url AS THE SENDER ADDRESSED IT — the string a vendor like Twilio signed.\n *\n * `x-forwarded-proto` / `x-forwarded-host` WIN when present, because behind a TLS-terminating\n * proxy (Cloud Run, any load balancer) express's own view is wrong in both halves: `req.protocol`\n * reads `http` and the Host header is the internal one, while the vendor signed the public\n * `https://...` url the customer configured. Reconstructing naively therefore fails 100% of the\n * time in production and works 100% of the time locally — the worst possible pairing, so this is\n * stated here and pinned by a test rather than left to each app.\n *\n * These headers are attacker-controllable when nothing strips them, and that is ACCEPTABLE here\n * precisely because of what the value is used for: a forged url produces a signature that does not\n * verify, i.e. a 401. It grants nothing. (It is used for verification only — never for a redirect.)\n */\n private absoluteUrl(req: Request): string {\n const forwardedProto = req.headers['x-forwarded-proto'];\n const forwardedHost = req.headers['x-forwarded-host'];\n // A proxy chain sends a comma-separated list; the FIRST entry is the original client's hop.\n const proto = this.firstForwarded(forwardedProto) ?? req.protocol;\n const host = this.firstForwarded(forwardedHost) ?? req.get('host') ?? '';\n return `${proto}://${host}${req.originalUrl ?? req.url ?? this.path}`;\n }\n\n private firstForwarded(value: string | string[] | undefined): string | undefined {\n const raw = Array.isArray(value) ? value[0] : value;\n const first = raw?.split(',')[0]?.trim();\n return first === undefined || first === '' ? undefined : first;\n }\n\n private readExpressHeaders(req: Request): Map<string, string[]> {\n const headers = new Map<string, string[]>();\n\n // Express stores headers in req.headers as Record<string, string | string[]>\n for (const [name, value] of Object.entries(req.headers)) {\n const lowerName = name.toLowerCase();\n\n if (typeof value === 'string') {\n headers.set(lowerName, [value]);\n } else if (Array.isArray(value)) {\n headers.set(lowerName, value);\n }\n }\n\n return headers;\n }\n\n /**\n * Read the raw request body as BYTES (we parse manually rather than mounting express.json()).\n *\n * Bytes, not a growing string: a per-chunk `toString()` splits any multi-byte character that\n * straddles a chunk boundary into two replacement characters, so the body a webhook hook verified\n * would not be the body the vendor signed.\n *\n * REFUSES a body over {@link maxBodyBytes} the moment it crosses the line — the chunks read so far\n * are dropped and the stream is destroyed, so an oversize body is never fully buffered. It answers\n * 400 rather than 401 even on a webhook route, unavoidably: there is no way to authenticate a\n * caller whose request we are refusing to finish reading, and that ordering is the point.\n */\n private async readRequestBody(req: Request): Promise<Buffer> {\n return new Promise((resolve: (body: Buffer) => void, reject: (err: Error) => void) => {\n let chunks: Buffer[] = [];\n let size = 0;\n // A socket emits Buffers; a stream someone put in string mode (or a test's Readable.from)\n // emits strings. Normalize to bytes ONCE, here, so everything downstream counts and\n // concatenates the same units.\n req.on('data', (data: Buffer | string) => {\n const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');\n size += chunk.length;\n if (size > this.maxBodyBytes) {\n chunks = [];\n req.destroy();\n reject(new HttpBadRequestError(\n `Request body exceeds the ${this.maxBodyBytes} byte limit`,\n ));\n return;\n }\n chunks.push(chunk);\n });\n req.on('end', () => {\n resolve(Buffer.concat(chunks));\n });\n req.on('error', (err: Error) => {\n reject(err);\n });\n });\n }\n\n /**\n * Handle errors - translate to JSON ProtocolError (SYMMETRIC with ClientErrorTranslator).\n * PUBLIC so wrapExpress can call it for symmetric error handling.\n * Maps HttpError subclasses to appropriate HTTP status codes and ProtocolError response.\n *\n * Maps all HttpError types (must match ClientErrorTranslator's built-in status mapping):\n * - HttpUserError → 266 (message + subType + errorCode)\n * - HttpBadRequestError → 400 (generic message + field, guiAlertMessage)\n * - HttpUnauthorizedError → 401 (generic message + subType)\n * - HttpForbiddenError → 403 (generic message)\n * - HttpNotFoundError → 404 (generic message)\n * - HttpTimeoutError → 408 (generic message)\n * - HttpTooManyRequestsError → 429 (generic message)\n * - HttpInternalServerError → 500 (generic message)\n * - HttpBadGatewayError → 502 (generic message)\n * - HttpServiceUnavailableError → 503 (generic message)\n * - HttpGatewayTimeoutError → 504 (generic message)\n * - HttpVendorError → 598 (generic message + waitSeconds)\n *\n * \"generic message\" is the point of {@link HttpErrorWireMapper}: ONLY `HttpUserError`'s message\n * was written for a human to read, so only it is copied to the response body. Every other type\n * sends the standard reason phrase for its status and logs the real message. Read that class's\n * doc for the full rule, including why `subType` is kept and `name` is not.\n */\n // webpieces-disable no-any-unknown -- a thrown error is genuinely unknown until narrowed below\n public handleError(res: Response, error: unknown): void {\n if (res.headersSent) {\n return;\n }\n\n // App-registered translations win, so an app can serialize its OWN error types (e.g. a\n // custom 460) AND override built-ins. `undefined` means \"not mine\" — fall through to the\n // built-in instanceof-HttpError ladder below, which stays the generic default. Symmetric\n // with the client's ClientErrorTranslator, which consults tryTranslateFromWire() first.\n if (error instanceof Error) {\n const wire = ClientRegistry.tryTranslateToWire(error);\n if (wire !== undefined) {\n res.status(wire.statusCode)\n .setHeader('Content-Type', 'application/json')\n .send(JSON.stringify(wire.protocolError));\n return;\n }\n }\n\n if (error instanceof HttpError) {\n // The mapper decides what the caller may see AND logs everything it withholds — see\n // HttpErrorWireMapper. Nothing type-specific is decided here any more.\n const protocolError = this.errorWireMapper.toWire(error);\n\n // Serialize ProtocolError to JSON (SYMMETRIC with client)\n const responseJson = JSON.stringify(protocolError);\n res.status(error.code).setHeader('Content-Type', 'application/json').send(responseJson);\n } else {\n // Unknown error - 500. This branch was ALREADY generic, which is what made the old\n // HttpError branch's verbatim `error.message` so obviously backwards: an unexpected crash\n // leaked nothing while a deliberate HttpInternalServerError leaked everything.\n const err = toError(error);\n const protocolError = new ProtocolError();\n protocolError.message = 'Internal Server Error';\n log.error('Unexpected error:', err);\n const responseJson = JSON.stringify(protocolError);\n res.status(500).setHeader('Content-Type', 'application/json').send(responseJson);\n }\n }\n}\n"]}
@@ -0,0 +1,82 @@
1
+ import { ProtocolError, HttpError } from '@webpieces/core-util';
2
+ /**
3
+ * Turns a thrown {@link HttpError} into the {@link ProtocolError} that goes on the wire — and, just
4
+ * as importantly, decides what does NOT go on the wire.
5
+ *
6
+ * # The rule: only {@link HttpUserError}'s `message` is caller-facing
7
+ *
8
+ * `Error.message` is an OPERATOR-facing field everywhere else in this framework. It is written for
9
+ * whoever reads the logs, and it routinely quotes internal detail: a downstream service url, an HTTP
10
+ * method and content-type, a body snippet, a table name, an internal id. `http-client-node` builds
11
+ * exactly such a message when a downstream dependency answers a 4xx — `ResponseBodyReader`'s
12
+ * foreign-body description names the url it called and embeds the html it got back — and that error
13
+ * arrives here as an `HttpInternalServerError`. Copying `error.message` onto the response body handed
14
+ * every one of those strings to an external, possibly partner-facing consumer.
15
+ *
16
+ * {@link HttpUserError} is the ONE type whose message was written for a human to read. That is what
17
+ * it is FOR: it is deliberately a 266 (a 2xx code, so it is not lumped in with failures), it carries
18
+ * an `errorCode` the caller branches on, and an app throws it on purpose to say something like
19
+ * "Email already exists". Its message goes out verbatim.
20
+ *
21
+ * Every other subclass sends a GENERIC, status-appropriate message (see {@link genericMessage}); the
22
+ * real message goes to the LOG only. The behaviour used to be exactly backwards — an unexpected crash
23
+ * was safely generic while a DELIBERATE `HttpInternalServerError` shipped its full message outward.
24
+ *
25
+ * # What still goes out, and why
26
+ *
27
+ * - `errorCode` ({@link HttpUserError}) and `waitSeconds` ({@link HttpVendorError}) are structured
28
+ * CONTRACT data the client is meant to branch on, not prose. They carry no internal detail.
29
+ * - `field` and `guiAlertMessage` ({@link HttpBadRequestError}) stay. `guiMessage` exists precisely
30
+ * to be the human-safe half of a bad request — its existence is the admission that `message` is the
31
+ * operator-facing half — and a form field name is not internal detail. The `message` itself is
32
+ * genericised like every other non-user error.
33
+ * - `subType` stays. It is NOT derived from a class name: it is an explicit constructor argument an
34
+ * app passes on purpose (`WRONG_LOGIN`, `NOT_APPROVED`, `EMAIL_NOT_CONFIRMED`, … from
35
+ * `core-util/src/http/errors.ts`), which makes it structured contract data of the same kind as
36
+ * `errorCode`. `ClientErrorTranslator` reads it back when reconstructing `HttpUnauthorizedError`
37
+ * and the generic `HttpError`, so dropping it would break that reconstruction for the one case —
38
+ * login failure reasons — where the caller genuinely has to branch on WHY.
39
+ * - `name` is GONE from this ladder. Nothing in `ClientErrorTranslator` ever read it, so it is not
40
+ * contract data. For a built-in it was a constant string 1:1 with the status code (`'BadRequest'`,
41
+ * `'InternalServerError'`), i.e. it told the caller nothing the status had not already told it —
42
+ * and for any SUBCLASS reaching this ladder it was literally an internal class name
43
+ * (`'EndpointNotFoundError'`, or whatever an app happened to name its type). That is a free read of
44
+ * the server's internals for zero caller benefit, so it is not sent. It is logged instead.
45
+ *
46
+ * An app that WANTS a different wire shape has an explicit opt-out on both sides:
47
+ * `ClientRegistry.addErrorTranslation()`. `ExpressWrapper.handleError` consults
48
+ * `tryTranslateToWire()` BEFORE reaching this class, and whatever that returns is sent verbatim —
49
+ * that is the app's own deliberate choice about what it publishes.
50
+ */
51
+ export declare class HttpErrorWireMapper {
52
+ /**
53
+ * status code → the generic, caller-safe text sent in its place. These are the standard HTTP
54
+ * reason phrases (plus 598, webpieces' own vendor code), so a caller reading the body learns
55
+ * exactly what the status line already told it and nothing more.
56
+ */
57
+ private readonly genericMessages;
58
+ /**
59
+ * Build the wire body for `error`, and LOG the operator-facing detail that is being withheld from
60
+ * it. Both halves happen here on purpose: the log line is the only remaining place the real
61
+ * message exists, so it must never be optional or skippable.
62
+ */
63
+ toWire(error: HttpError): ProtocolError;
64
+ /** The generic text for a status, or a code-free fallback for an app's own custom status. */
65
+ private genericMessage;
66
+ /**
67
+ * The full operator-facing string. `name` and `subType` are in here because `name` used to be
68
+ * visible ONLY on the wire — removing it from the body without adding it to the log would lose it
69
+ * entirely.
70
+ */
71
+ private operatorDetail;
72
+ /**
73
+ * Log at the level that matches who is at fault: `info` where the CALLER made a mistake (a 4xx is
74
+ * the server behaving correctly), `error` where the server or a dependency is broken.
75
+ */
76
+ private logOperatorDetail;
77
+ /**
78
+ * The structured, caller-facing fields — the ones a client BRANCHES on rather than displays as
79
+ * server prose. MUST match ClientErrorTranslator's built-in status mapping.
80
+ */
81
+ private addContractFields;
82
+ }
@@ -0,0 +1,164 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HttpErrorWireMapper = void 0;
4
+ const core_util_1 = require("@webpieces/core-util");
5
+ // The logging backend prepends this logger name to every line, so messages below carry NO
6
+ // "[HttpErrorWireMapper]" literal of their own — that would print the name twice.
7
+ const log = core_util_1.LogManager.getLogger('HttpErrorWireMapper');
8
+ /**
9
+ * Turns a thrown {@link HttpError} into the {@link ProtocolError} that goes on the wire — and, just
10
+ * as importantly, decides what does NOT go on the wire.
11
+ *
12
+ * # The rule: only {@link HttpUserError}'s `message` is caller-facing
13
+ *
14
+ * `Error.message` is an OPERATOR-facing field everywhere else in this framework. It is written for
15
+ * whoever reads the logs, and it routinely quotes internal detail: a downstream service url, an HTTP
16
+ * method and content-type, a body snippet, a table name, an internal id. `http-client-node` builds
17
+ * exactly such a message when a downstream dependency answers a 4xx — `ResponseBodyReader`'s
18
+ * foreign-body description names the url it called and embeds the html it got back — and that error
19
+ * arrives here as an `HttpInternalServerError`. Copying `error.message` onto the response body handed
20
+ * every one of those strings to an external, possibly partner-facing consumer.
21
+ *
22
+ * {@link HttpUserError} is the ONE type whose message was written for a human to read. That is what
23
+ * it is FOR: it is deliberately a 266 (a 2xx code, so it is not lumped in with failures), it carries
24
+ * an `errorCode` the caller branches on, and an app throws it on purpose to say something like
25
+ * "Email already exists". Its message goes out verbatim.
26
+ *
27
+ * Every other subclass sends a GENERIC, status-appropriate message (see {@link genericMessage}); the
28
+ * real message goes to the LOG only. The behaviour used to be exactly backwards — an unexpected crash
29
+ * was safely generic while a DELIBERATE `HttpInternalServerError` shipped its full message outward.
30
+ *
31
+ * # What still goes out, and why
32
+ *
33
+ * - `errorCode` ({@link HttpUserError}) and `waitSeconds` ({@link HttpVendorError}) are structured
34
+ * CONTRACT data the client is meant to branch on, not prose. They carry no internal detail.
35
+ * - `field` and `guiAlertMessage` ({@link HttpBadRequestError}) stay. `guiMessage` exists precisely
36
+ * to be the human-safe half of a bad request — its existence is the admission that `message` is the
37
+ * operator-facing half — and a form field name is not internal detail. The `message` itself is
38
+ * genericised like every other non-user error.
39
+ * - `subType` stays. It is NOT derived from a class name: it is an explicit constructor argument an
40
+ * app passes on purpose (`WRONG_LOGIN`, `NOT_APPROVED`, `EMAIL_NOT_CONFIRMED`, … from
41
+ * `core-util/src/http/errors.ts`), which makes it structured contract data of the same kind as
42
+ * `errorCode`. `ClientErrorTranslator` reads it back when reconstructing `HttpUnauthorizedError`
43
+ * and the generic `HttpError`, so dropping it would break that reconstruction for the one case —
44
+ * login failure reasons — where the caller genuinely has to branch on WHY.
45
+ * - `name` is GONE from this ladder. Nothing in `ClientErrorTranslator` ever read it, so it is not
46
+ * contract data. For a built-in it was a constant string 1:1 with the status code (`'BadRequest'`,
47
+ * `'InternalServerError'`), i.e. it told the caller nothing the status had not already told it —
48
+ * and for any SUBCLASS reaching this ladder it was literally an internal class name
49
+ * (`'EndpointNotFoundError'`, or whatever an app happened to name its type). That is a free read of
50
+ * the server's internals for zero caller benefit, so it is not sent. It is logged instead.
51
+ *
52
+ * An app that WANTS a different wire shape has an explicit opt-out on both sides:
53
+ * `ClientRegistry.addErrorTranslation()`. `ExpressWrapper.handleError` consults
54
+ * `tryTranslateToWire()` BEFORE reaching this class, and whatever that returns is sent verbatim —
55
+ * that is the app's own deliberate choice about what it publishes.
56
+ */
57
+ class HttpErrorWireMapper {
58
+ /**
59
+ * status code → the generic, caller-safe text sent in its place. These are the standard HTTP
60
+ * reason phrases (plus 598, webpieces' own vendor code), so a caller reading the body learns
61
+ * exactly what the status line already told it and nothing more.
62
+ */
63
+ genericMessages = new Map([
64
+ [400, 'Bad Request'],
65
+ [401, 'Unauthorized'],
66
+ [403, 'Forbidden'],
67
+ [404, 'Not Found'],
68
+ [408, 'Request Timeout'],
69
+ [429, 'Too Many Requests'],
70
+ [500, 'Internal Server Error'],
71
+ [502, 'Bad Gateway'],
72
+ [503, 'Service Unavailable'],
73
+ [504, 'Gateway Timeout'],
74
+ [598, 'Vendor Error'],
75
+ ]);
76
+ /**
77
+ * Build the wire body for `error`, and LOG the operator-facing detail that is being withheld from
78
+ * it. Both halves happen here on purpose: the log line is the only remaining place the real
79
+ * message exists, so it must never be optional or skippable.
80
+ */
81
+ toWire(error) {
82
+ const protocolError = new core_util_1.ProtocolError();
83
+ // The ONE type whose message was written for a human to read — see the class doc.
84
+ if (error instanceof core_util_1.HttpUserError) {
85
+ log.info(`User Error: ${this.operatorDetail(error)}`);
86
+ protocolError.message = error.message;
87
+ protocolError.subType = error.subType;
88
+ protocolError.errorCode = error.errorCode;
89
+ return protocolError;
90
+ }
91
+ protocolError.message = this.genericMessage(error.code);
92
+ protocolError.subType = error.subType;
93
+ this.logOperatorDetail(error);
94
+ this.addContractFields(error, protocolError);
95
+ return protocolError;
96
+ }
97
+ /** The generic text for a status, or a code-free fallback for an app's own custom status. */
98
+ genericMessage(code) {
99
+ return this.genericMessages.get(code) ?? 'Request Failed';
100
+ }
101
+ /**
102
+ * The full operator-facing string. `name` and `subType` are in here because `name` used to be
103
+ * visible ONLY on the wire — removing it from the body without adding it to the log would lose it
104
+ * entirely.
105
+ */
106
+ operatorDetail(error) {
107
+ const cause = error.httpCause === undefined ? '' : ` cause=${error.httpCause.message}`;
108
+ return `[name=${error.name} subType=${error.subType ?? 'none'}] ${error.message}${cause}`;
109
+ }
110
+ /**
111
+ * Log at the level that matches who is at fault: `info` where the CALLER made a mistake (a 4xx is
112
+ * the server behaving correctly), `error` where the server or a dependency is broken.
113
+ */
114
+ logOperatorDetail(error) {
115
+ const detail = this.operatorDetail(error);
116
+ if (error instanceof core_util_1.HttpBadRequestError) {
117
+ log.info(`Bad Request: ${detail}`);
118
+ }
119
+ else if (error instanceof core_util_1.HttpNotFoundError) {
120
+ log.info(`Not Found: ${detail}`);
121
+ }
122
+ else if (error instanceof core_util_1.HttpTimeoutError) {
123
+ log.error(`Timeout Error: ${detail}`);
124
+ }
125
+ else if (error instanceof core_util_1.HttpVendorError) {
126
+ log.error(`Vendor Error: ${detail}`);
127
+ }
128
+ else if (error instanceof core_util_1.HttpUnauthorizedError) {
129
+ log.info(`Unauthorized: ${detail}`);
130
+ }
131
+ else if (error instanceof core_util_1.HttpForbiddenError) {
132
+ log.info(`Forbidden: ${detail}`);
133
+ }
134
+ else if (error instanceof core_util_1.HttpInternalServerError) {
135
+ log.error(`Internal Server Error: ${detail}`);
136
+ }
137
+ else if (error instanceof core_util_1.HttpBadGatewayError) {
138
+ log.error(`Bad Gateway: ${detail}`);
139
+ }
140
+ else if (error instanceof core_util_1.HttpGatewayTimeoutError) {
141
+ log.error(`Gateway Timeout: ${detail}`);
142
+ }
143
+ else {
144
+ log.info(`Generic HttpError: ${detail}`);
145
+ }
146
+ }
147
+ /**
148
+ * The structured, caller-facing fields — the ones a client BRANCHES on rather than displays as
149
+ * server prose. MUST match ClientErrorTranslator's built-in status mapping.
150
+ */
151
+ addContractFields(error, protocolError) {
152
+ if (error instanceof core_util_1.HttpBadRequestError) {
153
+ protocolError.field = error.field;
154
+ // The human-safe half of a bad request. `error.message` is the operator half and is NOT
155
+ // sent — the generic 'Bad Request' went out above instead.
156
+ protocolError.guiAlertMessage = error.guiMessage;
157
+ }
158
+ else if (error instanceof core_util_1.HttpVendorError) {
159
+ protocolError.waitSeconds = error.waitSeconds;
160
+ }
161
+ }
162
+ }
163
+ exports.HttpErrorWireMapper = HttpErrorWireMapper;
164
+ //# sourceMappingURL=HttpErrorWireMapper.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"HttpErrorWireMapper.js","sourceRoot":"","sources":["../../../../../packages/http/http-server/src/HttpErrorWireMapper.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n ProtocolError,\n HttpError,\n HttpBadRequestError,\n HttpVendorError,\n HttpUserError,\n HttpNotFoundError,\n HttpTimeoutError,\n HttpUnauthorizedError,\n HttpForbiddenError,\n HttpInternalServerError,\n HttpBadGatewayError,\n HttpGatewayTimeoutError,\n LogManager,\n} from '@webpieces/core-util';\n\n// The logging backend prepends this logger name to every line, so messages below carry NO\n// \"[HttpErrorWireMapper]\" literal of their own — that would print the name twice.\nconst log = LogManager.getLogger('HttpErrorWireMapper');\n\n/**\n * Turns a thrown {@link HttpError} into the {@link ProtocolError} that goes on the wire — and, just\n * as importantly, decides what does NOT go on the wire.\n *\n * # The rule: only {@link HttpUserError}'s `message` is caller-facing\n *\n * `Error.message` is an OPERATOR-facing field everywhere else in this framework. It is written for\n * whoever reads the logs, and it routinely quotes internal detail: a downstream service url, an HTTP\n * method and content-type, a body snippet, a table name, an internal id. `http-client-node` builds\n * exactly such a message when a downstream dependency answers a 4xx — `ResponseBodyReader`'s\n * foreign-body description names the url it called and embeds the html it got back — and that error\n * arrives here as an `HttpInternalServerError`. Copying `error.message` onto the response body handed\n * every one of those strings to an external, possibly partner-facing consumer.\n *\n * {@link HttpUserError} is the ONE type whose message was written for a human to read. That is what\n * it is FOR: it is deliberately a 266 (a 2xx code, so it is not lumped in with failures), it carries\n * an `errorCode` the caller branches on, and an app throws it on purpose to say something like\n * \"Email already exists\". Its message goes out verbatim.\n *\n * Every other subclass sends a GENERIC, status-appropriate message (see {@link genericMessage}); the\n * real message goes to the LOG only. The behaviour used to be exactly backwards — an unexpected crash\n * was safely generic while a DELIBERATE `HttpInternalServerError` shipped its full message outward.\n *\n * # What still goes out, and why\n *\n * - `errorCode` ({@link HttpUserError}) and `waitSeconds` ({@link HttpVendorError}) are structured\n * CONTRACT data the client is meant to branch on, not prose. They carry no internal detail.\n * - `field` and `guiAlertMessage` ({@link HttpBadRequestError}) stay. `guiMessage` exists precisely\n * to be the human-safe half of a bad request — its existence is the admission that `message` is the\n * operator-facing half — and a form field name is not internal detail. The `message` itself is\n * genericised like every other non-user error.\n * - `subType` stays. It is NOT derived from a class name: it is an explicit constructor argument an\n * app passes on purpose (`WRONG_LOGIN`, `NOT_APPROVED`, `EMAIL_NOT_CONFIRMED`, … from\n * `core-util/src/http/errors.ts`), which makes it structured contract data of the same kind as\n * `errorCode`. `ClientErrorTranslator` reads it back when reconstructing `HttpUnauthorizedError`\n * and the generic `HttpError`, so dropping it would break that reconstruction for the one case —\n * login failure reasons — where the caller genuinely has to branch on WHY.\n * - `name` is GONE from this ladder. Nothing in `ClientErrorTranslator` ever read it, so it is not\n * contract data. For a built-in it was a constant string 1:1 with the status code (`'BadRequest'`,\n * `'InternalServerError'`), i.e. it told the caller nothing the status had not already told it —\n * and for any SUBCLASS reaching this ladder it was literally an internal class name\n * (`'EndpointNotFoundError'`, or whatever an app happened to name its type). That is a free read of\n * the server's internals for zero caller benefit, so it is not sent. It is logged instead.\n *\n * An app that WANTS a different wire shape has an explicit opt-out on both sides:\n * `ClientRegistry.addErrorTranslation()`. `ExpressWrapper.handleError` consults\n * `tryTranslateToWire()` BEFORE reaching this class, and whatever that returns is sent verbatim —\n * that is the app's own deliberate choice about what it publishes.\n */\nexport class HttpErrorWireMapper {\n /**\n * status code → the generic, caller-safe text sent in its place. These are the standard HTTP\n * reason phrases (plus 598, webpieces' own vendor code), so a caller reading the body learns\n * exactly what the status line already told it and nothing more.\n */\n private readonly genericMessages: Map<number, string> = new Map<number, string>([\n [400, 'Bad Request'],\n [401, 'Unauthorized'],\n [403, 'Forbidden'],\n [404, 'Not Found'],\n [408, 'Request Timeout'],\n [429, 'Too Many Requests'],\n [500, 'Internal Server Error'],\n [502, 'Bad Gateway'],\n [503, 'Service Unavailable'],\n [504, 'Gateway Timeout'],\n [598, 'Vendor Error'],\n ]);\n\n /**\n * Build the wire body for `error`, and LOG the operator-facing detail that is being withheld from\n * it. Both halves happen here on purpose: the log line is the only remaining place the real\n * message exists, so it must never be optional or skippable.\n */\n public toWire(error: HttpError): ProtocolError {\n const protocolError = new ProtocolError();\n\n // The ONE type whose message was written for a human to read — see the class doc.\n if (error instanceof HttpUserError) {\n log.info(`User Error: ${this.operatorDetail(error)}`);\n protocolError.message = error.message;\n protocolError.subType = error.subType;\n protocolError.errorCode = error.errorCode;\n return protocolError;\n }\n\n protocolError.message = this.genericMessage(error.code);\n protocolError.subType = error.subType;\n this.logOperatorDetail(error);\n this.addContractFields(error, protocolError);\n return protocolError;\n }\n\n /** The generic text for a status, or a code-free fallback for an app's own custom status. */\n private genericMessage(code: number): string {\n return this.genericMessages.get(code) ?? 'Request Failed';\n }\n\n /**\n * The full operator-facing string. `name` and `subType` are in here because `name` used to be\n * visible ONLY on the wire — removing it from the body without adding it to the log would lose it\n * entirely.\n */\n private operatorDetail(error: HttpError): string {\n const cause = error.httpCause === undefined ? '' : ` cause=${error.httpCause.message}`;\n return `[name=${error.name} subType=${error.subType ?? 'none'}] ${error.message}${cause}`;\n }\n\n /**\n * Log at the level that matches who is at fault: `info` where the CALLER made a mistake (a 4xx is\n * the server behaving correctly), `error` where the server or a dependency is broken.\n */\n private logOperatorDetail(error: HttpError): void {\n const detail = this.operatorDetail(error);\n if (error instanceof HttpBadRequestError) {\n log.info(`Bad Request: ${detail}`);\n } else if (error instanceof HttpNotFoundError) {\n log.info(`Not Found: ${detail}`);\n } else if (error instanceof HttpTimeoutError) {\n log.error(`Timeout Error: ${detail}`);\n } else if (error instanceof HttpVendorError) {\n log.error(`Vendor Error: ${detail}`);\n } else if (error instanceof HttpUnauthorizedError) {\n log.info(`Unauthorized: ${detail}`);\n } else if (error instanceof HttpForbiddenError) {\n log.info(`Forbidden: ${detail}`);\n } else if (error instanceof HttpInternalServerError) {\n log.error(`Internal Server Error: ${detail}`);\n } else if (error instanceof HttpBadGatewayError) {\n log.error(`Bad Gateway: ${detail}`);\n } else if (error instanceof HttpGatewayTimeoutError) {\n log.error(`Gateway Timeout: ${detail}`);\n } else {\n log.info(`Generic HttpError: ${detail}`);\n }\n }\n\n /**\n * The structured, caller-facing fields — the ones a client BRANCHES on rather than displays as\n * server prose. MUST match ClientErrorTranslator's built-in status mapping.\n */\n private addContractFields(error: HttpError, protocolError: ProtocolError): void {\n if (error instanceof HttpBadRequestError) {\n protocolError.field = error.field;\n // The human-safe half of a bad request. `error.message` is the operator half and is NOT\n // sent — the generic 'Bad Request' went out above instead.\n protocolError.guiAlertMessage = error.guiMessage;\n } else if (error instanceof HttpVendorError) {\n protocolError.waitSeconds = error.waitSeconds;\n }\n }\n}\n"]}
@@ -49,6 +49,11 @@ export declare class WebpiecesMiddleware {
49
49
  * Returns an HTML 500 page. Api routes translate their own errors to JSON inside the filter
50
50
  * chain (JsonFilter/ExpressWrapper), so this normally only fires for failures OUTSIDE a route
51
51
  * (body parsing, unmatched paths, a bug in the wrapper itself).
52
+ *
53
+ * The page carries NO `error.message`. It used to render one into a `<pre>` block, which is the
54
+ * same leak `HttpErrorWireMapper` closes on the JSON side and a worse one here: the errors that
55
+ * reach THIS handler are the unhandled ones, whose messages are stack-adjacent internals nobody
56
+ * wrote for a caller to read. The message is logged one line above, which is where it belongs.
52
57
  */
53
58
  errorHandler(err: unknown, req: Request, res: Response, next: NextFunction): void;
54
59
  /**
@@ -54,6 +54,11 @@ let WebpiecesMiddleware = class WebpiecesMiddleware {
54
54
  * Returns an HTML 500 page. Api routes translate their own errors to JSON inside the filter
55
55
  * chain (JsonFilter/ExpressWrapper), so this normally only fires for failures OUTSIDE a route
56
56
  * (body parsing, unmatched paths, a bug in the wrapper itself).
57
+ *
58
+ * The page carries NO `error.message`. It used to render one into a `<pre>` block, which is the
59
+ * same leak `HttpErrorWireMapper` closes on the JSON side and a worse one here: the errors that
60
+ * reach THIS handler are the unhandled ones, whose messages are stack-adjacent internals nobody
61
+ * wrote for a caller to read. The message is logged one line above, which is where it belongs.
57
62
  */
58
63
  // webpieces-disable no-any-unknown -- a thrown/forwarded express error is genuinely unknown until narrowed
59
64
  // eslint-disable-next-line @typescript-eslint/no-unused-vars -- express needs the 4-arg (err,req,res,next) arity to recognize this as error-handling middleware
@@ -71,7 +76,6 @@ let WebpiecesMiddleware = class WebpiecesMiddleware {
71
76
  <body>
72
77
  <h1>You hit a server error</h1>
73
78
  <p>An unexpected error occurred while processing your request.</p>
74
- <pre>${error.message}</pre>
75
79
  </body>
76
80
  </html>
77
81
  `);
@@ -1 +1 @@
1
- {"version":3,"file":"WebpiecesMiddleware.js","sourceRoot":"","sources":["../../../../../packages/http/http-server/src/WebpiecesMiddleware.ts"],"names":[],"mappings":"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","sourcesContent":["import { Request, Response, NextFunction, RequestHandler } from 'express';\nimport cors from 'cors';\nimport { provideFrameworkSingleton, WebpiecesConfig } from '@webpieces/http-routing';\nimport { toError } from '@webpieces/core-util';\nimport { RequestContextHeaders } from '@webpieces/core-context';\nimport { LogManager } from '@webpieces/core-util';\nimport { ExpressWrapper } from './ExpressWrapper';\n\nconst log = LogManager.getLogger('WebpiecesMiddleware');\n// CORS mount/allow/block lines log under their own name (not [WebpiecesMiddleware]); the backend\n// prepends \"[CORS]\" for us, so the message strings below carry no literal prefix of their own.\nconst corsLog = LogManager.getLogger('CORS');\n\n/**\n * Express route handler function type. Lives in http-server (the express adapter),\n * NOT in the node-only http-routing package, so http-routing stays express-free.\n * Used by WebpiecesExpressRouter to register handlers Express can call.\n */\nexport type ExpressRouteHandler = (\n req: Request,\n res: Response,\n next: NextFunction,\n) => Promise<void>;\n\n/**\n * WebpiecesMiddleware - Express middleware for WebPieces server.\n *\n * This class contains all Express middleware used by WebpiecesServer:\n * 1. errorHandler - Top-level 4-arg error middleware, returns HTML 500 page. Mounted AFTER the\n * routes so express can forward downstream failures to it (express routes errors DOWN a\n * separate pipeline, it does not bubble them back up through next()). Last-resort net only:\n * api routes translate their own errors to JSON inside the filter chain (ExpressWrapper).\n * 2. corsMiddleware - Opt-in CORS (mounted only when corsOrigins is non-empty).\n *\n * Per-request logging is intentionally NOT here — the api filter chain's LogApiCall already logs\n * every request/response with full context (requestId, method, path, body), so a plain express\n * START/END line would only duplicate it with less information.\n *\n * Route dispatch happens via Express's registered route handlers (the per-route ExpressWrapper),\n * NOT via this middleware.\n *\n * NEW: ExpressWrapper simplified - no longer handles JSON or headers\n * - JSON parsing/serialization moved to JsonFilter\n * - Header transfer moved to ContextFilter (injects PlatformHeadersExtension directly)\n * - ExpressWrapper just creates RouterReqResp and invokes filter chain\n *\n * Extension vs Plugin pattern:\n * - Extensions (DI-level): Contribute capabilities to framework (headers, converters, etc.)\n * - Plugins (App-level): Provide complete features with modules + routes (Hibernate, Jackson, etc.)\n */\n@provideFrameworkSingleton()\nexport class WebpiecesMiddleware {\n /** The ONE wire<->context transfer, handed to every route's ExpressWrapper. Stateless. */\n private readonly headers = new RequestContextHeaders();\n\n\n /**\n * Top-level error handler — the last-ditch catch-all. MUST be mounted AFTER all routes (see\n * {@link WebpiecesExpressRouter}). The 4-argument `(err, req, res, next)` signature is what\n * tells express this is an error-handling middleware: express's router forwards ANY downstream\n * failure to it — synchronous throws AND rejected async-handler promises alike (the router does\n * `promise.then(null, err => next(err))`, and a `next(err)` with a truthy arg jumps straight to\n * the first 4-arg middleware). This is why a `try { await next() } catch` wrapper is NOT needed\n * (and would not work) — express `next()` is not promise-aware, so it never hands the parent the\n * downstream promise; errors travel down this separate pipeline instead of bubbling back up.\n *\n * Returns an HTML 500 page. Api routes translate their own errors to JSON inside the filter\n * chain (JsonFilter/ExpressWrapper), so this normally only fires for failures OUTSIDE a route\n * (body parsing, unmatched paths, a bug in the wrapper itself).\n */\n // webpieces-disable no-any-unknown -- a thrown/forwarded express error is genuinely unknown until narrowed\n // eslint-disable-next-line @typescript-eslint/no-unused-vars -- express needs the 4-arg (err,req,res,next) arity to recognize this as error-handling middleware\n errorHandler(err: unknown, req: Request, res: Response, next: NextFunction): void {\n const error = toError(err);\n log.error(`Unhandled error: ${req.method} ${req.path}`, error);\n if (res.headersSent) {\n return;\n }\n // Return HTML error page (not JSON - api routes translate JSON errors in their filter chain)\n res.status(500).send(`\n <!DOCTYPE html>\n <html>\n <head><title>Server Error</title></head>\n <body>\n <h1>You hit a server error</h1>\n <p>An unexpected error occurred while processing your request.</p>\n <pre>${error.message}</pre>\n </body>\n </html>\n `);\n }\n\n /**\n * CORS middleware. DO NOT MOUNT UNCONDITIONALLY — {@link WebpiecesExpressRouter.bindAndStartExpress}\n * mounts it ONLY when {@link WebpiecesConfig.corsOrigins} is non-empty, and that is the point.\n *\n * CORS exists solely to let a browser on a DIFFERENT origin call this api — in practice\n * `ng serve` on :4200 hitting an api on :8080 during development, or a UI hosted on a different\n * host than the api. A server that serves its own browser app needs NO cors at all, because a\n * browser does not apply cors to a same-origin request. So in production this middleware is\n * normally ABSENT, and absent is the safe state: every origin it allows gains the right to make\n * CREDENTIALED cross-origin calls and READ the responses. Mounting it unconditionally (as the\n * old corsForLocalhost did) handed that right to anything on the victim's localhost, in prod,\n * for no benefit whatsoever.\n *\n * When mounted, allows: a request with NO Origin (curl, server-to-server, a CLI); the server's\n * OWN origin; and EXACTLY the origins in `corsOrigins` — nothing is implicit. Anything else gets\n * a clean 403, never the HTML 500 the old `callback(new Error(...))` produced.\n *\n * SAME-ORIGIN MUST STAY ALLOWED even though a same-origin request needs no cors headers, because\n * a browser attaches an `Origin` header to EVERY POST — including a same-origin POST — and every\n * webpieces route is a POST. Once mounted, this middleware SEES that origin, so if it did not\n * allow it, it would 403 the server's own UI. That was the production bug.\n *\n * The same-origin test compares HOST ONLY, deliberately. Behind a TLS-terminating proxy (Cloud\n * Run, any load balancer) `req.protocol` is `http` while the browser's `Origin` says `https`, so\n * comparing full origins would reject the server's own origin on every deploy.\n *\n * @returns Express middleware handler for CORS\n */\n corsMiddleware(config?: WebpiecesConfig): RequestHandler {\n const allowedOrigins = config?.corsOrigins ?? [];\n corsLog.info(\n `CORS MOUNTED. Allowing same-origin + [${allowedOrigins.join(', ')}]. ` +\n `Every other browser origin gets a 403.`,\n );\n\n const handler = cors({\n origin: true, // reflect the request origin — we have already vetted it below\n credentials: true,\n methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],\n allowedHeaders: '*',\n exposedHeaders: '*',\n maxAge: 3600,\n });\n\n return (req: Request, res: Response, next: NextFunction): void => {\n const origin = req.headers.origin;\n if (!origin) {\n // No Origin -> not a browser cross-origin request; nothing to negotiate.\n next();\n return;\n }\n if (this.isOriginAllowed(origin, req.get('host'), allowedOrigins)) {\n handler(req, res, next);\n return;\n }\n corsLog.info(`Blocked origin: ${origin}`);\n res.status(403).json({\n name: 'CorsError',\n message: `CORS not allowed for origin: ${origin}`,\n });\n };\n }\n\n /**\n * Same-origin (HOST ONLY — see corsMiddleware() on why the scheme is deliberately ignored), or an\n * explicit entry in corsOrigins. NOTHING is implicit: localhost is allowed only if the config\n * asked for it, so a production server that enables cors for a cross-host UI does not silently\n * open the door to localhost as well.\n */\n private isOriginAllowed(origin: string, host: string | undefined, allowedOrigins: string[]): boolean {\n let originHost: string;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- a malformed Origin is untrusted browser input, not a server fault; it must become a 403 here, never bubble to the 500 chokepoint\n try {\n originHost = new URL(origin).host;\n } catch (err: unknown) {\n const error = toError(err);\n corsLog.info(`Malformed Origin header '${origin}': ${error.message}`);\n return false;\n }\n if (host !== undefined && originHost === host) {\n return true; // same-origin: the server's own UI calling its own api\n }\n return allowedOrigins.some((allowed: string): boolean => this.matchesOrigin(origin, allowed));\n }\n\n /**\n * Exact origin match, except a `*` in the PORT position matches any port: `http://localhost:*`\n * is what a developer writes, because the angular dev-server port moves around.\n *\n * The `*` is deliberately NOT a general wildcard — it never spans a host, and what follows the\n * prefix must be a real (digits-only) port. So `http://localhost:*` cannot be tricked into\n * matching `http://localhost.evil.com`, and a bare `*` matches nothing at all.\n */\n private matchesOrigin(origin: string, allowed: string): boolean {\n if (allowed === origin) {\n return true;\n }\n const wildcardSuffix = ':*';\n if (!allowed.endsWith(wildcardSuffix)) {\n return false;\n }\n const prefix = allowed.slice(0, -wildcardSuffix.length);\n if (!origin.startsWith(`${prefix}:`)) {\n return false;\n }\n const port = origin.slice(prefix.length + 1);\n return /^\\d+$/.test(port);\n }\n\n /**\n * Create an ExpressWrapper for a route.\n * The wrapper handles the full request/response cycle (symmetric design): it publishes the\n * HttpRequest + fills the context, then invokes the api client method (the proxy).\n *\n * @param clientMethod - The api client's method for this route (dto → response); the proxy\n * runs the filter chain + controller.\n * @param path - The route path (used to build the HttpRequest).\n * @param formPost - True for an @Endpoint(..., { formPost: true }) route (parse body as\n * urlencoded, not JSON). Default false = JSON.\n * @param rawBody - True for an @Endpoint(..., { rawBody: true }) route: retain the verbatim\n * bytes + absolute url on the HttpRequest so an @AuthWebhook hook can verify a vendor\n * signature over them. Default false = the bytes are dropped once parsed.\n * @returns ExpressWrapper instance\n */\n createExpressWrapper(\n // webpieces-disable no-any-unknown -- request/response DTOs are erased at the routing boundary\n clientMethod: (requestDto: unknown) => Promise<unknown>,\n path: string,\n formPost: boolean = false,\n rawBody: boolean = false,\n ): ExpressWrapper {\n return new ExpressWrapper(clientMethod, path, this.headers, formPost, rawBody);\n }\n}\n"]}
1
+ {"version":3,"file":"WebpiecesMiddleware.js","sourceRoot":"","sources":["../../../../../packages/http/http-server/src/WebpiecesMiddleware.ts"],"names":[],"mappings":";;;;AACA,wDAAwB;AACxB,0DAAqF;AACrF,oDAA+C;AAC/C,0DAAgE;AAChE,oDAAkD;AAClD,qDAAkD;AAElD,MAAM,GAAG,GAAG,sBAAU,CAAC,SAAS,CAAC,qBAAqB,CAAC,CAAC;AACxD,iGAAiG;AACjG,+FAA+F;AAC/F,MAAM,OAAO,GAAG,sBAAU,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC;AAa7C;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEI,IAAM,mBAAmB,GAAzB,MAAM,mBAAmB;IAC5B,0FAA0F;IACzE,OAAO,GAAG,IAAI,oCAAqB,EAAE,CAAC;IAGvD;;;;;;;;;;;;;;;;;;OAkBG;IACH,2GAA2G;IAC3G,gKAAgK;IAChK,YAAY,CAAC,GAAY,EAAE,GAAY,EAAE,GAAa,EAAE,IAAkB;QACtE,MAAM,KAAK,GAAG,IAAA,mBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,GAAG,CAAC,KAAK,CAAC,oBAAoB,GAAG,CAAC,MAAM,IAAI,GAAG,CAAC,IAAI,EAAE,EAAE,KAAK,CAAC,CAAC;QAC/D,IAAI,GAAG,CAAC,WAAW,EAAE,CAAC;YAClB,OAAO;QACX,CAAC;QACD,6FAA6F;QAC7F,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;;;;;;;;;SASpB,CAAC,CAAC;IACP,CAAC;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;OA2BG;IACH,cAAc,CAAC,MAAwB;QACnC,MAAM,cAAc,GAAG,MAAM,EAAE,WAAW,IAAI,EAAE,CAAC;QACjD,OAAO,CAAC,IAAI,CACR,yCAAyC,cAAc,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK;YACnE,wCAAwC,CAC/C,CAAC;QAEF,MAAM,OAAO,GAAG,IAAA,cAAI,EAAC;YACjB,MAAM,EAAE,IAAI,EAAE,+DAA+D;YAC7E,WAAW,EAAE,IAAI;YACjB,OAAO,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,SAAS,CAAC;YAC7D,cAAc,EAAE,GAAG;YACnB,cAAc,EAAE,GAAG;YACnB,MAAM,EAAE,IAAI;SACf,CAAC,CAAC;QAEH,OAAO,CAAC,GAAY,EAAE,GAAa,EAAE,IAAkB,EAAQ,EAAE;YAC7D,MAAM,MAAM,GAAG,GAAG,CAAC,OAAO,CAAC,MAAM,CAAC;YAClC,IAAI,CAAC,MAAM,EAAE,CAAC;gBACV,yEAAyE;gBACzE,IAAI,EAAE,CAAC;gBACP,OAAO;YACX,CAAC;YACD,IAAI,IAAI,CAAC,eAAe,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,cAAc,CAAC,EAAE,CAAC;gBAChE,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,IAAI,CAAC,CAAC;gBACxB,OAAO;YACX,CAAC;YACD,OAAO,CAAC,IAAI,CAAC,mBAAmB,MAAM,EAAE,CAAC,CAAC;YAC1C,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;gBACjB,IAAI,EAAE,WAAW;gBACjB,OAAO,EAAE,gCAAgC,MAAM,EAAE;aACpD,CAAC,CAAC;QACP,CAAC,CAAC;IACN,CAAC;IAED;;;;;OAKG;IACK,eAAe,CAAC,MAAc,EAAE,IAAwB,EAAE,cAAwB;QACtF,IAAI,UAAkB,CAAC;QACvB,kMAAkM;QAClM,IAAI,CAAC;YACD,UAAU,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC;QACtC,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,mBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,OAAO,CAAC,IAAI,CAAC,4BAA4B,MAAM,MAAM,KAAK,CAAC,OAAO,EAAE,CAAC,CAAC;YACtE,OAAO,KAAK,CAAC;QACjB,CAAC;QACD,IAAI,IAAI,KAAK,SAAS,IAAI,UAAU,KAAK,IAAI,EAAE,CAAC;YAC5C,OAAO,IAAI,CAAC,CAAC,uDAAuD;QACxE,CAAC;QACD,OAAO,cAAc,CAAC,IAAI,CAAC,CAAC,OAAe,EAAW,EAAE,CAAC,IAAI,CAAC,aAAa,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAClG,CAAC;IAED;;;;;;;OAOG;IACK,aAAa,CAAC,MAAc,EAAE,OAAe;QACjD,IAAI,OAAO,KAAK,MAAM,EAAE,CAAC;YACrB,OAAO,IAAI,CAAC;QAChB,CAAC;QACD,MAAM,cAAc,GAAG,IAAI,CAAC;QAC5B,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAC,EAAE,CAAC;YACpC,OAAO,KAAK,CAAC;QACjB,CAAC;QACD,MAAM,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,cAAc,CAAC,MAAM,CAAC,CAAC;QACxD,IAAI,CAAC,MAAM,CAAC,UAAU,CAAC,GAAG,MAAM,GAAG,CAAC,EAAE,CAAC;YACnC,OAAO,KAAK,CAAC;QACjB,CAAC;QACD,MAAM,IAAI,GAAG,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;QAC7C,OAAO,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC9B,CAAC;IAED;;;;;;;;;;;;;;OAcG;IACH,oBAAoB;IAChB,+FAA+F;IAC/F,YAAuD,EACvD,IAAY,EACZ,WAAoB,KAAK,EACzB,UAAmB,KAAK;QAExB,OAAO,IAAI,+BAAc,CAAC,YAAY,EAAE,IAAI,EAAE,IAAI,CAAC,OAAO,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC;IACnF,CAAC;CACJ,CAAA;AAlLY,kDAAmB;8BAAnB,mBAAmB;IAD/B,IAAA,wCAAyB,GAAE;GACf,mBAAmB,CAkL/B","sourcesContent":["import { Request, Response, NextFunction, RequestHandler } from 'express';\nimport cors from 'cors';\nimport { provideFrameworkSingleton, WebpiecesConfig } from '@webpieces/http-routing';\nimport { toError } from '@webpieces/core-util';\nimport { RequestContextHeaders } from '@webpieces/core-context';\nimport { LogManager } from '@webpieces/core-util';\nimport { ExpressWrapper } from './ExpressWrapper';\n\nconst log = LogManager.getLogger('WebpiecesMiddleware');\n// CORS mount/allow/block lines log under their own name (not [WebpiecesMiddleware]); the backend\n// prepends \"[CORS]\" for us, so the message strings below carry no literal prefix of their own.\nconst corsLog = LogManager.getLogger('CORS');\n\n/**\n * Express route handler function type. Lives in http-server (the express adapter),\n * NOT in the node-only http-routing package, so http-routing stays express-free.\n * Used by WebpiecesExpressRouter to register handlers Express can call.\n */\nexport type ExpressRouteHandler = (\n req: Request,\n res: Response,\n next: NextFunction,\n) => Promise<void>;\n\n/**\n * WebpiecesMiddleware - Express middleware for WebPieces server.\n *\n * This class contains all Express middleware used by WebpiecesServer:\n * 1. errorHandler - Top-level 4-arg error middleware, returns HTML 500 page. Mounted AFTER the\n * routes so express can forward downstream failures to it (express routes errors DOWN a\n * separate pipeline, it does not bubble them back up through next()). Last-resort net only:\n * api routes translate their own errors to JSON inside the filter chain (ExpressWrapper).\n * 2. corsMiddleware - Opt-in CORS (mounted only when corsOrigins is non-empty).\n *\n * Per-request logging is intentionally NOT here — the api filter chain's LogApiCall already logs\n * every request/response with full context (requestId, method, path, body), so a plain express\n * START/END line would only duplicate it with less information.\n *\n * Route dispatch happens via Express's registered route handlers (the per-route ExpressWrapper),\n * NOT via this middleware.\n *\n * NEW: ExpressWrapper simplified - no longer handles JSON or headers\n * - JSON parsing/serialization moved to JsonFilter\n * - Header transfer moved to ContextFilter (injects PlatformHeadersExtension directly)\n * - ExpressWrapper just creates RouterReqResp and invokes filter chain\n *\n * Extension vs Plugin pattern:\n * - Extensions (DI-level): Contribute capabilities to framework (headers, converters, etc.)\n * - Plugins (App-level): Provide complete features with modules + routes (Hibernate, Jackson, etc.)\n */\n@provideFrameworkSingleton()\nexport class WebpiecesMiddleware {\n /** The ONE wire<->context transfer, handed to every route's ExpressWrapper. Stateless. */\n private readonly headers = new RequestContextHeaders();\n\n\n /**\n * Top-level error handler — the last-ditch catch-all. MUST be mounted AFTER all routes (see\n * {@link WebpiecesExpressRouter}). The 4-argument `(err, req, res, next)` signature is what\n * tells express this is an error-handling middleware: express's router forwards ANY downstream\n * failure to it — synchronous throws AND rejected async-handler promises alike (the router does\n * `promise.then(null, err => next(err))`, and a `next(err)` with a truthy arg jumps straight to\n * the first 4-arg middleware). This is why a `try { await next() } catch` wrapper is NOT needed\n * (and would not work) — express `next()` is not promise-aware, so it never hands the parent the\n * downstream promise; errors travel down this separate pipeline instead of bubbling back up.\n *\n * Returns an HTML 500 page. Api routes translate their own errors to JSON inside the filter\n * chain (JsonFilter/ExpressWrapper), so this normally only fires for failures OUTSIDE a route\n * (body parsing, unmatched paths, a bug in the wrapper itself).\n *\n * The page carries NO `error.message`. It used to render one into a `<pre>` block, which is the\n * same leak `HttpErrorWireMapper` closes on the JSON side and a worse one here: the errors that\n * reach THIS handler are the unhandled ones, whose messages are stack-adjacent internals nobody\n * wrote for a caller to read. The message is logged one line above, which is where it belongs.\n */\n // webpieces-disable no-any-unknown -- a thrown/forwarded express error is genuinely unknown until narrowed\n // eslint-disable-next-line @typescript-eslint/no-unused-vars -- express needs the 4-arg (err,req,res,next) arity to recognize this as error-handling middleware\n errorHandler(err: unknown, req: Request, res: Response, next: NextFunction): void {\n const error = toError(err);\n log.error(`Unhandled error: ${req.method} ${req.path}`, error);\n if (res.headersSent) {\n return;\n }\n // Return HTML error page (not JSON - api routes translate JSON errors in their filter chain)\n res.status(500).send(`\n <!DOCTYPE html>\n <html>\n <head><title>Server Error</title></head>\n <body>\n <h1>You hit a server error</h1>\n <p>An unexpected error occurred while processing your request.</p>\n </body>\n </html>\n `);\n }\n\n /**\n * CORS middleware. DO NOT MOUNT UNCONDITIONALLY — {@link WebpiecesExpressRouter.bindAndStartExpress}\n * mounts it ONLY when {@link WebpiecesConfig.corsOrigins} is non-empty, and that is the point.\n *\n * CORS exists solely to let a browser on a DIFFERENT origin call this api — in practice\n * `ng serve` on :4200 hitting an api on :8080 during development, or a UI hosted on a different\n * host than the api. A server that serves its own browser app needs NO cors at all, because a\n * browser does not apply cors to a same-origin request. So in production this middleware is\n * normally ABSENT, and absent is the safe state: every origin it allows gains the right to make\n * CREDENTIALED cross-origin calls and READ the responses. Mounting it unconditionally (as the\n * old corsForLocalhost did) handed that right to anything on the victim's localhost, in prod,\n * for no benefit whatsoever.\n *\n * When mounted, allows: a request with NO Origin (curl, server-to-server, a CLI); the server's\n * OWN origin; and EXACTLY the origins in `corsOrigins` — nothing is implicit. Anything else gets\n * a clean 403, never the HTML 500 the old `callback(new Error(...))` produced.\n *\n * SAME-ORIGIN MUST STAY ALLOWED even though a same-origin request needs no cors headers, because\n * a browser attaches an `Origin` header to EVERY POST — including a same-origin POST — and every\n * webpieces route is a POST. Once mounted, this middleware SEES that origin, so if it did not\n * allow it, it would 403 the server's own UI. That was the production bug.\n *\n * The same-origin test compares HOST ONLY, deliberately. Behind a TLS-terminating proxy (Cloud\n * Run, any load balancer) `req.protocol` is `http` while the browser's `Origin` says `https`, so\n * comparing full origins would reject the server's own origin on every deploy.\n *\n * @returns Express middleware handler for CORS\n */\n corsMiddleware(config?: WebpiecesConfig): RequestHandler {\n const allowedOrigins = config?.corsOrigins ?? [];\n corsLog.info(\n `CORS MOUNTED. Allowing same-origin + [${allowedOrigins.join(', ')}]. ` +\n `Every other browser origin gets a 403.`,\n );\n\n const handler = cors({\n origin: true, // reflect the request origin — we have already vetted it below\n credentials: true,\n methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],\n allowedHeaders: '*',\n exposedHeaders: '*',\n maxAge: 3600,\n });\n\n return (req: Request, res: Response, next: NextFunction): void => {\n const origin = req.headers.origin;\n if (!origin) {\n // No Origin -> not a browser cross-origin request; nothing to negotiate.\n next();\n return;\n }\n if (this.isOriginAllowed(origin, req.get('host'), allowedOrigins)) {\n handler(req, res, next);\n return;\n }\n corsLog.info(`Blocked origin: ${origin}`);\n res.status(403).json({\n name: 'CorsError',\n message: `CORS not allowed for origin: ${origin}`,\n });\n };\n }\n\n /**\n * Same-origin (HOST ONLY — see corsMiddleware() on why the scheme is deliberately ignored), or an\n * explicit entry in corsOrigins. NOTHING is implicit: localhost is allowed only if the config\n * asked for it, so a production server that enables cors for a cross-host UI does not silently\n * open the door to localhost as well.\n */\n private isOriginAllowed(origin: string, host: string | undefined, allowedOrigins: string[]): boolean {\n let originHost: string;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- a malformed Origin is untrusted browser input, not a server fault; it must become a 403 here, never bubble to the 500 chokepoint\n try {\n originHost = new URL(origin).host;\n } catch (err: unknown) {\n const error = toError(err);\n corsLog.info(`Malformed Origin header '${origin}': ${error.message}`);\n return false;\n }\n if (host !== undefined && originHost === host) {\n return true; // same-origin: the server's own UI calling its own api\n }\n return allowedOrigins.some((allowed: string): boolean => this.matchesOrigin(origin, allowed));\n }\n\n /**\n * Exact origin match, except a `*` in the PORT position matches any port: `http://localhost:*`\n * is what a developer writes, because the angular dev-server port moves around.\n *\n * The `*` is deliberately NOT a general wildcard — it never spans a host, and what follows the\n * prefix must be a real (digits-only) port. So `http://localhost:*` cannot be tricked into\n * matching `http://localhost.evil.com`, and a bare `*` matches nothing at all.\n */\n private matchesOrigin(origin: string, allowed: string): boolean {\n if (allowed === origin) {\n return true;\n }\n const wildcardSuffix = ':*';\n if (!allowed.endsWith(wildcardSuffix)) {\n return false;\n }\n const prefix = allowed.slice(0, -wildcardSuffix.length);\n if (!origin.startsWith(`${prefix}:`)) {\n return false;\n }\n const port = origin.slice(prefix.length + 1);\n return /^\\d+$/.test(port);\n }\n\n /**\n * Create an ExpressWrapper for a route.\n * The wrapper handles the full request/response cycle (symmetric design): it publishes the\n * HttpRequest + fills the context, then invokes the api client method (the proxy).\n *\n * @param clientMethod - The api client's method for this route (dto → response); the proxy\n * runs the filter chain + controller.\n * @param path - The route path (used to build the HttpRequest).\n * @param formPost - True for an @Endpoint(..., { formPost: true }) route (parse body as\n * urlencoded, not JSON). Default false = JSON.\n * @param rawBody - True for an @Endpoint(..., { rawBody: true }) route: retain the verbatim\n * bytes + absolute url on the HttpRequest so an @AuthWebhook hook can verify a vendor\n * signature over them. Default false = the bytes are dropped once parsed.\n * @returns ExpressWrapper instance\n */\n createExpressWrapper(\n // webpieces-disable no-any-unknown -- request/response DTOs are erased at the routing boundary\n clientMethod: (requestDto: unknown) => Promise<unknown>,\n path: string,\n formPost: boolean = false,\n rawBody: boolean = false,\n ): ExpressWrapper {\n return new ExpressWrapper(clientMethod, path, this.headers, formPost, rawBody);\n }\n}\n"]}