@beignet/core 0.0.50 → 0.0.52

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 (102) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +99 -32
  3. package/dist/application/index.d.ts +5 -5
  4. package/dist/application/index.d.ts.map +1 -1
  5. package/dist/application/index.js +8 -4
  6. package/dist/application/index.js.map +1 -1
  7. package/dist/events/index.d.ts +26 -2
  8. package/dist/events/index.d.ts.map +1 -1
  9. package/dist/events/index.js +84 -7
  10. package/dist/events/index.js.map +1 -1
  11. package/dist/events/payload-state.d.ts +16 -0
  12. package/dist/events/payload-state.d.ts.map +1 -0
  13. package/dist/events/payload-state.js +123 -0
  14. package/dist/events/payload-state.js.map +1 -0
  15. package/dist/events/transport.d.ts +25 -0
  16. package/dist/events/transport.d.ts.map +1 -0
  17. package/dist/events/transport.js +192 -0
  18. package/dist/events/transport.js.map +1 -0
  19. package/dist/locks/index.d.ts.map +1 -1
  20. package/dist/locks/index.js +0 -4
  21. package/dist/locks/index.js.map +1 -1
  22. package/dist/openapi/index.d.ts.map +1 -1
  23. package/dist/openapi/index.js +27 -4
  24. package/dist/openapi/index.js.map +1 -1
  25. package/dist/outbox/index.d.ts +163 -15
  26. package/dist/outbox/index.d.ts.map +1 -1
  27. package/dist/outbox/index.js +1009 -155
  28. package/dist/outbox/index.js.map +1 -1
  29. package/dist/payments/index.d.ts.map +1 -1
  30. package/dist/payments/index.js +0 -4
  31. package/dist/payments/index.js.map +1 -1
  32. package/dist/ports/events.d.ts +4 -1
  33. package/dist/ports/events.d.ts.map +1 -1
  34. package/dist/ports/index.d.ts +1 -1
  35. package/dist/ports/index.d.ts.map +1 -1
  36. package/dist/ports/index.js.map +1 -1
  37. package/dist/ports/storage.d.ts +7 -0
  38. package/dist/ports/storage.d.ts.map +1 -1
  39. package/dist/ports/storage.js +4 -0
  40. package/dist/ports/storage.js.map +1 -1
  41. package/dist/ports/testing.d.ts +16 -2
  42. package/dist/ports/testing.d.ts.map +1 -1
  43. package/dist/ports/testing.js +19 -4
  44. package/dist/ports/testing.js.map +1 -1
  45. package/dist/ports/unit-of-work.d.ts +9 -7
  46. package/dist/ports/unit-of-work.d.ts.map +1 -1
  47. package/dist/ports/unit-of-work.js +16 -7
  48. package/dist/ports/unit-of-work.js.map +1 -1
  49. package/dist/providers/index.d.ts +1 -1
  50. package/dist/providers/index.d.ts.map +1 -1
  51. package/dist/providers/index.js.map +1 -1
  52. package/dist/providers/provider.d.ts +0 -38
  53. package/dist/providers/provider.d.ts.map +1 -1
  54. package/dist/providers/provider.js.map +1 -1
  55. package/dist/search/index.d.ts.map +1 -1
  56. package/dist/search/index.js +2 -6
  57. package/dist/search/index.js.map +1 -1
  58. package/dist/server/hooks/cors.d.ts +5 -0
  59. package/dist/server/hooks/cors.d.ts.map +1 -1
  60. package/dist/server/hooks/cors.js +29 -1
  61. package/dist/server/hooks/cors.js.map +1 -1
  62. package/dist/server/instrumentation.d.ts +5 -5
  63. package/dist/server/instrumentation.d.ts.map +1 -1
  64. package/dist/server/instrumentation.js +3 -4
  65. package/dist/server/instrumentation.js.map +1 -1
  66. package/dist/server/response-finalization.d.ts.map +1 -1
  67. package/dist/server/response-finalization.js +25 -13
  68. package/dist/server/response-finalization.js.map +1 -1
  69. package/dist/server/server.d.ts +8 -5
  70. package/dist/server/server.d.ts.map +1 -1
  71. package/dist/server/server.js +35 -12
  72. package/dist/server/server.js.map +1 -1
  73. package/dist/testing/index.d.ts.map +1 -1
  74. package/dist/testing/index.js +12 -6
  75. package/dist/testing/index.js.map +1 -1
  76. package/dist/uploads/index.d.ts.map +1 -1
  77. package/dist/uploads/index.js +37 -16
  78. package/dist/uploads/index.js.map +1 -1
  79. package/package.json +2 -2
  80. package/skills/app-architecture/SKILL.md +16 -0
  81. package/src/application/index.ts +39 -7
  82. package/src/events/index.ts +137 -7
  83. package/src/events/payload-state.ts +223 -0
  84. package/src/events/transport.ts +242 -0
  85. package/src/locks/index.ts +0 -4
  86. package/src/openapi/index.ts +41 -3
  87. package/src/outbox/index.ts +1393 -178
  88. package/src/payments/index.ts +0 -4
  89. package/src/ports/events.ts +4 -1
  90. package/src/ports/index.ts +3 -0
  91. package/src/ports/storage.ts +10 -0
  92. package/src/ports/testing.ts +42 -4
  93. package/src/ports/unit-of-work.ts +33 -15
  94. package/src/providers/index.ts +0 -1
  95. package/src/providers/provider.ts +0 -40
  96. package/src/search/index.ts +2 -6
  97. package/src/server/hooks/cors.ts +42 -0
  98. package/src/server/instrumentation.ts +10 -8
  99. package/src/server/response-finalization.ts +33 -15
  100. package/src/server/server.ts +61 -27
  101. package/src/testing/index.ts +20 -9
  102. package/src/uploads/index.ts +34 -16
