@nwire/koa 0.11.1 → 0.12.1

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.
@@ -60,6 +60,20 @@ export interface HttpKoaConfig {
60
60
  * middleware running upstream).
61
61
  */
62
62
  readonly reqId?: boolean;
63
+ /**
64
+ * Echo raw thrown `Error.message` to the client for non-typed errors.
65
+ *
66
+ * Default `false` — a generic `throw new Error("…")` returns an opaque
67
+ * `{ code: "internal_error", summary: "Internal error" }` and the real
68
+ * message is logged server-side only. This is fail-safe: driver errors,
69
+ * connection strings, and PII attached to a stray throw never reach the
70
+ * client, in any environment.
71
+ *
72
+ * Set `true` in local dev to surface the message for debugging. Typed
73
+ * `defineError` values always render their own `{ code, summary }`
74
+ * regardless — those are author-defined and safe to expose.
75
+ */
76
+ readonly exposeErrors?: boolean;
63
77
  /**
64
78
  * Tenant resolver. The dev/test default reads `x-tenant` from the
65
79
  * request header — convenient for local multi-tenancy. Production
package/dist/http-koa.js CHANGED
@@ -35,6 +35,24 @@ import bodyParser from "koa-bodyparser";
35
35
  import cors from "@koa/cors";
36
36
  import helmet from "koa-helmet";
37
37
  import KoaRouter from "@koa/router";
38
+ /** Zod throws a `ZodError` (name + `issues[]`) on a failed `.parse()`. */
39
+ function isZodError(e) {
40
+ return (typeof e === "object" &&
41
+ e !== null &&
42
+ e.name === "ZodError" &&
43
+ Array.isArray(e.issues));
44
+ }
45
+ /**
46
+ * A human-readable summary of the first validation issue. Safe to expose:
47
+ * it describes the caller's own input, not internal state.
48
+ */
49
+ function zodSummary(e) {
50
+ const first = e.issues[0];
51
+ if (!first)
52
+ return "validation failed";
53
+ const path = (first.path ?? []).join(".") || "(body)";
54
+ return `${path}: ${first.message}`;
55
+ }
38
56
  function isHttpBinding(b) {
39
57
  return (typeof b === "object" &&
40
58
  b !== null &&
@@ -62,12 +80,14 @@ export function httpKoa(config = {}) {
62
80
  const koa = new Koa();
63
81
  koaInstance = koa;
64
82
  const isProd = process.env.NODE_ENV === "production";
83
+ const exposeErrors = config.exposeErrors === true;
65
84
  // Top-level error mapper — catches throws from middleware and
66
- // unmatched routes. Renders defineError-shaped values with their
67
- // {code, status, summary} envelope; in production, generic
68
- // (non-NwireError) throws return opaque "internal_error" so a
69
- // stray `throw new Error("DB password X")` cannot leak the
70
- // message to clients. The real error is always logged.
85
+ // unmatched routes. Typed `defineError` values render their own
86
+ // {code, status, summary}. Generic (non-NwireError) throws return an
87
+ // opaque "internal_error" by default so a stray
88
+ // `throw new Error("DB password X")` cannot leak the message to
89
+ // clients in any environment. `exposeErrors: true` (dev) echoes
90
+ // the message. The real error is always logged server-side.
71
91
  koa.use(async (kctx, next) => {
72
92
  try {
73
93
  await next();
@@ -75,21 +95,27 @@ export function httpKoa(config = {}) {
75
95
  catch (err) {
76
96
  const e = err;
77
97
  const isNwireError = e?.$kind === "error";
78
- const status = typeof e.status === "number" ? e.status : 500;
79
- kctx.status = status;
80
- if (!isNwireError && isProd) {
81
- // Server-side: log the full error so SRE can see it. Client:
82
- // opaque body no error.message leak.
83
- logger.error?.(`[http-koa] unhandled error: ${e?.message ?? e}`, undefined);
98
+ if (isZodError(err)) {
99
+ // A schema `.parse()` that escaped the per-route catch (e.g. an
100
+ // action's own input schema). Validation failures are the
101
+ // caller's fault 400 with a readable summary, never an opaque
102
+ // 500. The message is about their input, so it's safe to expose.
103
+ kctx.status = 400;
104
+ kctx.body = { error: { code: "validation_failed", summary: zodSummary(err) } };
105
+ return;
106
+ }
107
+ kctx.status = typeof e.status === "number" ? e.status : 500;
108
+ if (isNwireError) {
84
109
  kctx.body = {
85
- error: { code: "internal_error", summary: "Internal error" },
110
+ error: { code: e.code ?? "internal_error", summary: e.summary ?? "Internal error" },
86
111
  };
87
112
  }
88
113
  else {
114
+ logger.error?.(`[http-koa] unhandled error: ${e?.message ?? e}`, undefined);
89
115
  kctx.body = {
90
116
  error: {
91
- code: e.code ?? "internal_error",
92
- summary: e.summary ?? e.message ?? "Internal error",
117
+ code: "internal_error",
118
+ summary: exposeErrors ? (e.message ?? "Internal error") : "Internal error",
93
119
  },
94
120
  };
95
121
  }
@@ -250,7 +276,7 @@ export function httpKoa(config = {}) {
250
276
  kctx.body = {
251
277
  error: {
252
278
  code: "validation_failed",
253
- summary: err.message,
279
+ summary: isZodError(err) ? zodSummary(err) : err.message,
254
280
  },
255
281
  };
256
282
  return;
@@ -275,7 +301,12 @@ export function httpKoa(config = {}) {
275
301
  : (kc.request.headers["x-tenant"] ?? undefined));
276
302
  const envelopePartial = {
277
303
  tenant: resolveTenant(kctx),
278
- userId: kctx.state.userId ?? undefined,
304
+ // Prefer an explicit `state.userId`, but fall back to the
305
+ // user object's id so handlers reading `envelope.userId` work
306
+ // whenever auth middleware populated `state.user`.
307
+ userId: kctx.state.userId ??
308
+ kctx.state.user?.id ??
309
+ undefined,
279
310
  // Pass the full user object through when adopter auth middleware
280
311
  // populated it. Without this, transport-agnostic RBAC has to
281
312
  // reach into `ctx.koa.state.user` and couples the handler to
@@ -309,25 +340,26 @@ export function httpKoa(config = {}) {
309
340
  }
310
341
  }
311
342
  catch (err) {
312
- // Generic error envelope. NwireError-shaped values (defineError)
313
- // surface their {code, status, summary}. Other throws under
314
- // NODE_ENV=production — return opaque "internal_error" so a
315
- // stray `throw new Error("DB password X")` cannot leak the
316
- // message to clients. The real error is always logged.
343
+ // Generic error envelope. Typed `defineError` values surface
344
+ // their {code, status, summary}. Other throws return an opaque
345
+ // "internal_error" by default so a stray
346
+ // `throw new Error("DB password X")` cannot leak the message to
347
+ // clients in any environment. `exposeErrors: true` (dev)
348
+ // echoes the message. The real error is always logged.
317
349
  const e = err;
318
350
  const isNwireError = e?.$kind === "error";
319
351
  kctx.status = typeof e.status === "number" ? e.status : 500;
320
- if (!isNwireError && isProd) {
321
- logger.error?.(`[http-koa] unhandled error: ${e?.message ?? e}`, undefined);
352
+ if (isNwireError) {
322
353
  kctx.body = {
323
- error: { code: "internal_error", summary: "Internal error" },
354
+ error: { code: e.code ?? "internal_error", summary: e.summary ?? "Internal error" },
324
355
  };
325
356
  }
326
357
  else {
358
+ logger.error?.(`[http-koa] unhandled error: ${e?.message ?? e}`, undefined);
327
359
  kctx.body = {
328
360
  error: {
329
- code: e.code ?? "internal_error",
330
- summary: e.summary ?? e.message ?? "Internal error",
361
+ code: "internal_error",
362
+ summary: exposeErrors ? (e.message ?? "Internal error") : "Internal error",
331
363
  },
332
364
  };
333
365
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nwire/koa",
3
- "version": "0.11.1",
3
+ "version": "0.12.1",
4
4
  "description": "Nwire — Koa-backed HTTP adopter. Consumes wires from @nwire/wires/http and serves them as Koa routes under endpoint lifecycle.",
5
5
  "keywords": [
6
6
  "adopter",
@@ -33,10 +33,10 @@
33
33
  "koa-bodyparser": "^4.4.1",
34
34
  "koa-helmet": "^8.0.1",
35
35
  "zod": "^4.0.0",
36
- "@nwire/container": "0.11.1",
37
- "@nwire/endpoint": "0.11.1",
38
- "@nwire/logger": "0.11.1",
39
- "@nwire/wires": "0.11.1"
36
+ "@nwire/endpoint": "0.12.1",
37
+ "@nwire/wires": "0.12.1",
38
+ "@nwire/logger": "0.12.1",
39
+ "@nwire/container": "0.12.1"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@asteasolutions/zod-to-openapi": "^8.0.0",
@@ -47,8 +47,8 @@
47
47
  "@types/node": "^22.19.9",
48
48
  "typescript": "^5.9.3",
49
49
  "vitest": "^4.0.18",
50
- "@nwire/app": "0.11.1",
51
- "@nwire/handler": "0.11.1"
50
+ "@nwire/app": "0.12.1",
51
+ "@nwire/handler": "0.12.1"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "@asteasolutions/zod-to-openapi": "^8.0.0"