@ontemper/as2
v0.7.1
Published
AS2 (RFC 4130) client for Temper workflows: receive, send (pack -> deliver -> confirm), MDN handling. All S/MIME crypto executes on the platform engine - the SDK implements no cryptography itself.
Readme
@ontemper/as2
AS2 (RFC 4130) client for Temper workflows. The SDK ferries protocol bytes and
your AS2 configuration: identity and partner are the platform's built-in
as2_identity and as2_partner Resource records, resolved by the workflow with
@ontemper/resources and passed to every call. The SDK never looks up a record
itself; it only defines the As2IdentityConfig/As2PartnerConfig shape (the record
fields, verbatim) and sends whatever object you hand it. All crypto runs
platform-side — the workflow never signs, encrypts, or verifies anything itself.
Authentication uses createAs2Client({ token: context.token }) in the fetch-handler
model, then TEMPER_RUN_TOKEN, then UNNBOUND_SANDBOX_JWT. Each request sends the
selected credential as Authorization: Bearer; there is no static-token fallback.
Without a credential the SDK throws As2SdkError('NOT_CONFIGURED', ...).
Inbound — terminate AS2 on your workflow URL
Always terminate inbound AS2 with as2Endpoint from unnbound-events — it is the
verify-then-bind seam: the platform verifies the message before any customer trace is
chosen, only a verified ledger-backed trace may re-bind the request (the partner-controlled
trace header is never continuation authority), and request logs pass the AS2 header
allowlist so Receipt-Delivery-Option and Subject never appear. Calling receive()
from a plain route skips all three.
import * as as2 from '@ontemper/as2';
import { As2PartnerResource, env } from '@ontemper/resources';
import { as2Endpoint, ServerError } from 'unnbound-events';
// One route serves every partner: the sender's AS2-From selects the partner record,
// and the resolved partner carries the identity it trades with inline. Each partner's
// AS2 ID must be unique among the records this workflow can reach; `select` refuses an
// ambiguous match. A sender with no record is a permanent rejection (the resolver answers
// 404), so the partner does not retry it; any other resolver failure stays a 500 the
// partner retries later.
const isSelectorMiss = (error: unknown): boolean => {
for (let cause: unknown = error; cause instanceof Error; cause = cause.cause) {
if ('status' in cause && cause.status === 404) return true;
}
return false;
};
server.post(
'/as2/inbound',
as2Endpoint(
async ({ url, headers, body }) => {
const as2Id = as2.unquoteAs2Id(headers['as2-from']);
const selected = await env.resources
.select({ resourceTypeKey: 'as2_partner', match: { as2Id } })
.catch((error: unknown) => {
if (isSelectorMiss(error)) throw new ServerError({ status: 403, message: 'Unknown AS2 partner' });
throw error;
});
const partner = await env.resources.resolve(selected.key, As2PartnerResource);
return as2.receive({ url, headers, body: Buffer.from(body as ArrayBuffer), partner });
},
async (result) => {
if (result.kind === 'received') {
// Delivery is at-least-once if this process stops before acknowledge(), so
// make processing idempotent by Message-ID or a stable document key.
await processDocument(result.payload);
const receipt = await result.acknowledge();
// mdnDeliveryUrl (when present) is resolved from THIS PARTNER's own inbound request
// (their Receipt-Delivery-Option header). Nothing to configure here; just deliver as given.
if (receipt.mdnMode === 'async' && receipt.mdn && receipt.mdnDeliveryUrl) {
await as2.deliverMdn(receipt.mdn, receipt.mdnDeliveryUrl);
}
return receipt.response;
}
if (result.kind === 'duplicate' && result.mdnMode === 'async' && result.mdn && result.mdnDeliveryUrl) {
await as2.deliverMdn(result.mdn, result.mdnDeliveryUrl);
}
// Duplicate: signed sync MDN or empty async 200. In-flight/parked: retryable 503.
return result.response;
},
),
);Outbound — pack → deliver → confirm
const partner = await env.resources.resolve('walmart', As2PartnerResource);
const sent = await as2.send(ediDocument, {
partner, // identity: partner.identity — pass `identity` explicitly to override
idempotencyKey: po.number, // crash-retry re-delivers the ORIGINAL envelope
});
// sent.outcome: 'confirmed' (MIC matched) | 'failed' | 'pending' (async receipt)Async receipts (partner.receipt === 'async'): send() returns pending and tells the
partner to post the receipt to asyncReceiptUrl() — your workflow's own URL
(UNNBOUND_WORKFLOW_URL) plus AS2_ASYNC_RECEIPT_PATH (/as2/mdn); pass receiptUrl to
override it per call. Serve that route and settle the receipt there with
as2.confirm({ partner, mdnHeaders, mdnBody }) — no message ID needed: the platform
verifies the receipt first and settles the message named inside its signed content, and
result.messageId tells you which outbound message that was, even with many receipts
outstanding. (originalMessageId is an optional assertion — confirm fails if the signed
receipt names any other message.) Delivering your own MDNs in async inbound mode is
as2.deliverMdn(mdn, url) — dumb byte transport, url comes from result.mdnDeliveryUrl.
The partner chooses that URL, so deliverMdn connects directly (no proxy, no redirects) and
refuses a destination that is, or resolves to, a loopback, private, link-local, or metadata
address. It resolves the name once per connection and dials the address it checked.
Identity & partner records
Both records are created in the Resources UI (or by the builder agent) and read with
env.resources.resolve; the Zod schemas As2IdentityResource and As2PartnerResource
in @ontemper/resources parse them. Private keys are always Variable references on the
record; certificates may be literals or references. A rotation is an edit of the record,
never a redeploy.
import type { As2CertificateSlots, As2IdentityConfig, As2KeyPairSlots, As2PartnerConfig } from '@ontemper/as2';
// As2KeyPairSlots: { certificate: string; privateKey: string; secondaryCertificate?: string | null; secondaryPrivateKey?: string | null }
// As2CertificateSlots: { certificate: string; secondaryCertificate?: string | null }
const identity: As2IdentityConfig = {
as2Id: 'ACME-TEMPER',
keys: { mode: 'combined', certificate: certPem, privateKey: keyPem }, // or { mode: 'separate', signing: As2KeyPairSlots, encryption: As2KeyPairSlots }
};
const partner: As2PartnerConfig = {
as2Id: 'ACME',
url: 'https://as2.acme.example/inbox',
certificates: { mode: 'combined', certificate: acmeCertPem }, // or { mode: 'separate', encryption: As2CertificateSlots, signing: As2CertificateSlots }
signOutgoing: true, // default true
encryptOutgoing: true, // default true
requireSignedIncoming: true, // default true
requireEncryptedIncoming: true, // default true
receipt: 'sync', // default 'sync'; receipts are always signed
digestAlgorithm: 'sha-256', // default
encryptionAlgorithm: 'aes256-cbc', // default
identity, // inlined by `resolve`; used when a call omits `identity`
};Outgoing messages use each group's primary slot; incoming messages and receipts are
accepted with the secondary slot too, so both sides can rotate with an overlap. With
separate, messages are encrypted to the partner's encryption certificate and verified
with their signing certificate; our signing pair signs messages and receipts and our
encryption pair decrypts. Never log the resolved identity: it carries private key material.
Observability — one customer trace per execution
Every operation emits typed as2 milestones. Send/receive milestones carry the
cleartext business document and the MDN milestones carry the complete receipt
(payload / mdn), so an exchange is queryable and auditable in the product —
the same body-logging contract http and sftp logs already follow. Sealed
envelopes, delivery tokens, certificate material, and full URLs are never
logged. Internal platform calls and the raw delivery POST emit no generic
HTTP logs. The platform ledger owns trace correlation:
- Sync exchanges are one trace.
send()results carrycustomerTraceId(the trace stored on the outbound ledger row; on idempotent replay, the original's). A verified loopbackreceive()recovers the sending half's trace, so both halves of a self-exchange share it. - Async callbacks are their own trace, linked. A delayed MDN settles on the
callback's own execution trace;
confirm()returnsoriginatingTraceIdand its milestone carries the link, so searching the AS2 Message-ID unifies the exchange. - Inbound routing is verify-then-bind. Terminate
/as2/inboundwithas2Endpointfromunnbound-events(the primary inbound example above): the platform verifies the message before a customer trace is chosen, then binds the request to the ledger's canonical trace. The partner-controlled trace header is never continuation authority, and request logs pass an AS2 header allowlist (Receipt-Delivery-OptionandSubjectare never logged).
