@ontemper/sdk 0.5.0 → 0.6.0-pr-7774.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/README.md +62 -4
- package/dist/As2.d.ts +155 -0
- package/dist/As2.d.ts.map +1 -0
- package/dist/As2.js +708 -0
- package/dist/As2.js.map +1 -0
- package/dist/As2Destination.d.ts +8 -0
- package/dist/As2Destination.d.ts.map +1 -0
- package/dist/As2Destination.js +61 -0
- package/dist/As2Destination.js.map +1 -0
- package/dist/As2Error.d.ts +19 -0
- package/dist/As2Error.d.ts.map +1 -0
- package/dist/As2Error.js +16 -0
- package/dist/As2Error.js.map +1 -0
- package/dist/As2Route.d.ts +13 -0
- package/dist/As2Route.d.ts.map +1 -0
- package/dist/As2Route.js +13 -0
- package/dist/As2Route.js.map +1 -0
- package/dist/As2Testing.d.ts +73 -0
- package/dist/As2Testing.d.ts.map +1 -0
- package/dist/As2Testing.js +354 -0
- package/dist/As2Testing.js.map +1 -0
- package/dist/DeploySchema.d.ts +7 -0
- package/dist/DeploySchema.d.ts.map +1 -0
- package/dist/DeploySchema.js +225 -0
- package/dist/DeploySchema.js.map +1 -0
- package/dist/Manifest.d.ts +1 -1
- package/dist/Manifest.d.ts.map +1 -1
- package/dist/Manifest.js +1 -1
- package/dist/Manifest.js.map +1 -1
- package/dist/Middleware.d.ts.map +1 -1
- package/dist/Middleware.js +49 -14
- package/dist/Middleware.js.map +1 -1
- package/dist/Resource.d.ts +1 -1
- package/dist/Resource.d.ts.map +1 -1
- package/dist/Resource.js +4 -12
- package/dist/Resource.js.map +1 -1
- package/dist/Workflow.d.ts.map +1 -1
- package/dist/Workflow.js +15 -2
- package/dist/Workflow.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +10 -2
package/dist/As2.js
ADDED
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
import { Config, Context, Duration, Effect, Layer, Option, Predicate, Redacted, Schema, Stream } from 'effect';
|
|
2
|
+
import { FetchHttpClient, HttpClient, HttpClientError, HttpClientRequest, HttpServerResponse, } from 'effect/http';
|
|
3
|
+
import { isPublicDestination, lookupAllAddresses } from './As2Destination.js';
|
|
4
|
+
import { As2Error } from './As2Error.js';
|
|
5
|
+
import { As2IdentityResource, As2PartnerResource } from './As2Resource.js';
|
|
6
|
+
import { isTransientStatus } from './EdiError.js';
|
|
7
|
+
import { Invocation } from './Invocation.js';
|
|
8
|
+
import { CurrentLogContext, TRACE_HEADER, withSpanLines } from './LogContext.js';
|
|
9
|
+
import { registerSecrets } from './Redaction.js';
|
|
10
|
+
export { As2Error } from './As2Error.js';
|
|
11
|
+
export { As2Route } from './As2Route.js';
|
|
12
|
+
export { As2IdentityResource, As2PartnerResource } from './As2Resource.js';
|
|
13
|
+
/** The route an async receipt is posted to, under the sending workflow's own URL. */
|
|
14
|
+
export const AS2_ASYNC_RECEIPT_PATH = '/as2/mdn';
|
|
15
|
+
/** RFC 2045 quoted-string unquote for AS2-From and AS2-To: a bare token passes through. */
|
|
16
|
+
export const unquoteAs2Id = (value) => {
|
|
17
|
+
const trimmed = (value ?? '').trim();
|
|
18
|
+
if (trimmed.length < 2 || !trimmed.startsWith('"') || !trimmed.endsWith('"'))
|
|
19
|
+
return trimmed;
|
|
20
|
+
return trimmed.slice(1, -1).replace(/\\(.)/g, '$1');
|
|
21
|
+
};
|
|
22
|
+
/** Runs `effect` on the exchange's verified trace; with none, on the current one. */
|
|
23
|
+
export const withExchangeTrace = (customerTraceId) => (effect) => customerTraceId === null
|
|
24
|
+
? effect
|
|
25
|
+
: Effect.withFiber((fiber) => Effect.provideService(effect, CurrentLogContext, {
|
|
26
|
+
...fiber.getRef(CurrentLogContext),
|
|
27
|
+
traceId: customerTraceId,
|
|
28
|
+
}));
|
|
29
|
+
// Must stay in lockstep with AS2_HEADER_NAMES in apps/api/src/Platform/As2/Domain.ts; scripts/check-sdk-consistency.ts pins the sets.
|
|
30
|
+
const AS2_HEADERS = new Set([
|
|
31
|
+
'as2-from',
|
|
32
|
+
'as2-to',
|
|
33
|
+
'as2-version',
|
|
34
|
+
'content-transfer-encoding',
|
|
35
|
+
'content-type',
|
|
36
|
+
'disposition-notification-options',
|
|
37
|
+
'disposition-notification-to',
|
|
38
|
+
'message-id',
|
|
39
|
+
'mime-version',
|
|
40
|
+
'receipt-delivery-option',
|
|
41
|
+
'subject',
|
|
42
|
+
]);
|
|
43
|
+
/** Inbound headers are untrusted and may carry credentials: only protocol headers reach the platform. */
|
|
44
|
+
const protocolHeaders = (headers) => Object.fromEntries(Object.entries(headers).flatMap(([name, value]) => value !== undefined && AS2_HEADERS.has(name.toLowerCase()) ? [[name.toLowerCase(), value]] : []));
|
|
45
|
+
const headerOf = (headers, name) => Object.entries(headers).find(([key]) => key.toLowerCase() === name)?.[1];
|
|
46
|
+
/** A partner's largest legitimate answer is a signed MDN of some KiB; a gateway that streams more fails the delivery. */
|
|
47
|
+
const MAX_PARTNER_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
48
|
+
/** ENGINE_BUSY answers 503 before any work, so a bounded retry is safe; every AS2 call is idempotent. */
|
|
49
|
+
const CAPACITY_RETRIES = 3;
|
|
50
|
+
const CAPACITY_BACKOFF_MS = 500;
|
|
51
|
+
const HeaderMap = Schema.Record(Schema.String, Schema.String);
|
|
52
|
+
const MdnMode = Schema.Literals(['sync', 'async']);
|
|
53
|
+
const ReceiveAnswer = Schema.Union([
|
|
54
|
+
Schema.Struct({
|
|
55
|
+
kind: Schema.Literal('received'),
|
|
56
|
+
messageId: Schema.String,
|
|
57
|
+
payloadB64: Schema.String,
|
|
58
|
+
payloadContentType: Schema.NullOr(Schema.String),
|
|
59
|
+
mic: Schema.String,
|
|
60
|
+
deliveryToken: Schema.String,
|
|
61
|
+
mdnMode: MdnMode,
|
|
62
|
+
customerTraceId: Schema.NullOr(Schema.String),
|
|
63
|
+
}),
|
|
64
|
+
Schema.Struct({
|
|
65
|
+
kind: Schema.Literal('duplicate'),
|
|
66
|
+
messageId: Schema.String,
|
|
67
|
+
mic: Schema.String,
|
|
68
|
+
mdnHeaders: Schema.NullOr(HeaderMap),
|
|
69
|
+
mdnBodyB64: Schema.NullOr(Schema.String),
|
|
70
|
+
mdnMode: MdnMode,
|
|
71
|
+
mdnDeliveryUrl: Schema.NullOr(Schema.String),
|
|
72
|
+
customerTraceId: Schema.NullOr(Schema.String),
|
|
73
|
+
}),
|
|
74
|
+
Schema.Struct({ kind: Schema.Literal('inFlight'), messageId: Schema.String }),
|
|
75
|
+
Schema.Struct({ kind: Schema.Literal('parked'), messageId: Schema.String }),
|
|
76
|
+
]);
|
|
77
|
+
const AcknowledgeAnswer = Schema.Struct({
|
|
78
|
+
messageId: Schema.String,
|
|
79
|
+
mic: Schema.String,
|
|
80
|
+
mdnHeaders: Schema.NullOr(HeaderMap),
|
|
81
|
+
mdnBodyB64: Schema.NullOr(Schema.String),
|
|
82
|
+
mdnMode: MdnMode,
|
|
83
|
+
mdnDeliveryUrl: Schema.NullOr(Schema.String),
|
|
84
|
+
alreadyAcknowledged: Schema.Boolean,
|
|
85
|
+
customerTraceId: Schema.NullOr(Schema.String),
|
|
86
|
+
});
|
|
87
|
+
const PackAnswer = Schema.Struct({
|
|
88
|
+
messageId: Schema.String,
|
|
89
|
+
as2Headers: HeaderMap,
|
|
90
|
+
envelopeBodyB64: Schema.String,
|
|
91
|
+
mic: Schema.String,
|
|
92
|
+
partnerUrl: Schema.NullOr(Schema.String),
|
|
93
|
+
mdnMode: Schema.Literals(['none', 'sync', 'async']),
|
|
94
|
+
replayed: Schema.Boolean,
|
|
95
|
+
customerTraceId: Schema.NullOr(Schema.String),
|
|
96
|
+
});
|
|
97
|
+
const ConfirmAnswer = Schema.Struct({
|
|
98
|
+
outcome: Schema.Literals(['confirmed', 'failed']),
|
|
99
|
+
messageId: Schema.String,
|
|
100
|
+
disposition: Schema.NullOr(Schema.String),
|
|
101
|
+
receivedMic: Schema.NullOr(Schema.String),
|
|
102
|
+
packMic: Schema.NullOr(Schema.String),
|
|
103
|
+
micMatched: Schema.Boolean,
|
|
104
|
+
originatingTraceId: Schema.NullOr(Schema.String),
|
|
105
|
+
});
|
|
106
|
+
const SettleAnswer = Schema.Struct({
|
|
107
|
+
outcome: Schema.Literal('confirmed'),
|
|
108
|
+
messageId: Schema.String,
|
|
109
|
+
customerTraceId: Schema.NullOr(Schema.String),
|
|
110
|
+
});
|
|
111
|
+
const ErrorAnswer = Schema.Struct({
|
|
112
|
+
error: Schema.Struct({ code: Schema.String, message: Schema.String }),
|
|
113
|
+
});
|
|
114
|
+
const Settings = Config.all({
|
|
115
|
+
apiUrl: Config.option(Config.String('UNNBOUND_API_URL')),
|
|
116
|
+
// Hosts other than Firecracker pass no Run token; their workflow token is in the environment.
|
|
117
|
+
envToken: Config.option(Config.Redacted('TEMPER_RUN_TOKEN')),
|
|
118
|
+
sandboxToken: Config.option(Config.Redacted('UNNBOUND_SANDBOX_JWT')),
|
|
119
|
+
workflowUrl: Config.option(Config.String('UNNBOUND_WORKFLOW_URL')),
|
|
120
|
+
});
|
|
121
|
+
const present = (token) => Option.filter(token, (value) => Redacted.value(value).length > 0);
|
|
122
|
+
const toBase64 = (bytes) => Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64');
|
|
123
|
+
const fromBase64 = (text) => new Uint8Array(Buffer.from(text, 'base64'));
|
|
124
|
+
const bytesOf = (value) => Predicate.isString(value) ? new TextEncoder().encode(value) : value;
|
|
125
|
+
const keyPairSecrets = (slots) => [
|
|
126
|
+
slots.privateKey,
|
|
127
|
+
slots.secondaryPrivateKey,
|
|
128
|
+
];
|
|
129
|
+
const tlsSecrets = (tls) => [
|
|
130
|
+
tls?.clientKey,
|
|
131
|
+
tls?.clientKeyPassphrase,
|
|
132
|
+
];
|
|
133
|
+
/** Every secret field of the two records: the wire carries them in clear, so each becomes a log mask first. */
|
|
134
|
+
const secretsOf = ({ partner, identity }) => [
|
|
135
|
+
...(identity.keys.mode === 'combined'
|
|
136
|
+
? keyPairSecrets(identity.keys)
|
|
137
|
+
: [...keyPairSecrets(identity.keys.signing), ...keyPairSecrets(identity.keys.encryption)]),
|
|
138
|
+
partner.httpAuth.type === 'basic' ? partner.httpAuth.password : undefined,
|
|
139
|
+
...tlsSecrets(partner.tls),
|
|
140
|
+
partner.proxy?.auth.type === 'basic' ? partner.proxy.auth.password : undefined,
|
|
141
|
+
...tlsSecrets(partner.proxy?.tls),
|
|
142
|
+
].flatMap((secret) => (secret === undefined ? [] : [Redacted.value(secret)]));
|
|
143
|
+
const partiesOf = (operation, options) => Effect.gen(function* () {
|
|
144
|
+
const identity = options.identity ?? options.partner.identity;
|
|
145
|
+
// The decoded record always carries one; a hand-built value may not.
|
|
146
|
+
if (!Predicate.isObject(identity)) {
|
|
147
|
+
return yield* new As2Error({
|
|
148
|
+
operation,
|
|
149
|
+
code: 'NOT_CONFIGURED',
|
|
150
|
+
retryable: false,
|
|
151
|
+
message: 'An AS2 identity is required: pass one, or resolve a partner record that references one.',
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return { partner: options.partner, identity };
|
|
155
|
+
});
|
|
156
|
+
/** The platform wants the partner without its inlined identity, and the identity beside it. */
|
|
157
|
+
const wireParties = ({ partner, identity }) => Effect.gen(function* () {
|
|
158
|
+
yield* registerSecrets(secretsOf({ partner, identity }));
|
|
159
|
+
const { identity: _inlined, ...wirePartner } = Schema.encodeSync(As2PartnerResource)(partner);
|
|
160
|
+
return { partner: wirePartner, identity: Schema.encodeSync(As2IdentityResource)(identity) };
|
|
161
|
+
});
|
|
162
|
+
const isHttpUrl = (value) => {
|
|
163
|
+
if (!URL.canParse(value))
|
|
164
|
+
return false;
|
|
165
|
+
const protocol = new URL(value).protocol;
|
|
166
|
+
return protocol === 'http:' || protocol === 'https:';
|
|
167
|
+
};
|
|
168
|
+
// Userinfo, query, and fragment can carry credentials, so they never reach a log line.
|
|
169
|
+
const endpointOf = (url) => {
|
|
170
|
+
if (!isHttpUrl(url))
|
|
171
|
+
return {};
|
|
172
|
+
const parsed = new URL(url);
|
|
173
|
+
const scheme = parsed.protocol === 'https:' ? 'https' : 'http';
|
|
174
|
+
const port = parsed.port !== '' ? Number(parsed.port) : scheme === 'https' ? 443 : 80;
|
|
175
|
+
return { host: parsed.host, url: `${parsed.protocol}//${parsed.hostname}:${port}${parsed.pathname}`, scheme, port };
|
|
176
|
+
};
|
|
177
|
+
// A partner-chosen async-MDN URL can carry a credential in its path, so only its origin is logged.
|
|
178
|
+
const originOf = (url) => {
|
|
179
|
+
const endpoint = endpointOf(url);
|
|
180
|
+
if (endpoint.scheme === undefined)
|
|
181
|
+
return {};
|
|
182
|
+
return { ...endpoint, url: `${endpoint.scheme}://${new URL(url).hostname}:${endpoint.port}` };
|
|
183
|
+
};
|
|
184
|
+
const as2Line = (fields) => {
|
|
185
|
+
const as2 = {};
|
|
186
|
+
for (const [key, value] of Object.entries(fields))
|
|
187
|
+
if (value !== undefined)
|
|
188
|
+
as2[key] = value;
|
|
189
|
+
return { type: 'as2', as2 };
|
|
190
|
+
};
|
|
191
|
+
/** Answers each `As2Error` with the status a partner reads; every other failure passes through unchanged. */
|
|
192
|
+
const answerAs2Failures = (effect) => Effect.catch(effect, (error) => error instanceof As2Error
|
|
193
|
+
? Effect.succeed(as2FailureResponse(error))
|
|
194
|
+
: // SAFETY: the instanceof check above took every As2Error out of this branch.
|
|
195
|
+
Effect.fail(error));
|
|
196
|
+
const isEmptyBody = (error) => HttpClientError.isHttpClientError(error) && error.reason._tag === 'EmptyBodyError';
|
|
197
|
+
/** The outcome fields of a span's end line: none while it runs, else its outcome and, on failure, the error code. */
|
|
198
|
+
const endOf = (outcome, success) => outcome === undefined
|
|
199
|
+
? {}
|
|
200
|
+
: outcome.error === undefined
|
|
201
|
+
? { outcome: success }
|
|
202
|
+
: { outcome: 'failed', errorCode: outcome.error instanceof As2Error ? outcome.error.code : 'UNKNOWN' };
|
|
203
|
+
/** A fetch error can quote the URL, so a failure keeps only a symbolic code from its cause chain. */
|
|
204
|
+
const symbolicCause = (error) => {
|
|
205
|
+
let current = error;
|
|
206
|
+
for (let depth = 0; depth < 8 && current !== undefined; depth++) {
|
|
207
|
+
const code = Predicate.hasProperty(current, 'code') ? current.code : undefined;
|
|
208
|
+
if (Predicate.isString(code) && /^[A-Z][A-Z0-9_]*$/.test(code))
|
|
209
|
+
return code;
|
|
210
|
+
current = current.cause instanceof Error ? current.cause : undefined;
|
|
211
|
+
}
|
|
212
|
+
return 'unknown';
|
|
213
|
+
};
|
|
214
|
+
const retryLater = () => HttpServerResponse.text('AS2 message is being processed; retry later', {
|
|
215
|
+
status: 503,
|
|
216
|
+
headers: { 'retry-after': '60' },
|
|
217
|
+
});
|
|
218
|
+
const receiptResponse = (mdnMode, mdn) => mdnMode === 'async' || mdn === null
|
|
219
|
+
? HttpServerResponse.empty({ status: 200 })
|
|
220
|
+
: HttpServerResponse.uint8Array(mdn.body, {
|
|
221
|
+
status: 200,
|
|
222
|
+
headers: mdn.headers,
|
|
223
|
+
contentType: headerOf(mdn.headers, 'content-type'),
|
|
224
|
+
});
|
|
225
|
+
const RETRYABLE_WIRE = [502, 503, 504];
|
|
226
|
+
const PERMANENT_WIRE = [400, 413, 422];
|
|
227
|
+
/** Legacy `as2Endpoint`: a partner decides whether to resend from the status, so it gets the status and a generic message. */
|
|
228
|
+
export const as2FailureResponse = (error) => {
|
|
229
|
+
const status = error.status;
|
|
230
|
+
if (status !== undefined && RETRYABLE_WIRE.includes(status))
|
|
231
|
+
return HttpServerResponse.jsonUnsafe({ message: 'AS2 message could not be processed; retry later' }, { status });
|
|
232
|
+
if (status !== undefined && PERMANENT_WIRE.includes(status))
|
|
233
|
+
return HttpServerResponse.jsonUnsafe({ message: 'AS2 message was rejected and should not be resent' }, { status });
|
|
234
|
+
return HttpServerResponse.jsonUnsafe({ message: 'Internal server error' }, { status: 500 });
|
|
235
|
+
};
|
|
236
|
+
const makeAs2 = (lookupAddresses) => Effect.gen(function* () {
|
|
237
|
+
const client = yield* HttpClient.HttpClient;
|
|
238
|
+
const settings = (operation) => Settings.pipe(Effect.mapError((cause) => new As2Error({
|
|
239
|
+
operation,
|
|
240
|
+
code: 'NOT_CONFIGURED',
|
|
241
|
+
retryable: false,
|
|
242
|
+
message: `AS2 settings are unreadable: ${cause.message}`,
|
|
243
|
+
})));
|
|
244
|
+
/** One call to `/api/v2/internal/as2/<path>` with the Run token, retried while the engine answers 503. */
|
|
245
|
+
const platform = (operation, path, body, answer) => Effect.gen(function* () {
|
|
246
|
+
const config = yield* settings(operation);
|
|
247
|
+
const invocation = yield* Invocation;
|
|
248
|
+
const apiUrl = Option.filter(config.apiUrl, (url) => url.length > 0);
|
|
249
|
+
if (Option.isNone(apiUrl)) {
|
|
250
|
+
return yield* new As2Error({
|
|
251
|
+
operation,
|
|
252
|
+
code: 'NOT_CONFIGURED',
|
|
253
|
+
retryable: false,
|
|
254
|
+
message: 'UNNBOUND_API_URL is not set; the AS2 service is unreachable.',
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
const token = invocation.token.length > 0
|
|
258
|
+
? Option.some(invocation.token)
|
|
259
|
+
: Option.orElse(present(config.envToken), () => present(config.sandboxToken));
|
|
260
|
+
if (Option.isNone(token)) {
|
|
261
|
+
return yield* new As2Error({
|
|
262
|
+
operation,
|
|
263
|
+
code: 'NOT_CONFIGURED',
|
|
264
|
+
retryable: false,
|
|
265
|
+
message: 'AS2 requires a Run token or a workflow-scoped sandbox token.',
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
const url = `${apiUrl.value.replace(/\/+$/u, '')}/api/v2/internal/as2/${path}`;
|
|
269
|
+
const request = HttpClientRequest.post(url).pipe(HttpClientRequest.bearerToken(token.value), HttpClientRequest.bodyJsonUnsafe(body));
|
|
270
|
+
for (let attempt = 0;; attempt++) {
|
|
271
|
+
const response = yield* client.execute(request).pipe(
|
|
272
|
+
// Bodies carry decrypted documents, envelopes, receipts, and delivery tokens; the AS2 lines tell the story.
|
|
273
|
+
Effect.provideService(HttpClient.TracerDisabledWhen, () => true),
|
|
274
|
+
// A redirect must not carry the Run token or the body elsewhere.
|
|
275
|
+
Effect.provideService(FetchHttpClient.RequestInit, { redirect: 'error' }), Effect.mapError((cause) => new As2Error({
|
|
276
|
+
operation,
|
|
277
|
+
code: 'NETWORK_ERROR',
|
|
278
|
+
retryable: true,
|
|
279
|
+
message: `The AS2 service did not answer: ${symbolicCause(cause)}`,
|
|
280
|
+
})));
|
|
281
|
+
if (response.status === 503 && attempt < CAPACITY_RETRIES) {
|
|
282
|
+
const seconds = Number(response.headers['retry-after'] ?? Number.NaN);
|
|
283
|
+
const wait = Number.isFinite(seconds) && seconds >= 0
|
|
284
|
+
? Math.min(seconds, 30) * 1000
|
|
285
|
+
: CAPACITY_BACKOFF_MS * (attempt + 1);
|
|
286
|
+
yield* Effect.ignore(response.text);
|
|
287
|
+
yield* Effect.sleep(Duration.millis(wait));
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (response.status < 200 || response.status >= 300) {
|
|
291
|
+
const status = response.status;
|
|
292
|
+
const failure = yield* response.json.pipe(Effect.flatMap(Schema.decodeUnknownEffect(ErrorAnswer)), Effect.map((body) => body.error), Effect.orElseSucceed(() => undefined));
|
|
293
|
+
return yield* new As2Error({
|
|
294
|
+
operation,
|
|
295
|
+
code: failure?.code ?? (status === 401 || status === 403 ? 'UNAUTHORIZED' : 'API_ERROR'),
|
|
296
|
+
retryable: isTransientStatus(status),
|
|
297
|
+
status,
|
|
298
|
+
message: failure?.message ?? `The AS2 service answered HTTP ${status}.`,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
return yield* response.json.pipe(Effect.flatMap(Schema.decodeUnknownEffect(answer)), Effect.mapError(() => new As2Error({
|
|
302
|
+
operation,
|
|
303
|
+
code: 'INVALID_API_RESPONSE',
|
|
304
|
+
retryable: false,
|
|
305
|
+
status: response.status,
|
|
306
|
+
message: 'The AS2 service answered an unexpected body.',
|
|
307
|
+
})));
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
const mdnOf = (headers, bodyB64, operation) => {
|
|
311
|
+
if (headers === null && bodyB64 === null)
|
|
312
|
+
return Effect.succeed(null);
|
|
313
|
+
if (headers === null || bodyB64 === null) {
|
|
314
|
+
return Effect.fail(new As2Error({
|
|
315
|
+
operation,
|
|
316
|
+
code: 'INVALID_API_RESPONSE',
|
|
317
|
+
retryable: false,
|
|
318
|
+
message: 'The AS2 service answered an incomplete MDN.',
|
|
319
|
+
}));
|
|
320
|
+
}
|
|
321
|
+
return Effect.succeed({ headers, body: fromBase64(bodyB64) });
|
|
322
|
+
};
|
|
323
|
+
/** One milestone line, on the exchange's trace when the ledger names one. */
|
|
324
|
+
const milestone = (message, fields, customerTraceId = null) => Effect.logInfo(message, as2Line(fields)).pipe(withExchangeTrace(customerTraceId));
|
|
325
|
+
const warn = (message, fields) => Effect.logWarning(message, as2Line({ ...fields, outcome: 'failed' }));
|
|
326
|
+
const acknowledge = (deliveryToken) => Effect.gen(function* () {
|
|
327
|
+
const answer = yield* platform('acknowledge', 'acknowledge', { deliveryToken }, AcknowledgeAnswer);
|
|
328
|
+
const mdn = yield* mdnOf(answer.mdnHeaders, answer.mdnBodyB64, 'acknowledge');
|
|
329
|
+
yield* milestone('AS2 MDN generated.', {
|
|
330
|
+
operation: 'mdn-generate',
|
|
331
|
+
messageId: answer.messageId,
|
|
332
|
+
mdnMode: answer.mdnMode,
|
|
333
|
+
outcome: 'generated',
|
|
334
|
+
replayed: answer.alreadyAcknowledged,
|
|
335
|
+
byteLength: mdn?.body.byteLength,
|
|
336
|
+
}, answer.customerTraceId);
|
|
337
|
+
return {
|
|
338
|
+
messageId: answer.messageId,
|
|
339
|
+
mic: answer.mic,
|
|
340
|
+
mdn,
|
|
341
|
+
mdnMode: answer.mdnMode,
|
|
342
|
+
mdnDeliveryUrl: answer.mdnDeliveryUrl,
|
|
343
|
+
alreadyAcknowledged: answer.alreadyAcknowledged,
|
|
344
|
+
customerTraceId: answer.customerTraceId,
|
|
345
|
+
response: receiptResponse(answer.mdnMode, mdn),
|
|
346
|
+
};
|
|
347
|
+
}).pipe(Effect.tapError((error) => warn('AS2 acknowledgement failed.', { operation: 'mdn-generate', errorCode: error.code })));
|
|
348
|
+
const readRequest = (operation, request) => request.arrayBuffer.pipe(Effect.map((buffer) => new Uint8Array(buffer)), Effect.mapError(() => new As2Error({
|
|
349
|
+
operation,
|
|
350
|
+
code: 'BAD_REQUEST',
|
|
351
|
+
retryable: false,
|
|
352
|
+
status: 400,
|
|
353
|
+
message: 'The request body could not be read.',
|
|
354
|
+
})));
|
|
355
|
+
const receive = (options) => Effect.gen(function* () {
|
|
356
|
+
const headers = options.request.headers;
|
|
357
|
+
const proto = headers['x-forwarded-proto']?.split(',')[0]?.trim().toLowerCase();
|
|
358
|
+
const transportScheme = proto === 'http' || proto === 'https' ? proto : undefined;
|
|
359
|
+
const body = yield* readRequest('receive', options.request);
|
|
360
|
+
const fields = {
|
|
361
|
+
operation: 'receive',
|
|
362
|
+
scheme: transportScheme,
|
|
363
|
+
port: transportScheme === undefined ? undefined : transportScheme === 'https' ? 443 : 80,
|
|
364
|
+
clientIp: headers['x-forwarded-for']?.split(',')[0]?.trim(),
|
|
365
|
+
as2From: headers['as2-from'],
|
|
366
|
+
as2To: headers['as2-to'],
|
|
367
|
+
partner: options.partner.as2Id,
|
|
368
|
+
byteLength: body.byteLength,
|
|
369
|
+
};
|
|
370
|
+
const answer = yield* Effect.gen(function* () {
|
|
371
|
+
const parties = yield* wireParties(yield* partiesOf('receive', options));
|
|
372
|
+
return yield* platform('receive', 'receive', { ...parties, headers: protocolHeaders(headers), transportScheme, bodyB64: toBase64(body) }, ReceiveAnswer);
|
|
373
|
+
}).pipe(Effect.tapError((error) => warn('AS2 receive failed.', { ...fields, errorCode: error.code })));
|
|
374
|
+
if (answer.kind === 'inFlight' || answer.kind === 'parked') {
|
|
375
|
+
yield* milestone(answer.kind === 'inFlight' ? 'AS2 message already processing.' : 'AS2 message parked.', {
|
|
376
|
+
...fields,
|
|
377
|
+
messageId: answer.messageId,
|
|
378
|
+
outcome: answer.kind,
|
|
379
|
+
});
|
|
380
|
+
return { kind: answer.kind, messageId: answer.messageId, response: retryLater() };
|
|
381
|
+
}
|
|
382
|
+
if (answer.kind === 'duplicate') {
|
|
383
|
+
const mdn = yield* mdnOf(answer.mdnHeaders, answer.mdnBodyB64, 'receive');
|
|
384
|
+
yield* milestone('AS2 duplicate message answered.', { ...fields, messageId: answer.messageId, mdnMode: answer.mdnMode, outcome: 'duplicate' }, answer.customerTraceId);
|
|
385
|
+
return {
|
|
386
|
+
kind: 'duplicate',
|
|
387
|
+
messageId: answer.messageId,
|
|
388
|
+
mic: answer.mic,
|
|
389
|
+
mdn,
|
|
390
|
+
mdnMode: answer.mdnMode,
|
|
391
|
+
mdnDeliveryUrl: answer.mdnDeliveryUrl,
|
|
392
|
+
alreadyAcknowledged: true,
|
|
393
|
+
customerTraceId: answer.customerTraceId,
|
|
394
|
+
response: receiptResponse(answer.mdnMode, mdn),
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
yield* milestone('AS2 message received.', {
|
|
398
|
+
...fields,
|
|
399
|
+
messageId: answer.messageId,
|
|
400
|
+
contentType: answer.payloadContentType ?? undefined,
|
|
401
|
+
mdnMode: answer.mdnMode,
|
|
402
|
+
outcome: 'received',
|
|
403
|
+
}, answer.customerTraceId);
|
|
404
|
+
return {
|
|
405
|
+
kind: 'received',
|
|
406
|
+
messageId: answer.messageId,
|
|
407
|
+
payload: fromBase64(answer.payloadB64),
|
|
408
|
+
contentType: answer.payloadContentType,
|
|
409
|
+
mic: answer.mic,
|
|
410
|
+
mdnMode: answer.mdnMode,
|
|
411
|
+
customerTraceId: answer.customerTraceId,
|
|
412
|
+
acknowledge: acknowledge(answer.deliveryToken),
|
|
413
|
+
};
|
|
414
|
+
});
|
|
415
|
+
const confirm = (options) => Effect.gen(function* () {
|
|
416
|
+
const mdn = options.mdn ?? {
|
|
417
|
+
headers: { ...options.request.headers },
|
|
418
|
+
body: yield* readRequest('confirm', options.request),
|
|
419
|
+
};
|
|
420
|
+
const answer = yield* Effect.gen(function* () {
|
|
421
|
+
const parties = yield* wireParties(yield* partiesOf('confirm', options));
|
|
422
|
+
return yield* platform('confirm', 'confirm', {
|
|
423
|
+
...parties,
|
|
424
|
+
originalMessageId: options.originalMessageId ?? null,
|
|
425
|
+
mdnHeaders: protocolHeaders(mdn.headers),
|
|
426
|
+
mdnBodyB64: toBase64(mdn.body),
|
|
427
|
+
}, ConfirmAnswer);
|
|
428
|
+
}).pipe(Effect.tapError((error) => warn('AS2 MDN verification failed.', {
|
|
429
|
+
operation: 'mdn-verify',
|
|
430
|
+
originalMessageId: options.originalMessageId,
|
|
431
|
+
partner: options.partner.as2Id,
|
|
432
|
+
errorCode: error.code,
|
|
433
|
+
})));
|
|
434
|
+
// The current trace (an async callback's own) logs it, with a link to the sending trace; it never re-binds.
|
|
435
|
+
const line = as2Line({
|
|
436
|
+
operation: 'mdn-verify',
|
|
437
|
+
originalMessageId: answer.messageId,
|
|
438
|
+
partner: options.partner.as2Id,
|
|
439
|
+
outcome: answer.outcome,
|
|
440
|
+
disposition: answer.disposition ?? undefined,
|
|
441
|
+
micMatched: answer.micMatched,
|
|
442
|
+
originatingTraceId: answer.originatingTraceId ?? undefined,
|
|
443
|
+
byteLength: mdn.body.byteLength,
|
|
444
|
+
});
|
|
445
|
+
yield* answer.outcome === 'confirmed'
|
|
446
|
+
? Effect.logInfo('AS2 MDN verified.', line)
|
|
447
|
+
: Effect.logWarning('AS2 MDN verified.', line);
|
|
448
|
+
return answer;
|
|
449
|
+
});
|
|
450
|
+
const deliveryFailure = (code, operation, cause) => new As2Error({ operation, code, retryable: true, message: `request failed: ${symbolicCause(cause)}` });
|
|
451
|
+
/** The partner's answer, read up to the cap; a longer answer fails the delivery. */
|
|
452
|
+
const readCapped = (response, operation, code) => Effect.gen(function* () {
|
|
453
|
+
const declared = Number(response.headers['content-length'] ?? Number.NaN);
|
|
454
|
+
const tooLarge = new As2Error({
|
|
455
|
+
operation,
|
|
456
|
+
code,
|
|
457
|
+
retryable: false,
|
|
458
|
+
status: response.status,
|
|
459
|
+
message: 'The partner answered more than 8 MiB.',
|
|
460
|
+
});
|
|
461
|
+
if (Number.isFinite(declared) && declared > MAX_PARTNER_RESPONSE_BYTES)
|
|
462
|
+
return yield* tooLarge;
|
|
463
|
+
const chunks = [];
|
|
464
|
+
let size = 0;
|
|
465
|
+
yield* Stream.runForEach(response.stream, (chunk) => {
|
|
466
|
+
size += chunk.byteLength;
|
|
467
|
+
chunks.push(chunk);
|
|
468
|
+
return size > MAX_PARTNER_RESPONSE_BYTES ? Effect.fail(tooLarge) : Effect.void;
|
|
469
|
+
}).pipe(
|
|
470
|
+
// A response without a body is an empty answer, such as an async partner's bare 200.
|
|
471
|
+
Effect.catchIf(isEmptyBody, () => Effect.void), Effect.mapError((cause) => (cause instanceof As2Error ? cause : deliveryFailure(code, operation, cause))));
|
|
472
|
+
const bytes = new Uint8Array(size);
|
|
473
|
+
let offset = 0;
|
|
474
|
+
for (const chunk of chunks) {
|
|
475
|
+
bytes.set(chunk, offset);
|
|
476
|
+
offset += chunk.byteLength;
|
|
477
|
+
}
|
|
478
|
+
return bytes;
|
|
479
|
+
});
|
|
480
|
+
const deliverMdn = (mdn, url) => {
|
|
481
|
+
let status;
|
|
482
|
+
const refuseMdn = (code, message) => Effect.fail(new As2Error({ operation: 'deliverMdn', code, retryable: false, message }));
|
|
483
|
+
return Effect.gen(function* () {
|
|
484
|
+
if (!isHttpUrl(url))
|
|
485
|
+
return yield* refuseMdn('MDN_DELIVERY_FAILED', 'The MDN destination is not an http(s) URL.');
|
|
486
|
+
const parsed = new URL(url);
|
|
487
|
+
if (parsed.username !== '' || parsed.password !== '')
|
|
488
|
+
return yield* refuseMdn('MDN_DELIVERY_FAILED', 'The MDN destination carries credentials.');
|
|
489
|
+
const allowed = yield* Effect.tryPromise({
|
|
490
|
+
try: () => isPublicDestination(parsed.hostname, lookupAddresses),
|
|
491
|
+
catch: (cause) => deliveryFailure('MDN_DELIVERY_FAILED', 'deliverMdn', cause instanceof Error ? cause : new Error('lookup failed')),
|
|
492
|
+
});
|
|
493
|
+
if (!allowed) {
|
|
494
|
+
return yield* refuseMdn('MDN_DESTINATION_BLOCKED', 'The MDN destination resolves to a private or reserved address.');
|
|
495
|
+
}
|
|
496
|
+
const response = yield* client
|
|
497
|
+
.execute(HttpClientRequest.post(url).pipe(HttpClientRequest.bodyUint8Array(mdn.body, headerOf(mdn.headers, 'content-type')), HttpClientRequest.setHeaders(mdn.headers)))
|
|
498
|
+
.pipe(Effect.provideService(HttpClient.TracerDisabledWhen, () => true),
|
|
499
|
+
// The partner controls the URL; a redirect would send the POST where the destination check never looked.
|
|
500
|
+
Effect.provideService(FetchHttpClient.RequestInit, { redirect: 'manual' }), Effect.mapError((cause) => deliveryFailure('MDN_DELIVERY_FAILED', 'deliverMdn', cause)));
|
|
501
|
+
status = response.status;
|
|
502
|
+
yield* readCapped(response, 'deliverMdn', 'MDN_DELIVERY_FAILED');
|
|
503
|
+
if (response.status < 200 || response.status >= 300) {
|
|
504
|
+
return yield* new As2Error({
|
|
505
|
+
operation: 'deliverMdn',
|
|
506
|
+
code: 'MDN_DELIVERY_FAILED',
|
|
507
|
+
retryable: isTransientStatus(response.status),
|
|
508
|
+
status: response.status,
|
|
509
|
+
message: `The partner answered HTTP ${response.status}.`,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
}).pipe(withSpanLines('AS2 MDN delivery', (outcome) => as2Line({
|
|
513
|
+
operation: 'mdn-deliver',
|
|
514
|
+
...originOf(url),
|
|
515
|
+
as2From: headerOf(mdn.headers, 'as2-from'),
|
|
516
|
+
as2To: headerOf(mdn.headers, 'as2-to'),
|
|
517
|
+
byteLength: mdn.body.byteLength,
|
|
518
|
+
status,
|
|
519
|
+
...endOf(outcome, 'delivered'),
|
|
520
|
+
})), Effect.asVoid);
|
|
521
|
+
};
|
|
522
|
+
const refuse = (code, message) => Effect.fail(new As2Error({ operation: 'send', code, retryable: false, message }));
|
|
523
|
+
const hasTls = (tls) => tls !== undefined && Object.values(tls).some((value) => value !== undefined);
|
|
524
|
+
/** Checks that run before pack: pack records a pending message, so a refusal after it would strand that row. */
|
|
525
|
+
const checkDestination = (url, options) => Effect.gen(function* () {
|
|
526
|
+
const { partner } = options;
|
|
527
|
+
if (!isHttpUrl(url))
|
|
528
|
+
return yield* refuse('INVALID_PARTNER_URL', 'The partner URL must be an http(s) URL.');
|
|
529
|
+
const parsed = new URL(url);
|
|
530
|
+
if (parsed.username !== '' || parsed.password !== '')
|
|
531
|
+
return yield* refuse('INVALID_PARTNER_URL', 'The partner URL must not carry credentials.');
|
|
532
|
+
const basic = partner.httpAuth.type === 'basic';
|
|
533
|
+
const configured = partner.url;
|
|
534
|
+
if (options.url !== undefined &&
|
|
535
|
+
basic &&
|
|
536
|
+
(configured === undefined || parsed.origin !== new URL(configured).origin)) {
|
|
537
|
+
return yield* refuse('PARTNER_CREDENTIAL_ORIGIN_MISMATCH', 'Partner credentials go only to the origin of the partner URL.');
|
|
538
|
+
}
|
|
539
|
+
if (basic && parsed.protocol !== 'https:')
|
|
540
|
+
return yield* refuse('INSECURE_CREDENTIAL_TRANSPORT', 'HTTP Basic delivery auth requires an https:// partner URL.');
|
|
541
|
+
// The workflow fetch sends no client certificate, custom CA, SNI name, or proxy: refusing beats skipping them.
|
|
542
|
+
if (hasTls(partner.tls) || partner.proxy !== undefined) {
|
|
543
|
+
return yield* refuse('UNSUPPORTED_TRANSPORT', 'Partner TLS settings and proxies are not supported by @temper/workflow AS2 yet.');
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
const receiptUrlFor = (partner, options) => Effect.gen(function* () {
|
|
547
|
+
if (partner.receipt !== 'async')
|
|
548
|
+
return null;
|
|
549
|
+
if (options.receiptUrl !== undefined) {
|
|
550
|
+
if (!isHttpUrl(options.receiptUrl))
|
|
551
|
+
return yield* refuse('NOT_CONFIGURED', 'receiptUrl must be an http(s) URL.');
|
|
552
|
+
return options.receiptUrl;
|
|
553
|
+
}
|
|
554
|
+
const workflowUrl = Option.getOrUndefined((yield* settings('send')).workflowUrl)?.trim() ?? '';
|
|
555
|
+
// Partners reach workflows over HTTPS only, so a scheme-less value gets https://.
|
|
556
|
+
const href = /^https?:\/\//iu.test(workflowUrl) ? workflowUrl : `https://${workflowUrl}`;
|
|
557
|
+
if (workflowUrl === '' || !isHttpUrl(href)) {
|
|
558
|
+
return yield* refuse('NOT_CONFIGURED', 'The partner asks for async receipts, but neither receiptUrl nor UNNBOUND_WORKFLOW_URL is set.');
|
|
559
|
+
}
|
|
560
|
+
return `${href.replace(/\/+$/u, '')}${AS2_ASYNC_RECEIPT_PATH}`;
|
|
561
|
+
});
|
|
562
|
+
const deliver = (url, envelope, as2Headers, parties, messageId) => {
|
|
563
|
+
let status;
|
|
564
|
+
const auth = parties.partner.httpAuth;
|
|
565
|
+
return Effect.gen(function* () {
|
|
566
|
+
if (auth.type === 'basic')
|
|
567
|
+
yield* registerSecrets([Redacted.value(auth.password)]);
|
|
568
|
+
const response = yield* client
|
|
569
|
+
.execute(HttpClientRequest.post(url).pipe(HttpClientRequest.bodyUint8Array(envelope, headerOf(as2Headers, 'content-type')), HttpClientRequest.setHeaders(as2Headers), (request) => auth.type === 'basic'
|
|
570
|
+
? HttpClientRequest.basicAuth(request, auth.username, Redacted.value(auth.password))
|
|
571
|
+
: request))
|
|
572
|
+
.pipe(Effect.provideService(HttpClient.TracerDisabledWhen, () => true),
|
|
573
|
+
// AS2 endpoints are fixed, agreed URLs; a redirect would send the envelope where the partner's gateway chose.
|
|
574
|
+
Effect.provideService(FetchHttpClient.RequestInit, { redirect: 'manual' }), Effect.mapError((cause) => deliveryFailure('DELIVERY_FAILED', 'send', cause)));
|
|
575
|
+
status = response.status;
|
|
576
|
+
const body = yield* readCapped(response, 'send', 'DELIVERY_FAILED');
|
|
577
|
+
if (response.status < 200 || response.status >= 300) {
|
|
578
|
+
return yield* new As2Error({
|
|
579
|
+
operation: 'send',
|
|
580
|
+
code: 'DELIVERY_FAILED',
|
|
581
|
+
retryable: isTransientStatus(response.status),
|
|
582
|
+
status: response.status,
|
|
583
|
+
message: `The partner answered HTTP ${response.status}.`,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
return { headers: { ...response.headers }, body };
|
|
587
|
+
}).pipe(withSpanLines('AS2 delivery', (outcome) => as2Line({
|
|
588
|
+
operation: 'deliver',
|
|
589
|
+
messageId,
|
|
590
|
+
as2From: parties.identity.as2Id,
|
|
591
|
+
as2To: parties.partner.as2Id,
|
|
592
|
+
partner: parties.partner.as2Id,
|
|
593
|
+
...endpointOf(url),
|
|
594
|
+
byteLength: envelope.byteLength,
|
|
595
|
+
attempt: 1,
|
|
596
|
+
status,
|
|
597
|
+
...endOf(outcome, 'delivered'),
|
|
598
|
+
})));
|
|
599
|
+
};
|
|
600
|
+
const send = (payloadInput, options) => {
|
|
601
|
+
const payload = bytesOf(payloadInput);
|
|
602
|
+
const summary = {
|
|
603
|
+
operation: 'send',
|
|
604
|
+
partner: options.partner.as2Id,
|
|
605
|
+
as2To: options.partner.as2Id,
|
|
606
|
+
byteLength: payload.byteLength,
|
|
607
|
+
contentType: options.contentType,
|
|
608
|
+
};
|
|
609
|
+
return Effect.gen(function* () {
|
|
610
|
+
const intended = options.url ?? options.partner.url;
|
|
611
|
+
if (intended === undefined)
|
|
612
|
+
return yield* refuse('NO_PARTNER_URL', 'The partner record has no URL and the call passed none.');
|
|
613
|
+
Object.assign(summary, endpointOf(intended));
|
|
614
|
+
yield* checkDestination(intended, options);
|
|
615
|
+
const parties = yield* partiesOf('send', options);
|
|
616
|
+
summary.as2From = parties.identity.as2Id;
|
|
617
|
+
const receiptUrl = yield* receiptUrlFor(parties.partner, options);
|
|
618
|
+
const wire = yield* wireParties(parties);
|
|
619
|
+
const packed = yield* platform('send', 'pack', {
|
|
620
|
+
...wire,
|
|
621
|
+
payloadB64: toBase64(payload),
|
|
622
|
+
payloadContentType: options.contentType,
|
|
623
|
+
subject: options.subject ?? null,
|
|
624
|
+
idempotencyKey: options.idempotencyKey ?? null,
|
|
625
|
+
receiptUrl,
|
|
626
|
+
}, PackAnswer);
|
|
627
|
+
const url = options.url ?? packed.partnerUrl ?? intended;
|
|
628
|
+
summary.messageId = packed.messageId;
|
|
629
|
+
summary.replayed = packed.replayed;
|
|
630
|
+
if (packed.mdnMode !== 'none')
|
|
631
|
+
summary.mdnMode = packed.mdnMode;
|
|
632
|
+
// On a replay the ledger names the first send's trace: a link from this trace, never a re-bind.
|
|
633
|
+
if (packed.replayed && packed.customerTraceId !== null)
|
|
634
|
+
summary.originatingTraceId = packed.customerTraceId;
|
|
635
|
+
Object.assign(summary, endpointOf(url));
|
|
636
|
+
const delivered = yield* deliver(url, fromBase64(packed.envelopeBodyB64), packed.as2Headers, parties, packed.messageId);
|
|
637
|
+
const result = (outcome, disposition, customerTraceId) => {
|
|
638
|
+
summary.outcome = outcome;
|
|
639
|
+
return {
|
|
640
|
+
messageId: packed.messageId,
|
|
641
|
+
mic: packed.mic,
|
|
642
|
+
replayed: packed.replayed,
|
|
643
|
+
outcome,
|
|
644
|
+
disposition,
|
|
645
|
+
customerTraceId,
|
|
646
|
+
};
|
|
647
|
+
};
|
|
648
|
+
if (packed.mdnMode === 'none') {
|
|
649
|
+
const settled = yield* platform('send', 'settle-delivery', { ...wire, messageId: packed.messageId }, SettleAnswer);
|
|
650
|
+
return result('confirmed', null, settled.customerTraceId);
|
|
651
|
+
}
|
|
652
|
+
if (!/multipart\/(signed|report)|pkcs7/iu.test(headerOf(delivered.headers, 'content-type') ?? '')) {
|
|
653
|
+
if (packed.mdnMode === 'sync') {
|
|
654
|
+
return yield* new As2Error({
|
|
655
|
+
operation: 'send',
|
|
656
|
+
code: 'MDN_MISSING',
|
|
657
|
+
retryable: false,
|
|
658
|
+
message: 'The partner answered success without the synchronous MDN it owes.',
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
return result('pending', null, packed.customerTraceId);
|
|
662
|
+
}
|
|
663
|
+
const confirmed = yield* confirm({
|
|
664
|
+
partner: options.partner,
|
|
665
|
+
identity: parties.identity,
|
|
666
|
+
originalMessageId: packed.messageId,
|
|
667
|
+
mdn: delivered,
|
|
668
|
+
}).pipe(Effect.mapError((error) => new As2Error({
|
|
669
|
+
operation: 'send',
|
|
670
|
+
code: error.code,
|
|
671
|
+
retryable: error.retryable,
|
|
672
|
+
status: error.status,
|
|
673
|
+
message: error.message,
|
|
674
|
+
})));
|
|
675
|
+
summary.disposition = confirmed.disposition ?? undefined;
|
|
676
|
+
summary.micMatched = confirmed.micMatched;
|
|
677
|
+
return result(confirmed.outcome, confirmed.disposition, packed.customerTraceId);
|
|
678
|
+
}).pipe(withSpanLines('AS2 send', (outcome) => as2Line(outcome?.error === undefined ? summary : { ...summary, ...endOf(outcome, undefined) })));
|
|
679
|
+
};
|
|
680
|
+
const inbound = (options, handle) => Effect.gen(function* () {
|
|
681
|
+
const partner = yield* options.partner(unquoteAs2Id(options.request.headers['as2-from']));
|
|
682
|
+
const received = yield* receive({ request: options.request, partner, identity: options.identity });
|
|
683
|
+
if (received.kind !== 'received' && received.kind !== 'duplicate')
|
|
684
|
+
return received.response;
|
|
685
|
+
const response = yield* Effect.gen(function* () {
|
|
686
|
+
const receipt = received.kind === 'received' ? yield* Effect.andThen(handle(received), received.acknowledge) : received;
|
|
687
|
+
if (receipt.mdnMode === 'async' && receipt.mdn !== null && receipt.mdnDeliveryUrl !== null)
|
|
688
|
+
yield* deliverMdn(receipt.mdn, receipt.mdnDeliveryUrl);
|
|
689
|
+
return receipt.response;
|
|
690
|
+
}).pipe(answerAs2Failures, withExchangeTrace(received.customerTraceId));
|
|
691
|
+
return received.customerTraceId === null
|
|
692
|
+
? response
|
|
693
|
+
: HttpServerResponse.setHeader(response, TRACE_HEADER, received.customerTraceId);
|
|
694
|
+
}).pipe(
|
|
695
|
+
// Partner selection and verification fail before any exchange trace: the partner still reads the status.
|
|
696
|
+
answerAs2Failures);
|
|
697
|
+
return { receive, inbound, send, confirm, deliverMdn };
|
|
698
|
+
});
|
|
699
|
+
/**
|
|
700
|
+
* First-party AS2 on the workflow's `HttpClient`, logger, and Run token: `yield* As2` in a handler layer and
|
|
701
|
+
* provide `As2.layer`. All S/MIME crypto runs on the platform; the SDK carries bytes and records.
|
|
702
|
+
*/
|
|
703
|
+
export class As2 extends Context.Service()('@temper/workflow/As2') {
|
|
704
|
+
static layer = Layer.effect(As2, makeAs2(lookupAllAddresses));
|
|
705
|
+
/** `layer` with a replaced name lookup for the async-MDN destination check. */
|
|
706
|
+
static layerWith = (options) => Layer.effect(As2, makeAs2(options.lookupAddresses));
|
|
707
|
+
}
|
|
708
|
+
//# sourceMappingURL=As2.js.map
|