@mettlecast/domain-runtime 0.2.60 → 0.2.61

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.
@@ -16,8 +16,7 @@ export type BackendAccess = 'private' | 'domain' | 'platform';
16
16
  */
17
17
  export type ApiAuthMode = 'required' | 'none' | 'service';
18
18
  /**
19
- * Tenancy posture for an API-exposed action. Mirrors `defineApi`'s contract
20
- * so the same registry row can describe an action or a standalone API.
19
+ * Tenancy posture for an API-exposed action.
21
20
  */
22
21
  export type ApiTenancyMode = 'required' | 'none' | 'system';
23
22
  /**
@@ -37,8 +36,8 @@ export interface InternalExposure {
37
36
  type: 'internal';
38
37
  }
39
38
  /**
40
- * Action exposed as an HTTP endpoint. The shape mirrors `defineApi` so the
41
- * downstream CDK/registry pipeline can treat both primitives uniformly.
39
+ * Action exposed as an HTTP endpoint. The downstream CDK/registry
40
+ * pipeline generates API Gateway routes from this shape.
42
41
  */
43
42
  export interface ApiExposure {
44
43
  type: 'api';
@@ -62,12 +62,13 @@ const ActionExposureSchema = z.discriminatedUnion('type', [
62
62
  InternalExposureSchema,
63
63
  ApiExposureSchema,
64
64
  ]);
65
+ const ZodSchemaRefinement = z.custom((v) => typeof v === 'object' && v !== null && typeof v.parse === 'function', { message: 'input/output must be Zod schema objects' });
65
66
  const ActionConfigSchema = z.object({
66
67
  id: z.string().min(1),
67
68
  backendAccess: z.nativeEnum(BackendAccessLiteral),
68
69
  exposure: ActionExposureSchema,
69
- input: z.unknown(),
70
- output: z.unknown(),
70
+ input: ZodSchemaRefinement,
71
+ output: ZodSchemaRefinement,
71
72
  idempotent: z.boolean().optional(),
72
73
  allowedCallers: z.array(z.string().min(1)).optional(),
73
74
  outboundAccess: z.enum(['internal', 'internet']).default('internal'),
@@ -1,5 +1,4 @@
1
1
  export * from './domain.js';
2
- export * from './api.js';
3
2
  export * from './webhook.js';
4
3
  export * from './define-outbound-webhook.js';
5
4
  export * from './events.js';
@@ -1,5 +1,4 @@
1
1
  export * from './domain.js';
2
- export * from './api.js';
3
2
  export * from './webhook.js';
4
3
  export * from './define-outbound-webhook.js';
5
4
  export * from './events.js';
@@ -8,14 +8,13 @@
8
8
  * (validation, auth/tenancy, idempotency, output validation, audit) to
9
9
  * `executeAction`.
10
10
  *
11
- * Mirrors the structure of `createApiLambdaHandler` (api-handler.ts) so the
12
- * two adapters stay visually parallel but trades versioning/sunset/rate
13
- * limiting for action-specific concerns (exposure precondition, actor role
14
- * claims, output-as-action semantics).
11
+ * This is the canonical HTTP adapter now that public endpoints are declared
12
+ * as API-exposed actions. It keeps HTTP concerns local while action-specific
13
+ * validation, auth/tenancy, idempotency, and output checks stay in
14
+ * `executeAction`.
15
15
  *
16
- * DB lifecycle: the adapter owns `ctx.db.release()` in a `finally` block, just
17
- * like `createApiLambdaHandler`. `executeAction` does not auto-release by
18
- * default, so there is no double-release.
16
+ * DB lifecycle: the adapter owns `ctx.db.release()` in a `finally` block.
17
+ * `executeAction` does not auto-release by default, so there is no double-release.
19
18
  */
20
19
  import type { APIGatewayProxyEventV2WithJWTAuthorizer, APIGatewayProxyStructuredResultV2 } from 'aws-lambda';
21
20
  import type { ActionDefinition } from '../primitives/action.js';
@@ -8,14 +8,13 @@
8
8
  * (validation, auth/tenancy, idempotency, output validation, audit) to
9
9
  * `executeAction`.
10
10
  *
11
- * Mirrors the structure of `createApiLambdaHandler` (api-handler.ts) so the
12
- * two adapters stay visually parallel but trades versioning/sunset/rate
13
- * limiting for action-specific concerns (exposure precondition, actor role
14
- * claims, output-as-action semantics).
11
+ * This is the canonical HTTP adapter now that public endpoints are declared
12
+ * as API-exposed actions. It keeps HTTP concerns local while action-specific
13
+ * validation, auth/tenancy, idempotency, and output checks stay in
14
+ * `executeAction`.
15
15
  *
16
- * DB lifecycle: the adapter owns `ctx.db.release()` in a `finally` block, just
17
- * like `createApiLambdaHandler`. `executeAction` does not auto-release by
18
- * default, so there is no double-release.
16
+ * DB lifecycle: the adapter owns `ctx.db.release()` in a `finally` block.
17
+ * `executeAction` does not auto-release by default, so there is no double-release.
19
18
  */
20
19
  import { hydrateCtx } from './hydrate.js';
21
20
  import { extractActorFromApiGatewayEvent, extractTenantFromApiGatewayEvent, } from './extractors.js';
@@ -192,8 +191,8 @@ export function createExposedActionApiHandler(action, options = {}) {
192
191
  // always reach it (it may be unset if hydration itself throws).
193
192
  let ctx;
194
193
  try {
195
- // 2. Actor / tenant extraction — uses the same helpers as
196
- // createApiLambdaHandler so JWT/path behaviour stays uniform.
194
+ // 2. Actor / tenant extraction — use shared helpers so JWT/path
195
+ // behaviour stays uniform across HTTP adapters.
197
196
  const actor = extractActorFromApiGatewayEvent(event);
198
197
  const tenant = extractTenantFromApiGatewayEvent(event);
199
198
  // 3. Input parsing.
@@ -47,8 +47,6 @@ export { eventVersionMatches } from './event-version-matcher.js';
47
47
  export { extractActorFromApiGatewayEvent, extractTenantFromApiGatewayEvent } from './extractors.js';
48
48
  export { parseQuery, encodeCursor, decodeCursor, DEFAULT_LIMIT, MAX_LIMIT } from './query-parser.js';
49
49
  export type { ParsedQuery } from './query-parser.js';
50
- export { createApiLambdaHandler } from './api-handler.js';
51
- export type { CreateApiHandlerOptions } from './api-handler.js';
52
50
  export { createExposedActionApiHandler } from './exposed-action-api-handler.js';
53
51
  export type { CreateExposedActionHandlerOptions } from './exposed-action-api-handler.js';
54
52
  export { createActionLambdaHandler } from './action-handler.js';
@@ -53,7 +53,6 @@ export { extractActorFromApiGatewayEvent, extractTenantFromApiGatewayEvent } fro
53
53
  // Query parsing
54
54
  export { parseQuery, encodeCursor, decodeCursor, DEFAULT_LIMIT, MAX_LIMIT } from './query-parser.js';
55
55
  // Lambda handler wrappers
56
- export { createApiLambdaHandler } from './api-handler.js';
57
56
  export { createExposedActionApiHandler } from './exposed-action-api-handler.js';
58
57
  export { createActionLambdaHandler } from './action-handler.js';
59
58
  export { createSubscriberLambdaHandler } from './subscriber-handler.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mettlecast/domain-runtime",
3
- "version": "0.2.60",
3
+ "version": "0.2.61",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",
@@ -1,44 +0,0 @@
1
- import type { AuthConfig, RateLimitConfig, TenancyMode, DeploymentConfig, ObservabilityConfig, ApiVersion, OutboundAccess } from '../types/index.js';
2
- /** Configuration for an API primitive. */
3
- export interface ApiConfig {
4
- /** Machine-readable API identifier (kebab-case). */
5
- id: string;
6
- /** HTTP route path relative to domain base, e.g. '/users/:id'. */
7
- path: string;
8
- /** HTTP method. Defaults to ANY. */
9
- method?: string;
10
- /** Tenancy requirement for this API. */
11
- tenancy: TenancyMode;
12
- /**
13
- * Version map. Keys are version strings (e.g. 'v1', 'v2').
14
- * At least one version is required.
15
- */
16
- versions: Record<string, ApiVersion<any, any>>;
17
- /** Authentication configuration. Defaults to jwt. */
18
- auth?: AuthConfig;
19
- /** Rate limiting configuration. */
20
- rateLimit?: RateLimitConfig;
21
- /** Deployment overrides. */
22
- deployment?: DeploymentConfig;
23
- /** Whether this handler may use NAT-backed public internet egress. Defaults to internal. */
24
- outboundAccess?: OutboundAccess;
25
- /** Observability thresholds. */
26
- observability?: ObservabilityConfig;
27
- /** Example request/response payloads for documentation (no runtime impact). */
28
- examples?: {
29
- request?: Record<string, unknown>;
30
- response?: Record<string, unknown>;
31
- };
32
- }
33
- /** Return type of defineApi. */
34
- export interface ApiDefinition extends ApiConfig {
35
- /** Discriminant. */
36
- _kind: 'api';
37
- }
38
- /**
39
- * Register an API primitive with versioned handlers.
40
- * Validates that deprecated versions have a sunset date and that input/output
41
- * schemas are wrapped with `.default(...)` (the Zod v4 example-data pattern).
42
- * @throws {ZodError} if config is invalid
43
- */
44
- export declare function defineApi(config: ApiConfig): ApiDefinition;
@@ -1,70 +0,0 @@
1
- import { z } from 'zod';
2
- // Native enum objects — Zod v4's recommended pattern over `z.enum([...])`.
3
- // Each `as const` object becomes the source of truth; `z.nativeEnum(...)`
4
- // derives the inferred string-literal union from the keys.
5
- const TenancyModeLiteral = { required: 'required', none: 'none', system: 'system' };
6
- const VersionStatusLiteral = { preview: 'preview', stable: 'stable', deprecated: 'deprecated', sunset: 'sunset' };
7
- const AuthTypeLiteral = { jwt: 'jwt', 'api-key': 'api-key', none: 'none' };
8
- const DeploymentTargetLiteral = { single: 'single', split: 'split' };
9
- const OutboundAccessLiteral = { internal: 'internal', internet: 'internet' };
10
- /**
11
- * Returns true when a Zod schema has been wrapped with `.default(...)` (Zod v4
12
- * pattern). The wrapped schema exposes a `_def.typeName` of `ZodDefault`.
13
- * `defineApi` requires every version's `input` and `output` to be wrapped this
14
- * way so that the runtime has a concrete example payload for documentation and
15
- * schema-publish tooling.
16
- */
17
- const hasDefault = (schema) => {
18
- if (typeof schema !== 'object' || schema === null)
19
- return false;
20
- const def = schema._def;
21
- if (!def)
22
- return false;
23
- if (def.typeName === 'ZodDefault' || def.type === 'default')
24
- return true;
25
- // Unwrap ZodOptional / ZodNullable — e.g. z.object({}).default({}).optional()
26
- if (def.type === 'optional' || def.type === 'nullable') {
27
- return hasDefault(schema._def.innerType);
28
- }
29
- return false;
30
- };
31
- const ApiConfigSchema = z.object({
32
- id: z.string().min(1),
33
- path: z.string().startsWith('/'),
34
- method: z.string().optional(),
35
- tenancy: z.nativeEnum(TenancyModeLiteral),
36
- versions: z.record(z.string(), z.object({
37
- status: z.nativeEnum(VersionStatusLiteral),
38
- sunset: z.string().optional(),
39
- input: z.unknown().refine(hasDefault, { message: 'input schema must have .default()' }),
40
- output: z.unknown().refine(hasDefault, { message: 'output schema must have .default()' }),
41
- handler: z.function(),
42
- }).refine(v => v.status !== VersionStatusLiteral.deprecated || v.sunset !== undefined, {
43
- message: 'sunset date is required when status is deprecated',
44
- })),
45
- auth: z.object({
46
- type: z.nativeEnum(AuthTypeLiteral),
47
- roles: z.array(z.string()).optional(),
48
- }).optional(),
49
- rateLimit: z.object({
50
- perTenant: z.string().optional(),
51
- perApiKey: z.string().optional(),
52
- perIp: z.string().optional(),
53
- }).optional(),
54
- deployment: z.object({ target: z.nativeEnum(DeploymentTargetLiteral) }).passthrough().optional(),
55
- outboundAccess: z.nativeEnum(OutboundAccessLiteral).default(OutboundAccessLiteral.internal),
56
- observability: z.object({
57
- sloP99Ms: z.number().positive().optional(),
58
- alertOnErrorRate: z.number().min(0).max(1).optional(),
59
- }).optional(),
60
- }).refine(c => Object.keys(c.versions).length > 0, { message: 'at least one version required' });
61
- /**
62
- * Register an API primitive with versioned handlers.
63
- * Validates that deprecated versions have a sunset date and that input/output
64
- * schemas are wrapped with `.default(...)` (the Zod v4 example-data pattern).
65
- * @throws {ZodError} if config is invalid
66
- */
67
- export function defineApi(config) {
68
- ApiConfigSchema.parse(config);
69
- return { ...config, outboundAccess: config.outboundAccess ?? 'internal', _kind: 'api' };
70
- }
@@ -1,29 +0,0 @@
1
- import type { APIGatewayProxyEventV2WithJWTAuthorizer, APIGatewayProxyStructuredResultV2 } from 'aws-lambda';
2
- import type { ApiDefinition } from '../primitives/api.js';
3
- /**
4
- * Options for createApiLambdaHandler.
5
- */
6
- export interface CreateApiHandlerOptions {
7
- /** Default version when no Accept-Version header. Defaults to highest stable. */
8
- defaultVersion?: number;
9
- }
10
- /**
11
- * Wrap an ApiDefinition into an AWS Lambda HTTP API v2 handler with full
12
- * version negotiation, sunset enforcement, validation, ctx hydration, tenancy
13
- * guard, and error formatting.
14
- *
15
- * The handler performs the following steps in order:
16
- * 1. Version negotiation from Accept-Version or Accept headers
17
- * 2. Sunset check and response header decoration
18
- * 3. Tenancy validation (if required)
19
- * 4. Input validation via Zod schema
20
- * 5. Context hydration with actor, tenant, DB, secrets, etc.
21
- * 6. Handler invocation with typed input and ctx
22
- * 7. Output validation via Zod schema
23
- * 8. DB connection release
24
- *
25
- * @param api - ApiDefinition with versioned handlers.
26
- * @param options - Options for version negotiation and defaults.
27
- * @returns AWS Lambda HTTP API v2 handler function.
28
- */
29
- export declare function createApiLambdaHandler(api: ApiDefinition, options?: CreateApiHandlerOptions): (event: APIGatewayProxyEventV2WithJWTAuthorizer) => Promise<APIGatewayProxyStructuredResultV2>;
@@ -1,212 +0,0 @@
1
- import { negotiateVersion } from './version-negotiator.js';
2
- import { checkSunset, applySunsetHeaders } from './sunset-guard.js';
3
- import { hydrateCtx } from './hydrate.js';
4
- import { extractActorFromApiGatewayEvent, extractTenantFromApiGatewayEvent } from './extractors.js';
5
- import { formatError, extractTraceId, TibError } from './error-formatter.js';
6
- import { createMetrics } from './metrics.js';
7
- import { checkRateLimit } from './rate-limiter.js';
8
- /**
9
- * Wrap an ApiDefinition into an AWS Lambda HTTP API v2 handler with full
10
- * version negotiation, sunset enforcement, validation, ctx hydration, tenancy
11
- * guard, and error formatting.
12
- *
13
- * The handler performs the following steps in order:
14
- * 1. Version negotiation from Accept-Version or Accept headers
15
- * 2. Sunset check and response header decoration
16
- * 3. Tenancy validation (if required)
17
- * 4. Input validation via Zod schema
18
- * 5. Context hydration with actor, tenant, DB, secrets, etc.
19
- * 6. Handler invocation with typed input and ctx
20
- * 7. Output validation via Zod schema
21
- * 8. DB connection release
22
- *
23
- * @param api - ApiDefinition with versioned handlers.
24
- * @param options - Options for version negotiation and defaults.
25
- * @returns AWS Lambda HTTP API v2 handler function.
26
- */
27
- export function createApiLambdaHandler(api, options) {
28
- return async (event) => {
29
- const responseHeaders = { 'Content-Type': 'application/json' };
30
- // G7: Extract W3C traceparent header and propagate as traceId
31
- const traceparent = event.headers?.['traceparent'];
32
- const traceId = traceparent ?? extractTraceId(event.headers ?? {});
33
- // G6: Initialize EMF metrics (TODO: extract domainId from api definition or context)
34
- const metrics = createMetrics({
35
- domainId: api.id, // TODO: domainId should come from api definition or deployment context
36
- endpointId: api.id,
37
- primitiveClass: 'api',
38
- // tenantId will be added after tenant is extracted
39
- });
40
- const requestStart = Date.now();
41
- metrics.recordColdStart();
42
- try {
43
- // 1. Version negotiation
44
- const versionKeys = Object.keys(api.versions).sort();
45
- const stableVersions = versionKeys.filter(k => api.versions[k].status === 'stable');
46
- const defaultVersionKey = options?.defaultVersion !== undefined
47
- ? `v${options.defaultVersion}`
48
- : (stableVersions[stableVersions.length - 1] ?? versionKeys[versionKeys.length - 1] ?? 'v1');
49
- const defaultVersionNum = parseInt(defaultVersionKey.replace(/^v/, ''), 10);
50
- const negotiation = negotiateVersion(event.headers ?? {}, isNaN(defaultVersionNum) ? 1 : defaultVersionNum);
51
- const versionKey = `v${negotiation.requestedVersion}`;
52
- const apiVersion = api.versions[versionKey];
53
- if (!apiVersion) {
54
- // G1: Use standard error envelope for unsupported version
55
- return {
56
- statusCode: 406,
57
- headers: responseHeaders,
58
- body: JSON.stringify(formatError(new TibError('Unsupported version', 'UNSUPPORTED_VERSION'), traceId)),
59
- };
60
- }
61
- // 2. Sunset check
62
- const lifecycle = {
63
- version: negotiation.requestedVersion,
64
- status: apiVersion.status,
65
- ...(apiVersion.sunset && { sunsetAt: apiVersion.sunset }),
66
- };
67
- const sunsetResult = checkSunset(lifecycle, new Date());
68
- applySunsetHeaders(responseHeaders, sunsetResult);
69
- if (sunsetResult.blocked) {
70
- // G1: Use standard error envelope
71
- return {
72
- statusCode: 410,
73
- headers: responseHeaders,
74
- body: JSON.stringify(formatError(new TibError('Version sunset', 'VERSION_SUNSET'), traceId)),
75
- };
76
- }
77
- // 3. Tenancy guard (before validation, before hydrate — fail fast)
78
- const actor = extractActorFromApiGatewayEvent(event);
79
- const tenant = extractTenantFromApiGatewayEvent(event);
80
- if (api.tenancy === 'required') {
81
- if (tenant.id === 'unknown') {
82
- // G1: Use standard error envelope for auth errors
83
- return {
84
- statusCode: 401,
85
- headers: responseHeaders,
86
- body: JSON.stringify(formatError(new TibError('Tenant context required', 'TENANT_REQUIRED'), traceId)),
87
- };
88
- }
89
- const pathTenantId = event.pathParameters?.tenantId;
90
- if (pathTenantId && pathTenantId !== tenant.id) {
91
- // G1: Use standard error envelope for auth errors
92
- return {
93
- statusCode: 403,
94
- headers: responseHeaders,
95
- body: JSON.stringify(formatError(new TibError('Tenant mismatch', 'TENANT_MISMATCH'), traceId)),
96
- };
97
- }
98
- }
99
- // Rate limiting — per-tenant token bucket (in-memory, resets on cold start)
100
- if (api.rateLimit?.perTenant) {
101
- const tenantId = extractTenantFromApiGatewayEvent(event)?.id ?? 'anonymous';
102
- const rateLimitKey = `${api.id}:tenant:${tenantId}`;
103
- const allowed = checkRateLimit(rateLimitKey, api.rateLimit.perTenant);
104
- if (!allowed) {
105
- return {
106
- statusCode: 429,
107
- headers: { ...responseHeaders, 'Retry-After': '60' },
108
- body: JSON.stringify(formatError(new TibError('Rate limit exceeded', 'RATE_LIMIT_EXCEEDED'), traceId)),
109
- };
110
- }
111
- }
112
- if (api.rateLimit?.perIp) {
113
- const sourceIp = event.requestContext?.http?.sourceIp ?? 'unknown';
114
- const rateLimitKey = `${api.id}:ip:${sourceIp}`;
115
- const allowed = checkRateLimit(rateLimitKey, api.rateLimit.perIp);
116
- if (!allowed) {
117
- return {
118
- statusCode: 429,
119
- headers: { ...responseHeaders, 'Retry-After': '60' },
120
- body: JSON.stringify(formatError(new TibError('Rate limit exceeded', 'RATE_LIMIT_EXCEEDED'), traceId)),
121
- };
122
- }
123
- }
124
- // 4. Validate input
125
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
126
- let parsedInput;
127
- try {
128
- const rawBody = event.body ? (event.isBase64Encoded ? Buffer.from(event.body, 'base64').toString('utf8') : event.body) : undefined;
129
- const inputJson = rawBody ? JSON.parse(rawBody) : undefined;
130
- parsedInput = apiVersion.input.parse(inputJson);
131
- }
132
- catch (err) {
133
- // G1: Use standard error envelope for validation errors
134
- return {
135
- statusCode: 400,
136
- headers: responseHeaders,
137
- body: JSON.stringify(formatError(err, traceId)),
138
- };
139
- }
140
- // G5: Auto-trigger idempotency check for POST/PUT/PATCH
141
- const idempotencyKey = event.headers?.['idempotency-key'] ?? event.headers?.['Idempotency-Key'];
142
- const httpMethod = event.requestContext?.http?.method ?? '';
143
- // 5. Hydrate context
144
- const { tenantId: _tenantId, ...remainingPathParams } = event.pathParameters ?? {};
145
- const ctx = await hydrateCtx(event, {
146
- actor,
147
- tenant,
148
- databaseUrl: process.env['DATABASE_URL'],
149
- eventBusName: process.env['EVENT_BUS_NAME'],
150
- traceId,
151
- pathParams: remainingPathParams,
152
- });
153
- // G5: After hydration, check idempotency cache for POST/PUT/PATCH
154
- if (idempotencyKey && ['POST', 'PUT', 'PATCH'].includes(httpMethod)) {
155
- const cached = await ctx.idempotency.isProcessed();
156
- if (cached) {
157
- // Return cached response — for now return 200 with a replay indicator
158
- return {
159
- statusCode: 200,
160
- headers: { ...responseHeaders, 'Idempotency-Replayed': 'true' },
161
- body: JSON.stringify({ replayed: true, idempotencyKey }),
162
- };
163
- }
164
- }
165
- try {
166
- // 6. Call handler
167
- const result = await apiVersion.handler(parsedInput, ctx);
168
- // 7. Validate output
169
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
170
- let parsedOutput;
171
- try {
172
- parsedOutput = apiVersion.output.parse(result);
173
- }
174
- catch (err) {
175
- ctx.logger.error('Output validation failed');
176
- // G1: Use standard error envelope for output validation errors
177
- return {
178
- statusCode: 500,
179
- headers: responseHeaders,
180
- body: JSON.stringify(formatError(err, traceId, ctx.logger)),
181
- };
182
- }
183
- // G5: Mark as processed after successful handler completion
184
- if (idempotencyKey && ['POST', 'PUT', 'PATCH'].includes(httpMethod)) {
185
- await ctx.idempotency.markProcessed();
186
- }
187
- return { statusCode: 200, headers: responseHeaders, body: JSON.stringify(parsedOutput) };
188
- }
189
- finally {
190
- // 8. Always release DB
191
- try {
192
- await ctx.db.release();
193
- }
194
- catch { /* swallow */ }
195
- // G6: Record latency and flush metrics
196
- metrics.recordLatency(Date.now() - requestStart);
197
- metrics.flush();
198
- }
199
- }
200
- catch (err) {
201
- // Last-resort error handler
202
- // G1: Use standard error envelope for unhandled errors
203
- // eslint-disable-next-line no-console
204
- console.error('Unhandled handler error', err);
205
- return {
206
- statusCode: 500,
207
- headers: responseHeaders,
208
- body: JSON.stringify(formatError(err, traceId)),
209
- };
210
- }
211
- };
212
- }