@zuplo/errors 7.12.5 → 7.12.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/errors.d.ts CHANGED
@@ -2,6 +2,14 @@ import { ProblemDetails, ProblemDetailsFactory } from "./interfaces.js";
2
2
  import * as Problems from "./problems.js";
3
3
  export type { ProblemDetails, ProblemDetailsFactory };
4
4
  export { Problems };
5
+ /**
6
+ * Every problem `type` an {@link ApiError} accepts must be a URI under this
7
+ * prefix. Exported so callers that validate an untrusted problem body before
8
+ * constructing an `ApiError` test the same rule the constructor enforces —
9
+ * checking a divergent prefix turns a rejected `type` into a thrown plain
10
+ * `Error` instead of the upstream problem.
11
+ */
12
+ export declare const PROBLEM_TYPE_PREFIX = "https://zup.fail/";
5
13
  /**
6
14
  * The default format for Api errors
7
15
  */
@@ -1 +1 @@
1
- {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AACxE,OAAO,KAAK,QAAQ,MAAM,eAAe,CAAC;AAE1C,YAAY,EAAE,cAAc,EAAE,qBAAqB,EAAE,CAAC;AACtD,OAAO,EAAE,QAAQ,EAAE,CAAC;AACpB;;GAEG;AACH,qBAAa,QAAS,SAAQ,KAAK;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;gBAE3B,OAAO,EAAE,cAAc,EAAE,OAAO,CAAC,EAAE,YAAY;CAiB5D;AAED;;GAEG;AACH,qBAAa,gBAAiB,SAAQ,QAAQ;gBAChC,OAAO,GAAE,IAAI,CAAC,cAAc,EAAE,QAAQ,GAAG,UAAU,CAAM;CAOtE;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,cAAc,CAYpE"}
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AACxE,OAAO,KAAK,QAAQ,MAAM,eAAe,CAAC;AAE1C,YAAY,EAAE,cAAc,EAAE,qBAAqB,EAAE,CAAC;AACtD,OAAO,EAAE,QAAQ,EAAE,CAAC;AAEpB;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,sBAAsB,CAAC;AAEvD;;GAEG;AACH,qBAAa,QAAS,SAAQ,KAAK;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;gBAE3B,OAAO,EAAE,cAAc,EAAE,OAAO,CAAC,EAAE,YAAY;CAiB5D;AAED;;GAEG;AACH,qBAAa,gBAAiB,SAAQ,QAAQ;gBAChC,OAAO,GAAE,IAAI,CAAC,cAAc,EAAE,QAAQ,GAAG,UAAU,CAAM;CAOtE;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,cAAc,CAYpE"}
package/dist/errors.js CHANGED
@@ -1,5 +1,13 @@
1
1
  import * as Problems from "./problems.js";
2
2
  export { Problems };
3
+ /**
4
+ * Every problem `type` an {@link ApiError} accepts must be a URI under this
5
+ * prefix. Exported so callers that validate an untrusted problem body before
6
+ * constructing an `ApiError` test the same rule the constructor enforces —
7
+ * checking a divergent prefix turns a rejected `type` into a thrown plain
8
+ * `Error` instead of the upstream problem.
9
+ */
10
+ export const PROBLEM_TYPE_PREFIX = "https://zup.fail/";
3
11
  /**
4
12
  * The default format for Api errors
5
13
  */
@@ -11,8 +19,8 @@ export class ApiError extends Error {
11
19
  extra;
12
20
  constructor(problem, options) {
13
21
  super(problem.title, options);
14
- if (!problem.type.startsWith("https://zup.fail/")) {
15
- throw new Error(`Invalid 'type' specified on ApiError, must be a URI starting with 'https://zup.fail/'. Type was set to ${problem.type}`);
22
+ if (!problem.type.startsWith(PROBLEM_TYPE_PREFIX)) {
23
+ throw new Error(`Invalid 'type' specified on ApiError, must be a URI starting with '${PROBLEM_TYPE_PREFIX}'. Type was set to ${problem.type}`);
16
24
  }
17
25
  const { type, status, detail, instance, ...extra } = problem;
18
26
  this.name = "ApiProblemError";
@@ -1 +1 @@
1
- {"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,QAAQ,MAAM,eAAe,CAAC;AAG1C,OAAO,EAAE,QAAQ,EAAE,CAAC;AACpB;;GAEG;AACH,MAAM,OAAO,QAAS,SAAQ,KAAK;IAC1B,IAAI,CAAS;IACb,MAAM,CAAS;IACf,MAAM,CAAU;IAChB,QAAQ,CAAU;IAClB,KAAK,CAA2B;IAEvC,YAAY,OAAuB,EAAE,OAAsB;QACzD,KAAK,CAAC,OAAO,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC;QAE9B,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,UAAU,CAAC,mBAAmB,CAAC,EAAE,CAAC;YAClD,MAAM,IAAI,KAAK,CACb,0GAA0G,OAAO,CAAC,IAAI,EAAE,CACzH,CAAC;QACJ,CAAC;QAED,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,KAAK,EAAE,GAAG,OAAO,CAAC;QAC7D,IAAI,CAAC,IAAI,GAAG,iBAAiB,CAAC;QAC9B,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;QACzB,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;IACrB,CAAC;CACF;AAED;;GAEG;AACH,MAAM,OAAO,gBAAiB,SAAQ,QAAQ;IAC5C,YAAY,UAAuD,EAAE;QACnE,KAAK,CAAC;YACJ,GAAG,OAAO;YACV,GAAG,QAAQ,CAAC,SAAS;SACtB,CAAC,CAAC;QACH,IAAI,CAAC,IAAI,GAAG,yBAAyB,CAAC;IACxC,CAAC;CACF;AAED;;;;GAIG;AACH,MAAM,UAAU,gBAAgB,CAAC,GAAY;IAC3C,OAAO,CACL,GAAG,KAAK,SAAS;QACjB,GAAG,KAAK,IAAI;QACZ,OAAO,GAAG,KAAK,QAAQ;QACvB,MAAM,IAAI,GAAG;QACb,OAAO,IAAI,GAAG;QACd,QAAQ,IAAI,GAAG;QACf,OAAO,GAAG,CAAC,IAAI,KAAK,QAAQ;QAC5B,OAAO,GAAG,CAAC,KAAK,KAAK,QAAQ;QAC7B,OAAO,GAAG,CAAC,MAAM,KAAK,QAAQ,CAC/B,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,QAAQ,MAAM,eAAe,CAAC;AAG1C,OAAO,EAAE,QAAQ,EAAE,CAAC;AAEpB;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,mBAAmB,CAAC;AAEvD;;GAEG;AACH,MAAM,OAAO,QAAS,SAAQ,KAAK;IAC1B,IAAI,CAAS;IACb,MAAM,CAAS;IACf,MAAM,CAAU;IAChB,QAAQ,CAAU;IAClB,KAAK,CAA2B;IAEvC,YAAY,OAAuB,EAAE,OAAsB;QACzD,KAAK,CAAC,OAAO,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC;QAE9B,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,UAAU,CAAC,mBAAmB,CAAC,EAAE,CAAC;YAClD,MAAM,IAAI,KAAK,CACb,sEAAsE,mBAAmB,sBAAsB,OAAO,CAAC,IAAI,EAAE,CAC9H,CAAC;QACJ,CAAC;QAED,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,KAAK,EAAE,GAAG,OAAO,CAAC;QAC7D,IAAI,CAAC,IAAI,GAAG,iBAAiB,CAAC;QAC9B,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;QACzB,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;IACrB,CAAC;CACF;AAED;;GAEG;AACH,MAAM,OAAO,gBAAiB,SAAQ,QAAQ;IAC5C,YAAY,UAAuD,EAAE;QACnE,KAAK,CAAC;YACJ,GAAG,OAAO;YACV,GAAG,QAAQ,CAAC,SAAS;SACtB,CAAC,CAAC;QACH,IAAI,CAAC,IAAI,GAAG,yBAAyB,CAAC;IACxC,CAAC;CACF;AAED;;;;GAIG;AACH,MAAM,UAAU,gBAAgB,CAAC,GAAY;IAC3C,OAAO,CACL,GAAG,KAAK,SAAS;QACjB,GAAG,KAAK,IAAI;QACZ,OAAO,GAAG,KAAK,QAAQ;QACvB,MAAM,IAAI,GAAG;QACb,OAAO,IAAI,GAAG;QACd,QAAQ,IAAI,GAAG;QACf,OAAO,GAAG,CAAC,IAAI,KAAK,QAAQ;QAC5B,OAAO,GAAG,CAAC,KAAK,KAAK,QAAQ;QAC7B,OAAO,GAAG,CAAC,MAAM,KAAK,QAAQ,CAC/B,CAAC;AACJ,CAAC"}
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./errors.js";
2
2
  export type { HttpStatusCodeRangeDefinition } from "./http-status.js";
3
3
  export { HttpStatusCode } from "./http-status.js";
4
+ export * from "./problem-parsing.js";
4
5
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,YAAY,EAAE,6BAA6B,EAAE,MAAM,kBAAkB,CAAC;AACtE,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,YAAY,EAAE,6BAA6B,EAAE,MAAM,kBAAkB,CAAC;AACtE,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAClD,cAAc,sBAAsB,CAAC"}
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./errors.js";
2
2
  export { HttpStatusCode } from "./http-status.js";
3
+ export * from "./problem-parsing.js";
3
4
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAE5B,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAE5B,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAClD,cAAc,sBAAsB,CAAC"}
@@ -0,0 +1,77 @@
1
+ import { ProblemDetails } from "./interfaces.js";
2
+ /**
3
+ * Whether an untrusted value can be rethrown as an {@link ApiError} that
4
+ * faithfully represents the upstream failure.
5
+ *
6
+ * Every internal service client in this repo gets a non-2xx response from
7
+ * another Zuplo service and wants to rethrow its problem+json rather than
8
+ * flatten it into a generic 500. The naive form of that check —
9
+ * `if (body.type && body.status && body.title)` — is wrong in four ways, and
10
+ * each one turned a diagnosable upstream failure into an opaque one:
11
+ *
12
+ * - **Not an object.** `null` is valid JSON, and reading `.type` off it throws
13
+ * a `TypeError` out of the client. An array passes a `typeof === "object"`
14
+ * guard and then fails later.
15
+ * - **Wrong member types.** A `status` of `"400"` is truthy, so it reached
16
+ * `ApiError` as a string.
17
+ * - **A `type` outside {@link PROBLEM_TYPE_PREFIX}.** `ApiError`'s constructor
18
+ * rejects those, so passing one through throws a plain `Error` about the
19
+ * type instead of surfacing the problem. RFC 7807's default `about:blank`
20
+ * lands here.
21
+ * - **A `status` that disagrees with the response, or that is not an error
22
+ * status at all.** This is the dangerous one. `handleError` copies
23
+ * `ApiError.status` straight into `reply.status(...)` (`@zuplo/server`'s
24
+ * `fastify/error-handler.ts`), so the body dictates the response status.
25
+ * Two guards are needed, not one:
26
+ * - It must equal `httpStatus`. Otherwise a body claiming `200` serves an
27
+ * upstream failure as a success. RFC 7807 says `status` SHOULD match the
28
+ * response, so equality costs nothing on a conforming body.
29
+ * - It must be a 4xx or 5xx. Equality alone is not enough, because callers
30
+ * do not all gate on `>= 400`: `ApiKeyService` treats every
31
+ * `status >= 300` as a failure and `Response.ok` is false for 3xx too. A
32
+ * `301` response carrying a body that also says `301` would satisfy
33
+ * equality and turn into `reply.status(301)` — an upstream-dictated
34
+ * redirect emitted from an error path.
35
+ * - **Non-string `detail` or `instance`.** Both are declared `string`, and
36
+ * `handleError` copies them into the response body while
37
+ * `request.audit.setError` puts `detail` into the audit event. Left
38
+ * unchecked, an object or array from an untrusted body reaches both while
39
+ * the types claim it is a string.
40
+ *
41
+ * `httpStatus` is the status the upstream service actually responded with.
42
+ *
43
+ * Extra members are preserved: RFC 7807 allows them and callers pass them
44
+ * through to `ApiError.extra`.
45
+ */
46
+ export declare function isRethrowableProblem(value: unknown, httpStatus: number): value is ProblemDetails;
47
+ /**
48
+ * Parses an upstream error body and returns its problem detail, or `undefined`
49
+ * when the body is not one {@link isRethrowableProblem} accepts.
50
+ *
51
+ * Takes the body as text rather than a parsed value because a failing internal
52
+ * service is not guaranteed to answer with JSON at all: an HTML error page
53
+ * from a load balancer, or an empty body on a Cloud Run cold-start timeout,
54
+ * makes `Response.json()` throw a `SyntaxError` that escapes the caller as an
55
+ * opaque 500 — and skips the log that would have recorded what the upstream
56
+ * actually returned. Read `res.text()` and hand it here instead.
57
+ *
58
+ * Callers that already hold a parsed body (an SDK that deserializes for them)
59
+ * should use {@link isRethrowableProblem} directly rather than re-serializing.
60
+ */
61
+ export declare function parseProblemDetails(rawBody: string, httpStatus: number): ProblemDetails | undefined;
62
+ /**
63
+ * Caps how much of an unparsed upstream error body reaches the logs. A proxy
64
+ * error page is measured in kilobytes and the first few lines are enough to
65
+ * tell an HTML 502 from a truncated JSON payload.
66
+ */
67
+ export declare const MAX_LOGGED_PROBLEM_BODY_LENGTH = 512;
68
+ /**
69
+ * Trims an upstream error body to {@link MAX_LOGGED_PROBLEM_BODY_LENGTH} for
70
+ * logging, marking it when anything was dropped.
71
+ *
72
+ * Log the body whenever {@link parseProblemDetails} returns `undefined`:
73
+ * that is the branch where the upstream failure gets flattened into a generic
74
+ * 500, so the body is the only remaining evidence of what actually happened.
75
+ */
76
+ export declare function truncateProblemBody(body: string): string;
77
+ //# sourceMappingURL=problem-parsing.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"problem-parsing.d.ts","sourceRoot":"","sources":["../src/problem-parsing.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEjD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,wBAAgB,oBAAoB,CAClC,KAAK,EAAE,OAAO,EACd,UAAU,EAAE,MAAM,GACjB,KAAK,IAAI,cAAc,CAezB;AAiBD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,GACjB,cAAc,GAAG,SAAS,CAS5B;AAED;;;;GAIG;AACH,eAAO,MAAM,8BAA8B,MAAM,CAAC;AAElD;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAIxD"}
@@ -0,0 +1,113 @@
1
+ import { PROBLEM_TYPE_PREFIX } from "./errors.js";
2
+ /**
3
+ * Whether an untrusted value can be rethrown as an {@link ApiError} that
4
+ * faithfully represents the upstream failure.
5
+ *
6
+ * Every internal service client in this repo gets a non-2xx response from
7
+ * another Zuplo service and wants to rethrow its problem+json rather than
8
+ * flatten it into a generic 500. The naive form of that check —
9
+ * `if (body.type && body.status && body.title)` — is wrong in four ways, and
10
+ * each one turned a diagnosable upstream failure into an opaque one:
11
+ *
12
+ * - **Not an object.** `null` is valid JSON, and reading `.type` off it throws
13
+ * a `TypeError` out of the client. An array passes a `typeof === "object"`
14
+ * guard and then fails later.
15
+ * - **Wrong member types.** A `status` of `"400"` is truthy, so it reached
16
+ * `ApiError` as a string.
17
+ * - **A `type` outside {@link PROBLEM_TYPE_PREFIX}.** `ApiError`'s constructor
18
+ * rejects those, so passing one through throws a plain `Error` about the
19
+ * type instead of surfacing the problem. RFC 7807's default `about:blank`
20
+ * lands here.
21
+ * - **A `status` that disagrees with the response, or that is not an error
22
+ * status at all.** This is the dangerous one. `handleError` copies
23
+ * `ApiError.status` straight into `reply.status(...)` (`@zuplo/server`'s
24
+ * `fastify/error-handler.ts`), so the body dictates the response status.
25
+ * Two guards are needed, not one:
26
+ * - It must equal `httpStatus`. Otherwise a body claiming `200` serves an
27
+ * upstream failure as a success. RFC 7807 says `status` SHOULD match the
28
+ * response, so equality costs nothing on a conforming body.
29
+ * - It must be a 4xx or 5xx. Equality alone is not enough, because callers
30
+ * do not all gate on `>= 400`: `ApiKeyService` treats every
31
+ * `status >= 300` as a failure and `Response.ok` is false for 3xx too. A
32
+ * `301` response carrying a body that also says `301` would satisfy
33
+ * equality and turn into `reply.status(301)` — an upstream-dictated
34
+ * redirect emitted from an error path.
35
+ * - **Non-string `detail` or `instance`.** Both are declared `string`, and
36
+ * `handleError` copies them into the response body while
37
+ * `request.audit.setError` puts `detail` into the audit event. Left
38
+ * unchecked, an object or array from an untrusted body reaches both while
39
+ * the types claim it is a string.
40
+ *
41
+ * `httpStatus` is the status the upstream service actually responded with.
42
+ *
43
+ * Extra members are preserved: RFC 7807 allows them and callers pass them
44
+ * through to `ApiError.extra`.
45
+ */
46
+ export function isRethrowableProblem(value, httpStatus) {
47
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
48
+ return false;
49
+ }
50
+ const problem = value;
51
+ return (typeof problem.type === "string" &&
52
+ typeof problem.title === "string" &&
53
+ isErrorStatus(problem.status) &&
54
+ problem.status === httpStatus &&
55
+ problem.type.startsWith(PROBLEM_TYPE_PREFIX) &&
56
+ isAbsentOrString(problem.detail) &&
57
+ isAbsentOrString(problem.instance));
58
+ }
59
+ /** Whether a value is an integer HTTP status in the 4xx or 5xx range. */
60
+ function isErrorStatus(value) {
61
+ return (typeof value === "number" &&
62
+ Number.isInteger(value) &&
63
+ value >= 400 &&
64
+ value <= 599);
65
+ }
66
+ /** Whether an optional problem member is either absent or a string. */
67
+ function isAbsentOrString(value) {
68
+ return value === undefined || typeof value === "string";
69
+ }
70
+ /**
71
+ * Parses an upstream error body and returns its problem detail, or `undefined`
72
+ * when the body is not one {@link isRethrowableProblem} accepts.
73
+ *
74
+ * Takes the body as text rather than a parsed value because a failing internal
75
+ * service is not guaranteed to answer with JSON at all: an HTML error page
76
+ * from a load balancer, or an empty body on a Cloud Run cold-start timeout,
77
+ * makes `Response.json()` throw a `SyntaxError` that escapes the caller as an
78
+ * opaque 500 — and skips the log that would have recorded what the upstream
79
+ * actually returned. Read `res.text()` and hand it here instead.
80
+ *
81
+ * Callers that already hold a parsed body (an SDK that deserializes for them)
82
+ * should use {@link isRethrowableProblem} directly rather than re-serializing.
83
+ */
84
+ export function parseProblemDetails(rawBody, httpStatus) {
85
+ let parsed;
86
+ try {
87
+ parsed = JSON.parse(rawBody);
88
+ }
89
+ catch {
90
+ return undefined;
91
+ }
92
+ return isRethrowableProblem(parsed, httpStatus) ? parsed : undefined;
93
+ }
94
+ /**
95
+ * Caps how much of an unparsed upstream error body reaches the logs. A proxy
96
+ * error page is measured in kilobytes and the first few lines are enough to
97
+ * tell an HTML 502 from a truncated JSON payload.
98
+ */
99
+ export const MAX_LOGGED_PROBLEM_BODY_LENGTH = 512;
100
+ /**
101
+ * Trims an upstream error body to {@link MAX_LOGGED_PROBLEM_BODY_LENGTH} for
102
+ * logging, marking it when anything was dropped.
103
+ *
104
+ * Log the body whenever {@link parseProblemDetails} returns `undefined`:
105
+ * that is the branch where the upstream failure gets flattened into a generic
106
+ * 500, so the body is the only remaining evidence of what actually happened.
107
+ */
108
+ export function truncateProblemBody(body) {
109
+ return body.length > MAX_LOGGED_PROBLEM_BODY_LENGTH
110
+ ? `${body.slice(0, MAX_LOGGED_PROBLEM_BODY_LENGTH)}...[truncated]`
111
+ : body;
112
+ }
113
+ //# sourceMappingURL=problem-parsing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"problem-parsing.js","sourceRoot":"","sources":["../src/problem-parsing.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAGlD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,MAAM,UAAU,oBAAoB,CAClC,KAAc,EACd,UAAkB;IAElB,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QACxE,OAAO,KAAK,CAAC;IACf,CAAC;IAED,MAAM,OAAO,GAAG,KAAgC,CAAC;IACjD,OAAO,CACL,OAAO,OAAO,CAAC,IAAI,KAAK,QAAQ;QAChC,OAAO,OAAO,CAAC,KAAK,KAAK,QAAQ;QACjC,aAAa,CAAC,OAAO,CAAC,MAAM,CAAC;QAC7B,OAAO,CAAC,MAAM,KAAK,UAAU;QAC7B,OAAO,CAAC,IAAI,CAAC,UAAU,CAAC,mBAAmB,CAAC;QAC5C,gBAAgB,CAAC,OAAO,CAAC,MAAM,CAAC;QAChC,gBAAgB,CAAC,OAAO,CAAC,QAAQ,CAAC,CACnC,CAAC;AACJ,CAAC;AAED,yEAAyE;AACzE,SAAS,aAAa,CAAC,KAAc;IACnC,OAAO,CACL,OAAO,KAAK,KAAK,QAAQ;QACzB,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC;QACvB,KAAK,IAAI,GAAG;QACZ,KAAK,IAAI,GAAG,CACb,CAAC;AACJ,CAAC;AAED,uEAAuE;AACvE,SAAS,gBAAgB,CAAC,KAAc;IACtC,OAAO,KAAK,KAAK,SAAS,IAAI,OAAO,KAAK,KAAK,QAAQ,CAAC;AAC1D,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,mBAAmB,CACjC,OAAe,EACf,UAAkB;IAElB,IAAI,MAAe,CAAC;IACpB,IAAI,CAAC;QACH,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;IAC/B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;IAED,OAAO,oBAAoB,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC;AACvE,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,MAAM,8BAA8B,GAAG,GAAG,CAAC;AAElD;;;;;;;GAOG;AACH,MAAM,UAAU,mBAAmB,CAAC,IAAY;IAC9C,OAAO,IAAI,CAAC,MAAM,GAAG,8BAA8B;QACjD,CAAC,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,8BAA8B,CAAC,gBAAgB;QAClE,CAAC,CAAC,IAAI,CAAC;AACX,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=problem-parsing.spec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"problem-parsing.spec.d.ts","sourceRoot":"","sources":["../src/problem-parsing.spec.ts"],"names":[],"mappings":""}
@@ -0,0 +1,152 @@
1
+ import { assert } from "chai";
2
+ import { ApiError, PROBLEM_TYPE_PREFIX } from "./errors.js";
3
+ import { isRethrowableProblem, parseProblemDetails, } from "./problem-parsing.js";
4
+ const problem = {
5
+ type: `${PROBLEM_TYPE_PREFIX}auth/client-not-found`,
6
+ title: "Client not found",
7
+ status: 400,
8
+ detail: "No client matches that id",
9
+ };
10
+ describe("isRethrowableProblem", function () {
11
+ it("accepts a problem whose type and status line up", function () {
12
+ assert.isTrue(isRethrowableProblem(problem, 400));
13
+ });
14
+ it("rejects values that are not objects", function () {
15
+ // `null` parses out of valid JSON, and reading `.type` off it used to
16
+ // throw a TypeError out of the calling service client.
17
+ for (const value of [null, undefined, "a string", 42, true]) {
18
+ assert.isFalse(isRethrowableProblem(value, 400));
19
+ }
20
+ });
21
+ it("rejects an array", function () {
22
+ // Arrays are objects, so a null/typeof guard alone lets them through.
23
+ assert.isFalse(isRethrowableProblem([], 400));
24
+ assert.isFalse(isRethrowableProblem([problem], 400));
25
+ });
26
+ it("rejects a missing member", function () {
27
+ for (const member of ["type", "title", "status"]) {
28
+ const { [member]: _dropped, ...without } = problem;
29
+ assert.isFalse(isRethrowableProblem(without, 400), `expected a problem without '${member}' to be rejected`);
30
+ }
31
+ });
32
+ it("rejects members of the wrong type", function () {
33
+ assert.isFalse(isRethrowableProblem({ ...problem, status: "400" }, 400));
34
+ assert.isFalse(isRethrowableProblem({ ...problem, type: 1 }, 400));
35
+ assert.isFalse(isRethrowableProblem({ ...problem, title: null }, 400));
36
+ });
37
+ it("rejects a type outside the zup.fail prefix", function () {
38
+ // RFC 7807's default. `ApiError` rejects it, so rethrowing would throw a
39
+ // plain Error about the type rather than surfacing the upstream problem.
40
+ assert.isFalse(isRethrowableProblem({ ...problem, type: "about:blank" }, 400));
41
+ assert.isFalse(isRethrowableProblem({ ...problem, type: "https://example.com/errors/oops" }, 400));
42
+ });
43
+ describe("the status must match the response", function () {
44
+ // `ApiError.status` is echoed into `reply.status(...)`, so an unchecked
45
+ // body status is a response-status injection.
46
+ it("rejects a body claiming success on a failed response", function () {
47
+ assert.isFalse(isRethrowableProblem({ ...problem, status: 200 }, 500));
48
+ assert.isFalse(isRethrowableProblem({ ...problem, status: 204 }, 500));
49
+ });
50
+ it("rejects a body claiming zero", function () {
51
+ assert.isFalse(isRethrowableProblem({ ...problem, status: 0 }, 500));
52
+ });
53
+ it("rejects a body claiming a redirect", function () {
54
+ assert.isFalse(isRethrowableProblem({ ...problem, status: 301 }, 500));
55
+ });
56
+ it("rejects a body that merely disagrees", function () {
57
+ assert.isFalse(isRethrowableProblem({ ...problem, status: 403 }, 400));
58
+ });
59
+ it("accepts a matching 5xx", function () {
60
+ const upstream = { ...problem, status: 503 };
61
+ assert.isTrue(isRethrowableProblem(upstream, 503));
62
+ });
63
+ it("rejects a redirect even when the response agrees", function () {
64
+ // Equality alone is not enough. `ApiKeyService` treats every
65
+ // `status >= 300` as a failure and `Response.ok` is false for 3xx, so a
66
+ // 301 body on a 301 response would otherwise become `reply.status(301)`
67
+ // — an upstream-dictated redirect emitted from an error path.
68
+ for (const status of [301, 302, 304, 307, 308]) {
69
+ assert.isFalse(isRethrowableProblem({ ...problem, status }, status), `expected a matching ${status} to be rejected`);
70
+ }
71
+ });
72
+ it("rejects a matching 2xx", function () {
73
+ assert.isFalse(isRethrowableProblem({ ...problem, status: 200 }, 200));
74
+ });
75
+ it("rejects a status outside the HTTP range", function () {
76
+ for (const status of [399, 600, -400, 4000]) {
77
+ assert.isFalse(isRethrowableProblem({ ...problem, status }, status));
78
+ }
79
+ });
80
+ it("rejects a non-integer status", function () {
81
+ for (const status of [404.5, Number.NaN, Number.POSITIVE_INFINITY]) {
82
+ assert.isFalse(isRethrowableProblem({ ...problem, status }, status));
83
+ }
84
+ });
85
+ it("accepts the 4xx and 5xx boundaries", function () {
86
+ for (const status of [400, 499, 500, 599]) {
87
+ assert.isTrue(isRethrowableProblem({ ...problem, status }, status), `expected a matching ${status} to be accepted`);
88
+ }
89
+ });
90
+ });
91
+ describe("optional standard members", function () {
92
+ // `handleError` copies `detail` and `instance` into the response body,
93
+ // and `request.audit.setError` puts `detail` into the audit event. Both
94
+ // are declared `string`, so a non-string reaches those while the types
95
+ // claim otherwise.
96
+ it("rejects a non-string detail", function () {
97
+ for (const detail of [{ nested: true }, ["a"], 42, null]) {
98
+ assert.isFalse(isRethrowableProblem({ ...problem, detail }, 400));
99
+ }
100
+ });
101
+ it("rejects a non-string instance", function () {
102
+ for (const instance of [{ nested: true }, ["a"], 42, null]) {
103
+ assert.isFalse(isRethrowableProblem({ ...problem, instance }, 400));
104
+ }
105
+ });
106
+ it("accepts them when absent", function () {
107
+ const { detail: _detail, ...withoutDetail } = problem;
108
+ assert.isTrue(isRethrowableProblem(withoutDetail, 400));
109
+ });
110
+ it("accepts a string instance", function () {
111
+ assert.isTrue(isRethrowableProblem({ ...problem, instance: "/clients/abc" }, 400));
112
+ });
113
+ it("still allows non-string extra members", function () {
114
+ // Only the standard members are typed. RFC 7807 extensions are free-form
115
+ // and callers pass them through to `ApiError.extra`.
116
+ assert.isTrue(isRethrowableProblem({ ...problem, errors: [{ field: "id" }] }, 400));
117
+ });
118
+ });
119
+ it("accepts everything the ApiError constructor accepts", function () {
120
+ // Guards against this check drifting from the constructor's own rule.
121
+ assert.isTrue(isRethrowableProblem(problem, 400));
122
+ assert.doesNotThrow(() => new ApiError(problem));
123
+ });
124
+ });
125
+ describe("parseProblemDetails", function () {
126
+ it("returns the problem from a conforming body", function () {
127
+ assert.deepEqual(parseProblemDetails(JSON.stringify(problem), 400), problem);
128
+ });
129
+ it("preserves extra members", function () {
130
+ // RFC 7807 allows them and callers pass them to ApiError.extra.
131
+ const withExtra = { ...problem, clientId: "abc123" };
132
+ assert.deepEqual(parseProblemDetails(JSON.stringify(withExtra), 400), withExtra);
133
+ });
134
+ it("returns undefined for an HTML error page", function () {
135
+ // What a load balancer or ingress returns on a 502.
136
+ assert.isUndefined(parseProblemDetails("<html><head><title>502 Bad Gateway</title></head></html>", 502));
137
+ });
138
+ it("returns undefined for an empty body", function () {
139
+ // What a Cloud Run cold-start timeout leaves behind.
140
+ assert.isUndefined(parseProblemDetails("", 504));
141
+ });
142
+ it("returns undefined for truncated JSON", function () {
143
+ assert.isUndefined(parseProblemDetails('{"type":"https://zup.fail/a"', 500));
144
+ });
145
+ it("returns undefined for a JSON null body", function () {
146
+ assert.isUndefined(parseProblemDetails("null", 500));
147
+ });
148
+ it("returns undefined for a body the guard rejects", function () {
149
+ assert.isUndefined(parseProblemDetails(JSON.stringify({ ...problem, status: 200 }), 500));
150
+ });
151
+ });
152
+ //# sourceMappingURL=problem-parsing.spec.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"problem-parsing.spec.js","sourceRoot":"","sources":["../src/problem-parsing.spec.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@zuplo/errors",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "7.12.5",
5
+ "version": "7.12.6",
6
6
  "description": "Error types",
7
7
  "repository": {
8
8
  "type": "git",
@@ -21,7 +21,8 @@
21
21
  "author": "Zuplo",
22
22
  "license": "SEE LICENSE IN LICENSE.txt",
23
23
  "scripts": {
24
- "build": "tsc --build"
24
+ "build": "tsc --build",
25
+ "test": "mocha"
25
26
  },
26
27
  "devDependencies": {
27
28
  "@types/chai": "^5.0.0",
package/src/errors.ts CHANGED
@@ -3,6 +3,16 @@ import * as Problems from "./problems.js";
3
3
 
4
4
  export type { ProblemDetails, ProblemDetailsFactory };
5
5
  export { Problems };
6
+
7
+ /**
8
+ * Every problem `type` an {@link ApiError} accepts must be a URI under this
9
+ * prefix. Exported so callers that validate an untrusted problem body before
10
+ * constructing an `ApiError` test the same rule the constructor enforces —
11
+ * checking a divergent prefix turns a rejected `type` into a thrown plain
12
+ * `Error` instead of the upstream problem.
13
+ */
14
+ export const PROBLEM_TYPE_PREFIX = "https://zup.fail/";
15
+
6
16
  /**
7
17
  * The default format for Api errors
8
18
  */
@@ -16,9 +26,9 @@ export class ApiError extends Error {
16
26
  constructor(problem: ProblemDetails, options?: ErrorOptions) {
17
27
  super(problem.title, options);
18
28
 
19
- if (!problem.type.startsWith("https://zup.fail/")) {
29
+ if (!problem.type.startsWith(PROBLEM_TYPE_PREFIX)) {
20
30
  throw new Error(
21
- `Invalid 'type' specified on ApiError, must be a URI starting with 'https://zup.fail/'. Type was set to ${problem.type}`
31
+ `Invalid 'type' specified on ApiError, must be a URI starting with '${PROBLEM_TYPE_PREFIX}'. Type was set to ${problem.type}`
22
32
  );
23
33
  }
24
34
 
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./errors.js";
2
2
  export type { HttpStatusCodeRangeDefinition } from "./http-status.js";
3
3
  export { HttpStatusCode } from "./http-status.js";
4
+ export * from "./problem-parsing.js";
@@ -0,0 +1,220 @@
1
+ import { assert } from "chai";
2
+ import { ApiError, PROBLEM_TYPE_PREFIX } from "./errors.js";
3
+ import {
4
+ isRethrowableProblem,
5
+ parseProblemDetails,
6
+ } from "./problem-parsing.js";
7
+
8
+ const problem = {
9
+ type: `${PROBLEM_TYPE_PREFIX}auth/client-not-found`,
10
+ title: "Client not found",
11
+ status: 400,
12
+ detail: "No client matches that id",
13
+ };
14
+
15
+ describe("isRethrowableProblem", function () {
16
+ it("accepts a problem whose type and status line up", function () {
17
+ assert.isTrue(isRethrowableProblem(problem, 400));
18
+ });
19
+
20
+ it("rejects values that are not objects", function () {
21
+ // `null` parses out of valid JSON, and reading `.type` off it used to
22
+ // throw a TypeError out of the calling service client.
23
+ for (const value of [null, undefined, "a string", 42, true]) {
24
+ assert.isFalse(isRethrowableProblem(value, 400));
25
+ }
26
+ });
27
+
28
+ it("rejects an array", function () {
29
+ // Arrays are objects, so a null/typeof guard alone lets them through.
30
+ assert.isFalse(isRethrowableProblem([], 400));
31
+ assert.isFalse(isRethrowableProblem([problem], 400));
32
+ });
33
+
34
+ it("rejects a missing member", function () {
35
+ for (const member of ["type", "title", "status"] as const) {
36
+ const { [member]: _dropped, ...without } = problem;
37
+ assert.isFalse(
38
+ isRethrowableProblem(without, 400),
39
+ `expected a problem without '${member}' to be rejected`
40
+ );
41
+ }
42
+ });
43
+
44
+ it("rejects members of the wrong type", function () {
45
+ assert.isFalse(isRethrowableProblem({ ...problem, status: "400" }, 400));
46
+ assert.isFalse(isRethrowableProblem({ ...problem, type: 1 }, 400));
47
+ assert.isFalse(isRethrowableProblem({ ...problem, title: null }, 400));
48
+ });
49
+
50
+ it("rejects a type outside the zup.fail prefix", function () {
51
+ // RFC 7807's default. `ApiError` rejects it, so rethrowing would throw a
52
+ // plain Error about the type rather than surfacing the upstream problem.
53
+ assert.isFalse(
54
+ isRethrowableProblem({ ...problem, type: "about:blank" }, 400)
55
+ );
56
+ assert.isFalse(
57
+ isRethrowableProblem(
58
+ { ...problem, type: "https://example.com/errors/oops" },
59
+ 400
60
+ )
61
+ );
62
+ });
63
+
64
+ describe("the status must match the response", function () {
65
+ // `ApiError.status` is echoed into `reply.status(...)`, so an unchecked
66
+ // body status is a response-status injection.
67
+ it("rejects a body claiming success on a failed response", function () {
68
+ assert.isFalse(isRethrowableProblem({ ...problem, status: 200 }, 500));
69
+ assert.isFalse(isRethrowableProblem({ ...problem, status: 204 }, 500));
70
+ });
71
+
72
+ it("rejects a body claiming zero", function () {
73
+ assert.isFalse(isRethrowableProblem({ ...problem, status: 0 }, 500));
74
+ });
75
+
76
+ it("rejects a body claiming a redirect", function () {
77
+ assert.isFalse(isRethrowableProblem({ ...problem, status: 301 }, 500));
78
+ });
79
+
80
+ it("rejects a body that merely disagrees", function () {
81
+ assert.isFalse(isRethrowableProblem({ ...problem, status: 403 }, 400));
82
+ });
83
+
84
+ it("accepts a matching 5xx", function () {
85
+ const upstream = { ...problem, status: 503 };
86
+ assert.isTrue(isRethrowableProblem(upstream, 503));
87
+ });
88
+
89
+ it("rejects a redirect even when the response agrees", function () {
90
+ // Equality alone is not enough. `ApiKeyService` treats every
91
+ // `status >= 300` as a failure and `Response.ok` is false for 3xx, so a
92
+ // 301 body on a 301 response would otherwise become `reply.status(301)`
93
+ // — an upstream-dictated redirect emitted from an error path.
94
+ for (const status of [301, 302, 304, 307, 308]) {
95
+ assert.isFalse(
96
+ isRethrowableProblem({ ...problem, status }, status),
97
+ `expected a matching ${status} to be rejected`
98
+ );
99
+ }
100
+ });
101
+
102
+ it("rejects a matching 2xx", function () {
103
+ assert.isFalse(isRethrowableProblem({ ...problem, status: 200 }, 200));
104
+ });
105
+
106
+ it("rejects a status outside the HTTP range", function () {
107
+ for (const status of [399, 600, -400, 4000]) {
108
+ assert.isFalse(isRethrowableProblem({ ...problem, status }, status));
109
+ }
110
+ });
111
+
112
+ it("rejects a non-integer status", function () {
113
+ for (const status of [404.5, Number.NaN, Number.POSITIVE_INFINITY]) {
114
+ assert.isFalse(isRethrowableProblem({ ...problem, status }, status));
115
+ }
116
+ });
117
+
118
+ it("accepts the 4xx and 5xx boundaries", function () {
119
+ for (const status of [400, 499, 500, 599]) {
120
+ assert.isTrue(
121
+ isRethrowableProblem({ ...problem, status }, status),
122
+ `expected a matching ${status} to be accepted`
123
+ );
124
+ }
125
+ });
126
+ });
127
+
128
+ describe("optional standard members", function () {
129
+ // `handleError` copies `detail` and `instance` into the response body,
130
+ // and `request.audit.setError` puts `detail` into the audit event. Both
131
+ // are declared `string`, so a non-string reaches those while the types
132
+ // claim otherwise.
133
+ it("rejects a non-string detail", function () {
134
+ for (const detail of [{ nested: true }, ["a"], 42, null]) {
135
+ assert.isFalse(isRethrowableProblem({ ...problem, detail }, 400));
136
+ }
137
+ });
138
+
139
+ it("rejects a non-string instance", function () {
140
+ for (const instance of [{ nested: true }, ["a"], 42, null]) {
141
+ assert.isFalse(isRethrowableProblem({ ...problem, instance }, 400));
142
+ }
143
+ });
144
+
145
+ it("accepts them when absent", function () {
146
+ const { detail: _detail, ...withoutDetail } = problem;
147
+ assert.isTrue(isRethrowableProblem(withoutDetail, 400));
148
+ });
149
+
150
+ it("accepts a string instance", function () {
151
+ assert.isTrue(
152
+ isRethrowableProblem({ ...problem, instance: "/clients/abc" }, 400)
153
+ );
154
+ });
155
+
156
+ it("still allows non-string extra members", function () {
157
+ // Only the standard members are typed. RFC 7807 extensions are free-form
158
+ // and callers pass them through to `ApiError.extra`.
159
+ assert.isTrue(
160
+ isRethrowableProblem({ ...problem, errors: [{ field: "id" }] }, 400)
161
+ );
162
+ });
163
+ });
164
+
165
+ it("accepts everything the ApiError constructor accepts", function () {
166
+ // Guards against this check drifting from the constructor's own rule.
167
+ assert.isTrue(isRethrowableProblem(problem, 400));
168
+ assert.doesNotThrow(() => new ApiError(problem));
169
+ });
170
+ });
171
+
172
+ describe("parseProblemDetails", function () {
173
+ it("returns the problem from a conforming body", function () {
174
+ assert.deepEqual(
175
+ parseProblemDetails(JSON.stringify(problem), 400),
176
+ problem
177
+ );
178
+ });
179
+
180
+ it("preserves extra members", function () {
181
+ // RFC 7807 allows them and callers pass them to ApiError.extra.
182
+ const withExtra = { ...problem, clientId: "abc123" };
183
+
184
+ assert.deepEqual(
185
+ parseProblemDetails(JSON.stringify(withExtra), 400),
186
+ withExtra
187
+ );
188
+ });
189
+
190
+ it("returns undefined for an HTML error page", function () {
191
+ // What a load balancer or ingress returns on a 502.
192
+ assert.isUndefined(
193
+ parseProblemDetails(
194
+ "<html><head><title>502 Bad Gateway</title></head></html>",
195
+ 502
196
+ )
197
+ );
198
+ });
199
+
200
+ it("returns undefined for an empty body", function () {
201
+ // What a Cloud Run cold-start timeout leaves behind.
202
+ assert.isUndefined(parseProblemDetails("", 504));
203
+ });
204
+
205
+ it("returns undefined for truncated JSON", function () {
206
+ assert.isUndefined(
207
+ parseProblemDetails('{"type":"https://zup.fail/a"', 500)
208
+ );
209
+ });
210
+
211
+ it("returns undefined for a JSON null body", function () {
212
+ assert.isUndefined(parseProblemDetails("null", 500));
213
+ });
214
+
215
+ it("returns undefined for a body the guard rejects", function () {
216
+ assert.isUndefined(
217
+ parseProblemDetails(JSON.stringify({ ...problem, status: 200 }), 500)
218
+ );
219
+ });
220
+ });
@@ -0,0 +1,130 @@
1
+ import { PROBLEM_TYPE_PREFIX } from "./errors.js";
2
+ import { ProblemDetails } from "./interfaces.js";
3
+
4
+ /**
5
+ * Whether an untrusted value can be rethrown as an {@link ApiError} that
6
+ * faithfully represents the upstream failure.
7
+ *
8
+ * Every internal service client in this repo gets a non-2xx response from
9
+ * another Zuplo service and wants to rethrow its problem+json rather than
10
+ * flatten it into a generic 500. The naive form of that check —
11
+ * `if (body.type && body.status && body.title)` — is wrong in four ways, and
12
+ * each one turned a diagnosable upstream failure into an opaque one:
13
+ *
14
+ * - **Not an object.** `null` is valid JSON, and reading `.type` off it throws
15
+ * a `TypeError` out of the client. An array passes a `typeof === "object"`
16
+ * guard and then fails later.
17
+ * - **Wrong member types.** A `status` of `"400"` is truthy, so it reached
18
+ * `ApiError` as a string.
19
+ * - **A `type` outside {@link PROBLEM_TYPE_PREFIX}.** `ApiError`'s constructor
20
+ * rejects those, so passing one through throws a plain `Error` about the
21
+ * type instead of surfacing the problem. RFC 7807's default `about:blank`
22
+ * lands here.
23
+ * - **A `status` that disagrees with the response, or that is not an error
24
+ * status at all.** This is the dangerous one. `handleError` copies
25
+ * `ApiError.status` straight into `reply.status(...)` (`@zuplo/server`'s
26
+ * `fastify/error-handler.ts`), so the body dictates the response status.
27
+ * Two guards are needed, not one:
28
+ * - It must equal `httpStatus`. Otherwise a body claiming `200` serves an
29
+ * upstream failure as a success. RFC 7807 says `status` SHOULD match the
30
+ * response, so equality costs nothing on a conforming body.
31
+ * - It must be a 4xx or 5xx. Equality alone is not enough, because callers
32
+ * do not all gate on `>= 400`: `ApiKeyService` treats every
33
+ * `status >= 300` as a failure and `Response.ok` is false for 3xx too. A
34
+ * `301` response carrying a body that also says `301` would satisfy
35
+ * equality and turn into `reply.status(301)` — an upstream-dictated
36
+ * redirect emitted from an error path.
37
+ * - **Non-string `detail` or `instance`.** Both are declared `string`, and
38
+ * `handleError` copies them into the response body while
39
+ * `request.audit.setError` puts `detail` into the audit event. Left
40
+ * unchecked, an object or array from an untrusted body reaches both while
41
+ * the types claim it is a string.
42
+ *
43
+ * `httpStatus` is the status the upstream service actually responded with.
44
+ *
45
+ * Extra members are preserved: RFC 7807 allows them and callers pass them
46
+ * through to `ApiError.extra`.
47
+ */
48
+ export function isRethrowableProblem(
49
+ value: unknown,
50
+ httpStatus: number
51
+ ): value is ProblemDetails {
52
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
53
+ return false;
54
+ }
55
+
56
+ const problem = value as Partial<ProblemDetails>;
57
+ return (
58
+ typeof problem.type === "string" &&
59
+ typeof problem.title === "string" &&
60
+ isErrorStatus(problem.status) &&
61
+ problem.status === httpStatus &&
62
+ problem.type.startsWith(PROBLEM_TYPE_PREFIX) &&
63
+ isAbsentOrString(problem.detail) &&
64
+ isAbsentOrString(problem.instance)
65
+ );
66
+ }
67
+
68
+ /** Whether a value is an integer HTTP status in the 4xx or 5xx range. */
69
+ function isErrorStatus(value: unknown): value is number {
70
+ return (
71
+ typeof value === "number" &&
72
+ Number.isInteger(value) &&
73
+ value >= 400 &&
74
+ value <= 599
75
+ );
76
+ }
77
+
78
+ /** Whether an optional problem member is either absent or a string. */
79
+ function isAbsentOrString(value: unknown): boolean {
80
+ return value === undefined || typeof value === "string";
81
+ }
82
+
83
+ /**
84
+ * Parses an upstream error body and returns its problem detail, or `undefined`
85
+ * when the body is not one {@link isRethrowableProblem} accepts.
86
+ *
87
+ * Takes the body as text rather than a parsed value because a failing internal
88
+ * service is not guaranteed to answer with JSON at all: an HTML error page
89
+ * from a load balancer, or an empty body on a Cloud Run cold-start timeout,
90
+ * makes `Response.json()` throw a `SyntaxError` that escapes the caller as an
91
+ * opaque 500 — and skips the log that would have recorded what the upstream
92
+ * actually returned. Read `res.text()` and hand it here instead.
93
+ *
94
+ * Callers that already hold a parsed body (an SDK that deserializes for them)
95
+ * should use {@link isRethrowableProblem} directly rather than re-serializing.
96
+ */
97
+ export function parseProblemDetails(
98
+ rawBody: string,
99
+ httpStatus: number
100
+ ): ProblemDetails | undefined {
101
+ let parsed: unknown;
102
+ try {
103
+ parsed = JSON.parse(rawBody);
104
+ } catch {
105
+ return undefined;
106
+ }
107
+
108
+ return isRethrowableProblem(parsed, httpStatus) ? parsed : undefined;
109
+ }
110
+
111
+ /**
112
+ * Caps how much of an unparsed upstream error body reaches the logs. A proxy
113
+ * error page is measured in kilobytes and the first few lines are enough to
114
+ * tell an HTML 502 from a truncated JSON payload.
115
+ */
116
+ export const MAX_LOGGED_PROBLEM_BODY_LENGTH = 512;
117
+
118
+ /**
119
+ * Trims an upstream error body to {@link MAX_LOGGED_PROBLEM_BODY_LENGTH} for
120
+ * logging, marking it when anything was dropped.
121
+ *
122
+ * Log the body whenever {@link parseProblemDetails} returns `undefined`:
123
+ * that is the branch where the upstream failure gets flattened into a generic
124
+ * 500, so the body is the only remaining evidence of what actually happened.
125
+ */
126
+ export function truncateProblemBody(body: string): string {
127
+ return body.length > MAX_LOGGED_PROBLEM_BODY_LENGTH
128
+ ? `${body.slice(0, MAX_LOGGED_PROBLEM_BODY_LENGTH)}...[truncated]`
129
+ : body;
130
+ }