@gusnips/server 0.1.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 (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +323 -0
  3. package/dist/errors.d.ts +190 -0
  4. package/dist/errors.d.ts.map +1 -0
  5. package/dist/errors.js +154 -0
  6. package/dist/errors.js.map +1 -0
  7. package/dist/hono/errors.d.ts +46 -0
  8. package/dist/hono/errors.d.ts.map +1 -0
  9. package/dist/hono/errors.js +54 -0
  10. package/dist/hono/errors.js.map +1 -0
  11. package/dist/hono/guards.d.ts +45 -0
  12. package/dist/hono/guards.d.ts.map +1 -0
  13. package/dist/hono/guards.js +88 -0
  14. package/dist/hono/guards.js.map +1 -0
  15. package/dist/hono/index.d.ts +18 -0
  16. package/dist/hono/index.d.ts.map +1 -0
  17. package/dist/hono/index.js +15 -0
  18. package/dist/hono/index.js.map +1 -0
  19. package/dist/hono/request-logger.d.ts +31 -0
  20. package/dist/hono/request-logger.d.ts.map +1 -0
  21. package/dist/hono/request-logger.js +57 -0
  22. package/dist/hono/request-logger.js.map +1 -0
  23. package/dist/index.d.ts +7 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +4 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/logger/index.d.ts +44 -0
  28. package/dist/logger/index.d.ts.map +1 -0
  29. package/dist/logger/index.js +74 -0
  30. package/dist/logger/index.js.map +1 -0
  31. package/dist/logger/serialize.d.ts +68 -0
  32. package/dist/logger/serialize.d.ts.map +1 -0
  33. package/dist/logger/serialize.js +203 -0
  34. package/dist/logger/serialize.js.map +1 -0
  35. package/dist/responses.d.ts +107 -0
  36. package/dist/responses.d.ts.map +1 -0
  37. package/dist/responses.js +183 -0
  38. package/dist/responses.js.map +1 -0
  39. package/package.json +79 -0
  40. package/src/errors.test.ts +93 -0
  41. package/src/errors.ts +264 -0
  42. package/src/errors.types.test.ts +96 -0
  43. package/src/hono/errors.test.ts +215 -0
  44. package/src/hono/errors.ts +86 -0
  45. package/src/hono/guards.test.ts +234 -0
  46. package/src/hono/guards.ts +107 -0
  47. package/src/hono/index.ts +17 -0
  48. package/src/hono/request-logger.test.ts +200 -0
  49. package/src/hono/request-logger.ts +77 -0
  50. package/src/index.ts +6 -0
  51. package/src/logger/index.test.ts +137 -0
  52. package/src/logger/index.ts +112 -0
  53. package/src/logger/serialize.test.ts +300 -0
  54. package/src/logger/serialize.ts +202 -0
  55. package/src/readme.test.ts +132 -0
  56. package/src/responses.test.ts +291 -0
  57. package/src/responses.ts +277 -0
@@ -0,0 +1,291 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { z } from "zod";
3
+ import { AppError, createAppError } from "./errors.ts";
4
+ import { created, createErrorResponse, noContent, ok, paginated } from "./responses.ts";
5
+
6
+ type Code =
7
+ | "VALIDATION_ERROR"
8
+ | "UNAUTHORIZED"
9
+ | "NOT_FOUND"
10
+ | "RATE_LIMIT_EXCEEDED"
11
+ | "INTERNAL_ERROR"
12
+ | "SERVICE_UNAVAILABLE"
13
+ | "GATEWAY_ERROR";
14
+
15
+ const ERROR_STATUS = {
16
+ VALIDATION_ERROR: 400,
17
+ UNAUTHORIZED: 401,
18
+ NOT_FOUND: 404,
19
+ RATE_LIMIT_EXCEEDED: 429,
20
+ INTERNAL_ERROR: 500,
21
+ SERVICE_UNAVAILABLE: 503,
22
+ GATEWAY_ERROR: 502,
23
+ } as const satisfies Record<Code, number>;
24
+
25
+ const appError = createAppError<typeof ERROR_STATUS>(ERROR_STATUS);
26
+
27
+ const errorResponse = createErrorResponse<Code>({
28
+ internal: { code: "INTERNAL_ERROR", message: "Something on our side failed" },
29
+ validation: { code: "VALIDATION_ERROR", message: "The request could not be read" },
30
+ });
31
+
32
+ describe("success helpers", () => {
33
+ it("wraps every 2xx body in { data }", () => {
34
+ expect(ok({ id: 1 })).toEqual({ status: 200, body: { data: { id: 1 } } });
35
+ expect(created({ id: 1 })).toEqual({ status: 201, body: { data: { id: 1 } } });
36
+ expect(noContent()).toEqual({ status: 204, body: null });
37
+ });
38
+
39
+ it("omits meta when there is none", () => {
40
+ expect(ok({ id: 1 }).body).not.toHaveProperty("meta");
41
+ });
42
+
43
+ it("computes hasMore from the page it actually returned", () => {
44
+ expect(paginated([1, 2], { total: 10, limit: 2, offset: 0 }).body.meta).toEqual({
45
+ total: 10,
46
+ limit: 2,
47
+ offset: 0,
48
+ hasMore: true,
49
+ });
50
+ expect(paginated([9, 10], { total: 10, limit: 2, offset: 8 }).body.meta?.hasMore).toBe(false);
51
+ expect(paginated([], { total: 0, limit: 20, offset: 0 }).body.meta?.hasMore).toBe(false);
52
+ });
53
+ });
54
+
55
+ describe("the error envelope", () => {
56
+ it("omits every optional field that was not set", () => {
57
+ expect(errorResponse(appError("NOT_FOUND", "Workspace not found")).body).toEqual({
58
+ error: { code: "NOT_FOUND", message: "Workspace not found" },
59
+ });
60
+ });
61
+
62
+ it("carries messageKey, params and details when they are set", () => {
63
+ const body = errorResponse(
64
+ appError("NOT_FOUND", "No such plan", {
65
+ messageKey: "serverErrors.notFound",
66
+ params: { id: "pro" },
67
+ details: { id: "pro", upgradeTo: "starter" },
68
+ }),
69
+ ).body;
70
+ expect(body.error.messageKey).toBe("serverErrors.notFound");
71
+ expect(body.error.params).toEqual({ id: "pro" });
72
+ expect(body.error.details).toEqual({ id: "pro", upgradeTo: "starter" });
73
+ });
74
+
75
+ it("folds a stated wait into an object details", () => {
76
+ const err = appError("RATE_LIMIT_EXCEEDED", "Too many requests", {
77
+ details: { limit: 60 },
78
+ retryAfterSecs: 30,
79
+ });
80
+ expect(errorResponse(err).body.error.details).toEqual({ limit: 60, retryAfterSecs: 30 });
81
+ });
82
+
83
+ it("folds it into an absent details", () => {
84
+ const err = appError("RATE_LIMIT_EXCEEDED", "Too many requests", { retryAfterSecs: 30 });
85
+ expect(errorResponse(err).body.error.details).toEqual({ retryAfterSecs: 30 });
86
+ });
87
+
88
+ it("hands an ARRAY details back untouched", () => {
89
+ const issues = [{ path: ["url"], code: "invalid_string" }];
90
+ const err = appError("RATE_LIMIT_EXCEEDED", "Too many requests", {
91
+ details: issues,
92
+ retryAfterSecs: 5,
93
+ });
94
+ const details = errorResponse(err).body.error.details;
95
+ expect(details).toEqual(issues);
96
+ expect(Array.isArray(details)).toBe(true);
97
+ });
98
+
99
+ it("hands a STRING details back untouched", () => {
100
+ const err = appError("RATE_LIMIT_EXCEEDED", "Spent", {
101
+ details: "month",
102
+ retryAfterSecs: 5,
103
+ });
104
+ expect(errorResponse(err).body.error.details).toBe("month");
105
+ });
106
+ });
107
+
108
+ describe("a validation failure is a 400 that reflects nothing back", () => {
109
+ const schema = z
110
+ .object({ days: z.enum(["7", "30"]), timeoutMs: z.number().max(30_000) })
111
+ .strict();
112
+
113
+ it("answers 400 with the path and the rule", () => {
114
+ const parsed = schema.safeParse({ days: "90", timeoutMs: 1 });
115
+ const answer = errorResponse(parsed.error);
116
+ expect(answer.status).toBe(400);
117
+ expect(answer.kind).toBe("client");
118
+ expect(answer.body.error.code).toBe("VALIDATION_ERROR");
119
+ expect(answer.body.error.details).toEqual([{ path: ["days"], code: "invalid_value" }]);
120
+ });
121
+
122
+ it("carries the BOUND a range failed against, because the bound is our published contract", () => {
123
+ const parsed = schema.safeParse({ days: "7", timeoutMs: 999_999 });
124
+ expect(errorResponse(parsed.error).body.error.details).toEqual([
125
+ { path: ["timeoutMs"], code: "too_big", maximum: 30_000 },
126
+ ]);
127
+ });
128
+
129
+ it("never reflects the rejected value or the keys the caller sent", () => {
130
+ const parsed = schema.safeParse({ days: "90", timeoutMs: 1, secretGuess: "admin" });
131
+ const details = JSON.stringify(errorResponse(parsed.error).body.error.details);
132
+ expect(details).not.toContain("90");
133
+ expect(details).not.toContain("secretGuess");
134
+ expect(details).not.toContain("admin");
135
+ });
136
+
137
+ it("recognizes a ZodError structurally, with no zod import of its own", () => {
138
+ const hand = {
139
+ name: "ZodError",
140
+ issues: [{ path: ["a"], code: "custom", received: "sekrit" }],
141
+ };
142
+ const answer = errorResponse(hand);
143
+ expect(answer.status).toBe(400);
144
+ expect(JSON.stringify(answer.body.error.details)).not.toContain("sekrit");
145
+ });
146
+ });
147
+
148
+ describe("an AppError under 500 is answered as itself", () => {
149
+ it("keeps the status, the code and the message", () => {
150
+ const answer = errorResponse(appError("NOT_FOUND", "Workspace not found"));
151
+ expect(answer).toMatchObject({
152
+ status: 404,
153
+ kind: "client",
154
+ body: { error: { code: "NOT_FOUND", message: "Workspace not found" } },
155
+ });
156
+ });
157
+
158
+ it("names the scheme it wants on a 401", () => {
159
+ expect(errorResponse(appError("UNAUTHORIZED", "Sign in")).headers).toEqual({
160
+ "WWW-Authenticate": "Bearer",
161
+ });
162
+ });
163
+
164
+ it("says a refusal does NOT clear by waiting, and sends no Retry-After for it", () => {
165
+ // A concurrency slot frees when another job finishes, and a cap on live objects clears by
166
+ // archiving one. Neither is a wait a server can state, and both are 429s in the fleet.
167
+ const answer = errorResponse(
168
+ appError("RATE_LIMIT_EXCEEDED", "Three jobs already running — wait for one to finish", {
169
+ retryAfterSecs: null,
170
+ }),
171
+ );
172
+ expect(answer.headers).not.toHaveProperty("Retry-After");
173
+ expect(answer.body.error.details).toEqual({ retryAfterSecs: null });
174
+ });
175
+
176
+ it("states a wait in the header as well as in details", () => {
177
+ const answer = errorResponse(
178
+ appError("RATE_LIMIT_EXCEEDED", "Slow down", { retryAfterSecs: 45 }),
179
+ );
180
+ expect(answer.headers["Retry-After"]).toBe("45");
181
+ expect(answer.body.error.details).toEqual({ retryAfterSecs: 45 });
182
+ });
183
+ });
184
+
185
+ describe("the 5xx mask", () => {
186
+ it("replaces the message of the one code that may carry internals", () => {
187
+ const answer = errorResponse(
188
+ appError("INTERNAL_ERROR", 'duplicate key value violates "users_email_key"'),
189
+ );
190
+ expect(answer.status).toBe(500);
191
+ expect(answer.kind).toBe("server");
192
+ expect(answer.body.error.message).toBe("Something on our side failed");
193
+ expect(JSON.stringify(answer.body)).not.toContain("users_email_key");
194
+ });
195
+
196
+ it("drops a masked 5xx's details entirely, whatever was put in them", () => {
197
+ // The trap this closes: a driver error handed straight to `details`. Postgres writes the
198
+ // ENTIRE failing row into its `detail` field on a CHECK or NOT NULL violation — every
199
+ // column, values included — so a caught driver object in `details` is a row on the wire.
200
+ const err = appError("INTERNAL_ERROR", "insert failed", {
201
+ details: { detail: "Failing row contains (someone@example.com, 4242424242424242)." },
202
+ });
203
+ const answer = errorResponse(err);
204
+ expect(answer.body.error).not.toHaveProperty("details");
205
+ expect(JSON.stringify(answer)).not.toContain("4242");
206
+ });
207
+
208
+ it("lets an authored 5xx through, because it is what says whether to retry", () => {
209
+ const answer = errorResponse(appError("SERVICE_UNAVAILABLE", "Metering is unavailable"));
210
+ expect(answer.status).toBe(503);
211
+ expect(answer.body.error.message).toBe("Metering is unavailable");
212
+ });
213
+
214
+ it("keeps the STATUS when it masks the message", () => {
215
+ const all = createErrorResponse<Code>({
216
+ internal: { code: "INTERNAL_ERROR", message: "Something on our side failed" },
217
+ validation: { code: "VALIDATION_ERROR", message: "The request could not be read" },
218
+ maskAll: true,
219
+ });
220
+ const answer = all(appError("GATEWAY_ERROR", "upstream said: connection reset by peer"));
221
+ expect(answer.status).toBe(502);
222
+ expect(answer.body.error.message).toBe("Something on our side failed");
223
+ expect(answer.body.error.code).toBe("INTERNAL_ERROR");
224
+ });
225
+
226
+ it("lets `expose` opt an authored sentence back through maskAll", () => {
227
+ const all = createErrorResponse<Code>({
228
+ internal: { code: "INTERNAL_ERROR", message: "Something on our side failed" },
229
+ validation: { code: "VALIDATION_ERROR", message: "The request could not be read" },
230
+ maskAll: true,
231
+ });
232
+ const err = appError("SERVICE_UNAVAILABLE", "Payments are not set up here", { expose: true });
233
+ expect(all(err).body.error.message).toBe("Payments are not set up here");
234
+ });
235
+
236
+ it("drops a 5xx's details on its own knob, exposed or not", () => {
237
+ const quiet = createErrorResponse<Code>({
238
+ internal: { code: "INTERNAL_ERROR", message: "Something on our side failed" },
239
+ validation: { code: "VALIDATION_ERROR", message: "The request could not be read" },
240
+ maskDetails: true,
241
+ });
242
+ const err = appError("SERVICE_UNAVAILABLE", "A dependency is down", {
243
+ details: { pg: "connection refused at 10.0.0.4:5432" },
244
+ });
245
+ expect(quiet(err).body.error.message).toBe("A dependency is down");
246
+ expect(quiet(err).body.error).not.toHaveProperty("details");
247
+ expect(JSON.stringify(quiet(err))).not.toContain("10.0.0.4");
248
+ });
249
+
250
+ it("keeps a 5xx's details by default, because they name which dependency is down", () => {
251
+ const err = appError("SERVICE_UNAVAILABLE", "Not ready", {
252
+ details: { pg: "down", redis: "ok" },
253
+ });
254
+ expect(errorResponse(err).body.error.details).toEqual({ pg: "down", redis: "ok" });
255
+ });
256
+
257
+ it("leaves a 4xx's details alone whatever the mask says", () => {
258
+ const quiet = createErrorResponse<Code>({
259
+ internal: { code: "INTERNAL_ERROR", message: "Something on our side failed" },
260
+ validation: { code: "VALIDATION_ERROR", message: "The request could not be read" },
261
+ maskAll: true,
262
+ maskDetails: true,
263
+ });
264
+ const err = appError("NOT_FOUND", "No such plan", { details: { id: "pro" } });
265
+ expect(quiet(err).body.error.details).toEqual({ id: "pro" });
266
+ expect(quiet(err).body.error.message).toBe("No such plan");
267
+ });
268
+ });
269
+
270
+ describe("a throw nobody raised on purpose", () => {
271
+ it("answers a generic 500 and never repeats what it caught", () => {
272
+ const answer = errorResponse(new Error("ECONNREFUSED redis://10.0.0.4:6379"));
273
+ expect(answer.status).toBe(500);
274
+ expect(answer.kind).toBe("unexpected");
275
+ expect(answer.body.error.code).toBe("INTERNAL_ERROR");
276
+ expect(JSON.stringify(answer)).not.toContain("10.0.0.4");
277
+ });
278
+
279
+ it("does the same for a plain object a driver rejected with", () => {
280
+ const answer = errorResponse({ code: "PGRST205", message: 'Could not find table "app.jobs"' });
281
+ expect(answer.status).toBe(500);
282
+ expect(answer.kind).toBe("unexpected");
283
+ expect(JSON.stringify(answer)).not.toContain("app.jobs");
284
+ });
285
+
286
+ it("separates a 5xx we raised from one that escaped", () => {
287
+ expect(errorResponse(appError("INTERNAL_ERROR", "we broke it")).kind).toBe("server");
288
+ expect(errorResponse(new AppError(500, "INTERNAL_ERROR", "we broke it")).kind).toBe("server");
289
+ expect(errorResponse("a string nobody expected").kind).toBe("unexpected");
290
+ });
291
+ });
@@ -0,0 +1,277 @@
1
+ /**
2
+ * How a backend answers: the success envelope, and the one function that turns a thrown thing
3
+ * into an HTTP answer.
4
+ *
5
+ * Nothing here knows about a framework, and that is a measurement rather than a preference.
6
+ * Five backends put this logic inside one `app.onError`, and then **three of them re-derived
7
+ * the same two arms inside an MCP tool wrapper** so an agent would get a real refusal instead
8
+ * of "an unexpected error occurred". A fourth re-derived the error body inside a background
9
+ * worker's health handler and got it wrong, answering a caught Redis message on a 503 — the
10
+ * API next door masks exactly that. A `Context`-shaped function would serve one of those four
11
+ * callers. The framework adapter is eight lines and lives in `/hono`.
12
+ */
13
+ import type { ApiError, ApiSuccess, PaginationMeta } from "@gusnips/http";
14
+ import { AppError } from "./errors.ts";
15
+
16
+ /** Every 2xx body is `{ data }`, or `{ data, meta }` where a route has counts to report. */
17
+ export function ok<T, M = PaginationMeta>(data: T, meta?: M) {
18
+ const body: ApiSuccess<T, M> = meta === undefined ? { data } : { data, meta };
19
+ return { status: 200 as const, body };
20
+ }
21
+
22
+ export function created<T>(data: T) {
23
+ const body: ApiSuccess<T> = { data };
24
+ return { status: 201 as const, body };
25
+ }
26
+
27
+ /**
28
+ * `hasMore` is computed from the page that was actually returned, not from `limit`: a page cut
29
+ * short by a filter still has to answer the question honestly. Identical, to the field, in five
30
+ * donors.
31
+ */
32
+ export function paginated<T>(rows: T[], meta: Omit<PaginationMeta, "hasMore">) {
33
+ const body: ApiSuccess<T[]> = {
34
+ data: rows,
35
+ meta: { ...meta, hasMore: meta.offset + rows.length < meta.total },
36
+ };
37
+ return { status: 200 as const, body };
38
+ }
39
+
40
+ export function noContent() {
41
+ return { status: 204 as const, body: null };
42
+ }
43
+
44
+ export interface ErrorAnswer<Code extends string = string> {
45
+ status: number;
46
+ body: ApiError<Code>;
47
+ /** `Retry-After` when the refusal states a wait; `WWW-Authenticate` on a 401. */
48
+ headers: Record<string, string>;
49
+ /**
50
+ * What kind of failure this was, which is the one thing a caller cannot work out from the
51
+ * status. A 500 raised on purpose and a `TypeError` that escaped are both 500s, and only the
52
+ * second one means nobody is watching a log for it — every donor fires its admin alert on
53
+ * exactly that branch.
54
+ */
55
+ kind: "client" | "server" | "unexpected";
56
+ }
57
+
58
+ /** The envelope a masked 5xx, an unexpected throw, or a refused body is answered with. */
59
+ export interface CannedError<Code extends string, Key extends string> {
60
+ code: Code;
61
+ /** English, for logs, `curl` and agents. A client localizes from `messageKey`. */
62
+ message: string;
63
+ messageKey?: Key;
64
+ }
65
+
66
+ export interface ErrorResponseOptions<Code extends string, Key extends string> {
67
+ /** Answers a masked 5xx and anything that escaped. */
68
+ internal: CannedError<Code, Key>;
69
+ /** Answers a validation failure. */
70
+ validation: CannedError<Code, Key>;
71
+ /**
72
+ * The 5xx codes whose message is replaced. Defaults to `["INTERNAL_ERROR"]`, which is what
73
+ * four of the five newest donors do, and their reason is worth keeping: flattening a
74
+ * `GATEWAY_ERROR` or a `SERVICE_UNAVAILABLE` into a generic 500 "would take away the one
75
+ * thing that tells a developer whether to retry."
76
+ *
77
+ * **That default is safe because of your call sites, not because of this code.** It holds
78
+ * only while every non-masked 5xx is handed a message somebody wrote for the client. A repo
79
+ * whose repository layer interpolates the driver's error into the message it raises — one
80
+ * donor's does, deliberately, so that duplicate-key heuristics keep working — wants
81
+ * `maskAll` instead.
82
+ */
83
+ maskedCodes?: readonly Code[];
84
+ /** Replace every 5xx message, and let `expose` be what opts an authored sentence back in. */
85
+ maskAll?: boolean;
86
+ /**
87
+ * Drop `details` from a 5xx whose message you did NOT mask — a separate knob because it is a
88
+ * separate decision. The newest donors put a readiness report in a 503's details, naming
89
+ * which dependency is down so a deploy gate and a human at 3am can both read it; another
90
+ * donor's details are where caught error text is recorded, and must never go out. Both are
91
+ * right about their own repo, which is why this is not folded into the mask above. (A masked
92
+ * 5xx drops its details on its own: the body is built fresh from `internal`.)
93
+ *
94
+ * The sharpest reason to turn it on is a caught driver error handed straight to `details`.
95
+ * On a CHECK or NOT NULL violation Postgres writes the ENTIRE failing row into its `detail`
96
+ * field — `Failing row contains (someone@example.com, 4242…)`, every column, values
97
+ * included. Measured against a real server, not assumed.
98
+ */
99
+ maskDetails?: boolean;
100
+ }
101
+
102
+ /**
103
+ * `retryAfterSecs` also rides inside `details`, because that is where clients already look —
104
+ * an explicit `null` included, since "waiting cannot fix this" is an answer a client needs and
105
+ * the only alternative is the hand-written code list this replaces.
106
+ *
107
+ * Only an object `details` can carry it. Spreading an ARRAY — a list of validation issues —
108
+ * turns it into `{"0": …}` and breaks every client that parses it; spreading a STRING turns it
109
+ * into one key per character. Anything that is not a plain object is handed back untouched, and
110
+ * the header still tells that caller when to come back.
111
+ */
112
+ function detailsWithRetry(err: AppError): unknown {
113
+ if (err.retryAfterSecs === undefined) return err.details;
114
+ const carries =
115
+ err.details === undefined ||
116
+ (typeof err.details === "object" && err.details !== null && !Array.isArray(err.details));
117
+ if (!carries) return err.details;
118
+ return { ...err.details, retryAfterSecs: err.retryAfterSecs };
119
+ }
120
+
121
+ /**
122
+ * The wire body for an error somebody raised on purpose.
123
+ *
124
+ * It is a function rather than a method on `AppError`, and that is a fix rather than a style
125
+ * choice — see the note on the class. It also keeps the envelope in one file with the mask,
126
+ * instead of in two.
127
+ */
128
+ function appErrorBody<Code extends string>(err: AppError<Code>): ApiError<Code> {
129
+ const details = detailsWithRetry(err);
130
+ return {
131
+ error: {
132
+ code: err.code,
133
+ message: err.message,
134
+ ...(err.messageKey !== undefined && { messageKey: err.messageKey }),
135
+ ...(err.params !== undefined && { params: err.params }),
136
+ ...(details !== undefined && { details }),
137
+ },
138
+ };
139
+ }
140
+
141
+ function envelope<Code extends string, Key extends string>(
142
+ canned: CannedError<Code, Key>,
143
+ details?: unknown,
144
+ ): ApiError<Code> {
145
+ return {
146
+ error: {
147
+ code: canned.code,
148
+ message: canned.message,
149
+ ...(canned.messageKey !== undefined && { messageKey: canned.messageKey }),
150
+ ...(details !== undefined && { details }),
151
+ },
152
+ };
153
+ }
154
+
155
+ interface RawIssue {
156
+ path?: unknown;
157
+ code?: unknown;
158
+ maximum?: unknown;
159
+ minimum?: unknown;
160
+ }
161
+
162
+ /**
163
+ * Recognize a validation failure without importing the validator.
164
+ *
165
+ * `name === "ZodError"` plus an `issues` array holds for zod 3.25, 4.4 and 4.5 — measured, all
166
+ * three, because this package must not make an adopter's validator its own dependency. It also
167
+ * accepts an issue list that arrived some other way, which is what a second door (an MCP tool,
168
+ * a queue consumer) needs.
169
+ */
170
+ function zodIssues(err: unknown): RawIssue[] | null {
171
+ if (typeof err !== "object" || err === null) return null;
172
+ const { name, issues } = err as { name?: unknown; issues?: unknown };
173
+ if (name !== "ZodError" || !Array.isArray(issues)) return null;
174
+ return issues as RawIssue[];
175
+ }
176
+
177
+ /**
178
+ * The field path, the rule it failed, and — for a range — the BOUND it failed against.
179
+ *
180
+ * Never the rejected value, and never the schema's internals. All six donors carry a version of
181
+ * that comment; what none of them carries is the proof, so here it is: handing the validator's
182
+ * issues straight to the client ships back the caller's own key names (`keys`), the enum's
183
+ * allowed values (`values`), the validator's English sentence and the expected type
184
+ * (`origin`) — four disclosures from one convenience, and two repos in the fleet do it today.
185
+ * An audit note written against an older validator looks for `received`, which the current one
186
+ * no longer emits; the projection is an allow-list precisely so a rename cannot reopen this.
187
+ *
188
+ * The bound is the exception, and it belongs to the caller: it is the published contract, and
189
+ * a `too_big` without it costs somebody a bisect to rediscover a number our own docs state.
190
+ */
191
+ function safeIssues(issues: RawIssue[]): unknown[] {
192
+ return issues.map((issue) => ({
193
+ path: issue.path,
194
+ code: issue.code,
195
+ ...(typeof issue.maximum === "number" && { maximum: issue.maximum }),
196
+ ...(typeof issue.minimum === "number" && { minimum: issue.minimum }),
197
+ }));
198
+ }
199
+
200
+ /**
201
+ * `AppError` is generic over the product's own code union, and no runtime check can verify
202
+ * membership. The predicate asserts what `createAppError` guarantees: every `AppError` in this
203
+ * app was built from the map whose keys are `Code`.
204
+ */
205
+ function isAppError<Code extends string>(err: unknown): err is AppError<Code> {
206
+ return err instanceof AppError;
207
+ }
208
+
209
+ const DEFAULT_MASKED = ["INTERNAL_ERROR"];
210
+
211
+ /**
212
+ * Binds the mask policy and the two canned bodies, and returns the function that answers.
213
+ *
214
+ * Bound once, at the app's edge, because the alternative is what the reading found: four places
215
+ * in one fleet deciding the mask separately, and the one furthest from the API getting it
216
+ * wrong. Every other door — a tool wrapper, a worker's health port — imports the same bound
217
+ * function and cannot disagree with the API about what a refusal looks like.
218
+ *
219
+ * ```ts
220
+ * export const errorResponse = createErrorResponse<ErrorCode, MessageKey>({
221
+ * internal: { code: "INTERNAL_ERROR", message: "Something on our side failed" },
222
+ * validation: { code: "VALIDATION_ERROR", message: "The request could not be read" },
223
+ * });
224
+ * ```
225
+ */
226
+ export function createErrorResponse<Code extends string = string, Key extends string = string>(
227
+ opts: ErrorResponseOptions<Code, Key>,
228
+ ) {
229
+ const maskedCodes: readonly string[] = opts.maskedCodes ?? DEFAULT_MASKED;
230
+
231
+ return function errorResponse(err: unknown): ErrorAnswer<Code> {
232
+ const issues = zodIssues(err);
233
+ if (issues !== null) {
234
+ return {
235
+ status: 400,
236
+ body: envelope(opts.validation, safeIssues(issues)),
237
+ headers: {},
238
+ kind: "client",
239
+ };
240
+ }
241
+
242
+ if (isAppError<Code>(err)) {
243
+ const headers: Record<string, string> = {};
244
+ // The standard header, not just our envelope: every HTTP client, proxy and SDK already
245
+ // knows how to wait on `Retry-After`, and none of them knows `details.retryAfterSecs`.
246
+ // A number only — a refusal that waiting cannot fix says so in the body, because
247
+ // `Retry-After: null` is a header that states a wait and names no time.
248
+ if (typeof err.retryAfterSecs === "number") {
249
+ headers["Retry-After"] = String(err.retryAfterSecs);
250
+ }
251
+ // RFC 6750 §3: a 401 names the scheme it wants. Without it a 401 is a closed door with no
252
+ // handle — which is what an agent, with no human to ask, is left holding.
253
+ if (err.statusCode === 401) headers["WWW-Authenticate"] = "Bearer";
254
+
255
+ if (err.statusCode < 500)
256
+ return { status: err.statusCode, body: appErrorBody(err), headers, kind: "client" };
257
+
258
+ const hide = !err.expose && (opts.maskAll === true || maskedCodes.includes(err.code));
259
+ if (hide) {
260
+ // The message is replaced; the STATUS is not. A status is chosen by our own map and
261
+ // discloses nothing, while it is the only thing left telling a client whether waiting
262
+ // can help — collapsing a masked 502 into a 500 throws that away for no gain. One
263
+ // donor does collapse it, and never noticed because it masks only the code that is
264
+ // already a 500.
265
+ return { status: err.statusCode, body: envelope(opts.internal), headers, kind: "server" };
266
+ }
267
+ const body = appErrorBody(err);
268
+ if (opts.maskDetails === true) delete body.error.details;
269
+ return { status: err.statusCode, body, headers, kind: "server" };
270
+ }
271
+
272
+ // Nothing raised this on purpose, so nothing in it was written for a reader. Whatever it
273
+ // says stays in the log: a background worker in the fleet answers `toMessage(err)` on its
274
+ // health port today, which is a driver's sentence on the wire.
275
+ return { status: 500, body: envelope(opts.internal), headers: {}, kind: "unexpected" };
276
+ };
277
+ }