@setu-ts/exceptions 0.0.0-stage → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/AGENT_CARD.md +50 -0
  2. package/LICENSE +21 -0
  3. package/README.md +188 -2
  4. package/dist/errors/exceptions.d.ts +146 -0
  5. package/dist/errors/exceptions.d.ts.map +1 -0
  6. package/dist/errors/exceptions.js +189 -0
  7. package/dist/errors/exceptions.js.map +1 -0
  8. package/dist/errors/http-error.d.ts +111 -0
  9. package/dist/errors/http-error.d.ts.map +1 -0
  10. package/dist/errors/http-error.js +81 -0
  11. package/dist/errors/http-error.js.map +1 -0
  12. package/dist/formatters/error-formatter.d.ts +81 -0
  13. package/dist/formatters/error-formatter.d.ts.map +1 -0
  14. package/dist/formatters/error-formatter.js +58 -0
  15. package/dist/formatters/error-formatter.js.map +1 -0
  16. package/dist/formatters/problem-details.d.ts +118 -0
  17. package/dist/formatters/problem-details.d.ts.map +1 -0
  18. package/dist/formatters/problem-details.js +108 -0
  19. package/dist/formatters/problem-details.js.map +1 -0
  20. package/dist/formatters/rfc7807-formatter.d.ts +20 -0
  21. package/dist/formatters/rfc7807-formatter.d.ts.map +1 -0
  22. package/dist/formatters/rfc7807-formatter.js +27 -0
  23. package/dist/formatters/rfc7807-formatter.js.map +1 -0
  24. package/dist/formatters/rfc9457-formatter.d.ts +29 -0
  25. package/dist/formatters/rfc9457-formatter.d.ts.map +1 -0
  26. package/dist/formatters/rfc9457-formatter.js +43 -0
  27. package/dist/formatters/rfc9457-formatter.js.map +1 -0
  28. package/dist/index.d.ts +33 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.js +33 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/middleware/error-handler.d.ts +111 -0
  33. package/dist/middleware/error-handler.d.ts.map +1 -0
  34. package/dist/middleware/error-handler.js +221 -0
  35. package/dist/middleware/error-handler.js.map +1 -0
  36. package/dist/middleware/error-responder-impl.d.ts +57 -0
  37. package/dist/middleware/error-responder-impl.d.ts.map +1 -0
  38. package/dist/middleware/error-responder-impl.js +158 -0
  39. package/dist/middleware/error-responder-impl.js.map +1 -0
  40. package/package.json +53 -3
  41. package/src/errors/exceptions.ts +209 -0
  42. package/src/errors/http-error.ts +132 -0
  43. package/src/formatters/error-formatter.ts +127 -0
  44. package/src/formatters/problem-details.ts +177 -0
  45. package/src/formatters/rfc7807-formatter.ts +48 -0
  46. package/src/formatters/rfc9457-formatter.ts +69 -0
  47. package/src/index.ts +57 -0
  48. package/src/middleware/error-handler.ts +357 -0
  49. package/src/middleware/error-responder-impl.ts +192 -0
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Global error-handler middleware factory.
3
+ *
4
+ * Returns a {@linkcode MiddlewareFunction} that wraps `next()` in a try/catch,
5
+ * so any error thrown by downstream middleware or the route handler is caught,
6
+ * logged (when a logger is registered), formatted, and sent as a JSON error
7
+ * response. Register it as the **outermost** middleware (lowest priority number)
8
+ * so it wraps the entire pipeline.
9
+ *
10
+ * @example
11
+ * ```typescript
12
+ * import { errorHandler } from '@setu-ts/exceptions';
13
+ *
14
+ * app.middleware.add(errorHandler({
15
+ * format: 'rfc9457',
16
+ * includeStackTrace: config.get('NODE_ENV') === 'development',
17
+ * logErrors: true,
18
+ * }), { priority: 0, name: 'error-handler' });
19
+ * ```
20
+ *
21
+ * @module
22
+ */
23
+ import type {
24
+ HandlerResult,
25
+ ILogger,
26
+ IRequestContext,
27
+ IRuntimeServices,
28
+ MiddlewareFunction,
29
+ } from '@setu-ts/common';
30
+ import {
31
+ brandErrorResponder,
32
+ CAPABILITIES,
33
+ ERROR_RESPONDER_STATE_KEY,
34
+ httpStatusHintOf,
35
+ resolveResponseStatus,
36
+ serializeError,
37
+ } from '@setu-ts/common';
38
+
39
+ import { HttpError } from '../errors/http-error.ts';
40
+ import { internalServerError, statusTitle } from '../errors/exceptions.ts';
41
+ import {
42
+ buildErrorFromInit,
43
+ createErrorResponder,
44
+ thrownErrorStatus,
45
+ } from './error-responder-impl.ts';
46
+ import {
47
+ type ErrorFormat,
48
+ type ErrorHandlerFormatter,
49
+ selectFormatter,
50
+ } from '../formatters/error-formatter.ts';
51
+ import { rfc9457Formatter } from '../formatters/rfc9457-formatter.ts';
52
+ import { rfc7807Formatter } from '../formatters/rfc7807-formatter.ts';
53
+
54
+ /**
55
+ * Options for the {@linkcode errorHandler} middleware factory.
56
+ *
57
+ * @since 0.1.0
58
+ */
59
+ export interface ErrorHandlerOptions {
60
+ /**
61
+ * The error body format: `'default'`, `'rfc9457'`, the deprecated
62
+ * `'rfc7807'`, or a custom formatter function. Defaults to `'default'`.
63
+ */
64
+ readonly format?: ErrorFormat | ErrorHandlerFormatter;
65
+ /**
66
+ * When `true`, the error `stack` trace is included in the response body.
67
+ * **Never** enable this in production — pass a config-derived boolean (e.g.
68
+ * `config.get('NODE_ENV') === 'development'`), never read `process.env`
69
+ * directly (AI_GUIDELINES §4.1). Defaults to `false`.
70
+ *
71
+ * Note the stack is the _secondary_ disclosure: the primary one is the
72
+ * error **message**, which for a failed query carries the SQL and its bound
73
+ * parameter values. Masking that is `maskInternalErrors`' job.
74
+ *
75
+ * The two compose safely, and masking wins: an error masked by
76
+ * {@linkcode ErrorHandlerOptions.maskInternalErrors} carries **no** stack in
77
+ * the body even when this option is `true`, because a stack begins with the
78
+ * very message that was masked. Set `maskInternalErrors: false` to see the
79
+ * stack of an internal error.
80
+ */
81
+ readonly includeStackTrace?: boolean;
82
+ /**
83
+ * When `true` (the default), a caught value that was **not** an
84
+ * {@linkcode HttpError} and resolves to a status `>= 500` is masked in the
85
+ * response: its `detail`/`message` becomes the status title
86
+ * (`'Internal Server Error'`) and the raw message — which for a failed
87
+ * query carries the SQL and its bound parameter values — is dropped from the
88
+ * body. The log is unaffected: `logErrors` still records the unmasked error
89
+ * and its cause chain, so an operator loses nothing unless `logErrors` is
90
+ * _also_ `false`, the configuration that already logs nothing.
91
+ *
92
+ * A deliberately thrown `HttpError` is never masked — `instanceof HttpError`
93
+ * is the line between "the developer wrote this for a caller" and "this
94
+ * escaped from a driver". `false` restores the previous behaviour verbatim.
95
+ */
96
+ readonly maskInternalErrors?: boolean;
97
+ /**
98
+ * When `true` (the default), logs once at `warn` below 500 and `error`
99
+ * otherwise. Without a logger, only 5xx reaches runtime.reportError.
100
+ */
101
+ readonly logErrors?: boolean;
102
+ /**
103
+ * Lets the application write its own response for a caught error. The hook
104
+ * receives the normalized error after status hints, internal-error masking,
105
+ * and status resolution, so `error.statusCode` is safe to serve. Return a
106
+ * {@linkcode HandlerResult} produced by `ctx.response` to use that response;
107
+ * it may resolve that result asynchronously. Return or resolve to `undefined`
108
+ * to fall through to the configured formatter unchanged.
109
+ *
110
+ * The hook owns its returned response's status, headers, and body. It is
111
+ * called only for errors caught by this middleware, not responder-based
112
+ * terminals such as an unmatched-path `404`.
113
+ */
114
+ readonly respond?: (
115
+ error: HttpError,
116
+ ctx: IRequestContext,
117
+ ) => HandlerResult | undefined | Promise<HandlerResult | undefined>;
118
+ }
119
+
120
+ /** The `application/problem+json` content type for Problem Details responses. */
121
+ const PROBLEM_JSON = 'application/problem+json';
122
+ /** The default JSON content type. */
123
+ const JSON_CONTENT_TYPE = 'application/json; charset=utf-8';
124
+
125
+ /**
126
+ * The formatters whose bodies are Problem Details and therefore require the
127
+ * `application/problem+json` media type (RFC 9457 §3).
128
+ *
129
+ * Membership is keyed on the RESOLVED formatter rather than on the format
130
+ * string, because both formatters are exported: `format: rfc9457Formatter`
131
+ * passes a reference and must carry the same media type as `format: 'rfc9457'`.
132
+ * A body served as `application/json` is ignored by generic problem-details
133
+ * clients, so a formatter missing from this set is a silent interoperability
134
+ * defect rather than a visible failure.
135
+ */
136
+ const PROBLEM_DETAILS_FORMATTERS: ReadonlySet<ErrorHandlerFormatter> = new Set([
137
+ rfc9457Formatter,
138
+ rfc7807Formatter,
139
+ ]);
140
+
141
+ /**
142
+ * Creates a global error-handler middleware.
143
+ *
144
+ * Behavior:
145
+ * 1. Calls `next()` inside a try/catch.
146
+ * 2. If `next()` throws an `HttpError`, its `statusCode` is used as-is.
147
+ * 3. If `next()` throws any other `Error`, it is wrapped in a `500`
148
+ * `internalServerError` carrying the original as `cause`.
149
+ * 4. When configured, lets the application write its own response. A returned
150
+ * or resolved `HandlerResult` short-circuits formatting; `undefined` falls
151
+ * through.
152
+ * 5. When `logErrors` is on and a logger is registered, the unmasked error is
153
+ * logged with the status the selected response serves.
154
+ * 6. Otherwise, formats the error via {@linkcode selectFormatter}, optionally
155
+ * enriches it with a `stack` trace, and sends it with the right status and
156
+ * content type. The middleware **returns a `HandlerResult`** (short-circuit)
157
+ * and never re-invokes `next()`.
158
+ *
159
+ * @param options - Error handler configuration
160
+ * @returns A middleware function
161
+ * @since 0.1.0
162
+ */
163
+ export function errorHandler(options?: ErrorHandlerOptions): MiddlewareFunction {
164
+ const format = options?.format ?? 'default';
165
+ const includeStackTrace = options?.includeStackTrace ?? false;
166
+ const maskInternalErrors = options?.maskInternalErrors ?? true;
167
+ const logErrors = options?.logErrors ?? true;
168
+ const respond = options?.respond;
169
+ const formatter = selectFormatter(format);
170
+ const contentType = PROBLEM_DETAILS_FORMATTERS.has(formatter) ? PROBLEM_JSON : JSON_CONTENT_TYPE;
171
+ // The responder is built ONCE at factory time from the formatter and content
172
+ // type already resolved above, and published per request below: one
173
+ // `Map.set` of a pre-built object, not a re-resolution (AI_GUIDELINES §14).
174
+ const responder = createErrorResponder(formatter, contentType);
175
+
176
+ const handleError = async (
177
+ ctx: IRequestContext,
178
+ next: () => Promise<void>,
179
+ ): Promise<void | HandlerResult> => {
180
+ // Publish the responder BEFORE `next()` so every site inside the pipeline
181
+ // — the kernel's own terminals and every short-circuiting middleware —
182
+ // answers in this application's configured format (M70f).
183
+ ctx.state.set(ERROR_RESPONDER_STATE_KEY, responder);
184
+ try {
185
+ await next();
186
+ return;
187
+ } catch (rawError) {
188
+ // Normalize: HttpError passes through, anything else becomes a 500.
189
+ const isHttpError = rawError instanceof HttpError;
190
+ const error: HttpError = isHttpError
191
+ ? rawError
192
+ : internalServerError(
193
+ rawError instanceof Error ? rawError.message : 'Internal Server Error',
194
+ rawError instanceof Error ? rawError : undefined,
195
+ );
196
+
197
+ // A thrower that cannot import this package states the status its own
198
+ // error means by branding it with an `HttpStatusHint` (M89b) — the seam
199
+ // `@setu-ts/database-plugin` uses for its query-shape refusals, which
200
+ // are caller errors and were answered as masked 500s. The hint carries
201
+ // its own caller-safe `detail`, so the response is built from THAT and
202
+ // never from the error's message, and `buildErrorFromInit` is the same
203
+ // constructor `respondWithError` reaches: one mapping, one body shape.
204
+ //
205
+ // Read only for a non-`HttpError`. A deliberately thrown `HttpError`
206
+ // already states its own status, and letting a brand override it would
207
+ // give one error two answers.
208
+ const hint = isHttpError ? undefined : httpStatusHintOf(rawError);
209
+
210
+ // Mask a driver-shaped 500 for the response: the raw message becomes the
211
+ // status title so the body carries neither the statement nor the values.
212
+ // A deliberately thrown HttpError is never masked, and a 4xx is left
213
+ // alone — only a non-HttpError with status >= 500 is rewritten.
214
+ //
215
+ // A hinted error is exempt, and the exemption is narrow by construction
216
+ // rather than by trust: what it serves is the hint's own `detail`, a
217
+ // fixed sentence the brand site wrote, never the `Error`'s message. So
218
+ // there is no driver diagnostic in the body for masking to remove. It is
219
+ // checked FIRST because a hinted 501 satisfies all three masking clauses
220
+ // and would otherwise be rewritten into the exact symptom this closes.
221
+ let responseError = error;
222
+ if (hint !== undefined) {
223
+ responseError = buildErrorFromInit(hint);
224
+ } else if (maskInternalErrors && !isHttpError && error.statusCode >= 500) {
225
+ // Preserve the log's cause chain without leaking the message. `cause`
226
+ // is `unknown` on `Error`; only an `Error` value is a valid
227
+ // `HttpError` cause, so narrow rather than cast.
228
+ const cause = error.cause instanceof Error ? error.cause : undefined;
229
+ responseError = new HttpError(
230
+ error.statusCode,
231
+ statusTitle(error.statusCode),
232
+ undefined,
233
+ cause,
234
+ );
235
+ }
236
+
237
+ // An `HttpError` carries whatever status its thrower passed: the
238
+ // constructor validates nothing, and its own JSDoc says the factory
239
+ // functions are what "guarantee a correct status code". So
240
+ // `throw new HttpError(4004, ...)` — a typo for `404` — reached
241
+ // `response.status()` and threw `RangeError` out of the one `catch` that
242
+ // exists to contain throws. The status is clamped BEFORE the log and the
243
+ // body are built, so the logged status, the body's own status member and
244
+ // the written status are one number. Rebuilding rather than clamping at
245
+ // the write also makes `responseError === error` false, which suppresses
246
+ // the stack — correct by that check's own stated invariant, since the
247
+ // error being SERVED is no longer the error that was LOGGED.
248
+ const servedStatus = resolveResponseStatus(thrownErrorStatus(rawError).requested, ctx);
249
+ if (servedStatus !== responseError.statusCode) {
250
+ responseError = new HttpError(
251
+ servedStatus,
252
+ responseError.message,
253
+ responseError.details,
254
+ responseError.cause instanceof Error ? responseError.cause : undefined,
255
+ );
256
+ }
257
+
258
+ // The hook sees the same safe error and served status that the formatter
259
+ // would receive. It can perform asynchronous view rendering; only
260
+ // `undefined` means "use the framework formatter". A hook failure stays
261
+ // an application failure, but records the original caught diagnostic
262
+ // before it propagates to the surrounding pipeline.
263
+ let result: HandlerResult | undefined;
264
+ try {
265
+ result = await respond?.(responseError, ctx);
266
+ } catch (hookError) {
267
+ if (logErrors) {
268
+ logError(ctx, error, responseError.statusCode);
269
+ }
270
+ throw hookError;
271
+ }
272
+ if (result !== undefined) {
273
+ // The application-owned result can choose a status different from the
274
+ // normalized error (for example a 418 HTML page for a caught 500), so
275
+ // inspect the completed writer rather than reporting the fallback.
276
+ if (logErrors) {
277
+ logError(ctx, error, ctx.response.snapshot().status);
278
+ }
279
+ return result;
280
+ }
281
+
282
+ // Log the unmasked diagnostic and cause chain, with the status the
283
+ // formatter below will actually serve. A hint may change a normalized
284
+ // `500` to `501`, so logging the pre-hint status would mislead operators.
285
+ if (logErrors) {
286
+ logError(ctx, error, responseError.statusCode);
287
+ }
288
+
289
+ const body = formatter(responseError, ctx);
290
+ // Masking wins over `includeStackTrace`. A stack's first line is
291
+ // `<name>: <message>`, so attaching the unmasked error's stack would put
292
+ // the SQL and its bound parameter values straight back into the body that
293
+ // was just masked — defeating the mask through the one option documented
294
+ // as unsafe in production. A masked error therefore carries no stack at
295
+ // all; the unmasked one is in the log, where `logErrors` already sent it.
296
+ //
297
+ // The condition is `responseError === error` rather than `!masked`
298
+ // because a hinted response is the second case with the same property:
299
+ // its body deliberately carries the brand site's sentence and not the
300
+ // error's own text, and a stack's first line is `<name>: <message>`. So
301
+ // the stack is attached exactly when the error being SERVED is the error
302
+ // that was LOGGED, which is the invariant both exclusions want.
303
+ if (includeStackTrace && responseError === error && error.stack !== undefined) {
304
+ body.stack = error.stack;
305
+ }
306
+
307
+ // Serialize and send via `.send(bytes)` rather than `.json(body)` so
308
+ // the content-type header we set is not overwritten by json()'s own
309
+ // `application/json` default — RFC 9457 requires `application/problem+json`.
310
+ const bytes = new TextEncoder().encode(JSON.stringify(body));
311
+ return ctx.response
312
+ .status(responseError.statusCode)
313
+ .header('content-type', contentType)
314
+ .send(bytes);
315
+ }
316
+ };
317
+
318
+ // Brand the middleware with the SAME responder it publishes into `ctx.state`
319
+ // below, so the kernel can seed it into the state of the pre-pipeline sites
320
+ // — the drain `503`, the malformed-request `400`, and the request-lifecycle
321
+ // hooks — which run before this middleware and would otherwise always take
322
+ // the fallback shape (M70f re-review, findings 1 & 2).
323
+ brandErrorResponder(handleError, responder);
324
+ return handleError;
325
+ }
326
+
327
+ /**
328
+ * Logs once through the registered logger, or the runtime sink for 5xx when
329
+ * no logger is registered. Handled 4xx without a logger stays silent.
330
+ *
331
+ * @param ctx - The request context
332
+ * @param error - The error to log, carrying the unmasked message and cause
333
+ * @param statusCode - The status actually served, which a hint may have changed
334
+ */
335
+ function logError(ctx: IRequestContext, error: HttpError, statusCode: number): void {
336
+ if (!ctx.services.has(CAPABILITIES.LOGGER)) {
337
+ if (statusCode >= 500 && ctx.services.has(CAPABILITIES.RUNTIME)) {
338
+ ctx.services.get<IRuntimeServices>(CAPABILITIES.RUNTIME).reportError?.({
339
+ level: 'error',
340
+ ...serializeError(error),
341
+ statusCode,
342
+ requestId: ctx.id,
343
+ method: ctx.request.method,
344
+ path: ctx.request.path,
345
+ });
346
+ }
347
+ return;
348
+ }
349
+ const logger = ctx.services.get<ILogger>(CAPABILITIES.LOGGER);
350
+ // Serialize the cause (X2-5): a raw `Error` in log metadata renders as `{}`
351
+ // under `JSON.stringify` because `message`/`stack` are non-enumerable.
352
+ logger[statusCode < 500 ? 'warn' : 'error'](error.message, {
353
+ statusCode,
354
+ requestId: ctx.id,
355
+ ...(error.cause !== undefined && { cause: serializeError(error.cause) }),
356
+ });
357
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * The `exceptions` implementation of the request-scoped error responder seam
3
+ * (M70f).
4
+ *
5
+ * This module is **internal**: it is not exported from `src/index.ts`. The
6
+ * interface it satisfies (`IErrorResponder`), the state key, the init type, and
7
+ * the `respondWithError` free function all live in `@setu-ts/common`; exporting
8
+ * the implementation as well would give one concept two public names. A
9
+ * `barrel-exports.test.ts` assertion pins that it stays out of the barrel.
10
+ *
11
+ * @module
12
+ */
13
+ import type {
14
+ ErrorResponderTarget,
15
+ ErrorResponseInit,
16
+ IErrorResponder,
17
+ IRequestContext,
18
+ } from '@setu-ts/common';
19
+ import { httpStatusHintOf, resolveResponseStatus } from '@setu-ts/common';
20
+
21
+ import { HttpError } from '../errors/http-error.ts';
22
+ import { RESPONDER_DETAIL } from '../formatters/problem-details.ts';
23
+ import type { ErrorHandlerFormatter } from '../formatters/error-formatter.ts';
24
+
25
+ /**
26
+ * The `application/problem+json` content type for Problem Details responses.
27
+ *
28
+ * Mirrors the constant `errorHandler` uses to decide the media type: a
29
+ * formatter answers Problem Details (and so derives `instance` from the
30
+ * request path) exactly when its resolved content type is this one.
31
+ */
32
+ const PROBLEM_JSON = 'application/problem+json';
33
+
34
+ /** The pure thrown-value rule, shared by serving and request-log prediction. */
35
+ export function thrownErrorStatus(error: unknown): { requested: number; status: number } {
36
+ const requested =
37
+ error instanceof HttpError ? error.statusCode : (httpStatusHintOf(error)?.status ?? 500);
38
+ // Fetch refuses informational responses and bodies on 204, 205 and 304.
39
+ const status =
40
+ Number.isInteger(requested) &&
41
+ requested >= 200 &&
42
+ requested <= 599 &&
43
+ requested !== 204 &&
44
+ requested !== 205 &&
45
+ requested !== 304
46
+ ? requested
47
+ : 500;
48
+ return { requested, status };
49
+ }
50
+
51
+ /**
52
+ * Narrows an {@linkcode ErrorResponderTarget} to a full {@linkcode
53
+ * IRequestContext} when it genuinely is one.
54
+ *
55
+ * The responder is reached two ways. In-pipeline sites — the kernel's terminals
56
+ * and every short-circuiting middleware — pass the live `IRequestContext`,
57
+ * which carries `id`, `services`, and `startTime`. The kernel's PRE-pipeline
58
+ * sites — the shutdown-drain `503` and the malformed-request `400` — pass a
59
+ * bare target of just `state`, `response`, and an optional safe `request`
60
+ * path, because no request context exists yet. The three members checked here
61
+ * are present on every real context and absent from every bare target, so the
62
+ * test distinguishes the two without a lie.
63
+ *
64
+ * @param target - The responder target to test
65
+ * @returns `true` when the target is a full request context
66
+ */
67
+ function isFullRequestContext(target: ErrorResponderTarget): target is IRequestContext {
68
+ const ctx = target as IRequestContext;
69
+ return (
70
+ typeof ctx.id === 'string' &&
71
+ typeof ctx.services === 'object' &&
72
+ ctx.services !== null &&
73
+ typeof ctx.startTime === 'number'
74
+ );
75
+ }
76
+
77
+ /**
78
+ * Builds the {@linkcode HttpError} that carries one error response's status,
79
+ * title and disclosure.
80
+ *
81
+ * This is the single owner of that mapping, and it has **two** callers: the
82
+ * responder below, and `errorHandler`'s status-hint path (M89b), which answers
83
+ * a thrown error branded with an `HttpStatusHint` — a type that IS an
84
+ * `ErrorResponseInit`. Sharing it is what makes the two agree: a hinted throw
85
+ * and a `respondWithError` call carrying the same values produce byte-identical
86
+ * bodies under `default`, `rfc9457`, `rfc7807` and any custom formatter. A
87
+ * second hand-rolled construction would silently diverge — the `default`
88
+ * formatter reads `details.detail` while the Problem Details formatters read
89
+ * the {@linkcode RESPONDER_DETAIL} symbol, so getting either half wrong loses
90
+ * the disclosure in exactly one format.
91
+ *
92
+ * @param init - The status, title, disclosure and optional structured details
93
+ * @returns An `HttpError` every formatter renders with the disclosure intact
94
+ */
95
+ export function buildErrorFromInit(init: ErrorResponseInit): HttpError {
96
+ // The title is the framework-default `error` member — the `message` of the
97
+ // `default` format and the `detail` fallback of the Problem Details
98
+ // formatters — so it stays `error.message`. The disclosure (`init.detail`)
99
+ // is kept verbatim by every format (the `ErrorResponseInit` contract in
100
+ // `@setu-ts/common`): it is carried in the error's structured `details`
101
+ // under a `detail` key, so the `default` format emits it as `details.detail`
102
+ // beside `message`, and `buildProblemDetails` promotes it to the Problem
103
+ // Details `detail` member (falling back to the title when a site supplies no
104
+ // disclosure). `init.details` (e.g. a validation `errors` array) is merged
105
+ // through unchanged. Carrying it this way — rather than in `error.message` —
106
+ // keeps the title verbatim, which a site's (non-)disclosure decision depends
107
+ // on.
108
+ const details =
109
+ init.detail !== undefined ? { ...init.details, detail: init.detail } : init.details;
110
+ const error = new HttpError(init.status, init.title, details);
111
+ // Mark the error as responder-built and carry the disclosure on a
112
+ // module-private symbol, which is what `buildProblemDetails` promotes to the
113
+ // Problem Details `detail` member. The `details.detail` key above is what the
114
+ // `default` formatter emits (it serializes `details` verbatim); the Problem
115
+ // Details formatters read the symbol instead, so a THROWN error that happens
116
+ // to carry a `details.detail` key of its own keeps `detail: <message>` (M70f
117
+ // code review, finding 4). Non-enumerable so it never reaches a serialized
118
+ // body.
119
+ if (init.detail !== undefined) {
120
+ Object.defineProperty(error, RESPONDER_DETAIL, {
121
+ value: init.detail,
122
+ enumerable: false,
123
+ writable: false,
124
+ configurable: true,
125
+ });
126
+ }
127
+ return error;
128
+ }
129
+
130
+ /**
131
+ * Builds an {@linkcode IErrorResponder} from the formatter and content type
132
+ * that `errorHandler` has **already** resolved at factory time.
133
+ *
134
+ * The responder keeps the `HttpError` construction inside the only package that
135
+ * owns it: it builds an `HttpError` from the init (so `buildProblemDetails` sees
136
+ * a real `statusCode` and the validation `errors` extension, and
137
+ * `maskInternalErrors` would never mask a deliberately 4xx), runs the resolved
138
+ * formatter over it, and writes status, `content-type` and the serialized body
139
+ * — the same three-step tail `errorHandler`'s catch path performs.
140
+ *
141
+ * @param formatter - The resolved error formatter
142
+ * @param contentType - The resolved response content type
143
+ * @returns A responder publishing the application's error format
144
+ * @since 0.1.0
145
+ */
146
+ export function createErrorResponder(
147
+ formatter: ErrorHandlerFormatter,
148
+ contentType: string,
149
+ ): IErrorResponder {
150
+ return {
151
+ statusFor: (error: unknown): number => thrownErrorStatus(error).status,
152
+ respond(target: ErrorResponderTarget, init: ErrorResponseInit): void {
153
+ // Sanitized before the error is built, not just before the status is
154
+ // written, so the formatted body's `status` member agrees with the status
155
+ // actually written. Idempotent, so the common path through
156
+ // `respondWithError` — which already sanitized — reports nothing twice.
157
+ const status = resolveResponseStatus(init.status, target);
158
+ const error = buildErrorFromInit(status === init.status ? init : { ...init, status });
159
+ // The formatter's `ctx` parameter is a FULL `IRequestContext` by its
160
+ // contract (M70f re-review round 2, finding 2). The target is a full
161
+ // context only at the in-pipeline sites; the kernel's pre-pipeline sites
162
+ // hand over a bare target whose `services`, `id`, `params`, `query`,
163
+ // `signal` and `startTime` do not exist. Passing that partial object
164
+ // would let a custom formatter read a documented member and throw
165
+ // `TypeError: Cannot read properties of undefined` — replacing the
166
+ // configured 503/400 with an unhandled exception. So the formatter
167
+ // receives the context only when it genuinely is one, and `undefined`
168
+ // otherwise — the value its optional `ctx` parameter already declares.
169
+ const fullContext = isFullRequestContext(target) ? target : undefined;
170
+ let body = formatter(error, fullContext);
171
+ // The Problem Details `instance` member comes from the request path. At
172
+ // the in-pipeline sites the formatter read it off the full context; at
173
+ // the pre-pipeline sites there is no context, so the responder supplies
174
+ // the safe path the kernel captured separately — a separate internal
175
+ // mechanism, since the formatter's optional `ctx` parameter is the only
176
+ // public channel and it must stay honest (M70f re-review round 2,
177
+ // finding 2). A malformed request carries no path, in which case the
178
+ // member is simply absent. A fresh object is composed rather than
179
+ // mutating the formatter's return value, which a custom formatter may
180
+ // share or freeze.
181
+ if (
182
+ fullContext === undefined &&
183
+ contentType === PROBLEM_JSON &&
184
+ target.request !== undefined
185
+ ) {
186
+ body = { ...body, instance: target.request.path };
187
+ }
188
+ const bytes = new TextEncoder().encode(JSON.stringify(body));
189
+ target.response.status(status).header('content-type', contentType).send(bytes);
190
+ },
191
+ };
192
+ }