unnbound-events 3.0.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/index.d.ts CHANGED
@@ -2,6 +2,8 @@ export { createEnqueuer } from './lib/enqueue';
2
2
  export type { ServerErrorOptions } from './lib/error';
3
3
  export { ServerError } from './lib/error';
4
4
  export type { EventMetadata, EventSource, IncomingEvent, IncomingRequest } from './lib/event';
5
+ export type { As2Prepared, As2RawRequest } from './lib/routing/as2';
6
+ export { as2Endpoint } from './lib/routing/as2';
5
7
  export type { Handler } from './lib/routing/endpoint';
6
8
  export type { MiddlewareHandler } from './lib/routing/middleware';
7
9
  export type { EventVariables, MaybePromise } from './lib/routing/types';
package/dist/index.js CHANGED
@@ -1,9 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.createServer = exports.ServerError = exports.createEnqueuer = void 0;
3
+ exports.createServer = exports.as2Endpoint = exports.ServerError = exports.createEnqueuer = void 0;
4
4
  var enqueue_1 = require("./lib/enqueue");
5
5
  Object.defineProperty(exports, "createEnqueuer", { enumerable: true, get: function () { return enqueue_1.createEnqueuer; } });
6
6
  var error_1 = require("./lib/error");
7
7
  Object.defineProperty(exports, "ServerError", { enumerable: true, get: function () { return error_1.ServerError; } });
8
+ var as2_1 = require("./lib/routing/as2");
9
+ Object.defineProperty(exports, "as2Endpoint", { enumerable: true, get: function () { return as2_1.as2Endpoint; } });
8
10
  var server_1 = require("./lib/server");
