@gusnips/server 0.1.0 → 0.2.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.
@@ -1 +1 @@
1
- {"version":3,"file":"responses.js","sourceRoot":"","sources":["../src/responses.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"responses.js","sourceRoot":"","sources":["../src/responses.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gusnips/server",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "The layer under a TypeScript backend: one error shape, one response envelope, one retry rule, one logger.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/hono/index.ts CHANGED
@@ -6,6 +6,11 @@
6
6
  * app.onError(errorHandler({ errorResponse, logger }));
7
7
  * app.notFound(notFoundHandler(errorResponse(errors.notFound("Route"))));
8
8
  *
9
+ * `ok`, `created`, `paginated` and `noContent` are the success half: they take the `Context` and
10
+ * put the envelope on the wire, so no route has to unwrap the `{ status, body }` answer that the
11
+ * framework-free builders return. Three adopters wrote them by hand and one got that unwrap wrong
12
+ * in production — see `responses.ts` beside this file.
13
+ *
9
14
  * and, in a test of the real app, `assertEveryRouteGuarded(app, { isPublic })`, with the rule the
10
15
  * app itself uses for what anyone may call.
11
16
  */
@@ -13,5 +18,6 @@ export { errorBoundary, errorHandler, notFoundHandler } from "./errors.ts";
13
18
  export type { ErrorHandlerOptions } from "./errors.ts";
14
19
  export { assertEveryRouteGuarded, guard, underAny } from "./guards.ts";
15
20
  export type { GuardCheckOptions } from "./guards.ts";
21
+ export { created, noContent, ok, paginated } from "./responses.ts";
16
22
  export { requestLogger } from "./request-logger.ts";
17
23
  export type { RequestLoggerOptions, RequestVariables } from "./request-logger.ts";
@@ -7,20 +7,92 @@ import {
7
7
  type RequestVariables,
8
8
  } from "./request-logger.ts";
9
9
 
10
- function setup(options: Omit<RequestLoggerOptions, "logger"> = {}) {
10
+ function setup<E extends { Variables: RequestVariables } = { Variables: RequestVariables }>(
11
+ options: Omit<RequestLoggerOptions<E>, "logger"> = {},
12
+ ) {
11
13
  const lines: Array<Record<string, unknown>> = [];
12
14
  const logger = createLogger({
13
15
  level: "debug",
14
16
  write: (line) => lines.push(JSON.parse(line) as Record<string, unknown>),
15
17
  });
16
- const app = new Hono<{ Variables: RequestVariables }>();
17
- app.use(requestLogger({ logger, ...options }));
18
+ const app = new Hono<E>();
19
+ app.use(requestLogger<E>({ logger, ...options }));
18
20
  return { app, lines };
19
21
  }
20
22
 
21
23
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
22
24
 
25
+ type AppEnv = {
26
+ Variables: RequestVariables<"DENIED"> & { actorId: string };
27
+ };
28
+
23
29
  describe("the request line", () => {
30
+ it("adds typed adopter fields after the response exists", async () => {
31
+ const { app, lines } = setup<AppEnv>({
32
+ fields: (c) => ({ actorId: c.get("actorId"), responseStatus: c.res.status }),
33
+ });
34
+ app.post("/items", (c) => {
35
+ c.set("actorId", "user_1");
36
+ return c.text("created", 201);
37
+ });
38
+
39
+ await app.request("/items", { method: "POST" });
40
+
41
+ expect(lines[0]).toMatchObject({ actorId: "user_1", responseStatus: 201 });
42
+ });
43
+
44
+ it("keeps every canonical request field when adopter fields use the same names", async () => {
45
+ const { app, lines } = setup<AppEnv>({
46
+ fields: () => ({
47
+ actorId: "user_1",
48
+ requestId: "wrong",
49
+ method: "DELETE",
50
+ route: "/wrong",
51
+ status: 599,
52
+ ms: -1,
53
+ errorCode: "WRONG",
54
+ }),
55
+ });
56
+ app.post("/items/:id", (c) => {
57
+ c.set("errorCode", "DENIED");
58
+ return c.text("no", 403);
59
+ });
60
+
61
+ await app.request("/items/secret", {
62
+ method: "POST",
63
+ headers: { "X-Request-ID": "trace_1" },
64
+ });
65
+
66
+ expect(lines[0]).toMatchObject({
67
+ actorId: "user_1",
68
+ requestId: "trace_1",
69
+ method: "POST",
70
+ route: "/items/:id",
71
+ status: 403,
72
+ errorCode: "DENIED",
73
+ });
74
+ expect(lines[0]!.ms).not.toBe(-1);
75
+ });
76
+
77
+ it("does not let a broken adopter field hook lose the response or its request line", async () => {
78
+ const { app, lines } = setup<AppEnv>({
79
+ fields: () => {
80
+ throw new Error("field hook broke");
81
+ },
82
+ });
83
+ app.get("/items", (c) => c.text("ok"));
84
+
85
+ const response = await app.request("/items");
86
+
87
+ expect(response.status).toBe(200);
88
+ expect(lines[0]).toMatchObject({
89
+ requestFieldsFailed: true,
90
+ method: "GET",
91
+ route: "/items",
92
+ status: 200,
93
+ });
94
+ });
95
+
24
96
  it("names the route template, never the path", async () => {
25
97
  // A path carries whatever the caller put in it. In one backend that was a customer's national
26
98
  // id number, and the request line carried it into the log and on into an analytics event.
@@ -1,4 +1,4 @@
1
- import type { MiddlewareHandler } from "hono";
1
+ import type { Context, MiddlewareHandler } from "hono";
2
2
  import { routePath } from "hono/route";
3
3
  import type { Logger } from "../logger/index.ts";
4
4
 
@@ -9,8 +9,20 @@ export interface RequestVariables<Code extends string = string> {
9
9
  errorCode: Code | null;
10
10
  }
11
11
 
12
- export interface RequestLoggerOptions {
12
+ type RequestLoggerEnv = { Variables: RequestVariables };
13
+
14
+ export interface RequestLoggerOptions<E extends RequestLoggerEnv = RequestLoggerEnv> {
13
15
  logger: Logger;
16
+ /**
17
+ * Sanitized product fields to add to the request line. Runs after the response exists, so it can
18
+ * read downstream variables and `c.res`. Keep caller-controlled values bounded; never return a
19
+ * raw path, query, header set, body or authentication object.
20
+ *
21
+ * Return plain data, not live objects: a throw from this hook is caught, but a value whose own
22
+ * `toJSON` throws is caught by the logger instead, which costs the whole line rather than the
23
+ * field.
24
+ */
25
+ fields?: (c: Context<E>) => Record<string, unknown>;
14
26
  /**
15
27
  * Paths answered but never logged, each with everything under it: `/health` covers
16
28
  * `/health/db` and not `/healthz`. Defaults to `["/health"]`. A throw is logged anyway.
@@ -25,6 +37,20 @@ export interface RequestLoggerOptions {
25
37
  */
26
38
  const REQUEST_ID = /^[A-Za-z0-9._-]{1,64}$/;
27
39
 
40
+ function collectFields<E extends RequestLoggerEnv>(
41
+ fields: RequestLoggerOptions<E>["fields"],
42
+ c: Context<E>,
43
+ ): Record<string, unknown> {
44
+ if (fields === undefined) return {};
45
+ try {
46
+ // Materialize here too: a throwing getter is just as capable of losing the request line as a
47
+ // throwing callback. Logging metadata must never change the response it describes.
48
+ return { ...fields(c) };
49
+ } catch {
50
+ return { requestFieldsFailed: true };
51
+ }
52
+ }
53
+
28
54
  /**
29
55
  * One line per request, and the request id.
30
56
  *
@@ -36,10 +62,11 @@ const REQUEST_ID = /^[A-Za-z0-9._-]{1,64}$/;
36
62
  * answer including `onError`'s and `notFound`'s. Cross-origin, list that header in your CORS
37
63
  * `exposeHeaders` or the browser hides it from the page.
38
64
  */
39
- export function requestLogger({
65
+ export function requestLogger<E extends RequestLoggerEnv = RequestLoggerEnv>({
40
66
  logger,
67
+ fields,
41
68
  skipPaths = ["/health"],
42
- }: RequestLoggerOptions): MiddlewareHandler<{ Variables: RequestVariables }> {
69
+ }: RequestLoggerOptions<E>): MiddlewareHandler<E> {
43
70
  return async (c, next) => {
44
71
  const supplied = c.req.header("X-Request-ID");
45
72
  const requestId = supplied && REQUEST_ID.test(supplied) ? supplied : crypto.randomUUID();
@@ -72,6 +99,7 @@ export function requestLogger({
72
99
  // returns a Response it built itself.
73
100
  c.header("X-Request-ID", requestId);
74
101
  const skipped = skipPaths.some((skip) => path === skip || path.startsWith(`${skip}/`));
75
- if (method !== "OPTIONS" && !skipped) logger.info("request", line(c.res.status));
102
+ if (method !== "OPTIONS" && !skipped)
103
+ logger.info("request", { ...collectFields(fields, c), ...line(c.res.status) });
76
104
  };
77
105
  }
@@ -0,0 +1,50 @@
1
+ import { Hono } from "hono";
2
+ import { describe, expect, it } from "vitest";
3
+ import { created, noContent, ok, paginated } from "./responses.ts";
4
+
5
+ /**
6
+ * Every assertion here reads the BYTES off a real request, never the return value of a builder.
7
+ * That is the point: the outage this file exists to prevent was invisible to any test that calls
8
+ * the module, because the wrong value was still a perfectly good JSON value.
9
+ */
10
+ const app = new Hono()
11
+ .get("/ok", (c) => ok(c, { id: "a" }))
12
+ .get("/accepted", (c) => ok(c, { id: "a" }, 202))
13
+ .post("/created", (c) => created(c, { id: "a" }))
14
+ .get("/page", (c) => paginated(c, [1, 2], { total: 9, limit: 2, offset: 0 }))
15
+ .get("/last-page", (c) => paginated(c, [9], { total: 9, limit: 2, offset: 8 }))
16
+ .delete("/gone", (c) => noContent(c));
17
+
18
+ describe("the Hono success adapters", () => {
19
+ it("answers the envelope itself, never the { status, body } wrapper around it", async () => {
20
+ const res = await app.request("/ok");
21
+ expect(res.status).toBe(200);
22
+ expect(await res.json()).toEqual({ data: { id: "a" } });
23
+ });
24
+
25
+ it("keeps the status the route asked for", async () => {
26
+ expect((await app.request("/accepted")).status).toBe(202);
27
+ expect((await app.request("/created", { method: "POST" })).status).toBe(201);
28
+ expect(await (await app.request("/created", { method: "POST" })).json()).toEqual({
29
+ data: { id: "a" },
30
+ });
31
+ });
32
+
33
+ it("computes hasMore from the rows returned, not from the limit", async () => {
34
+ expect(await (await app.request("/page")).json()).toEqual({
35
+ data: [1, 2],
36
+ meta: { total: 9, limit: 2, offset: 0, hasMore: true },
37
+ });
38
+ expect(await (await app.request("/last-page")).json()).toEqual({
39
+ data: [9],
40
+ meta: { total: 9, limit: 2, offset: 8, hasMore: false },
41
+ });
42
+ });
43
+
44
+ it("sends a 204 with no body and no content-type", async () => {
45
+ const res = await app.request("/gone", { method: "DELETE" });
46
+ expect(res.status).toBe(204);
47
+ expect(res.headers.get("content-type")).toBeNull();
48
+ expect(await res.text()).toBe("");
49
+ });
50
+ });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The success half of the Hono edge: four adapters that put the envelope on the wire.
3
+ *
4
+ * They exist because the builders in `../responses.ts` return an ANSWER — `{ status, body }` —
5
+ * and every Hono adopter therefore has to unwrap one before `c.json` sees it. Three of three
6
+ * wrote these same four functions by hand, and one of the three wrote `c.json(ok(data))` instead
7
+ * of `c.json(ok(data).body)`: every 200 from a live API answered
8
+ * `{"status":200,"body":{"data":…}}` for fifty minutes. Nothing caught it. `c.json` takes any
9
+ * JSON value, so the types were satisfied; the status was still 200, so every probe and every
10
+ * deploy gate was satisfied; and a test that calls the module never sees the body its caller
11
+ * sends. A client found it, because a client is the only reader that parses the envelope.
12
+ *
13
+ * A doc comment would have been read by whoever was already careful. These make the wrong line
14
+ * unreachable, which is the only fix available to a package that owns both sides of the seam.
15
+ */
16
+ import type { PaginationMeta } from "@gusnips/http";
17
+ import type { Context } from "hono";
18
+ import type { ContentfulStatusCode } from "hono/utils/http-status";
19
+ import { ok as okBody, paginated as paginatedBody } from "../responses.ts";
20
+
21
+ /** `return ok(c, user)` — or `ok(c, job, 202)` where the route accepted rather than answered. */
22
+ export function ok<T>(c: Context, data: T, status: ContentfulStatusCode = 200) {
23
+ return c.json(okBody(data).body, status);
24
+ }
25
+
26
+ export function created<T>(c: Context, data: T) {
27
+ return ok(c, data, 201);
28
+ }
29
+
30
+ /** `hasMore` is computed from the rows actually returned — see `paginated` in `../responses.ts`. */
31
+ export function paginated<T>(c: Context, rows: T[], meta: Omit<PaginationMeta, "hasMore">) {
32
+ return c.json(paginatedBody(rows, meta).body, 200);
33
+ }
34
+
35
+ /**
36
+ * `c.body(null, 204)`, not `c.json`: a 204 carries no body, and `c.json(null, 204)` writes the
37
+ * four bytes `null` and a `content-type` header under a status that promises neither.
38
+ */
39
+ export function noContent(c: Context) {
40
+ return c.body(null, 204);
41
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,18 @@
1
1
  export { AppError, createAppError, toMessage } from "./errors.ts";
2
2
  export type { AppErrorOptions } from "./errors.ts";
3
- export { created, createErrorResponse, noContent, ok, paginated } from "./responses.ts";
4
- export type { CannedError, ErrorAnswer, ErrorResponseOptions } from "./responses.ts";
3
+ export {
4
+ created,
5
+ createErrorResponse,
6
+ noContent,
7
+ ok,
8
+ paginated,
9
+ validationIssues,
10
+ } from "./responses.ts";
11
+ export type {
12
+ CannedError,
13
+ ErrorAnswer,
14
+ ErrorResponseOptions,
15
+ ValidationIssue,
16
+ } from "./responses.ts";
5
17
  export { createLogger, errorReplacer, keptErrorFields } from "./logger/index.ts";
6
18
  export type { Logger, LoggerOptions, LogLevel, LogThreshold } from "./logger/index.ts";
@@ -221,6 +221,53 @@ describe("what an Error contributes to a log line", () => {
221
221
  expect(JSON.stringify(line)).not.toContain("4242");
222
222
  });
223
223
 
224
+ it("keeps the stack of an error that crossed a queue and arrived as a plain object", () => {
225
+ // A job queue stores a failed job's error through a serializer, so the dead-letter handler is
226
+ // handed a plain object — and the stack is the whole of the "why" in a line whose job is to
227
+ // say which job died and why. Everything the SDK hung beside it still goes.
228
+ const stored = {
229
+ name: "HttpError",
230
+ message: "Not Found",
231
+ stack: "HttpError: Not Found\n at workers.ts:1:1",
232
+ status: 404,
233
+ request: { method: "GET", body: '{"query":"private"}' },
234
+ };
235
+
236
+ const line = JSON.parse(JSON.stringify({ error: stored }, errorReplacer())) as Record<
237
+ string,
238
+ Record<string, unknown>
239
+ >;
240
+
241
+ expect(line.error).toEqual({
242
+ name: "HttpError",
243
+ message: "Not Found",
244
+ stack: "HttpError: Not Found\n at workers.ts:1:1",
245
+ status: 404,
246
+ });
247
+ });
248
+
249
+ it("allow-lists a thrown plain object passed directly as the error", () => {
250
+ // Hono wraps a non-Error throw as a cause, but workers, fire-and-forget catches and a database
251
+ // client's `{ code, message, details }` rejection reach the logger directly. `error` is the
252
+ // raw-error slot the logger documents; an ordinary metadata object under another key stays
253
+ // ordinary metadata.
254
+ const rejection = {
255
+ message: "insert failed",
256
+ code: "23514",
257
+ detail: "Failing row contains (someone@example.com, 4242424242424242).",
258
+ payload: '{"customer_email":"someone@example.com"}',
259
+ };
260
+
261
+ const line = serialized(rejection);
262
+
263
+ expect(line).toEqual({
264
+ message: "insert failed",
265
+ code: "23514",
266
+ detail: "[row omitted: Postgres DETAIL for this error is the whole failing row]",
267
+ });
268
+ expect(entryFor({ context: rejection }).context).toEqual(rejection);
269
+ });
270
+
224
271
  it("survives a chain of plain-object causes that holds itself", () => {
225
272
  // The Error branch has had this guard since it was written; the narrowing builds a new object
226
273
  // and so needs its own, or a self-referencing rejection recurses until the stack ends — inside
@@ -91,9 +91,18 @@ function keptValue(key: string, value: unknown): unknown {
91
91
  * a PostgREST client rejects with plain objects. So in a Hono app the cause slot is precisely where
92
92
  * a vendor's rejection object ends up, and a leak there reads as if the list had run.
93
93
  *
94
- * `cause` means "the error this one came from", so whatever sits in it is in the error slot and
95
- * gets the same treatment. `name` and `message` come along because a rejection object usually
96
- * carries them and a line with neither says nothing at all.
94
+ * A plain object passed directly as `meta.error` is the other door. Hono wraps it, but a worker,
95
+ * a fire-and-forget catch or a database client outside Hono does not. `error` is the raw-error slot
96
+ * the logger documents, so it gets the same treatment as `cause`; an ordinary metadata object under
97
+ * any other key stays untouched. `name`, `message` and `stack` come along because a rejection
98
+ * object usually carries them and a line with none of them says nothing at all.
99
+ *
100
+ * `stack` is here because an adopter's queue found it missing. A job that dies is stored by its
101
+ * queue through a serializer, so the error reaching the dead-letter handler is a plain object with
102
+ * its stack in a string — and that stack is the whole of the "why" in a line whose job is to say
103
+ * which job died and why. The Error branch below has always written `stack` unfiltered; leaving it
104
+ * out here was an asymmetry, not a decision. It is a conventional field name, not one an SDK hangs
105
+ * its own inputs off, which is what the allow-list exists to stop.
97
106
  *
98
107
  * Deliberate state it does NOT keep: context an app attaches on purpose. That belongs in the
99
108
  * logger's `meta`, which is untouched — `cause` is not the place for it, and one incident of a
@@ -106,13 +115,15 @@ export function narrowErrorLike(value: object): Record<string, unknown> {
106
115
  function narrow(value: object, seen: WeakSet<object>): Record<string, unknown> {
107
116
  seen.add(value);
108
117
  const out: Record<string, unknown> = {};
109
- const { name, message, cause } = value as {
118
+ const { name, message, stack, cause } = value as {
110
119
  name?: unknown;
111
120
  message?: unknown;
121
+ stack?: unknown;
112
122
  cause?: unknown;
113
123
  };
114
124
  if (typeof name === "string") out.name = name;
115
125
  if (typeof message === "string") out.message = message;
126
+ if (typeof stack === "string") out.stack = stack;
116
127
  for (const [k, v] of Object.entries(value)) {
117
128
  if (KEPT_ERROR_FIELDS.has(k)) out[k] = keptValue(k, v);
118
129
  }
@@ -139,6 +150,9 @@ function narrowCause(cause: unknown, seen: WeakSet<object>): unknown {
139
150
  * `JSON.stringify(err)` is `{}` — which is how a logger ends up printing nothing about the
140
151
  * failure it was called to report. They are added explicitly, and the allow-listed extras ride
141
152
  * along beside them.
153
+ * - A plain object in the root `error` slot is narrowed through the same allow-list. Hono's
154
+ * boundary turns one into an Error cause, but workers and swallowed catches log it directly.
155
+ * Other metadata objects stay untouched.
142
156
  * - A nested `cause` is followed, and so is an `AggregateError`'s `errors`. Both are
143
157
  * non-enumerable, so both are invisible to the loop above; without this line "all attempts
144
158
  * failed" is the whole log entry. Each one goes back through this replacer, so the allow-list
@@ -170,12 +184,17 @@ function narrowCause(cause: unknown, seen: WeakSet<object>): unknown {
170
184
  */
171
185
  export function errorReplacer(): (this: unknown, key: string, value: unknown) => unknown {
172
186
  const seen = new WeakSet<object>();
187
+ let root: object | undefined;
173
188
  return function (key, value) {
174
189
  const held =
175
190
  typeof this === "object" && this !== null
176
191
  ? (this as Record<string, unknown>)[key]
177
192
  : undefined;
193
+ if (key === "" && typeof held === "object" && held !== null) root = held;
178
194
  if (held instanceof Error) value = held;
195
+ else if (this === root && key === "error" && typeof held === "object" && held !== null) {
196
+ return seen.has(held) ? "[Circular]" : narrow(held, seen);
197
+ }
179
198
  if (typeof value === "bigint") return value.toString();
180
199
  if (value instanceof Error) {
181
200
  if (seen.has(value)) return "[Circular]";
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { z } from "zod";
3
+ import { type ValidationIssue, validationIssues } from "./index.ts";
3
4
  import { AppError, createAppError } from "./errors.ts";
4
5
  import { created, createErrorResponse, noContent, ok, paginated } from "./responses.ts";
5
6
 
@@ -110,6 +111,43 @@ describe("a validation failure is a 400 that reflects nothing back", () => {
110
111
  .object({ days: z.enum(["7", "30"]), timeoutMs: z.number().max(30_000) })
111
112
  .strict();
112
113
 
114
+ it("exports the safe issue projection for every door that validates input", () => {
115
+ const validationError = {
116
+ issues: [
117
+ {
118
+ path: ["items", 0, Symbol("field"), Symbol()],
119
+ code: "too_big",
120
+ maximum: 10,
121
+ minimum: 1,
122
+ received: "secret",
123
+ values: ["private", "schema"],
124
+ message: "Expected a private schema value",
125
+ },
126
+ { path: ["amount"], code: "too_big", maximum: 10n, minimum: "1" },
127
+ ],
128
+ } as const;
129
+ const projected: ValidationIssue[] = validationIssues(validationError);
130
+
131
+ expect(projected).toEqual([
132
+ { path: ["items", 0, "field", ""], code: "too_big", maximum: 10, minimum: 1 },
133
+ { path: ["amount"], code: "too_big" },
134
+ ]);
135
+ expect(() => JSON.stringify(projected)).not.toThrow();
136
+ expect(JSON.stringify(projected)).not.toContain("secret");
137
+ expect(JSON.stringify(projected)).not.toContain("private");
138
+ });
139
+
140
+ it("answers rather than throws when an issue arrives without a usable path", () => {
141
+ // The gate admits anything named ZodError with an array of issues, so an element that lost a
142
+ // field crossing a queue or a tool boundary reaches the projection. Throwing here throws
143
+ // inside the function whose whole job is to turn a thrown thing into an answer.
144
+ for (const issue of [{ code: "custom" }, null, { path: "amount", code: "too_big" }]) {
145
+ const answer = errorResponse({ name: "ZodError", issues: [issue] });
146
+ expect(answer.status).toBe(400);
147
+ expect(answer.body.error.details).toEqual([{ path: [], code: issue?.code ?? "" }]);
148
+ }
149
+ });
150
+
113
151
  it("answers 400 with the path and the rule", () => {
114
152
  const parsed = schema.safeParse({ days: "90", timeoutMs: 1 });
115
153
  const answer = errorResponse(parsed.error);
package/src/responses.ts CHANGED
@@ -13,7 +13,20 @@
13
13
  import type { ApiError, ApiSuccess, PaginationMeta } from "@gusnips/http";
14
14
  import { AppError } from "./errors.ts";
15
15
 
16
- /** Every 2xx body is `{ data }`, or `{ data, meta }` where a route has counts to report. */
16
+ /**
17
+ * The `body` is `{ data }`, or `{ data, meta }` where a route has counts to report.
18
+ *
19
+ * What comes back is an ANSWER — `{ status, body }` — not a body, because this layer is
20
+ * framework-free and has to hand its caller a status too. An adapter takes `.body`:
21
+ *
22
+ * return c.json(ok(data), status); // WRONG: {"status":200,"body":{"data":…}}
23
+ * return c.json(ok(data).body, status); // the envelope
24
+ *
25
+ * Nothing catches the first line — `c.json` takes any JSON value, the status is still whatever
26
+ * you passed, and a test that calls this module never sees the body its caller sends. It shipped,
27
+ * and a client found it. On Hono, import `ok` from `@gusnips/server/hono` instead: the four
28
+ * adapters there take the `Context`, and the question does not arise.
29
+ */
17
30
  export function ok<T, M = PaginationMeta>(data: T, meta?: M) {
18
31
  const body: ApiSuccess<T, M> = meta === undefined ? { data } : { data, meta };
19
32
  return { status: 200 as const, body };
@@ -152,11 +165,27 @@ function envelope<Code extends string, Key extends string>(
152
165
  };
153
166
  }
154
167
 
168
+ /**
169
+ * What an issue MIGHT carry — every field optional, because the gate below proves only that
170
+ * `issues` is an array and nothing at all about an element. Typing the element as certain is
171
+ * what turned this projection into a throw: `path.map` on an issue that arrived without one.
172
+ */
155
173
  interface RawIssue {
156
- path?: unknown;
157
- code?: unknown;
158
- maximum?: unknown;
159
- minimum?: unknown;
174
+ readonly path?: unknown;
175
+ readonly code?: unknown;
176
+ readonly maximum?: unknown;
177
+ readonly minimum?: unknown;
178
+ }
179
+
180
+ /** One rejected field: enough to fix the call, and nothing about the schema. */
181
+ export interface ValidationIssue {
182
+ /** The field that failed, as the caller spelled it. */
183
+ path: (string | number)[];
184
+ /** The rule that rejected it, such as `too_big`. */
185
+ code: string;
186
+ /** The numeric bound, when the rule has one. */
187
+ maximum?: number;
188
+ minimum?: number;
160
189
  }
161
190
 
162
191
  /**
@@ -174,6 +203,18 @@ function zodIssues(err: unknown): RawIssue[] | null {
174
203
  return issues as RawIssue[];
175
204
  }
176
205
 
206
+ /**
207
+ * One path segment, as something that survives `JSON.stringify`.
208
+ *
209
+ * A symbol keyed a field the caller cannot name back at us, so its description is the only
210
+ * useful thing in it — and an unnamed symbol has none, which is an empty segment rather than
211
+ * the `null` that `JSON.stringify` would otherwise write.
212
+ */
213
+ function pathSegment(segment: unknown): string | number {
214
+ if (typeof segment === "symbol") return segment.description ?? "";
215
+ return typeof segment === "number" ? segment : String(segment);
216
+ }
217
+
177
218
  /**
178
219
  * The field path, the rule it failed, and — for a range — the BOUND it failed against.
179
220
  *
@@ -187,14 +228,24 @@ function zodIssues(err: unknown): RawIssue[] | null {
187
228
  *
188
229
  * The bound is the exception, and it belongs to the caller: it is the published contract, and
189
230
  * a `too_big` without it costs somebody a bisect to rediscover a number our own docs state.
231
+ *
232
+ * Total on purpose: this runs inside the function that turns a thrown thing into an answer, and
233
+ * it is advertised to the queue and tool doors, where an issue list has crossed a serialization
234
+ * hop. An allow-list that throws on a malformed issue sends its caller back to shipping the
235
+ * validator's issues raw, which is the disclosure it exists to prevent.
190
236
  */
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
- }));
237
+ export function validationIssues(error: {
238
+ readonly issues: readonly RawIssue[];
239
+ }): ValidationIssue[] {
240
+ return error.issues.map((issue): ValidationIssue => {
241
+ const { path, code, maximum, minimum } = issue ?? {};
242
+ return {
243
+ path: Array.isArray(path) ? path.map(pathSegment) : [],
244
+ code: typeof code === "string" ? code : "",
245
+ ...(typeof maximum === "number" && { maximum }),
246
+ ...(typeof minimum === "number" && { minimum }),
247
+ };
248
+ });
198
249
  }
199
250
 
200
251
  /**
@@ -233,7 +284,7 @@ export function createErrorResponse<Code extends string = string, Key extends st
233
284
  if (issues !== null) {
234
285
  return {
235
286
  status: 400,
236
- body: envelope(opts.validation, safeIssues(issues)),
287
+ body: envelope(opts.validation, validationIssues({ issues })),
237
288
  headers: {},
238
289
  kind: "client",
239
290
  };