@jskit-ai/http-runtime 0.1.183 → 0.1.185

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/http-runtime",
3
- "version": "0.1.183",
3
+ "version": "0.1.185",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -78,6 +78,6 @@
78
78
  }
79
79
  },
80
80
  "peerDependencies": {
81
- "@jskit-ai/kernel": "0.1.185"
81
+ "@jskit-ai/kernel": "0.1.187"
82
82
  }
83
83
  }
@@ -1,3 +1,4 @@
1
+ import { normalizeTransactionOutcome } from "@jskit-ai/kernel/shared/support/normalize";
1
2
  import { isRecord, resolveFieldErrors } from "../support/fieldErrors.js";
2
3
  import { createJsonApiClientErrorPayload } from "./jsonApiResourceTransport.js";
3
4
 
@@ -20,6 +21,10 @@ function createHttpError(response, data = {}) {
20
21
  const normalizedFieldErrors = resolveFieldErrors(payload);
21
22
  error.status = Number(response?.status || 0);
22
23
  error.code = String(payload.code || "").trim() || null;
24
+ const transactionOutcome = normalizeTransactionOutcome(payload.transactionOutcome);
25
+ if (transactionOutcome) {
26
+ error.transactionOutcome = transactionOutcome;
27
+ }
23
28
  error.fieldErrors = Object.keys(normalizedFieldErrors).length > 0 ? normalizedFieldErrors : null;
24
29
  if (isRecord(payload.details)) {
25
30
  error.details = payload.details;
@@ -1,4 +1,4 @@
1
- import { normalizeArray, normalizeObject, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
1
+ import { normalizeArray, normalizeObject, normalizeText, normalizeTransactionOutcome } from "@jskit-ai/kernel/shared/support/normalize";
2
2
  import {
3
3
  JSON_API_CONTENT_TYPE,
4
4
  createJsonApiDocument,
@@ -331,12 +331,14 @@ function createJsonApiClientErrorPayload(payload = {}) {
331
331
 
332
332
  const firstError = normalizeArray(document.errors)[0] || {};
333
333
  const fieldErrors = decodeJsonApiErrorFieldErrors(payload);
334
+ const transactionOutcome = normalizeTransactionOutcome(firstError.meta?.transactionOutcome);
334
335
 
335
336
  return {
336
337
  error: normalizeText(firstError.detail || firstError.title, {
337
338
  fallback: "Request failed."
338
339
  }),
339
340
  code: normalizeText(firstError.code) || null,
341
+ ...(transactionOutcome ? { transactionOutcome } : {}),
340
342
  ...(Object.keys(fieldErrors).length > 0
341
343
  ? {
342
344
  fieldErrors,
@@ -1,3 +1,6 @@
1
+ import { normalizeTransactionOutcome } from "@jskit-ai/kernel/shared/support/normalize";
2
+ import { createJsonApiClientErrorPayload } from "./jsonApiResourceTransport.js";
3
+
1
4
  const DEFAULT_RETRYABLE_CSRF_ERROR_CODES = Object.freeze(["FST_CSRF_INVALID_TOKEN", "FST_CSRF_MISSING_SECRET"]);
2
5
 
3
6
  function toUpperStringSet(values, fallback = []) {
@@ -28,7 +31,13 @@ function shouldRetryForCsrfFailure({
28
31
  return false;
29
32
  }
30
33
 
31
- const code = String(data?.details?.code || "")
34
+ const payload = createJsonApiClientErrorPayload(data) || data;
35
+ const transactionOutcome = normalizeTransactionOutcome(payload?.transactionOutcome);
36
+ if (["pending", "committed", "unknown"].includes(transactionOutcome)) {
37
+ return false;
38
+ }
39
+
40
+ const code = String(payload?.details?.code || payload?.code || "")
32
41
  .trim()
33
42
  .toUpperCase();
34
43
  const retryableCodes = toUpperStringSet(retryableErrorCodes, DEFAULT_RETRYABLE_CSRF_ERROR_CODES);
@@ -1,4 +1,5 @@
1
1
  import { createSchema } from "json-rest-schema";
2
+ import { TRANSACTION_OUTCOMES } from "@jskit-ai/kernel/shared/support/normalize";
2
3
  import { deepFreeze } from "@jskit-ai/kernel/shared/support/deepFreeze";
3
4
  import { createEmbeddableTransportSchemaDocument } from "./transportSchemaEmbedding.js";
4
5
 
@@ -28,6 +29,7 @@ const apiErrorOutputValidator = deepFreeze({
28
29
  schema: createSchema({
29
30
  error: { type: "string", required: true, minLength: 1 },
30
31
  code: { type: "string", required: false, minLength: 1 },
32
+ transactionOutcome: { type: "string", required: false, enum: TRANSACTION_OUTCOMES },
31
33
  details: {
32
34
  type: "object",
33
35
  required: false,
@@ -46,6 +48,7 @@ const apiValidationErrorOutputValidator = deepFreeze({
46
48
  schema: createSchema({
47
49
  error: { type: "string", required: true, minLength: 1 },
48
50
  code: { type: "string", required: false, minLength: 1 },
51
+ transactionOutcome: { type: "string", required: false, enum: TRANSACTION_OUTCOMES },
49
52
  fieldErrors: {
50
53
  ...fieldErrorsFieldDefinition,
51
54
  required: true
@@ -77,6 +80,7 @@ const fastifyDefaultErrorTransportSchema = {
77
80
  error: { type: "string", minLength: 1 },
78
81
  message: { type: "string", minLength: 1 },
79
82
  code: { type: "string", minLength: 1 },
83
+ transactionOutcome: { type: "string", enum: TRANSACTION_OUTCOMES },
80
84
  details: {},
81
85
  fieldErrors: {
82
86
  type: "object",
@@ -864,9 +864,11 @@ function createJsonApiResourceRouteTransport({
864
864
  },
865
865
  error(error, {
866
866
  statusCode = 500,
867
- code = ""
867
+ code = "",
868
+ message = error?.message,
869
+ exposeDetails = true
868
870
  } = {}) {
869
- const fieldErrors = isRecord(error?.fieldErrors)
871
+ const fieldErrors = !exposeDetails ? {} : isRecord(error?.fieldErrors)
870
872
  ? error.fieldErrors
871
873
  : isRecord(error?.details?.fieldErrors)
872
874
  ? error.details.fieldErrors
@@ -875,9 +877,10 @@ function createJsonApiResourceRouteTransport({
875
877
  return createJsonApiErrorDocumentFromFailure({
876
878
  statusCode,
877
879
  code,
878
- message: error?.message,
880
+ message,
881
+ transactionOutcome: error?.transactionOutcome,
879
882
  fieldErrors,
880
- validationIssues: Array.isArray(error?.validation) ? error.validation : [],
883
+ validationIssues: exposeDetails && Array.isArray(error?.validation) ? error.validation : [],
881
884
  validationContext: String(error?.validationContext || "").trim(),
882
885
  pointerPrefix
883
886
  });
@@ -1,4 +1,4 @@
1
- import { normalizeArray, normalizeObject, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
1
+ import { normalizeArray, normalizeObject, normalizeText, normalizeTransactionOutcome } from "@jskit-ai/kernel/shared/support/normalize";
2
2
  import { simplifyJsonApiResourceWithRelationshipIds } from "../support/jsonApiSimplify.js";
3
3
 
4
4
  const JSON_API_CONTENT_TYPE = "application/vnd.api+json";
@@ -388,11 +388,14 @@ function createJsonApiErrorDocumentFromFailure({
388
388
  statusCode = 500,
389
389
  code = "",
390
390
  message = "",
391
+ transactionOutcome,
391
392
  fieldErrors = {},
392
393
  validationIssues = [],
393
394
  validationContext = "",
394
395
  pointerPrefix = "/data/attributes"
395
396
  } = {}) {
397
+ const outcome = normalizeTransactionOutcome(transactionOutcome);
398
+ const meta = outcome ? { transactionOutcome: outcome } : undefined;
396
399
  const normalizedStatus = String(Number(statusCode) || 500);
397
400
  const normalizedCode = String(code || "").trim();
398
401
  const normalizedMessage = String(message || "").trim() || "Request failed.";
@@ -404,6 +407,7 @@ function createJsonApiErrorDocumentFromFailure({
404
407
  errors: issues.map((issue) =>
405
408
  createJsonApiErrorObject({
406
409
  status: normalizedStatus,
410
+ meta,
407
411
  code: normalizedCode,
408
412
  title: normalizedMessage,
409
413
  detail: String(issue?.message || "Invalid value.").trim() || "Invalid value.",
@@ -419,6 +423,7 @@ function createJsonApiErrorDocumentFromFailure({
419
423
  errors: fieldEntries.map(([field, detail]) =>
420
424
  createJsonApiErrorObject({
421
425
  status: normalizedStatus,
426
+ meta,
422
427
  code: normalizedCode,
423
428
  title: normalizedMessage,
424
429
  detail: String(detail || "Invalid value.").trim() || "Invalid value.",
@@ -434,6 +439,7 @@ function createJsonApiErrorDocumentFromFailure({
434
439
  errors: [
435
440
  createJsonApiErrorObject({
436
441
  status: normalizedStatus,
442
+ meta,
437
443
  code: normalizedCode,
438
444
  title: normalizedMessage
439
445
  })
@@ -21,6 +21,82 @@ function mockResponse({ status = 200, data = {}, contentType = "application/json
21
21
  };
22
22
  }
23
23
 
24
+ test("request retains standard and JSON:API write outcomes without retrying", async () => {
25
+ for (const jsonapi of [false, true]) {
26
+ for (const outcome of ["committed", "rolledBack", "unknown", "invalid"]) {
27
+ let requests = 0;
28
+ const data = jsonapi
29
+ ? { errors: [{ status: "500", title: "Internal server error.", meta: { transactionOutcome: outcome } }] }
30
+ : { error: "Internal server error.", transactionOutcome: outcome };
31
+ const client = createHttpClient({
32
+ fetchImpl: async () => {
33
+ requests += 1;
34
+ return mockResponse({ status: 500, data, contentType: jsonapi ? "application/vnd.api+json" : "application/json" });
35
+ }
36
+ });
37
+ await assert.rejects(
38
+ () => client.request("/api/books/1", { method: "PATCH", body: { title: "Dune" }, csrf: false }),
39
+ (error) => {
40
+ assert.equal(error.status, 500);
41
+ assert.equal(error.transactionOutcome, outcome === "invalid" ? undefined : outcome);
42
+ return true;
43
+ }
44
+ );
45
+ assert.equal(requests, 1);
46
+ }
47
+ }
48
+ });
49
+
50
+ test("CSRF recovery replays writes only when their transaction outcome permits it", async (t) => {
51
+ for (const requestMethod of ["request", "requestStream"]) {
52
+ for (const jsonapi of [false, true]) {
53
+ for (const outcome of ["none", "rolledBack", "pending", "committed", "unknown"]) {
54
+ await t.test(`${requestMethod} ${jsonapi ? "JSON:API" : "plain"} ${outcome}`, async () => {
55
+ const calls = [];
56
+ let writes = 0;
57
+ const client = createHttpClient({
58
+ fetchImpl: async (url) => {
59
+ calls.push(url);
60
+ if (url === "/api/session") {
61
+ return mockResponse({ data: { csrfToken: "csrf-token" } });
62
+ }
63
+ writes += 1;
64
+ if (writes > 1) {
65
+ return mockResponse({ data: { ok: true } });
66
+ }
67
+ return mockResponse({
68
+ status: 403,
69
+ contentType: jsonapi ? "application/vnd.api+json" : "application/json",
70
+ data: jsonapi
71
+ ? { errors: [{ status: "403", code: "FST_CSRF_INVALID_TOKEN", title: "Invalid CSRF token.", meta: { transactionOutcome: outcome } }] }
72
+ : { error: "Invalid CSRF token.", details: { code: "FST_CSRF_INVALID_TOKEN" }, transactionOutcome: outcome }
73
+ });
74
+ }
75
+ });
76
+ const request = () => client[requestMethod]("/api/books", {
77
+ method: "POST",
78
+ body: { title: "Dune" }
79
+ });
80
+
81
+ if (["none", "rolledBack"].includes(outcome)) {
82
+ await request();
83
+ assert.equal(writes, 2);
84
+ assert.deepEqual(calls, ["/api/session", "/api/books", "/api/session", "/api/books"]);
85
+ } else {
86
+ await assert.rejects(request, (error) => {
87
+ assert.equal(error.status, 403);
88
+ assert.equal(error.transactionOutcome, outcome);
89
+ return true;
90
+ });
91
+ assert.equal(writes, 1);
92
+ assert.deepEqual(calls, ["/api/session", "/api/books"]);
93
+ }
94
+ });
95
+ }
96
+ }
97
+ }
98
+ });
99
+
24
100
  test("request serializes json body and injects csrf token for unsafe methods", async () => {
25
101
  const calls = [];
26
102
  const fetchImpl = async (url, options) => {
@@ -118,3 +118,11 @@ test("error response validators export transport schemas from the same contracts
118
118
  true
119
119
  );
120
120
  });
121
+
122
+ test("standard error schemas preserve optional transaction outcomes during serialization", () => {
123
+ const outcomes = ["none", "pending", "committed", "rolledBack", "unknown"];
124
+ for (const schema of [apiErrorTransportSchema, apiValidationErrorTransportSchema, fastifyDefaultErrorTransportSchema]) {
125
+ assert.deepEqual(schema.properties.transactionOutcome.enum, outcomes);
126
+ assert.equal(schema.required.includes("transactionOutcome"), false);
127
+ }
128
+ });
@@ -1,8 +1,22 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
+ import Fastify from "fastify";
4
+ import knexLib from "knex";
5
+ import {
6
+ RestApiFieldsetError,
7
+ RestApiIncludeError,
8
+ RestApiPayloadError,
9
+ RestApiPreconditionFailedError,
10
+ RestApiResourceError,
11
+ RestApiTemporalDataError,
12
+ RestApiValidationError,
13
+ RestApiVersionConflictError,
14
+ RestApiWriteError
15
+ } from "json-rest-api";
3
16
 
4
17
  import {
5
18
  JSON_API_CONTENT_TYPE,
19
+ apiErrorTransportSchema,
6
20
  encodeJsonApiResourceQueryObject,
7
21
  createJsonApiResourceQueryTransportSchema,
8
22
  createJsonApiResourceRequestBodyTransportSchema,
@@ -15,6 +29,10 @@ import {
15
29
  } from "../src/shared/index.js";
16
30
  import { createSchema } from "../../kernel/shared/validators/index.js";
17
31
  import { resolveRouteValidatorOptions } from "../../kernel/server/http/lib/routeValidator.js";
32
+ import { registerApiErrorHandler } from "../../kernel/server/runtime/fastifyBootstrap.js";
33
+ import { AppError, isAppError } from "../../kernel/server/runtime/errors.js";
34
+ import { createHttpError } from "../src/shared/clientRuntime/errors.js";
35
+ import { createJsonRestApiHost } from "../../json-rest-api-core/src/server/jsonRestApiHost.js";
18
36
 
19
37
  const CONTACT_BODY_SCHEMA = Object.freeze({
20
38
  schema: createSchema({
@@ -197,6 +215,189 @@ test("createJsonApiResourceRouteTransport unwraps request payloads and wraps res
197
215
  assert.equal(errorPayload.errors[0].source.pointer, "/data/attributes/name");
198
216
  });
199
217
 
218
+ test("real HTTP responses classify typed JSON REST read and write errors", async (t) => {
219
+ const app = Fastify();
220
+ t.after(() => app.close());
221
+ registerApiErrorHandler(app, { isAppError });
222
+ const transport = createJsonApiResourceRouteTransport({ type: "books" });
223
+ const cases = [
224
+ ["validation", () => new RestApiValidationError("Title is required."), 422],
225
+ ["missing", () => new RestApiResourceError("Book not found.", { subtype: "not_found" }), 404],
226
+ ["forbidden", () => new RestApiResourceError("Book is forbidden.", { subtype: "forbidden" }), 403],
227
+ ["conflict", () => new RestApiResourceError("Book conflicts.", { subtype: "conflict" }), 409],
228
+ ["resource", () => new RestApiResourceError("Invalid resource."), 400],
229
+ ["version", () => new RestApiVersionConflictError({ resourceType: "books", resourceId: "1" }), 409],
230
+ ["precondition", () => new RestApiPreconditionFailedError({ resourceType: "books", resourceId: "1" }), 412],
231
+ ["fieldset", () => new RestApiFieldsetError({ resourceType: "books", field: "missing" }), 400],
232
+ ["include", () => new RestApiIncludeError({ resourceType: "books", path: "missing" }), 400],
233
+ ["payload", () => new RestApiPayloadError("Invalid document."), 400],
234
+ ["large-payload", () => new RestApiPayloadError("Document too large.", { statusCode: 413 }), 413],
235
+ ["temporal", () => new RestApiTemporalDataError({ resourceType: "private_table", field: "private_column", fieldType: "date" }), 500],
236
+ ["custom-status", () => Object.assign(new Error("Custom error."), { status: 418 }), 418],
237
+ ["server-status", () => Object.assign(new Error("private server failure"), { statusCode: 503 }), 503],
238
+ ["invalid-status", () => Object.assign(new Error("private invalid status"), { statusCode: 200 }), 500]
239
+ ];
240
+
241
+ for (const jsonapi of [false, true]) {
242
+ for (const wrapped of [false, true]) {
243
+ const prefix = `/${jsonapi ? "jsonapi" : "plain"}/${wrapped ? "write" : "read"}`;
244
+ app.get(`${prefix}/:kind`, {
245
+ config: jsonapi ? { transport: { runtime: transport } } : {},
246
+ ...(jsonapi ? {} : { schema: { response: { "4xx": apiErrorTransportSchema, "5xx": apiErrorTransportSchema } } })
247
+ }, async (request) => {
248
+ const [, createError] = cases.find(([name]) => name === request.params.kind);
249
+ const cause = createError();
250
+ if (wrapped) {
251
+ throw new RestApiWriteError(cause.message, { cause, transactionOutcome: "rolledBack" });
252
+ }
253
+ throw cause;
254
+ });
255
+ }
256
+ }
257
+
258
+ for (const jsonapi of [false, true]) {
259
+ for (const wrapped of [false, true]) {
260
+ for (const [name, createError, expectedStatus] of cases) {
261
+ const url = `/${jsonapi ? "jsonapi" : "plain"}/${wrapped ? "write" : "read"}/${name}`;
262
+ const response = await app.inject({ method: "GET", url });
263
+ assert.equal(response.statusCode, expectedStatus, url);
264
+ const payload = response.json();
265
+ const clientError = createHttpError({ status: response.statusCode }, payload);
266
+ assert.equal(clientError.status, expectedStatus, url);
267
+ assert.equal(clientError.transactionOutcome, wrapped ? "rolledBack" : undefined, url);
268
+ assert.equal(clientError.message, expectedStatus >= 500 ? "Internal server error." : createError().message, url);
269
+ assert.equal(clientError.cause, undefined);
270
+ if (jsonapi) {
271
+ assert.equal(payload.errors[0].status, String(expectedStatus));
272
+ assert.match(response.headers["content-type"], /^application\/vnd\.api\+json/u);
273
+ }
274
+ if (expectedStatus >= 500) {
275
+ assert.doesNotMatch(response.body, /private/u);
276
+ }
277
+ }
278
+ }
279
+ }
280
+ });
281
+
282
+ test("real resource validation, permission hooks and completion failures retain HTTP semantics", async (t) => {
283
+ const app = Fastify();
284
+ t.after(() => app.close());
285
+ const knex = knexLib({ client: "better-sqlite3", connection: { filename: ":memory:" }, useNullAsDefault: true });
286
+ t.after(() => knex.destroy());
287
+ await knex.schema.createTable("books", (table) => {
288
+ table.increments("id");
289
+ table.string("title").notNullable();
290
+ });
291
+ const api = await createJsonRestApiHost({ knex, logger: { error() {} } });
292
+ await api.addResource("books", { schema: { title: { type: "string", required: true } } });
293
+ await api.customize({ hooks: {
294
+ checkPermissions({ context }) {
295
+ if ((context.originalContext ?? context).deny) {
296
+ throw new RestApiResourceError("Book access denied.", { subtype: "forbidden" });
297
+ }
298
+ },
299
+ afterCommit({ context }) {
300
+ if (context.failAfterCommit) {
301
+ throw new Error("private notification failure");
302
+ }
303
+ }
304
+ } });
305
+ registerApiErrorHandler(app, { isAppError });
306
+ const transport = createJsonApiResourceRouteTransport({ type: "books" });
307
+ for (const jsonapi of [false, true]) {
308
+ const prefix = `/${jsonapi ? "jsonapi" : "plain"}`;
309
+ const options = { config: jsonapi ? { transport: { runtime: transport } } : {} };
310
+ app.get(`${prefix}/denied`, options, async () => api.resources.books.query({}, { deny: true }));
311
+ app.post(`${prefix}/invalid`, options, async (request) => api.resources.books.post({ data: request.body }));
312
+ app.post(`${prefix}/committed`, options, async (request) => api.resources.books.post({ data: request.body }, { failAfterCommit: true }));
313
+ }
314
+
315
+ for (const jsonapi of [false, true]) {
316
+ const prefix = `/${jsonapi ? "jsonapi" : "plain"}`;
317
+ for (const [path, method, payload, status, code, outcome] of [
318
+ ["denied", "GET", undefined, 403, "REST_API_RESOURCE", undefined],
319
+ ["invalid", "POST", {}, 422, "REST_API_VALIDATION", "rolledBack"],
320
+ ["committed", "POST", { title: "Stored book" }, 500, "REST_API_WRITE", "committed"]
321
+ ]) {
322
+ const response = await app.inject({ method, url: `${prefix}/${path}`, ...(payload ? { payload } : {}) });
323
+ assert.equal(response.statusCode, status, response.body);
324
+ const clientError = createHttpError({ status: response.statusCode }, response.json());
325
+ assert.equal(clientError.status, status);
326
+ assert.equal(clientError.code, code);
327
+ assert.equal(clientError.transactionOutcome, outcome);
328
+ if (status === 500) {
329
+ assert.equal(clientError.message, "Internal server error.");
330
+ assert.doesNotMatch(response.body, /private/u);
331
+ }
332
+ }
333
+ }
334
+ assert.deepEqual(await knex("books").pluck("title"), ["Stored book", "Stored book"]);
335
+ });
336
+
337
+ test("error handler, JSON:API transport and client preserve outcomes without disclosing internal failures", () => {
338
+ const transport = createJsonApiResourceRouteTransport({ type: "books" });
339
+ const app = { log: { error() {} }, setErrorHandler(handler) { this.errorHandler = handler; } };
340
+ registerApiErrorHandler(app, { isAppError });
341
+
342
+ for (const transactionOutcome of ["none", "pending", "committed", "rolledBack", "unknown", "invalid", undefined]) {
343
+ const failure = Object.assign(new Error("Private SQL statement"), {
344
+ transactionOutcome,
345
+ code: "REST_API_WRITE",
346
+ fieldErrors: { secret: "Private SQL value" },
347
+ cause: { secret: "Private cause" },
348
+ cleanupErrors: [{ message: "Private cleanup" }]
349
+ });
350
+ for (const jsonapi of [false, true]) {
351
+ const reply = {
352
+ statusCode: 200,
353
+ headers: {},
354
+ code(value) { this.statusCode = value; return this; },
355
+ header(name, value) { this.headers[name] = value; return this; },
356
+ send(payload) { this.payload = payload; return this; }
357
+ };
358
+ app.errorHandler(failure, jsonapi ? { routeTransport: transport } : {}, reply);
359
+ assert.equal(reply.statusCode, 500);
360
+ const clientError = createHttpError({ status: reply.statusCode }, reply.payload);
361
+ assert.equal(clientError.message, "Internal server error.");
362
+ assert.equal(clientError.code, "REST_API_WRITE");
363
+ assert.equal(clientError.transactionOutcome, transactionOutcome === "invalid" ? undefined : transactionOutcome);
364
+ assert.equal(clientError.cause, undefined);
365
+ assert.equal(clientError.fieldErrors, null);
366
+ assert.equal(JSON.stringify(reply.payload).includes("Private"), false);
367
+ if (jsonapi) {
368
+ assert.equal(reply.headers["Content-Type"], JSON_API_CONTENT_TYPE);
369
+ assert.equal(reply.payload.errors[0].meta?.transactionOutcome, clientError.transactionOutcome);
370
+ }
371
+ }
372
+ }
373
+ });
374
+
375
+ test("JSON:API field errors retain outcomes and permission details remain private", () => {
376
+ const transport = createJsonApiResourceRouteTransport({ type: "books" });
377
+ const payload = transport.error({
378
+ message: "Validation failed.",
379
+ transactionOutcome: "rolledBack",
380
+ fieldErrors: { title: "Required", author: "Missing" }
381
+ }, { statusCode: 422 });
382
+ assert.equal(payload.errors.length, 2);
383
+ assert.ok(payload.errors.every((error) => error.meta.transactionOutcome === "rolledBack"));
384
+
385
+ const app = { log: { error() {} }, setErrorHandler(handler) { this.errorHandler = handler; } };
386
+ registerApiErrorHandler(app, { isAppError });
387
+ const reply = {
388
+ code() { return this; },
389
+ header() { return this; },
390
+ send(value) { this.payload = value; return this; }
391
+ };
392
+ app.errorHandler(new AppError(403, "Forbidden.", {
393
+ code: "ACTION_PERMISSION_DENIED",
394
+ details: { fieldErrors: { secret: "Required private permission" } }
395
+ }), { routeTransport: transport }, reply);
396
+ assert.deepEqual(reply.payload, {
397
+ errors: [{ status: "403", code: "ACTION_PERMISSION_DENIED", title: "Forbidden." }]
398
+ });
399
+ });
400
+
200
401
  test("createJsonApiResourceQueryTransportSchema and route transport map list query params to JSON:API", () => {
201
402
  const schema = createJsonApiResourceQueryTransportSchema({
202
403
  query: CONTACT_LIST_QUERY_SCHEMA,