9
11
  Object.defineProperty(exports, "createServer", { enumerable: true, get: function () { return server_1.createServer; } });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ function assertAs2ResponseAssignableToHandlerResult(response) {
4
+ // If As2HttpResponse ever widens `status` back to `number`, or StrictEventHandlerResult's
5
+ // `status` narrows further than Hono's StatusCode, this return stops compiling.
6
+ return response;
7
+ }
8
+ // Referenced so `noUnusedLocals`-style checks never flag this fixture as dead code.
9
+ void assertAs2ResponseAssignableToHandlerResult;
@@ -0,0 +1,61 @@
1
+ import type { EventHandlerResult } from '../event';
2
+ import { type Handler } from './endpoint';
3
+ import type { EventVariables, MaybePromise } from './types';
4
+ /** Raw wire request handed to the platform verification call, byte-exact. */
5
+ export interface As2RawRequest {
6
+ headers: Record<string, string>;
7
+ body: ArrayBuffer | Uint8Array | string | undefined;
8
+ }
9
+ /**
10
+ * Result of the platform verification call. `as2.receive` results carry the exchange's
11
+ * canonical customer trace (recovered from the ledger for a loopback, server-minted for
12
+ * an external inbound); `as2.confirm` results deliberately do not — an async MDN
13
+ * callback is its own execution and stays on its own trace, linked by Message-ID.
14
+ */
15
+ export interface As2Prepared {
16
+ customerTraceId?: string | null;
17
+ }
18
+ /**
19
+ * Verify-then-bind AS2 endpoint. The public `x-unnbound-trace-id` header is never AS2
20
+ * continuation authority: the route starts on a fresh trace, runs `prepare` (the hidden
21
+ * `as2.receive`/`as2.confirm` platform call) before any customer request span exists,
22
+ * and only a verified ledger-backed trace may re-bind the request. Unverified traffic
23
+ * fails on its own sanitized trace and can assert nothing. Request/response headers are
24
+ * logged through the AS2 allowlist — Receipt-Delivery-Option and Subject never appear.
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
+ *
31
+ * ```ts
32
+ * // identity/partner are plain objects built inline from ordinary scalar workflow
33
+ * // variables — never a variable name passed to `as2.receive`/`as2.send` (see
34
+ * // @ontemper/as2's README). partner.mdn.to only matters for outbound `as2.send`.
35
+ * const identity = { as2Id: 'ACME-TEMPER', keypairs: [{ certPem: process.env.AS2_CERT_PEM!, keyPem: process.env.AS2_KEY_PEM! }] };
36
+ * const partner = { as2Id: 'ACME', url: process.env.ACME_AS2_URL!, certs: [process.env.ACME_CERT_PEM!] };
37
+ *
38
+ * server.post('/as2/inbound', as2Endpoint(
39
+ * ({ headers, body }) => as2.receive({ headers, body: Buffer.from(body as ArrayBuffer), partner, identity }),
40
+ * async (result) => {
41
+ * if (result.kind === 'received') {
42
+ * // Delivery is at-least-once, so make processing idempotent by Message-ID.
43
+ * await processDocument(result.payload);
44
+ * const receipt = await result.acknowledge();
45
+ * // Async MDN partners get no receipt unless you deliver it explicitly — the
46
+ * // response below is deliberately an empty 200 in that mode.
47
+ * if (receipt.mdnMode === 'async' && receipt.mdn && receipt.mdnDeliveryUrl) {
48
+ * await as2.deliverMdn(receipt.mdn, receipt.mdnDeliveryUrl);
49
+ * }
50
+ * return receipt.response;
51
+ * }
52
+ * // Duplicate retries need the same async redelivery, or the partner never settles.
53
+ * if (result.kind === 'duplicate' && result.mdnMode === 'async' && result.mdn && result.mdnDeliveryUrl) {
54
+ * await as2.deliverMdn(result.mdn, result.mdnDeliveryUrl);
55
+ * }
56
+ * return result.response;
57
+ * },
58
+ * ));
59
+ * ```
60
+ */
61
+ export declare const as2Endpoint: <V extends object, P extends object>(prepare: (request: As2RawRequest, event: EventVariables<V>) => Promise<P>, handler: (prepared: P, event: EventVariables<V>) => MaybePromise<EventHandlerResult>) => Handler<EventVariables<V>>;
@@ -0,0 +1,147 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.as2Endpoint = void 0;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const unnbound_logger_sdk_1 = require("unnbound-logger-sdk");
6
+ const error_1 = require("../error");
7
+ const utils_1 = require("../utils");
8
+ const endpoint_1 = require("./endpoint");
9
+ const response_1 = require("./response");
10
+ /**
11
+ * Transient platform failures a partner should re-send against. `As2SdkError` already computed
12
+ * these from the platform response, but it arrives here as a plain unknown — this package cannot
13
+ * import `@ontemper/as2` (the dependency runs the other way), so the shape is read defensively.
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];
22
+ /**
23
+ * AS2 partners drive their re-send behaviour off the status code, so collapsing every failure to
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.
28
+ */
29
+ const toWireFailure = (error) => {
30
+ if (error && typeof error === 'object') {
31
+ const { name, status } = error;
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
+ }
50
+ }
51
+ }
52
+ return error_1.ServerError.fromError(error);
53
+ };
54
+ /**
55
+ * Verify-then-bind AS2 endpoint. The public `x-unnbound-trace-id` header is never AS2
56
+ * continuation authority: the route starts on a fresh trace, runs `prepare` (the hidden
57
+ * `as2.receive`/`as2.confirm` platform call) before any customer request span exists,
58
+ * and only a verified ledger-backed trace may re-bind the request. Unverified traffic
59
+ * fails on its own sanitized trace and can assert nothing. Request/response headers are
60
+ * logged through the AS2 allowlist — Receipt-Delivery-Option and Subject never appear.
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
+ *
67
+ * ```ts
68
+ * // identity/partner are plain objects built inline from ordinary scalar workflow
69
+ * // variables — never a variable name passed to `as2.receive`/`as2.send` (see
70
+ * // @ontemper/as2's README). partner.mdn.to only matters for outbound `as2.send`.
71
+ * const identity = { as2Id: 'ACME-TEMPER', keypairs: [{ certPem: process.env.AS2_CERT_PEM!, keyPem: process.env.AS2_KEY_PEM! }] };
72
+ * const partner = { as2Id: 'ACME', url: process.env.ACME_AS2_URL!, certs: [process.env.ACME_CERT_PEM!] };
73
+ *
74
+ * server.post('/as2/inbound', as2Endpoint(
75
+ * ({ headers, body }) => as2.receive({ headers, body: Buffer.from(body as ArrayBuffer), partner, identity }),
76
+ * async (result) => {
77
+ * if (result.kind === 'received') {
78
+ * // Delivery is at-least-once, so make processing idempotent by Message-ID.
79
+ * await processDocument(result.payload);
80
+ * const receipt = await result.acknowledge();
81
+ * // Async MDN partners get no receipt unless you deliver it explicitly — the
82
+ * // response below is deliberately an empty 200 in that mode.
83
+ * if (receipt.mdnMode === 'async' && receipt.mdn && receipt.mdnDeliveryUrl) {
84
+ * await as2.deliverMdn(receipt.mdn, receipt.mdnDeliveryUrl);
85
+ * }
86
+ * return receipt.response;
87
+ * }
88
+ * // Duplicate retries need the same async redelivery, or the partner never settles.
89
+ * if (result.kind === 'duplicate' && result.mdnMode === 'async' && result.mdn && result.mdnDeliveryUrl) {
90
+ * await as2.deliverMdn(result.mdn, result.mdnDeliveryUrl);
91
+ * }
92
+ * return result.response;
93
+ * },
94
+ * ));
95
+ * ```
96
+ */
97
+ // P is constrained to object, not As2Prepared: prepared results are unions whose
98
+ // members don't all carry customerTraceId (e.g. inFlight/parked), and TypeScript's
99
+ // weak-type check would reject those. The As2Prepared contract is read defensively.
100
+ const as2Endpoint = (prepare, handler) => {
101
+ return (0, endpoint_1.markSelfTraced)(async (event) => {
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)();
108
+ let prepared;
109
+ try {
110
+ prepared = await (0, unnbound_logger_sdk_1.withTrace)(() => prepare({ headers: event.request.headers, body: event.request.body }, event), {
111
+ traceId: requestTraceId,
112
+ });
113
+ }
114
+ catch (error) {
115
+ return (0, unnbound_logger_sdk_1.withTrace)(async () => {
116
+ const failure = toWireFailure(error);
117
+ // Bookend the request on its own failure trace: the span records the sanitized
118
+ // AS2 payload and rethrows; the wire response carries no protocol claims.
119
+ await (0, unnbound_logger_sdk_1.startSpan)('Event request', () => Promise.reject(failure), (o) => (0, utils_1.buildAs2IncomingPayload)(event, o)).catch(() => undefined);
120
+ const result = failure.toJson();
121
+ return { ...result, headers: (0, response_1.attachTraceHeaders)(result.headers) };
122
+ }, { traceId: requestTraceId });
123
+ }
124
+ const verified = prepared.customerTraceId;
125
+ const traceId = (0, utils_1.isCanonicalTraceId)(verified) ? verified : requestTraceId;
126
+ return (0, unnbound_logger_sdk_1.withTrace)(async () => {
127
+ // Response trace headers must be attached inside this scope: respondWith runs after
128
+ // the re-bound trace exits and would otherwise advertise the pre-bind trace.
129
+ try {
130
+ const result = await (0, unnbound_logger_sdk_1.startSpan)('Event request', async () => {
131
+ const result = await handler(prepared, event);
132
+ return { status: (0, response_1.resolveStatus)(result), headers: result?.headers, body: result?.body };
133
+ }, (o) => (0, utils_1.buildAs2IncomingPayload)(event, o));
134
+ return { ...result, headers: (0, response_1.attachTraceHeaders)(result.headers) };
135
+ }
136
+ catch (error) {
137
+ // The span above already logged the failure on the bound trace; respond with the
138
+ // sanitized shape instead of rethrowing into the header-derived middleware trace.
139
+ // `acknowledge()` runs in here too, so a transient platform failure keeps its
140
+ // retryable status rather than becoming an indistinguishable 500.
141
+ const result = toWireFailure(error).toJson();
142
+ return { ...result, headers: (0, response_1.attachTraceHeaders)(result.headers) };
143
+ }
144
+ }, { traceId });
145
+ });
146
+ };
147
+ exports.as2Endpoint = as2Endpoint;
@@ -1,8 +1,9 @@
1
1
  import type { Context } from 'hono';