@@ -624,10 +624,6 @@ export function createMemoryPaymentsProvider(
624
624
 
625
625
  return createProvider({
626
626
  name,
627
- metadata: {
628
- ports: ["payments"],
629
- watchers: ["payments"],
630
- },
631
627
  setup({ ports }) {
632
628
  const instrumentation = createProviderInstrumentation(ports, {
633
629
  providerName: name,
@@ -53,7 +53,10 @@ export type InferJobPayload<J extends JobDef> = InferContractJobPayload<J>;
53
53
  * An EventBus port for publishing and subscribing to domain events.
54
54
  *
55
55
  * This interface defines a framework-agnostic contract for event-driven
56
- * communication within your application.
56
+ * communication within your application. Implementations must prepare
57
+ * producer payloads with `prepareEventPayloadForTransport(...)` from
58
+ * `@beignet/core/events` so direct publication and provider swaps preserve the
59
+ * same canonical JSON semantics.
57
60
  *
58
61
  * @example
59
62
  * ```ts
@@ -283,6 +283,7 @@ export type {
283
283
  ClaimedOutboxMessage,
284
284
  OutboxAdminPort,
285
285
  OutboxClaimBatchOptions,
286
+ OutboxClaimBatchResult,
286
287
  OutboxCountMessagesOptions,
287
288
  OutboxDeleteResult,
288
289
  OutboxEnqueueInput,
@@ -296,6 +297,8 @@ export type {
296
297
  OutboxPort,
297
298
  OutboxPruneDeliveredInput,
298
299
  OutboxPurgeDeadLetteredInput,
300
+ OutboxRenewClaimInput,
301
+ OutboxRenewClaimResult,
299
302
  OutboxRequeueMessageInput,
300
303
  } from "../outbox/index.js";
301
304
  /**
@@ -82,6 +82,13 @@ export interface StorageObjectBody extends StorageObject {
82
82
  * buffering them.
83
83
  */
84
84
  readonly bodyUsed: boolean;
85
+ /**
86
+ * Discard an unread body and release any resources held by its provider.
87
+ *
88
+ * Calling this after consumption has started is a no-op. Callers that only
89
+ * inspect object metadata should cancel the body in a `finally` block.
90
+ */
91
+ cancel(reason?: unknown): Promise<void>;
85
92
  /**
86
93
  * Consume the object as a readable byte stream.
87
94
  */
@@ -376,6 +383,9 @@ function createObjectBody(entry: MemoryStorageEntry): StorageObjectBody {
376
383
  get bodyUsed() {
377
384
  return bodyUsed;
378
385
  },
386
+ async cancel() {
387
+ if (!bodyUsed) bodyUsed = true;
388
+ },
379
389
  stream() {
380
390
  return bytesToStream(consumeBytes());
381
391
  },
@@ -1,3 +1,4 @@
1
+ import { prepareEventPayloadForTransport } from "../events/index.js";
1
2
  import type {
2
3
  MemoryIdempotencyEntry,
3
4
  MemoryIdempotencyStore,
@@ -81,8 +82,9 @@ export interface RecordedEventExpectation {
81
82
  /**
82
83
  * Create a recording event bus for testing.
83
84
  *
84
- * This bus records all published events for later assertion,
85
- * but does not support subscription (throws if called).
85
+ * This bus validates canonical transport output and records published events
86
+ * asynchronously for later assertion. Await `publish(...)` before reading the
87
+ * captured log. Subscription is not supported and throws when called.
86
88
  *
87
89
  * @example
88
90
  * ```ts
@@ -104,8 +106,13 @@ export function createRecordingEventBus(): {
104
106
  const events: RecordedEvent[] = [];
105
107
 
106
108
  const bus: EventBusPort = {
107
- publish(event, payload) {
108
- events.push({ name: event.name, payload });
109
+ async publish(event, payload, options) {
110
+ const prepared = await prepareEventPayloadForTransport(
111
+ event,
112
+ payload,
113
+ options,
114
+ );
115
+ events.push({ name: event.name, payload: prepared.payload });
109
116
  },
110
117
  subscribe() {
111
118
  throw new Error("Not implemented for recording bus");
@@ -953,6 +960,19 @@ export interface OutboxDrainResultExpectation {
953
960
  * Expected dead-lettered count.
954
961
  */
955
962
  deadLettered?: number;
963
+ /**
964
+ * Expected count of eligible messages dead-lettered after exhausting their
965
+ * claim attempt budget.
966
+ */
967
+ abandonedDeadLettered?: number;
968
+ /**
969
+ * Expected count of messages whose durable settlement remained unknown.
970
+ */
971
+ settlementFailed?: number;
972
+ /**
973
+ * Expected count of messages whose active claim could not be confirmed.
974
+ */
975
+ leaseLost?: number;
956
976
  }
957
977
 
958
978
  /**
@@ -2421,6 +2441,24 @@ function outboxDrainResultMatches(
2421
2441
  ) {
2422
2442
  return false;
2423
2443
  }
2444
+ if (
2445
+ expectation.abandonedDeadLettered !== undefined &&
2446
+ result.abandonedDeadLettered !== expectation.abandonedDeadLettered
2447
+ ) {
2448
+ return false;
2449
+ }
2450
+ if (
2451
+ expectation.settlementFailed !== undefined &&
2452
+ result.settlementFailed !== expectation.settlementFailed
2453
+ ) {
2454
+ return false;
2455
+ }
2456
+ if (
2457
+ expectation.leaseLost !== undefined &&
2458
+ result.leaseLost !== expectation.leaseLost
2459
+ ) {
2460
+ return false;
2461
+ }
2424
2462
 
2425
2463
  return true;
2426
2464
  }
@@ -1,7 +1,8 @@
1
1
  import {
2
2
  type EventPublishOptions,
3
- parseEventPayload,
3
+ prepareEventPayloadForTransport,
4
4
  } from "../events/index.js";
5
+ import { isEventPayloadParsed } from "../events/payload-state.js";
5
6
  import type {
6
7
  DomainEventDef,
7
8
  EventBusPort,
@@ -83,17 +84,18 @@ export interface RecordedDomainEvent {
83
84
  /**
84
85
  * Event definition used to validate the payload before publishing.
85
86
  */
86
- event: DomainEventDef;
87
+ readonly event: DomainEventDef;
87
88
  /**
88
89
  * Stable event name.
89
90
  */
90
- eventName: string;
91
+ readonly eventName: string;
91
92
  /**
92
- * Unparsed payload recorded during the transaction.
93
+ * Recorded payload. Use-case helpers store canonical transport-stable schema
94
+ * output; direct recorder calls are validated when the buffer is flushed.
93
95
  */
94
- payload: unknown;
96
+ readonly payload: unknown;
95
97
  /** Optional metadata propagated when the event is flushed. */
96
- options?: EventPublishOptions;
98
+ readonly options?: EventPublishOptions;
97
99
  }
98
100
 
99
101
  /**
@@ -119,7 +121,7 @@ export interface DomainEventRecorderPort {
119
121
  */
120
122
  export interface BufferedDomainEventRecorder extends DomainEventRecorderPort {
121
123
  /**
122
- * Return recorded events without clearing them.
124
+ * Return a snapshot of recorded events without clearing them.
123
125
  */
124
126
  entries(): readonly RecordedDomainEvent[];
125
127
  /**
@@ -127,7 +129,8 @@ export interface BufferedDomainEventRecorder extends DomainEventRecorderPort {
127
129
  */
128
130
  clear(): void;
129
131
  /**
130
- * Validate and publish all recorded events to an event bus in FIFO order.
132
+ * Publish recorded events in FIFO order, proving transport stability for
133
+ * entries recorded directly without the use-case event helper.
131
134
  */
132
135
  flush(eventBus: EventBusPort): Promise<void>;
133
136
  }
@@ -223,19 +226,30 @@ export function createObservedUnitOfWork<TxPorts>(
223
226
  */
224
227
  export function createDomainEventRecorder(): BufferedDomainEventRecorder {
225
228
  const records: RecordedDomainEvent[] = [];
229
+ const validationOptions = new WeakMap<
230
+ RecordedDomainEvent,
231
+ EventPublishOptions
232
+ >();
226
233
 
227
234
  return {
228
235
  record(event, payload, options) {
229
- records.push({
236
+ const record: RecordedDomainEvent = {
230
237
  event,
231
238
  eventName: event.name,
232
239
  payload,
233
- ...(options ? { options } : {}),
234
- });
240
+ ...(options?.trace ? { options: { trace: options.trace } } : {}),
241
+ };
242
+ if (isEventPayloadParsed(event, payload, options) && options) {
243
+ validationOptions.set(record, { ...options });
244
+ }
245
+ records.push(record);
235
246
  },
236
247
 
237
248
  entries() {
238
- return records;
249
+ return records.map((record) => ({
250
+ ...record,
251
+ ...(record.options ? { options: { ...record.options } } : {}),
252
+ }));
239
253
  },
240
254
 
241
255
  clear() {
@@ -245,11 +259,15 @@ export function createDomainEventRecorder(): BufferedDomainEventRecorder {
245
259
  async flush(eventBus) {
246
260
  while (records.length > 0) {
247
261
  const record = records[0];
248
- await parseEventPayload(record.event, record.payload);
262
+ const prepared = await prepareEventPayloadForTransport(
263
+ record.event,
264
+ record.payload,
265
+ validationOptions.get(record) ?? record.options,
266
+ );
249
267
  await eventBus.publish(
250
268
  record.event,
251
- record.payload as never,
252
- record.options,
269
+ prepared.payload as never,
270
+ prepared.publishOptions,
253
271
  );
254
272
  records.shift();
255
273
  }
@@ -51,5 +51,4 @@ export {
51
51
  type ProviderServiceContextFactory,
52
52
  type ProviderSetupResult,
53
53
  type ServiceProvider,
54
- type ServiceProviderMetadata,
55
54
  } from "./provider.js";
@@ -126,41 +126,6 @@ export type ProviderSetupResult<
126
126
  ): MaybePromise<void>;
127
127
  };
128
128
 
129
- /**
130
- * Static provider metadata used by docs and app-local tooling.
131
- *
132
- * Metadata is descriptive. It does not change provider setup, ordering, or
133
- * runtime port merging behavior.
134
- *
135
- * Reusable provider packages should also declare package-owned
136
- * `beignet.provider` metadata in package.json so external tooling can inspect
137
- * provider facts without importing runtime code.
138
- */
139
- export interface ServiceProviderMetadata {
140
- /**
141
- * Package that exports this provider, when it comes from a reusable package.
142
- */
143
- packageName?: string;
144
- /**
145
- * App port keys this provider contributes or replaces.
146
- */
147
- ports?: readonly string[];
148
- /**
149
- * App port keys this provider expects previous providers or base app ports to
150
- * have installed before setup runs.
151
- */
152
- requires?: readonly string[];
153
- /**
154
- * Environment variables this provider reads directly or via config loading.
155
- */
156
- env?: readonly string[];
157
- /**
158
- * Devtools watcher names this provider can emit through provider
159
- * instrumentation.
160
- */
161
- watchers?: readonly string[];
162
- }
163
-
164
129
  /**
165
130
  * A service provider that can extend or replace ports during app initialization.
166
131
  *
@@ -205,11 +170,6 @@ export interface ServiceProvider<
205
170
  */
206
171
  name: string;
207
172
 
208
- /**
209
- * Optional static metadata for docs and diagnostics.
210
- */
211
- metadata?: ServiceProviderMetadata;
212
-
213
173
  /**
214
174
  * Optional configuration definition.
215
175
  * If provided, the config will be loaded and validated before calling setup.
@@ -351,10 +351,6 @@ export function createMemorySearchProvider(
351
351
 
352
352
  return createProvider({
353
353
  name,
354
- metadata: {
355
- ports: ["search"],
356
- watchers: ["search"],
357
- },
358
354
  setup({ ports }) {
359
355
  const instrumentation = createProviderInstrumentation(ports, {
360
356
  providerName: name,
@@ -439,10 +435,10 @@ function instrumentSearch(
439
435
  instrumentation.custom({
440
436
  name: "search.query",
441
437
  label: "Search query",
442
- summary: `${index.name}: ${result.query}`,
438
+ summary: `${index.name}: ${result.hits.length} hits`,
443
439
  details: {
444
440
  index: index.name,
445
- query: result.query,
441
+ queryLength: result.query.length,
446
442
  hits: result.hits.length,
447
443
  total: result.page.total,
448
444
  durationMs: Date.now() - startedAt,
@@ -2,6 +2,7 @@
2
2
  * CORS hook utilities for @beignet/core/server
3
3
  */
4
4
 
5
+ import { BEIGNET_ERROR_OWNER_HEADER } from "../../contracts/types.js";
5
6
  import type {
6
7
  HttpRequestLike,
7
8
  HttpResponseHeaders,
@@ -25,6 +26,11 @@ export interface CorsConfig {
25
26
  * Allowed request headers.
26
27
  */
27
28
  headers?: string[];
29
+ /**
30
+ * Additional response headers browser JavaScript may read. Beignet always
31
+ * exposes its framework error-ownership header.
32
+ */
33
+ exposedHeaders?: string[];
28
34
  /**
29
35
  * Whether credentialed requests are allowed.
30
36
  */
@@ -35,6 +41,7 @@ const DEFAULT_CORS: Required<CorsConfig> = {
35
41
  origins: "*",
36
42
  methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
37
43
  headers: ["Content-Type", "Authorization"],
44
+ exposedHeaders: [],
38
45
  credentials: false,
39
46
  };
40
47
 
@@ -43,6 +50,7 @@ function resolveCorsConfig(config: CorsConfig): Required<CorsConfig> {
43
50
  origins: config.origins ?? DEFAULT_CORS.origins,
44
51
  methods: config.methods ?? DEFAULT_CORS.methods,
45
52
  headers: config.headers ?? DEFAULT_CORS.headers,
53
+ exposedHeaders: config.exposedHeaders ?? DEFAULT_CORS.exposedHeaders,
46
54
  credentials: config.credentials ?? DEFAULT_CORS.credentials,
47
55
  };
48
56
 
@@ -71,6 +79,35 @@ function appendVaryOrigin(headers: HttpResponseHeaders): void {
71
79
  headers[varyKey] = current ? `${current}, Origin` : "Origin";
72
80
  }
73
81
 
82
+ function appendCommaSeparatedHeader(
83
+ headers: HttpResponseHeaders,
84
+ name: string,
85
+ appended: readonly string[],
86
+ ): void {
87
+ const key =
88
+ Object.keys(headers).find(
89
+ (candidate) => candidate.toLowerCase() === name.toLowerCase(),
90
+ ) ?? name;
91
+ const existing = headers[key];
92
+ const values = [
93
+ ...(typeof existing === "string"
94
+ ? existing.split(",")
95
+ : (existing ?? []).flatMap((value) => value.split(","))),
96
+ ...appended,
97
+ ]
98
+ .map((value) => value.trim())
99
+ .filter(Boolean);
100
+ const seen = new Set<string>();
101
+ const unique = values.filter((value) => {
102
+ const normalized = value.toLowerCase();
103
+ if (seen.has(normalized)) return false;
104
+ seen.add(normalized);
105
+ return true;
106
+ });
107
+
108
+ headers[key] = unique.join(", ");
109
+ }
110
+
74
111
  /**
75
112
  * Apply CORS response headers to a mutable header record.
76
113
  *
@@ -86,6 +123,7 @@ export function applyCorsHeaders(
86
123
  origins,
87
124
  methods,
88
125
  headers: allowedHeaders,
126
+ exposedHeaders,
89
127
  credentials,
90
128
  } = resolveCorsConfig(corsConfig);
91
129
 
@@ -101,6 +139,10 @@ export function applyCorsHeaders(
101
139
 
102
140
  headers["Access-Control-Allow-Methods"] = methods.join(", ");
103
141
  headers["Access-Control-Allow-Headers"] = allowedHeaders.join(", ");
142
+ appendCommaSeparatedHeader(headers, "Access-Control-Expose-Headers", [
143
+ BEIGNET_ERROR_OWNER_HEADER,
144
+ ...exposedHeaders,
145
+ ]);
104
146
 
105
147
  if (credentials) {
106
148
  headers["Access-Control-Allow-Credentials"] = "true";
@@ -61,8 +61,8 @@ export interface ServerInstrumentationOptions<Ctx = unknown> {
61
61
  traceContextHeader?: string | false;
62
62
 
63
63
  /**
64
- * Request path prefixes that should not enter ambient correlation or record
65
- * events. Response headers are still written.
64
+ * Request path prefixes that should not record instrumentation events.
65
+ * Ambient correlation still runs, and enabled response headers are written.
66
66
  *
67
67
  * Defaults to the devtools dashboard prefix so its polling traffic does not
68
68
  * fill the event timeline.
@@ -125,10 +125,10 @@ export interface ServerInstrumentationRuntime<Ctx> {
125
125
  */
126
126
  createServiceCorrelation(): RequestCorrelation;
127
127
  /**
128
- * Pipeline hook installed before user hooks, when instrumentation is
129
- * enabled.
128
+ * Pipeline hook installed before user hooks. It always owns ambient
129
+ * correlation; response headers and event recording remain configurable.
130
130
  */
131
- hook?: ServerHook<Ctx, AnyPorts>;
131
+ hook: ServerHook<Ctx, AnyPorts>;
132
132
  }
133
133
 
134
134
  type TraceContextFields = {
@@ -350,7 +350,6 @@ export function createServerInstrumentation<Ctx>(
350
350
  req: HttpRequestLike;
351
351
  ctx?: unknown;
352
352
  }) => {
353
- if (isIgnoredPath(getPathname(args.req), ignorePaths)) return;
354
353
  const trace = resolveTraceContext(args);
355
354
  enterActiveRequestContext({
356
355
  requestId: resolveRequestId(args),
@@ -377,7 +376,10 @@ export function createServerInstrumentation<Ctx>(
377
376
  return undefined;
378
377
  },
379
378
  beforeSend: ({ req, ctx, response }) => {
380
- if (requestIdHeader === false && traceContextHeader === false) {
379
+ if (
380
+ !enabled ||
381
+ (requestIdHeader === false && traceContextHeader === false)
382
+ ) {
381
383
  return undefined;
382
384
  }
383
385
 
@@ -502,6 +504,6 @@ export function createServerInstrumentation<Ctx>(
502
504
  requestId: createRequestId(),
503
505
  trace: tracing?.current() ?? createTraceContext(),
504
506
  }),
505
- hook: enabled ? hook : undefined,
507
+ hook,
506
508
  };
507
509
  }
@@ -312,18 +312,18 @@ function getDeclaredCatalogErrorsForStatus(
312
312
  );
313
313
  }
314
314
 
315
- async function validateCatalogErrorResponse<C extends HttpContractConfig>(
315
+ async function parseCatalogErrorResponse<C extends HttpContractConfig>(
316
316
  contract: C,
317
317
  res: HttpResponseLike,
318
- ): Promise<void> {
318
+ ): Promise<HttpResponseLike> {
319
319
  const body = res.body;
320
- if (res.status < 400 || !isErrorResponseBody(body)) return;
320
+ if (res.status < 400 || !isErrorResponseBody(body)) return res;
321
321
 
322
322
  const declaredErrors = getDeclaredCatalogErrorsForStatus(
323
323
  contract,
324
324
  res.status,
325
325
  );
326
- if (declaredErrors.length === 0) return;
326
+ if (declaredErrors.length === 0) return res;
327
327
 
328
328
  const matchingError = declaredErrors.find(
329
329
  (error) => error.code === body.code,
@@ -346,7 +346,18 @@ async function validateCatalogErrorResponse<C extends HttpContractConfig>(
346
346
 
347
347
  if (matchingError.details && body.details !== undefined) {
348
348
  try {
349
- await parseStandardSchema(matchingError.details, body.details);
349
+ const parsedDetails = await parseStandardSchema(
350
+ matchingError.details,
351
+ body.details,
352
+ );
353
+ const { details: _details, ...bodyWithoutDetails } = body;
354
+ return {
355
+ ...res,
356
+ body:
357
+ parsedDetails === undefined
358
+ ? bodyWithoutDetails
359
+ : { ...bodyWithoutDetails, details: parsedDetails },
360
+ };
350
361
  } catch (error) {
351
362
  if (error instanceof SchemaValidationError) {
352
363
  throw new ResponseContractViolationError({
@@ -360,18 +371,20 @@ async function validateCatalogErrorResponse<C extends HttpContractConfig>(
360
371
  throw error;
361
372
  }
362
373
  }
374
+
375
+ return res;
363
376
  }
364
377
 
365
- async function validateResponseAgainstContract<C extends HttpContractConfig>(
378
+ async function parseResponseAgainstContract<C extends HttpContractConfig>(
366
379
  contract: C,
367
380
  res: HttpResponseLike,
368
381
  responseValidationExemptStatus?: number,
369
- ): Promise<void> {
382
+ ): Promise<HttpResponseLike> {
370
383
  const statusKey = String(res.status);
371
384
  const hasDeclaredStatus = Object.hasOwn(contract.responses, statusKey);
372
385
 
373
386
  if (!hasDeclaredStatus) {
374
- if (Object.keys(contract.responses).length === 0) return;
387
+ if (Object.keys(contract.responses).length === 0) return res;
375
388
 
376
389
  throw new ResponseContractViolationError({
377
390
  code: "UNDECLARED_RESPONSE_STATUS",
@@ -400,19 +413,22 @@ async function validateResponseAgainstContract<C extends HttpContractConfig>(
400
413
  }),
401
414
  });
402
415
  }
403
- return;
416
+ return res;
404
417
  }
405
418
 
406
- if (!responseSchema) return;
419
+ if (!responseSchema) return res;
407
420
 
408
421
  // Binder routes whose use case output schema is the same object as the
409
422
  // declared success response schema skip the redundant success-status parse.
410
423
  // Error statuses and undeclared statuses are validated unchanged.
411
- if (res.status === responseValidationExemptStatus) return;
424
+ if (res.status === responseValidationExemptStatus) return res;
412
425
 
413
426
  try {
414
- await parseStandardSchema(responseSchema, res.body);
415
- await validateCatalogErrorResponse(contract, res);
427
+ const parsed = {
428
+ ...res,
429
+ body: await parseStandardSchema(responseSchema, res.body),
430
+ };
431
+ return await parseCatalogErrorResponse(contract, parsed);
416
432
  } catch (error) {
417
433
  if (error instanceof SchemaValidationError) {
418
434
  throw new ResponseContractViolationError({
@@ -463,7 +479,7 @@ export async function finalizeResponse<C extends HttpContractConfig>(
463
479
  const normalized = normalizeResponse(res);
464
480
  validateHttpResponseSemantics(contract, normalized);
465
481
  if (options.validateContract ?? true) {
466
- await validateResponseAgainstContract(
482
+ return parseResponseAgainstContract(
467
483
  contract,
468
484
  normalized,
469
485
  responseValidationExemptStatus,
@@ -490,6 +506,8 @@ export function defaultErrorResponse(
490
506
  ctx?: unknown,
491
507
  ): HttpResponseLike {
492
508
  const requestId = getRequestIdFromContext(ctx);
509
+ const exposeErrorDetails =
510
+ process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test";
493
511
  return {
494
512
  status: 500,
495
513
  body: createErrorResponseBody({
@@ -497,7 +515,7 @@ export function defaultErrorResponse(
497
515
  message: "Internal server error",
498
516
  requestId,
499
517
  details:
500
- process.env.NODE_ENV !== "production" && err instanceof Error
518
+ exposeErrorDetails && err instanceof Error
501
519
  ? {
502
520
  error: {
503
521
  message: err.message,