unnbound-events 3.1.0 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,6 +23,11 @@ export interface As2Prepared {
23
23
  * fails on its own sanitized trace and can assert nothing. Request/response headers are
24
24
  * logged through the AS2 allowlist — Receipt-Delivery-Option and Subject never appear.
25
25
  *
26
+ * Do not attach route middleware that can answer an as2Endpoint request itself (auth
27
+ * shims, rate limiters): a middleware early-return never reaches this wrapper, so it is
28
+ * logged through the generic header policy on the public header-derived trace instead of
29
+ * the AS2 policy above. Rejection logic belongs inside `prepare`/`handler`.
30
+ *
26
31
  * ```ts
27
32
  * // identity/partner are plain objects built inline from ordinary scalar workflow
28
33
  * // variables — never a variable name passed to `as2.receive`/`as2.send` (see
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.as2Endpoint = void 0;
4
+ const node_crypto_1 = require("node:crypto");
4
5
  const unnbound_logger_sdk_1 = require("unnbound-logger-sdk");
5
6
  const error_1 = require("../error");
6
7
  const utils_1 = require("../utils");
@@ -12,22 +13,40 @@ const response_1 = require("./response");
12
13
  * import `@ontemper/as2` (the dependency runs the other way), so the shape is read defensively.
13
14
  */
14
15
  const RETRYABLE_AS2_STATUSES = [502, 503, 504];
16
+ /**
17
+ * Permanent client errors the partner must NOT re-send: an oversized document is 413, a
18
+ * malformed/undecryptable envelope is 400/422. Collapsing these to 500 tells the partner it
19
+ * was a transient server fault, so it retries a payload that can never succeed.
20
+ */
21
+ const PERMANENT_AS2_STATUSES = [400, 413, 422];
15
22
  /**
16
23
  * AS2 partners drive their re-send behaviour off the status code, so collapsing every failure to
17
- * a flat 500 throws away the one signal telling them to try again the same signal the
18
- * in-flight/parked path already sends deliberately as a 503. Lift the transient statuses onto the
19
- * wire; the message stays generic so no internal detail reaches the partner.
24
+ * a flat 500 throws away the one signal telling them what to doretry a transient fault, or
25
+ * stop resending a permanently bad payload. Lift both classes onto the wire from the platform's
26
+ * `As2SdkError` status; the message stays generic so no internal detail reaches the partner.
27
+ * Anything unrecognized still collapses to 500.
20
28
  */
21
29
  const toWireFailure = (error) => {
22
30
  if (error && typeof error === 'object') {
23
31
  const { name, status } = error;
24
- const retryable = RETRYABLE_AS2_STATUSES.find((candidate) => candidate === status);
25
- if (name === 'As2SdkError' && retryable) {
26
- return new error_1.ServerError({
27
- message: 'AS2 message could not be processed; retry later',
28
- status: retryable,
29
- cause: error,
30
- });
32
+ if (name === 'As2SdkError') {
33
+ // .find (not .some) so the matched literal narrows to StatusCode without a cast.
34
+ const retryable = RETRYABLE_AS2_STATUSES.find((candidate) => candidate === status);
35
+ if (retryable) {
36
+ return new error_1.ServerError({
37
+ message: 'AS2 message could not be processed; retry later',
38
+ status: retryable,
39
+ cause: error,
40
+ });
41
+ }
42
+ const permanent = PERMANENT_AS2_STATUSES.find((candidate) => candidate === status);
43
+ if (permanent) {
44
+ return new error_1.ServerError({
45
+ message: 'AS2 message was rejected and should not be resent',
46
+ status: permanent,
47
+ cause: error,
48
+ });
49
+ }
31
50
  }
32
51
  }
33
52
  return error_1.ServerError.fromError(error);
@@ -40,6 +59,11 @@ const toWireFailure = (error) => {
40
59
  * fails on its own sanitized trace and can assert nothing. Request/response headers are
41
60
  * logged through the AS2 allowlist — Receipt-Delivery-Option and Subject never appear.
42
61
  *
62
+ * Do not attach route middleware that can answer an as2Endpoint request itself (auth
63
+ * shims, rate limiters): a middleware early-return never reaches this wrapper, so it is
64
+ * logged through the generic header policy on the public header-derived trace instead of
65
+ * the AS2 policy above. Rejection logic belongs inside `prepare`/`handler`.
66
+ *
43
67
  * ```ts
44
68
  * // identity/partner are plain objects built inline from ordinary scalar workflow
45
69
  * // variables — never a variable name passed to `as2.receive`/`as2.send` (see
@@ -75,7 +99,12 @@ const toWireFailure = (error) => {
75
99
  // weak-type check would reject those. The As2Prepared contract is read defensively.
76
100
  const as2Endpoint = (prepare, handler) => {
77
101
  return (0, endpoint_1.markSelfTraced)(async (event) => {
78
- const requestTraceId = (0, unnbound_logger_sdk_1.getTraceId)();
102
+ // Mint a fresh pre-verification trace, do NOT read the ambient one: getIncomingEvent has
103
+ // already adopted a partner-supplied canonical x-unnbound-trace-id by the time this wrapper
104
+ // runs, so getTraceId() would return a trace the partner chose. Unverified traffic must not
105
+ // be able to select the trace its prepare/failure/inFlight/parked spans land on — only a
106
+ // ledger-verified customerTraceId may re-bind below.
107
+ const requestTraceId = (0, node_crypto_1.randomUUID)();
79
108
  let prepared;
80
109
  try {
81
110
  prepared = await (0, unnbound_logger_sdk_1.withTrace)(() => prepare({ headers: event.request.headers, body: event.request.body }, event), {
@@ -100,7 +129,7 @@ const as2Endpoint = (prepare, handler) => {
100
129
  try {
101
130
  const result = await (0, unnbound_logger_sdk_1.startSpan)('Event request', async () => {
102
131
  const result = await handler(prepared, event);
103
- return { status: result?.status ?? 204, headers: result?.headers, body: result?.body };
132
+ return { status: (0, response_1.resolveStatus)(result), headers: result?.headers, body: result?.body };
104
133
  }, (o) => (0, utils_1.buildAs2IncomingPayload)(event, o));
105
134
  return { ...result, headers: (0, response_1.attachTraceHeaders)(result.headers) };
106
135
  }
@@ -1,7 +1,7 @@
1
1
  import type { MiddlewareHandler as HonoMiddlewareHandler, Next } from 'hono';
2
2
  import type { EventHandlerResult } from '../event';
3
3
  import type { EventEnvironment, EventVariables, MaybePromise } from './types';
4
- export type MiddlewareHandler<I> = (input: I, next: Next) => MaybePromise<EventHandlerResult | void>;
4
+ export type MiddlewareHandler<I> = (input: I, next: Next) => MaybePromise<EventHandlerResult | undefined> | Promise<void>;
5
5
  export interface Middleware<I> {
6
6
  (...middlewares: MiddlewareHandler<I>[]): MaybePromise<void>;
7
7
  (path: string, ...middlewares: MiddlewareHandler<I>[]): MaybePromise<void>;
@@ -20,7 +20,7 @@ export declare const buildIncomingPayload: (event: IncomingEvent<MatchedIncoming
20
20
  http: {
21
21
  url: string;
22
22
  method: string;
23
- query: Record<string, any>;
23
+ query: Record<string, any> | undefined;
24
24
  params: Record<string, string> | undefined;
25
25
  ip: string | undefined;
26
26
  incoming: boolean;
@@ -42,7 +42,7 @@ export declare const buildAs2IncomingPayload: (event: IncomingEvent<MatchedIncom
42
42
  http: {
43
43
  url: string;
44
44
  method: string;
45
- query: Record<string, any>;
45
+ query: Record<string, any> | undefined;
46
46
  params: Record<string, string> | undefined;
47
47
  ip: string | undefined;
48
48
  incoming: boolean;
package/dist/lib/utils.js CHANGED
@@ -46,18 +46,57 @@ const getIncomingRequest = async (request) => {
46
46
  params: parseParams(request),
47
47
  };
48
48
  };
49
+ /**
50
+ * Outer body ceiling for EVERY workflow endpoint, matched to the ingress contract
51
+ * (apps/ingress `maxRequestBodySize`, 100 MiB) — this reader runs for all routes before
52
+ * dispatch, so a tighter value here silently shrinks the supported payload of unrelated
53
+ * customer endpoints and strands queued S3-backed events in the 64–100 MiB band. AS2's own
54
+ * far tighter bounds live downstream where the route is known (the API route's 48 MiB
55
+ * bodyLimit and the engine's 24 MiB raw ceiling); per-tenant AS2 flood protection is T-3265.
56
+ * The cap still bounds one request from the declared length, or as the stream crosses it,
57
+ * before a full copy materializes.
58
+ */
59
+ const MAX_REQUEST_BODY_BYTES = 100 * 1024 * 1024;
49
60
  const readBody = async (request) => {
61
+ const declared = Number(request.header('content-length') ?? Number.NaN);
62
+ if (Number.isFinite(declared) && declared > MAX_REQUEST_BODY_BYTES) {
63
+ throw new error_1.ServerError({ status: 413, message: 'Request body too large.' });
64
+ }
50
65
  try {
51
- return await request.arrayBuffer();
66
+ const stream = request.raw.body;
67
+ if (!stream)
68
+ return new ArrayBuffer(0);
69
+ const chunks = [];
70
+ let total = 0;
71
+ const reader = stream.getReader();
72
+ while (true) {
73
+ const next = await reader.read();
74
+ if (next.done)
75
+ break;
76
+ total += next.value.byteLength;
77
+ if (total > MAX_REQUEST_BODY_BYTES) {
78
+ await reader.cancel();
79
+ throw new error_1.ServerError({ status: 413, message: 'Request body too large.' });
80
+ }
81
+ chunks.push(next.value);
82
+ }
83
+ const bytes = new Uint8Array(total);
84
+ let offset = 0;
85
+ for (const chunk of chunks) {
86
+ bytes.set(chunk, offset);
87
+ offset += chunk.byteLength;
88
+ }
89
+ return bytes.buffer;
52
90
  }
53
91
  catch (error) {
92
+ if (error instanceof error_1.ServerError)
93
+ throw error;
54
94
  throw new error_1.ServerError({ status: 400, message: 'Failed to read request body.', cause: error });
55
95
  }
56
96
  };
57
97
  /**
58
- * The customer trace ID the product groups logs by a logger-SDK UUID. Public callers
59
- * can set the trace header, so anything non-canonical (oversized values, W3C
60
- * traceparents, arbitrary strings) is ignored and a fresh trace is minted instead.
98
+ * AS2's verify-then-bind boundary uses this to accept only ledger-owned trace IDs.
99
+ * Generic event routes preserve their historical correlation-header contract below.
61
100
  */
62
101
  const CANONICAL_TRACE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
63
102
  const isCanonicalTraceId = (value) => typeof value === 'string' && CANONICAL_TRACE_ID.test(value);
@@ -65,7 +104,7 @@ exports.isCanonicalTraceId = isCanonicalTraceId;
65
104
  const getMetadataFromRequest = (request, source) => {
66
105
  const headerTraceId = request.headers[types_1.defaultTraceHeaderKey];
67
106
  return {
68
- traceId: (0, exports.isCanonicalTraceId)(headerTraceId) ? headerTraceId : (0, unnbound_logger_sdk_1.getTraceId)(),
107
+ traceId: headerTraceId || (0, unnbound_logger_sdk_1.getTraceId)(),
69
108
  messageId: request.headers[types_1.defaultMessageHeaderKey] ?? (0, trace_1.getMessageId)(),
70
109
  source,
71
110
  };
@@ -164,15 +203,30 @@ const buildResponsePayloadWith = (headersPolicy, result) => {
164
203
  body: body ? describeBody(body, declaredHeaders?.['content-type']) : undefined,
165
204
  };
166
205
  };
167
- const buildIncomingPayloadWith = (headersPolicy) => (event, result) => {
206
+ /**
207
+ * Drop the URL's search component so no query-string credential (a `?token=…` on the
208
+ * inbound AS2 endpoint) survives into customer-visible trace data. The AS2 header allowlist
209
+ * already scrubs sensitive headers; the URL and its parsed query bypassed it entirely.
210
+ */
211
+ const stripUrlQuery = (rawUrl) => {
212
+ try {
213
+ const url = new URL(rawUrl);
214
+ url.search = '';
215
+ return url.toString();
216
+ }
217
+ catch {
218
+ return rawUrl.split('?', 1)[0] ?? rawUrl;
219
+ }
220
+ };
221
+ const buildIncomingPayloadWith = (headersPolicy, redactUrlQuery = false) => (event, result) => {
168
222
  return {
169
223
  type: 'http',
170
224
  source: event.metadata.source,
171
225
  delay: event.metadata.source === 'queue' ? Date.now() - event.timestamp : undefined,
172
226
  http: {
173
- url: event.request.url,
227
+ url: redactUrlQuery ? stripUrlQuery(event.request.url) : event.request.url,
174
228
  method: event.request.method.toLowerCase(),
175
- query: event.request.query,
229
+ query: redactUrlQuery ? undefined : event.request.query,
176
230
  params: event.request.params,
177
231
  ip: (0, utils_1.normalizeIp)(event.request.headers['cf-connecting-ip'] ??
178
232
  event.request.headers['x-forwarded-for'] ??
@@ -191,7 +245,7 @@ const buildIncomingPayloadWith = (headersPolicy) => (event, result) => {
191
245
  };
192
246
  };
193
247
  exports.buildIncomingPayload = buildIncomingPayloadWith(headersForTrace);
194
- exports.buildAs2IncomingPayload = buildIncomingPayloadWith(as2HeadersForTrace);
248
+ exports.buildAs2IncomingPayload = buildIncomingPayloadWith(as2HeadersForTrace, true);
195
249
  const batchArray = (array, size) => {
196
250
  return [...Array(Math.ceil(array.length / size))].map((_, i) => array.slice(i * size, i * size + size));
197
251
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "unnbound-events",
3
3
  "description": "Unified events SDK to handle HTTP routes and queued messages with a single routing API.",
4
- "version": "3.1.0",
4
+ "version": "3.1.1",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "author": "Unnbound Team",