2
- import type { EventHandlerResult } from '../event';
2
+ import type { EventHandlerResult, StrictEventHandlerResult } from '../event';
3
3
  import { type MiddlewareHandler } from './middleware';
4
4
  import type { EventEnvironment, EventVariables, MaybePromise } from './types';
5
5
  export type Handler<I, R = EventHandlerResult> = (input: I) => MaybePromise<R>;
6
+ export declare const markSelfTraced: <I>(handler: (input: I) => Promise<StrictEventHandlerResult>) => Handler<I>;
6
7
  export interface Endpoint<I> {
7
8
  (path: string, handler: Handler<I>): MaybePromise<void>;
8
9
  (path: string, middleware: MiddlewareHandler<I>, handler: Handler<I>): MaybePromise<void>;
@@ -1,10 +1,22 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.patchEndpoint = exports.parseEndpointArguments = exports.buildEndpointArguments = void 0;
3
+ exports.patchEndpoint = exports.parseEndpointArguments = exports.buildEndpointArguments = exports.markSelfTraced = void 0;
4
4
  const unnbound_logger_sdk_1 = require("unnbound-logger-sdk");
5
5
  const utils_1 = require("../utils");
6
6
  const middleware_1 = require("./middleware");
7
7
  const response_1 = require("./response");
8
+ /**
9
+ * Marks a handler that owns its customer trace and request span. The generic endpoint
10
+ * wrapper binds the header-derived trace before running a handler; AS2 endpoints must
11
+ * instead verify the protocol exchange first and only then choose the trace, so their
12
+ * wrapper (see routing/as2.ts) emits the request span itself and returns a strict result.
13
+ */
14
+ const selfTraced = Symbol.for('unnbound-events.selfTraced');
15
+ const markSelfTraced = (handler) => {
16
+ return Object.assign(handler, { [selfTraced]: true });
17
+ };
18
+ exports.markSelfTraced = markSelfTraced;
19
+ const isSelfTraced = (handler) => selfTraced in handler;
8
20
  const buildEndpointArguments = (...args) => {
9
21
  const { path, middlewares, handler } = (0, exports.parseEndpointArguments)(...args);
10
22
  const patchedHandler = (0, exports.patchEndpoint)(handler);
@@ -22,10 +34,12 @@ const patchEndpoint = (handler) => {
22
34
  const state = context.get('state');
23
35
  // We have to recalculate the params because the handler path might contain url params
24
36
  state.request.params = { ...state.request.params, ...context.req.param() };
25
- const result = await (0, unnbound_logger_sdk_1.startSpan)('Event request', async () => {
26
- const result = await handler(state);
27
- return { status: (0, response_1.resolveStatus)(result), headers: result?.headers, body: result?.body };
28
- }, (o) => (0, utils_1.buildIncomingPayload)(state, o));
37
+ const result = isSelfTraced(handler)
38
+ ? await handler(state)
39
+ : await (0, unnbound_logger_sdk_1.startSpan)('Event request', async () => {
40
+ const result = await handler(state);
41
+ return { status: (0, response_1.resolveStatus)(result), headers: result?.headers, body: result?.body };
42
+ }, (o) => (0, utils_1.buildIncomingPayload)(state, o));
29
43
  return (0, response_1.respondWith)(context, result);
30
44
  };
31
45
  };
@@ -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>;
@@ -3,6 +3,7 @@ import { type Maybe } from 'unnbound-logger-sdk/dist/types';
3
3
  import type { EventHandlerResult, EventMetadata, EventSource, IncomingEvent, IncomingRequest, MatchedIncomingRequest } from './event';
4
4
  export declare const normalizeHeaders: (headers: Record<string, string | string[]>) => Record<string, string>;
5
5
  export declare const getIncomingEvent: (event: HonoRequest) => Promise<IncomingEvent<MatchedIncomingRequest>>;
6
+ export declare const isCanonicalTraceId: (value: string | undefined | null) => value is string;
6
7
  export declare const getMetadataFromRequest: (request: Pick<IncomingRequest, 'headers'>, source: EventSource) => EventMetadata;
7
8
  interface ForwardedMetadata {
8
9
  source: EventSource;
@@ -19,7 +20,29 @@ export declare const buildIncomingPayload: (event: IncomingEvent<MatchedIncoming
19
20
  http: {
20
21
  url: string;
21
22
  method: string;
22
- query: Record<string, any>;
23
+ query: Record<string, any> | undefined;
24
+ params: Record<string, string> | undefined;
25
+ ip: string | undefined;
26
+ incoming: boolean;
27
+ request: {
28
+ headers: Record<string, string>;
29
+ body: unknown;
30
+ };
31
+ response?: {
32
+ status: import("hono/utils/http-status").StatusCode;
33
+ headers: Record<string, string>;
34
+ body: unknown;
35
+ } | undefined;
36
+ };
37
+ };
38
+ export declare const buildAs2IncomingPayload: (event: IncomingEvent<MatchedIncomingRequest>, result?: Maybe<EventHandlerResult>) => {
39
+ type: string;
40
+ source: EventSource;
41
+ delay: number | undefined;
42
+ http: {
43
+ url: string;
44
+ method: string;
45
+ query: Record<string, any> | undefined;
23
46
  params: Record<string, string> | undefined;
24
47
  ip: string | undefined;
25
48
  incoming: boolean;
package/dist/lib/utils.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.batchArray = exports.buildIncomingPayload = exports.serializeMetadata = exports.getMetadataFromRequest = exports.getIncomingEvent = exports.normalizeHeaders = void 0;
3
+ exports.batchArray = exports.buildAs2IncomingPayload = exports.buildIncomingPayload = exports.serializeMetadata = exports.getMetadataFromRequest = exports.isCanonicalTraceId = exports.getIncomingEvent = exports.normalizeHeaders = void 0;
4
4
  const unnbound_logger_sdk_1 = require("unnbound-logger-sdk");
5
5
  const trace_1 = require("unnbound-logger-sdk/dist/trace");
6
6
  const types_1 = require("unnbound-logger-sdk/dist/types");
@@ -46,19 +46,69 @@ 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
- const getMetadataFromRequest = (request, source) => ({
58
- traceId: request.headers[types_1.defaultTraceHeaderKey] ?? (0, unnbound_logger_sdk_1.getTraceId)(),
59
- messageId: request.headers[types_1.defaultMessageHeaderKey] ?? (0, trace_1.getMessageId)(),
60
- source,
61
- });
97
+ /**
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.
100
+ */
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;
102
+ const isCanonicalTraceId = (value) => typeof value === 'string' && CANONICAL_TRACE_ID.test(value);
103
+ exports.isCanonicalTraceId = isCanonicalTraceId;
104
+ const getMetadataFromRequest = (request, source) => {
105
+ const headerTraceId = request.headers[types_1.defaultTraceHeaderKey];
106
+ return {
107
+ traceId: headerTraceId || (0, unnbound_logger_sdk_1.getTraceId)(),
108
+ messageId: request.headers[types_1.defaultMessageHeaderKey] ?? (0, trace_1.getMessageId)(),
109
+ source,
110
+ };
111
+ };
62
112
  exports.getMetadataFromRequest = getMetadataFromRequest;
63
113
  const sourceKey = 'x-unnbound-source';
64
114
  const timestampKey = 'x-unnbound-timestamp';
@@ -112,13 +162,36 @@ const headersForTrace = (headers) => Object.fromEntries(Object.entries(headers).
112
162
  name,
113
163
  REDACTED_TRACE_HEADERS.has(name.toLowerCase()) ? '[redacted]' : value,
114
164
  ]));
115
- const buildResponsePayload = (result) => {
165
+ // AS2 routes carry partner-controlled protocol headers, so logging flips from a
166
+ // redaction denylist to an allowlist: protocol identity/framing and Temper correlation
167
+ // headers pass; everything else is redacted. Receipt-Delivery-Option (its URL may embed
168
+ // query credentials) and Subject (business metadata) are deliberately NOT listed.
169
+ const AS2_TRACE_HEADER_ALLOWLIST = new Set([
170
+ 'as2-from',
171
+ 'as2-to',
172
+ 'as2-version',
173
+ 'content-length',
174
+ 'content-transfer-encoding',
175
+ 'content-type',
176
+ 'date',
177
+ 'disposition-notification-options',
178
+ 'disposition-notification-to',
179
+ 'message-id',
180
+ 'mime-version',
181
+ 'x-message-id',
182
+ 'x-unnbound-trace-id',
183
+ ]);
184
+ const as2HeadersForTrace = (headers) => Object.fromEntries(Object.entries(headers).map(([name, value]) => [
185
+ name,
186
+ AS2_TRACE_HEADER_ALLOWLIST.has(name.toLowerCase()) ? value : '[redacted]',
187
+ ]));
188
+ const buildResponsePayloadWith = (headersPolicy, result) => {
116
189
  const declaredHeaders = result?.headers
117
190
  ? Object.fromEntries(Object.entries((0, exports.normalizeHeaders)(result.headers)).map(([name, value]) => [name.toLowerCase(), value]))
118
191
  : undefined;
119
192
  // Binary responses keep declared headers; JSON gets the SDK default.
120
193
  // The declared content type also controls trace decoding.
121
- const traceHeaders = headersForTrace(declaredHeaders ?? {});
194
+ const traceHeaders = headersPolicy(declaredHeaders ?? {});
122
195
  const status = (0, response_1.resolveStatus)(result);
123
196
  // Mirror the wire: respondWith drops the body on contentless statuses, so the
124
197
  // trace must not claim one — nor a content type for a body never sent.
@@ -130,33 +203,49 @@ const buildResponsePayload = (result) => {
130
203
  body: body ? describeBody(body, declaredHeaders?.['content-type']) : undefined,
131
204
  };
132
205
  };
133
- const buildIncomingPayload = (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) => {
134
222
  return {
135
223
  type: 'http',
136
224
  source: event.metadata.source,
137
225
  delay: event.metadata.source === 'queue' ? Date.now() - event.timestamp : undefined,
138
226
  http: {
139
- url: event.request.url,
227
+ url: redactUrlQuery ? stripUrlQuery(event.request.url) : event.request.url,
140
228
  method: event.request.method.toLowerCase(),
141
- query: event.request.query,
229
+ query: redactUrlQuery ? undefined : event.request.query,
142
230
  params: event.request.params,
143
231
  ip: (0, utils_1.normalizeIp)(event.request.headers['cf-connecting-ip'] ??
144
232
  event.request.headers['x-forwarded-for'] ??
145
233
  event.request.headers['x-real-ip']),
146
234
  incoming: true,
147
235
  request: {
148
- headers: headersForTrace(event.request.headers),
236
+ headers: headersPolicy(event.request.headers),
149
237
  body: describeBody(event.request.body, event.request.headers['content-type']),
150
238
  },
151
239
  ...(result && {
152
240
  response: result.error
153
- ? buildResponsePayload(error_1.ServerError.fromError(result.error).toJson())
154
- : buildResponsePayload(result.result),
241
+ ? buildResponsePayloadWith(headersPolicy, error_1.ServerError.fromError(result.error).toJson())
242
+ : buildResponsePayloadWith(headersPolicy, result.result),
155
243
  }),
156
244
  },
157
245
  };
158
246
  };
159
- exports.buildIncomingPayload = buildIncomingPayload;
247
+ exports.buildIncomingPayload = buildIncomingPayloadWith(headersForTrace);
248
+ exports.buildAs2IncomingPayload = buildIncomingPayloadWith(as2HeadersForTrace, true);
160
249
  const batchArray = (array, size) => {
161
250
  return [...Array(Math.ceil(array.length / size))].map((_, i) => array.slice(i * size, i * size + size));
162
251
  };
package/package.json CHANGED
@@ -1,19 +1,9 @@
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.0.0",
4
+ "version": "3.1.1",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
- "scripts": {
8
- "build": "tsc",
9
- "test": "vitest run",
10
- "typecheck": "tsc --noEmit",
11
- "format": "biome format --write .",
12
- "format:check": "biome format .",
13
- "prepublishOnly": "pnpm run build",
14
- "start:example": "tsx watch examples/node-server.ts",
15
- "version:bump": "npm version patch"
16
- },
17
7
  "author": "Unnbound Team",
18
8
  "license": "MIT",
19
9
  "repository": {
@@ -30,7 +20,7 @@
30
20
  "@hono/node-server": "^1.19.11",
31
21
  "axios": "1.16.0",
32
22
  "hono": "^4.12.21",
33
- "unnbound-logger-sdk": "^3.0.34"
23
+ "unnbound-logger-sdk": "3.1.0"
34
24
  },
35
25
  "devDependencies": {
36
26
  "@types/jest": "^29.5.12",
@@ -45,5 +35,14 @@
45
35
  "engines": {
46
36
  "node": ">=22"
47
37
  },
48
- "sideEffects": false
49
- }
38
+ "sideEffects": false,
39
+ "scripts": {
40
+ "build": "tsc",
41
+ "test": "vitest run",
42
+ "typecheck": "tsc --noEmit",
43
+ "format": "biome format --write .",
44
+ "format:check": "biome format .",
45
+ "start:example": "tsx watch examples/node-server.ts",
46
+ "version:bump": "npm version patch"
47
+ }
48
+ }