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.
- package/dist/lib/routing/as2.d.ts +5 -0
- package/dist/lib/routing/as2.js +41 -12
- package/dist/lib/routing/middleware.d.ts +1 -1
- package/dist/lib/utils.d.ts +2 -2
- package/dist/lib/utils.js +63 -9
- package/package.json +1 -1
|
@@ -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
|
package/dist/lib/routing/as2.js
CHANGED
|
@@ -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
|
|
18
|
-
*
|
|
19
|
-
*
|
|
24
|
+
* a flat 500 throws away the one signal telling them what to do — retry 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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
|
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>;
|
package/dist/lib/utils.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
59
|
-
*
|
|
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:
|
|
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
|
-
|
|
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.
|
|
4
|
+
"version": "3.1.1",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"author": "Unnbound Team",
|