@jskit-ai/kernel 0.1.245 → 0.1.246

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 (101) hide show
  1. package/client/appConfig.js +2 -1
  2. package/client/asyncModuleRecovery.js +2 -4
  3. package/client/index.d.ts +13 -75
  4. package/client/index.js +1 -1
  5. package/client/logging.js +8 -2
  6. package/client/moduleBootstrap.js +41 -33
  7. package/client/moduleBootstrap.test.js +35 -0
  8. package/client/shellBootstrap.js +1 -8
  9. package/client/shellBootstrap.test.js +32 -5
  10. package/client/shellRouting.js +16 -61
  11. package/client/shellRouting.test.js +30 -1
  12. package/client/vite/clientBootstrapPlugin.js +0 -6
  13. package/client/vite/clientBootstrapPlugin.test.js +2 -3
  14. package/internal/node/installedPackages.js +14 -58
  15. package/package.json +2 -4
  16. package/server/actions/actionCatalogue.js +13 -34
  17. package/{shared → server}/actions/actionContributorHelpers.js +0 -5
  18. package/{shared → server}/actions/actionDefinitions.js +24 -131
  19. package/{shared → server}/actions/actionDefinitions.test.js +75 -21
  20. package/server/actions/actionEvents.js +97 -2
  21. package/server/actions/actionEvents.test.js +79 -0
  22. package/{shared → server}/actions/executionContext.js +3 -13
  23. package/server/actions/idempotency.js +24 -0
  24. package/server/actions/pipeline.js +87 -0
  25. package/{shared → server}/actions/policies.js +20 -33
  26. package/{shared → server}/actions/policies.test.js +6 -6
  27. package/{shared → server}/actions/registry.js +3 -22
  28. package/{shared → server}/actions/registry.test.js +70 -32
  29. package/server/actions/requestMeta.js +17 -0
  30. package/server/http/capabilityHttpRuntime.js +1 -1
  31. package/server/http/capabilityHttpRuntime.test.js +56 -0
  32. package/server/http/errorHandling.js +291 -0
  33. package/server/{runtime/fastifyBootstrap.test.js → http/errorHandling.test.js} +72 -120
  34. package/server/http/lib/routeRegistration.js +36 -150
  35. package/server/http/lib/routeTransport.js +2 -0
  36. package/server/http/lib/routeValidator.js +5 -47
  37. package/server/http/lib/routeValidator.test.js +15 -17
  38. package/server/http/lib/router.js +10 -94
  39. package/server/platform/providerRuntime/capabilityProviderLoader.js +2 -1
  40. package/server/platform/providerRuntime/helpers.js +1 -34
  41. package/server/runtime/integers.js +1 -5
  42. package/server/runtime/serviceAuthorization.js +4 -9
  43. package/server/support/appConfig.js +2 -1
  44. package/server/support/appConfigFiles.js +1 -38
  45. package/server/support/appConfigFiles.test.js +3 -39
  46. package/server/support/index.js +1 -20
  47. package/shared/capabilities/defineProvider.js +1 -1
  48. package/shared/capabilities/index.js +1 -1
  49. package/shared/index.js +1 -1
  50. package/shared/support/index.js +1 -4
  51. package/shared/support/mobileConfig.js +99 -0
  52. package/shared/support/mobileConfig.test.js +113 -0
  53. package/shared/support/normalize.js +0 -108
  54. package/shared/support/normalize.test.js +0 -134
  55. package/shared/{actions/actionContributorHelpers.test.js → support/permissions.test.js} +1 -1
  56. package/shared/support/returnToPath.js +1 -20
  57. package/shared/support/returnToPath.test.js +1 -36
  58. package/shared/support/shellLayoutTargets.js +2 -140
  59. package/shared/support/shellLayoutTargets.test.js +1 -110
  60. package/shared/support/sorting.js +1 -9
  61. package/shared/surface/apiPaths.js +1 -82
  62. package/shared/surface/paths.js +38 -171
  63. package/shared/surface/registry.js +2 -1
  64. package/shared/surface/runtime.js +18 -109
  65. package/shared/surface/runtime.test.js +57 -0
  66. package/shared/validators/index.js +1 -4
  67. package/shared/validators/jsonRestSchemaSupport.js +0 -9
  68. package/shared/validators/recordIdParamsValidator.js +0 -6
  69. package/shared/validators/schemaDefinitions.js +4 -30
  70. package/shared/validators/schemaPayloadValidation.js +2 -6
  71. package/test/barrelExposure.test.js +3 -7
  72. package/test/exportsContract.test.js +1 -3
  73. package/client/mobileLaunchRouting.js +0 -231
  74. package/client/mobileLaunchRouting.test.js +0 -230
  75. package/server/http/lib/controller.js +0 -183
  76. package/server/http/lib/controller.test.js +0 -143
  77. package/server/http/lib/index.js +0 -5
  78. package/server/runtime/canonicalJson.js +0 -74
  79. package/server/runtime/entityChangeEvents.js +0 -307
  80. package/server/runtime/entityChangeEvents.test.js +0 -274
  81. package/server/runtime/fastifyBootstrap.js +0 -553
  82. package/server/runtime/moduleConfig.js +0 -223
  83. package/server/runtime/moduleConfig.test.js +0 -165
  84. package/server/runtime/pagination.js +0 -13
  85. package/server/runtime/realtimeNormalization.js +0 -21
  86. package/server/runtime/routeUtils.js +0 -20
  87. package/server/runtime/securityAudit.js +0 -269
  88. package/server/runtime/securityAudit.test.js +0 -41
  89. package/server/support/pageTargets.js +0 -772
  90. package/server/support/pageTargets.test.js +0 -659
  91. package/server/support/shellOutlets.js +0 -576
  92. package/server/support/shellOutlets.test.js +0 -336
  93. package/server/surface/index.js +0 -18
  94. package/shared/actions/audit.js +0 -7
  95. package/shared/actions/idempotency.js +0 -62
  96. package/shared/actions/observability.js +0 -10
  97. package/shared/actions/pipeline.js +0 -309
  98. package/shared/actions/requestMeta.js +0 -36
  99. package/shared/actions/textNormalization.js +0 -3
  100. package/shared/actions/withActionDefaults.js +0 -34
  101. /package/{shared → server}/actions/executionContext.test.js +0 -0
