Overview
punchout-simulator is a single-process developer tool for testing cXML PunchOut integrations. It plays the part of a virtual counterparty: it can behave as a virtual buyer that drives a real supplier's catalog (Mode A), or as a virtual supplier that serves a mock catalog to a real procurement platform (Mode B). Either way, the operator works against one honest, inspectable end of a PunchOut conversation while the other end is the real system under test.
The whole tool ships as one npm package. A Node process (Hono HTTP server) serves both a JSON control-plane API and a built single-page application from the same origin; the SPA is a React + Vite + Monaco workspace for composing, sending, and inspecting cXML documents.
This document describes the technical architecture. For the business context — what PunchOut is and where this tool sits in a procurement workflow — see the PunchOut business primer. For day-to-day usage (sessions, operations, profiles, product lists), see the Operations & usage guide.
Design constraints
The architecture is shaped by a few deliberate constraints:
- cXML-only. The tool speaks cXML PunchOut end to end. OCI and other protocols are out of scope (they are discussed in the business primer for contrast only).
- Single process, local-first. One
nodeprocess, onedata/directory. No external database, no message broker, no container orchestration. It runs on a developer laptop withnpx punchout-simulator. - Loopback by default, token when exposed. The control plane binds to
loopback. The moment it is exposed on a non-loopback address,
/apiis gated behind a token. The inbound buyer-facing surface (/sim,/punchout) stays open so a real buyer system can reach it. - Append-only, inspectable history. Every message in a conversation is appended to a per-session JSONL log. Nothing is destructive except an explicit session delete.
System context
At runtime the simulator is one server that faces two directions. Toward the operator it serves the SPA and the control-plane API. Toward the network it either calls out to a real supplier (Mode A) or receives calls from a real buyer (Mode B).
Fig. 1: The simulator faces the operator (SPA + API) and the network (Mode A calls out to a real supplier; Mode B is called in by a real buyer).
Component architecture
Internally the server is a set of Hono route modules over a small cXML core and
a file-backed store. The SPA is a flat React app talking to those routes through
a single typed api client.
Fig. 2: Route modules sit over a shared cXML core (build / parse / validate / multipart) and a file-backed store.
Two SPA conveniences worth knowing about architecturally: every cXML field is
rendered through a shared CxmlEditor component that carries a Copy +
Prettify toolbar (prettify is local and display-only on read-only fields),
and a session's flow can be exported as a PDF — flow-pdf.ts renders a
self-contained print-ready HTML report (validation results, HTTP statuses,
attachments, timestamps) and hands it to the browser's native print-to-PDF, so
the tool takes no PDF library dependency.
The cXML core
The cxml/ package is protocol logic with no HTTP or storage knowledge:
| Module | Responsibility |
|---|---|
build.ts |
Emits cXML documents from typed inputs using template literals. Handles PunchOutSetupRequest, OrderRequest, address/contact blocks, classification blocks, and line-item totals (returns 0 for mixed-currency carts). |
parse.ts |
Parses inbound cXML with fast-xml-parser. XXE is rejected. |
validate.ts |
Bidirectional, field-level validation against expectations: credentials (only on header-bearing docs), single-currency, address completeness, operation/items coherence. Produces typed issues (error / warning). |
multipart.ts |
Hand-assembles and parses multipart/related for OrderRequest attachments. |
types.ts + *-presets.ts |
The shared domain types and the built-in presets. Profile presets ship for Generic, SAP Ariba, Coupa, Jaggaer (two UserAgent variants: JAGGAER and legacy SciQuest), Oracle iProcurement, SAP SRM / Business Network, and Workday — one source of truth for both the rows seeded into config.json and the in-memory fallback. |
The store
State lives in a single data/ directory:
config.json— a normalized lowdb document holding the entity tables (buyers,suppliers,connections,profiles,productLists). Migrations run at boot (legacy flat connections → normalized Buyer/Supplier/Connection rows, inline-catalog → product list,unspsc→ classifications, profile address-mode backfill).data/sessions/<sessionId>.jsonl— append-only message logs, one file per PunchOut conversation (keyed byBuyerCookie). Each line is one record (request or response, direction-tagged).- Attachments — stored as separate content-addressed files, referenced from records rather than inlined.
- Carts — held in an in-memory
cart-storekeyed bysessionId; the durable record of a returned cart is the JSONL log.
Data model
The configuration is normalized into independent entities so the same party or
product set can be reused across many integrations. A Connection is the unit
under test: it binds one Buyer to one Supplier, fixes which side the tool
simulates (mode), and carries the pair-specific credentials and deployment
options.
Fig. 3: Buyer, Supplier, Connection, Profile and ProductList are normalized config entities; Sessions are append-only JSONL logs anchored to a Connection.
Endpoints (punchoutUrl, orderUrl) are intrinsic to the Supplier — they
are the same for every buyer that talks to it, so they live on the Supplier,
never per-Connection. Pair-specific data (credentials, deployment mode) lives
on the Connection.
Message flows
Mode A — virtual buyer drives a real supplier
In Mode A the operator composes and sends the buyer-side documents; the real
supplier responds. The cart comes back through the browser to the tool's own
/punchout callback, which parses it and files it under the session.
Fig. 4: Mode A — the tool sends buyer documents, the real supplier responds, and the cart returns through the /punchout callback.
Mode B — virtual supplier serves a mock catalog
In Mode B a real procurement platform calls the tool. The tool answers the
PunchOutSetupRequest with a StartPage, serves a themed HTML catalog at
/sim/:supplierId/*, and when the operator checks out it builds the
PunchOutOrderMessage and auto-POSTs it back to the buyer's BrowserFormPost
URL. The mock supplier also honours the setup operation: for edit it
re-reads the carried ItemOut from the session log and pre-fills the catalog
quantities (items it doesn't list show as extra rows); for inspect it renders
the carried item(s) read-only and returns them unchanged.
Fig. 5: Mode B — the tool answers the buyer's setup, serves a catalog, and posts the cart back through the browser to the buyer's form-post URL.
/sim/:id/checkout requires a BuyerCookie. A cookie-less checkout is
rejected with 400 and creates no session. This guards against a phantom
session being minted on every stray request — the catalog must be opened from
a real PunchOutSetupResponse StartPage for a checkout to count.
Sessions
A session is one PunchOut conversation, identified by its BuyerCookie
(used verbatim as the sessionId). It is the primary unit the operator works
with: every request and response in the conversation is appended to that
session's JSONL log, and the SPA renders a per-session message log rather than a
global firehose.
Fig. 6: A session moves from setup through shopping and cart return to order; historical Mode A sessions can be resumed, and any session can be deleted.
The operation attribute (create / edit / inspect) is captured from the
PunchOutSetupRequest and shown on the session row. It drives what the outbound
document carries — see the Operations & usage guide for the business meaning of
each.
Security posture
The tool is built to be safe to run locally and to expose deliberately:
- Exposure gating.
/api/*(except/api/health) requires the token whenever one is configured — which happens automatically on a non-loopback--public-urlor--host. The token is accepted as a Bearer header,x-pos-tokenheader,?token=query, orpos-api-tokencookie. - Inbound surface stays open.
/simand/punchoutare not token-gated, so a real buyer system can reach the Mode-B supplier and the Mode-A callback. - Secrets are write-only. Shared secrets are accepted on write and redacted from every log and API read-back.
- Body-size limit. Requests are capped (24 MB, generous for attachments) so an oversized unauthenticated POST cannot exhaust memory.
- XXE rejection in the parser, SSE connection cap on the live log, and path sanitization in the session-file resolver (with a containment assertion on delete).
Outbound networking and corporate proxies
Node's global fetch (undici) does not honour HTTP_PROXY /
HTTPS_PROXY / NO_PROXY on its own — unlike curl or most CLIs. On a
locked-down corporate network a direct connection to an external supplier is
silently black-holed, so a Mode-A send would hang into the abort timeout. The
proxy subsystem closes that gap in two layers:
proxy.ts— wiring. When a proxy env var is present at boot, undici'sEnvHttpProxyAgentis installed as the global dispatcher, making every outboundfetchrespect the standard variables (upper- and lower-case).NO_PROXYis merged with the loopback hosts (localhost,127.0.0.1,::1) so the tool's own/simself-calls and localhost suppliers always bypass the proxy. Credentials embedded in a proxy URL are redacted before the URL appears in any log or API response.proxy-detect.ts— diagnosis. When no env vars are set, the boot sequence additionally reads the OS-level proxy configuration (best-effort, read-only): the Windows registry, macOSscutil --proxy, GNOMEgsettings, and KDE'skioslaverc, including PAC scripts and WPAD auto-detect. The per-platform parsers are pure functions, unit-tested on captured fixtures.
The combined result is a three-state ProxyStatus — env (a proxy is in
effect), none (direct), or system-ignored (the OS has a proxy this tool
will not use, with a ready-to-paste HTTPS_PROXY= suggestion when the
concrete host is known). It is printed as the startup banner's proxy: lines
and served over /api/runtime, where the SPA header renders it as a
proxy badge — visible even when the banner has scrolled away or the tool
runs as a service. The operational guidance lives in the Operations & usage
guide.
Build and distribution
| Aspect | Detail |
|---|---|
| Language | TypeScript, ESM, single package |
| Server | Hono on @hono/node-server |
| SPA | React + Vite + Monaco editor |
| Dev | npm run dev — Vite (:5173) + tsx watch server concurrently |
| Build | vite build (SPA) + tsc (server) into dist/ |
| Publish | Only dist/ is published; bin → dist/server/cli.js |
| Run | npx punchout-simulator (loopback, opens browser, seeds demo data) |
CI publishes with npm publish --provenance (SLSA) on GitHub Release.