@@ -26,10 +26,6 @@ test("action registry executes latest version by default", async () => {
26
26
  surfaces: ["app", "admin", "console"],
27
27
  input: { schema: createPassThroughSchema() },
28
28
  idempotency: "none",
29
- audit: {
30
- actionName: "settings.read"
31
- },
32
- observability: {},
33
29
  async execute() {
34
30
  calls.push("version-one");
35
31
  return {
@@ -46,10 +42,6 @@ test("action registry executes latest version by default", async () => {
46
42
  surfaces: ["app", "admin", "console"],
47
43
  input: { schema: createPassThroughSchema() },
48
44
  idempotency: "none",
49
- audit: {
50
- actionName: "settings.read"
51
- },
52
- observability: {},
53
45
  async execute() {
54
46
  calls.push("v2");
55
47
  return {
@@ -107,10 +99,6 @@ test("action registry validates action input with a single schema contract", asy
107
99
  })
108
100
  },
109
101
  idempotency: "optional",
110
- audit: {
111
- actionName: "workspace.settings.update"
112
- },
113
- observability: {},
114
102
  async execute(input) {
115
103
  return input;
116
104
  }
@@ -157,10 +145,6 @@ test("action registry fails startup on duplicate action id + version", () => {
157
145
  surfaces: ["app"],
158
146
  input: { schema: createPassThroughSchema() },
159
147
  idempotency: "optional",
160
- audit: {
161
- actionName: "settings.profile.update"
162
- },
163
- observability: {},
164
148
  async execute() {
165
149
  return {
166
150
  ok: true
@@ -182,10 +166,6 @@ test("action registry fails startup on duplicate action id + version", () => {
182
166
  surfaces: ["app"],
183
167
  input: { schema: createPassThroughSchema() },
184
168
  idempotency: "optional",
185
- audit: {
186
- actionName: "settings.profile.update"
187
- },
188
- observability: {},
189
169
  async execute() {
190
170
  return {
191
171
  ok: true
@@ -216,10 +196,6 @@ test("action registry rejects invalid version requests", async () => {
216
196
  surfaces: ["app"],
217
197
  input: { schema: createPassThroughSchema() },
218
198
  idempotency: "none",
219
- audit: {
220
- actionName: "settings.read"
221
- },
222
- observability: {},
223
199
  async execute() {
224
200
  return {
225
201
  ok: true
@@ -268,10 +244,6 @@ test("action registry ignores unknown extra fields", async () => {
268
244
  consoleUsersOnly: true,
269
245
  input: { schema: createPassThroughSchema() },
270
246
  idempotency: "none",
271
- audit: {
272
- actionName: "settings.internal.ping"
273
- },
274
- observability: {},
275
247
  async execute() {
276
248
  return { ok: true };
277
249
  }
@@ -311,10 +283,6 @@ test("action registry enforces action-level permissions", async () => {
311
283
  },
312
284
  input: { schema: createPassThroughSchema() },
313
285
  idempotency: "optional",
314
- audit: {
315
- actionName: "workspace.settings.update"
316
- },
317
- observability: {},
318
286
  async execute() {
319
287
  return { ok: true };
320
288
  }
@@ -353,3 +321,73 @@ test("action registry enforces action-level permissions", async () => {
353
321
 
354
322
  assert.deepEqual(allowed, { ok: true });
355
323
  });
324
+
325
+ function createSingleActionRegistry(action, options = {}) {
326
+ return createActionRegistry({
327
+ ...options,
328
+ contributors: [
329
+ {
330
+ contributorId: "tests.single",
331
+ domain: "tests",
332
+ actions: [
333
+ {
334
+ id: "tests.run",
335
+ version: 1,
336
+ kind: "command",
337
+ channels: ["api"],
338
+ surfaces: ["app"],
339
+ input: { schema: createPassThroughSchema() },
340
+ ...action
341
+ }
342
+ ]
343
+ }
344
+ ]
345
+ });
346
+ }
347
+
348
+ test("action registry requires an idempotency key only for required actions", async () => {
349
+ const registry = createSingleActionRegistry({
350
+ idempotency: "required",
351
+ async execute() {
352
+ return { ok: true };
353
+ }
354
+ }, { logger: null });
355
+ const context = { channel: "api", surface: "app" };
356
+
357
+ await assert.rejects(
358
+ () => registry.execute({ actionId: "tests.run", context }),
359
+ { code: "ACTION_IDEMPOTENCY_KEY_REQUIRED", status: 400 }
360
+ );
361
+ assert.deepEqual(
362
+ await registry.execute({ actionId: "tests.run", context: { ...context, requestMeta: { commandId: " cmd-1 " } } }),
363
+ { ok: true }
364
+ );
365
+ });
366
+
367
+ test("action registry logs one debug entry per outcome and returns the result directly", async () => {
368
+ const entries = [];
369
+ const logger = { debug: (payload, message) => entries.push({ payload, message }) };
370
+ let fail = false;
371
+ const registry = createSingleActionRegistry({
372
+ idempotency: "optional",
373
+ async execute() {
374
+ if (fail) {
375
+ throw Object.assign(new Error("boom"), { code: "TEST_FAILED" });
376
+ }
377
+ return { ok: true };
378
+ }
379
+ }, { logger });
380
+ const context = { channel: "api", surface: "app", requestMeta: { requestId: "req-1" } };
381
+
382
+ assert.deepEqual(await registry.execute({ actionId: "tests.run", context }), { ok: true });
383
+ fail = true;
384
+ await assert.rejects(() => registry.execute({ actionId: "tests.run", context }), { code: "TEST_FAILED" });
385
+
386
+ assert.deepEqual(
387
+ entries.map(({ payload, message }) => ({ ...payload, durationMs: typeof payload.durationMs, message })),
388
+ [
389
+ { action: "tests.run", version: 1, channel: "api", surface: "app", requestId: "req-1", durationMs: "number", outcome: "success", errorCode: null, message: "action.execution" },
390
+ { action: "tests.run", version: 1, channel: "api", surface: "app", requestId: "req-1", durationMs: "number", outcome: "failure", errorCode: "TEST_FAILED", message: "action.execution" }
391
+ ]
392
+ );
393
+ });
@@ -0,0 +1,17 @@
1
+ import { normalizeText } from "../../shared/support/normalize.js";
2
+
3
+ const TEXT_FIELDS = Object.freeze(["requestId", "commandId", "idempotencyKey", "ip", "userAgent"]);
4
+
5
+ function normalizeRequestMeta(requestMeta = {}) {
6
+ const source = requestMeta && typeof requestMeta === "object" ? requestMeta : {};
7
+ const normalized = {
8
+ ...source,
9
+ request: source.request || null
10
+ };
11
+ for (const field of TEXT_FIELDS) {
12
+ normalized[field] = normalizeText(source[field]);
13
+ }
14
+ return normalized;
15
+ }
16
+
17
+ export { normalizeRequestMeta };
@@ -9,7 +9,7 @@ import { registerRoutes } from "./lib/routeRegistration.js";
9
9
  import {
10
10
  registerApiErrorHandler,
11
11
  registerBodylessContentTypeNormalizer
12
- } from "../runtime/fastifyBootstrap.js";
12
+ } from "./errorHandling.js";
13
13
  import { isAppError } from "../runtime/errors.js";
14
14
 
15
15
  function normalizeResolver({ id, resolve } = {}) {
@@ -159,3 +159,59 @@ test("direct HTTP action execution preserves request-scoped stream dependencies"
159
159
  assert.deepEqual(reply.payload, { writerId: "writer-1", aborted: false });
160
160
  await runtime.shutdown();
161
161
  });
162
+
163
+ test("route transport reaches the error handler before the route handler runs, even without a kind", async () => {
164
+ const fastify = createFastifyStub();
165
+ const transport = {
166
+ contentType: "application/vnd.api+json",
167
+ request: {
168
+ query: (query) => ({ ...query, steps: [...(query.steps || []), "transport"] })
169
+ },
170
+ error(error, { statusCode, code }) {
171
+ return { errors: [{ status: String(statusCode), code, title: error.message }] };
172
+ }
173
+ };
174
+ const feature = defineFeature({
175
+ id: "feature.transport-proof",
176
+ domain: "transport-proof",
177
+ requires: { http: "runtime.http" },
178
+ setup({ http }) {
179
+ http.router.get("/transport-proof/", {
180
+ surface: "app",
181
+ visibility: "public",
182
+ transport
183
+ }, async (request, reply) => {
184
+ reply.code(200).send({ query: request.input.query });
185
+ });
186
+ return {};
187
+ },
188
+ actions: []
189
+ });
190
+ const runtime = createCapabilityRuntime({
191
+ inputs: { "runtime.fastify": fastify },
192
+ providers: [createActionProvider(), HttpProvider, feature]
193
+ });
194
+
195
+ await runtime.start();
196
+ const [route] = fastify.routes;
197
+ assert.equal(route.url, "/transport-proof");
198
+ assert.equal(typeof route.config.transport.error, "function");
199
+
200
+ const errorReply = createReply();
201
+ fastify.errorHandler(
202
+ { message: "querystring/limit must be integer", validation: [{ instancePath: "/limit", message: "must be integer" }] },
203
+ { routeOptions: { config: route.config } },
204
+ errorReply
205
+ );
206
+ assert.equal(errorReply.statusCode, 400);
207
+ assert.equal(errorReply.headers["content-type"], "application/vnd.api+json");
208
+ assert.deepEqual(errorReply.payload, {
209
+ errors: [{ status: "400", code: "validation_failed", title: "querystring/limit must be integer" }]
210
+ });
211
+
212
+ const reply = createReply();
213
+ await route.handler({ query: { steps: ["client"] }, routeOptions: { config: route.config } }, reply);
214
+ assert.deepEqual(reply.payload, { query: { steps: ["client", "transport"] } });
215
+ assert.equal(reply.headers["content-type"], "application/vnd.api+json");
216
+ await runtime.shutdown();
217
+ });
@@ -0,0 +1,291 @@
1
+ import { ActionRuntimeError } from "../actions/actionDefinitions.js";
2
+ import { isRecord, normalizeTransactionOutcome } from "../../shared/support/normalize.js";
3
+ import { JSON_API_CONTENT_TYPE } from "./lib/routeTransport.js";
4
+
5
+ const JSON_API_CONTENT_TYPE_PARSER_MARKER = Symbol.for("jskit.fastify.jsonApiContentTypeParserRegistered");
6
+ const BODYLESS_CONTENT_TYPE_NORMALIZER_MARKER = Symbol.for("jskit.fastify.bodylessContentTypeNormalizerRegistered");
7
+
8
+ function registerJsonApiContentTypeParser(fastify) {
9
+ if (!fastify || typeof fastify.addContentTypeParser !== "function") {
10
+ throw new TypeError("registerJsonApiContentTypeParser requires a Fastify instance.");
11
+ }
12
+
13
+ if (fastify[JSON_API_CONTENT_TYPE_PARSER_MARKER]) {
14
+ return false;
15
+ }
16
+
17
+ if (typeof fastify.hasContentTypeParser === "function" && fastify.hasContentTypeParser(JSON_API_CONTENT_TYPE)) {
18
+ fastify[JSON_API_CONTENT_TYPE_PARSER_MARKER] = true;
19
+ return false;
20
+ }
21
+
22
+ fastify.addContentTypeParser(
23
+ JSON_API_CONTENT_TYPE,
24
+ { parseAs: "string" },
25
+ fastify.getDefaultJsonParser("ignore", "ignore")
26
+ );
27
+ fastify[JSON_API_CONTENT_TYPE_PARSER_MARKER] = true;
28
+ return true;
29
+ }
30
+
31
+ function hasHeaderValue(headers = {}, key = "") {
32
+ return String(headers?.[key] ?? "").trim().length > 0;
33
+ }
34
+
35
+ function hasRequestBody(headers = {}) {
36
+ const transferEncoding = String(headers?.["transfer-encoding"] ?? "").trim();
37
+ if (transferEncoding) {
38
+ return true;
39
+ }
40
+
41
+ const contentLengthHeader = String(headers?.["content-length"] ?? "").trim();
42
+ if (!contentLengthHeader) {
43
+ return false;
44
+ }
45
+
46
+ const contentLength = Number(contentLengthHeader);
47
+ if (!Number.isFinite(contentLength)) {
48
+ return true;
49
+ }
50
+
51
+ return contentLength > 0;
52
+ }
53
+
54
+ function normalizeBodylessContentTypeHeader(request = null) {
55
+ const headers = request?.headers;
56
+ if (!headers || typeof headers !== "object" || Array.isArray(headers)) {
57
+ return false;
58
+ }
59
+
60
+ if (!hasHeaderValue(headers, "content-type")) {
61
+ return false;
62
+ }
63
+
64
+ if (hasRequestBody(headers)) {
65
+ return false;
66
+ }
67
+
68
+ delete headers["content-type"];
69
+ return true;
70
+ }
71
+
72
+ function registerBodylessContentTypeNormalizer(fastify) {
73
+ if (!fastify || typeof fastify.addHook !== "function") {
74
+ throw new TypeError("registerBodylessContentTypeNormalizer requires a Fastify instance.");
75
+ }
76
+
77
+ if (fastify[BODYLESS_CONTENT_TYPE_NORMALIZER_MARKER]) {
78
+ return false;
79
+ }
80
+
81
+ fastify.addHook("onRequest", async (request) => {
82
+ normalizeBodylessContentTypeHeader(request);
83
+ });
84
+ fastify[BODYLESS_CONTENT_TYPE_NORMALIZER_MARKER] = true;
85
+ return true;
86
+ }
87
+
88
+ function resolveValidationFieldErrors(error) {
89
+ const issues = Array.isArray(error?.validation) ? error.validation : [];
90
+ const fieldErrors = {};
91
+
92
+ for (const issue of issues) {
93
+ const fieldFromPath = String(issue.instancePath || "")
94
+ .replace(/^\//, "")
95
+ .replace(/\//g, ".");
96
+ const field =
97
+ fieldFromPath || String(issue.params?.missingProperty || issue.params?.additionalProperty || "request").trim();
98
+
99
+ if (!fieldErrors[field]) {
100
+ fieldErrors[field] = issue.message || "Invalid value.";
101
+ }
102
+ }
103
+
104
+ return fieldErrors;
105
+ }
106
+
107
+ function resolveRequestRouteTransport(request) {
108
+ const transport = request?.routeOptions?.config?.transport;
109
+ return isRecord(transport) ? transport : null;
110
+ }
111
+
112
+ function applyRouteTransportErrorResponse(reply, request, error, {
113
+ statusCode = 500,
114
+ normalizedErrorCode = "",
115
+ message = error?.message,
116
+ exposeDetails = true
117
+ } = {}) {
118
+ const transport = resolveRequestRouteTransport(request);
119
+ const errorSerializer = transport && typeof transport.error === "function" ? transport.error : null;
120
+ if (!errorSerializer) {
121
+ return false;
122
+ }
123
+
124
+ const payload = errorSerializer(error, {
125
+ request,
126
+ reply,
127
+ statusCode,
128
+ code: normalizedErrorCode,
129
+ message,
130
+ exposeDetails
131
+ });
132
+
133
+ if (payload && typeof payload.then === "function") {
134
+ throw new TypeError("Route transport error serializer must return synchronously.");
135
+ }
136
+
137
+ if (transport.contentType) {
138
+ reply.header("Content-Type", transport.contentType);
139
+ }
140
+
141
+ reply.code(statusCode).send(payload);
142
+ return true;
143
+ }
144
+
145
+ function resolveApiErrorStatus(error, code) {
146
+ // JSON REST's public error codes need HTTP classification even without status hints.
147
+ switch (code) {
148
+ case "REST_API_VALIDATION":
149
+ return 422;
150
+ case "REST_API_RESOURCE":
151
+ switch (error?.subtype) {
152
+ case "not_found": return 404;
153
+ case "forbidden": return 403;
154
+ case "conflict": return 409;
155
+ default: return 400;
156
+ }
157
+ case "REST_API_VERSION_CONFLICT":
158
+ return 409;
159
+ case "REST_API_PRECONDITION_FAILED":
160
+ return 412;
161
+ case "REST_API_FIELDSET_INVALID":
162
+ case "REST_API_INCLUDE_INVALID":
163
+ return 400;
164
+ case "REST_API_PAYLOAD":
165
+ return error?.statusCode === 413 ? 413 : 400;
166
+ case "REST_API_TEMPORAL_DATA_INVALID":
167
+ return 500;
168
+ default: {
169
+ const status = Number(error?.statusCode || error?.status);
170
+ return Number.isInteger(status) && status >= 400 && status <= 599 ? status : 500;
171
+ }
172
+ }
173
+ }
174
+
175
+ function applyErrorHeaders(reply, error) {
176
+ if (error?.headers && typeof error.headers === "object") {
177
+ for (const [name, value] of Object.entries(error.headers)) {
178
+ reply.header(name, value);
179
+ }
180
+ }
181
+ }
182
+
183
+ function registerApiErrorHandler(app, { isAppError } = {}) {
184
+ if (!app || typeof app.setErrorHandler !== "function") {
185
+ throw new TypeError("registerApiErrorHandler requires a Fastify instance.");
186
+ }
187
+ if (typeof isAppError !== "function") {
188
+ throw new TypeError("registerApiErrorHandler requires isAppError.");
189
+ }
190
+
191
+ function shouldExposeAppErrorDetails(errorCode = "") {
192
+ return String(errorCode || "").trim() !== "ACTION_PERMISSION_DENIED";
193
+ }
194
+
195
+ app.setErrorHandler((error, request, reply) => {
196
+ const normalizedErrorCode = String(error?.code || "").trim();
197
+ const transactionOutcome = normalizeTransactionOutcome(error?.transactionOutcome);
198
+ const outcomeFields = transactionOutcome ? { transactionOutcome } : {};
199
+ const isCsrfErrorCode = normalizedErrorCode.startsWith("FST_CSRF_");
200
+ const statusCode = resolveApiErrorStatus(error, normalizedErrorCode);
201
+
202
+ if (Array.isArray(error?.validation)) {
203
+ const fieldErrors = resolveValidationFieldErrors(error);
204
+ const validationErrorCode = normalizedErrorCode || "validation_failed";
205
+ if (applyRouteTransportErrorResponse(reply, request, error, {
206
+ statusCode: 400,
207
+ normalizedErrorCode: validationErrorCode
208
+ })) {
209
+ return;
210
+ }
211
+ reply.code(400).send({
212
+ error: "Validation failed.",
213
+ code: validationErrorCode,
214
+ ...outcomeFields,
215
+ fieldErrors,
216
+ details: {
217
+ fieldErrors
218
+ }
219
+ });
220
+ return;
221
+ }
222
+
223
+ applyErrorHeaders(reply, error);
224
+
225
+ if (isAppError(error) || error instanceof ActionRuntimeError) {
226
+ if (error.status >= 500) {
227
+ app.log.error({ err: error }, "AppError 5xx");
228
+ }
229
+ const appErrorCode = normalizedErrorCode || "app_error";
230
+ const exposeDetails = shouldExposeAppErrorDetails(appErrorCode);
231
+ if (applyRouteTransportErrorResponse(reply, request, error, {
232
+ statusCode: error.status,
233
+ normalizedErrorCode: appErrorCode,
234
+ exposeDetails
235
+ })) {
236
+ return;
237
+ }
238
+
239
+ const payload = {
240
+ error: error.message,
241
+ code: appErrorCode,
242
+ ...outcomeFields
243
+ };
244
+ if (error.details && exposeDetails) {
245
+ payload.details = error.details;
246
+ if (error.details.fieldErrors) {
247
+ payload.fieldErrors = error.details.fieldErrors;
248
+ }
249
+ }
250
+
251
+ reply.code(error.status).send(payload);
252
+ return;
253
+ }
254
+
255
+ if (statusCode >= 500) {
256
+ app.log.error({ err: error }, "Unhandled error");
257
+ } else {
258
+ app.log.info({ err: error, statusCode }, "Request rejected");
259
+ }
260
+
261
+ const message = statusCode >= 500 ? "Internal server error." : String(error?.message || "Request failed.");
262
+ const fallbackErrorCode =
263
+ normalizedErrorCode || (statusCode >= 500 ? "internal_server_error" : "request_failed");
264
+ if (applyRouteTransportErrorResponse(reply, request, error, {
265
+ statusCode,
266
+ normalizedErrorCode: fallbackErrorCode,
267
+ message,
268
+ exposeDetails: statusCode < 500
269
+ })) {
270
+ return;
271
+ }
272
+ const payload = {
273
+ error: message,
274
+ code: fallbackErrorCode,
275
+ ...outcomeFields
276
+ };
277
+ if (isCsrfErrorCode) {
278
+ payload.details = {
279
+ code: normalizedErrorCode
280
+ };
281
+ }
282
+ reply.code(statusCode).send(payload);
283
+ });
284
+ }
285
+
286
+ export {
287
+ registerJsonApiContentTypeParser,
288
+ normalizeBodylessContentTypeHeader,
289
+ registerBodylessContentTypeNormalizer,
290
+ registerApiErrorHandler
291
+ };