punchout-simulator 0.1.0

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.
Files changed (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +163 -0
  3. package/dist/server/cli.js +1889 -0
  4. package/dist/server/cli.js.map +1 -0
  5. package/dist/web/assets/abap-BrgZPUOV.js +6 -0
  6. package/dist/web/assets/apex-DyP6w7ZV.js +6 -0
  7. package/dist/web/assets/azcli-BaLxmfj-.js +6 -0
  8. package/dist/web/assets/bat-CFOPXBzS.js +6 -0
  9. package/dist/web/assets/bicep-BfEKNvv3.js +7 -0
  10. package/dist/web/assets/cameligo-BFG1Mk7z.js +6 -0
  11. package/dist/web/assets/clojure-DTECt2xU.js +6 -0
  12. package/dist/web/assets/codicon-DCmgc-ay.ttf +0 -0
  13. package/dist/web/assets/coffee-CDGzqUPQ.js +6 -0
  14. package/dist/web/assets/cpp-CLLBncYj.js +6 -0
  15. package/dist/web/assets/csharp-dUCx_-0o.js +6 -0
  16. package/dist/web/assets/csp-5Rap-vPy.js +6 -0
  17. package/dist/web/assets/css-D3h14YRZ.js +8 -0
  18. package/dist/web/assets/cssMode-DLffFuN6.js +9 -0
  19. package/dist/web/assets/cypher-DrQuvNYM.js +6 -0
  20. package/dist/web/assets/dart-CFKIUWau.js +6 -0
  21. package/dist/web/assets/dockerfile-Zznr-cwX.js +6 -0
  22. package/dist/web/assets/ecl-Ce3n6wWz.js +6 -0
  23. package/dist/web/assets/editor.worker-BCzxt1at.js +12 -0
  24. package/dist/web/assets/elixir-deUWdS0T.js +6 -0
  25. package/dist/web/assets/flow9-i9-g7ZhI.js +6 -0
  26. package/dist/web/assets/freemarker2-zfr9jUGh.js +8 -0
  27. package/dist/web/assets/fsharp-CzKuDChf.js +6 -0
  28. package/dist/web/assets/go-Cphgjts3.js +6 -0
  29. package/dist/web/assets/graphql-Cg7bfA9N.js +6 -0
  30. package/dist/web/assets/handlebars-M_QseyaG.js +6 -0
  31. package/dist/web/assets/hcl-0cvrggvQ.js +6 -0
  32. package/dist/web/assets/html-BRMBHmCq.js +6 -0
  33. package/dist/web/assets/htmlMode-CicmEJMR.js +9 -0
  34. package/dist/web/assets/index-Cm4lvUlh.js +800 -0
  35. package/dist/web/assets/index-sxaLM6ld.css +1 -0
  36. package/dist/web/assets/ini-Drc7WvVn.js +6 -0
  37. package/dist/web/assets/java-B_fMsGYe.js +6 -0
  38. package/dist/web/assets/javascript-D6_vZEGx.js +6 -0
  39. package/dist/web/assets/jsonMode-CTxq7vAq.js +15 -0
  40. package/dist/web/assets/julia-Bqgm2twL.js +6 -0
  41. package/dist/web/assets/kotlin-BSkB5QuD.js +6 -0
  42. package/dist/web/assets/less-BsTHnhdd.js +7 -0
  43. package/dist/web/assets/lexon-YWi4-JPR.js +6 -0
  44. package/dist/web/assets/liquid-BUpPE-Wb.js +6 -0
  45. package/dist/web/assets/lua-nf6ki56Z.js +6 -0
  46. package/dist/web/assets/m3-Cpb6xl2v.js +6 -0
  47. package/dist/web/assets/markdown-DSZPf7rp.js +6 -0
  48. package/dist/web/assets/mdx-ymOE7fzM.js +6 -0
  49. package/dist/web/assets/mips-B_c3zf-v.js +6 -0
  50. package/dist/web/assets/msdax-rUNN04Wq.js +6 -0
  51. package/dist/web/assets/mysql-DDwshQtU.js +6 -0
  52. package/dist/web/assets/objective-c-B5zXfXm9.js +6 -0
  53. package/dist/web/assets/pascal-CXOwvkN_.js +6 -0
  54. package/dist/web/assets/pascaligo-Bc-ZgV77.js +6 -0
  55. package/dist/web/assets/perl-CwNk8-XU.js +6 -0
  56. package/dist/web/assets/pgsql-tGk8EFnU.js +6 -0
  57. package/dist/web/assets/php-CpIb_Oan.js +6 -0
  58. package/dist/web/assets/pla-B03wrqEc.js +6 -0
  59. package/dist/web/assets/postiats-BKlk5iyT.js +6 -0
  60. package/dist/web/assets/powerquery-Bhzvs7bI.js +6 -0
  61. package/dist/web/assets/powershell-Dd3NCNK9.js +6 -0
  62. package/dist/web/assets/protobuf-COyEY5Pt.js +7 -0
  63. package/dist/web/assets/pug-BaJupSGV.js +6 -0
  64. package/dist/web/assets/python-Db74sog5.js +6 -0
  65. package/dist/web/assets/qsharp-DXyYeYxl.js +6 -0
  66. package/dist/web/assets/r-CdQndTaG.js +6 -0
  67. package/dist/web/assets/razor-BxQoOni_.js +6 -0
  68. package/dist/web/assets/redis-CVwtpugi.js +6 -0
  69. package/dist/web/assets/redshift-25W9uPmb.js +6 -0
  70. package/dist/web/assets/restructuredtext-DfzH4Xui.js +6 -0
  71. package/dist/web/assets/ruby-Cp1zYvxS.js +6 -0
  72. package/dist/web/assets/rust-D5C2fndG.js +6 -0
  73. package/dist/web/assets/sb-CDntyWJ8.js +6 -0
  74. package/dist/web/assets/scala-BoFRg7Ot.js +6 -0
  75. package/dist/web/assets/scheme-Bio4gycK.js +6 -0
  76. package/dist/web/assets/scss-4Ik7cdeQ.js +8 -0
  77. package/dist/web/assets/shell-CX-rkNHf.js +6 -0
  78. package/dist/web/assets/solidity-Tw7wswEv.js +6 -0
  79. package/dist/web/assets/sophia-C5WLch3f.js +6 -0
  80. package/dist/web/assets/sparql-DHaeiCBh.js +6 -0
  81. package/dist/web/assets/sql-CCSDG5nI.js +6 -0
  82. package/dist/web/assets/st-pnP8ivHi.js +6 -0
  83. package/dist/web/assets/swift-DwJ7jVG9.js +8 -0
  84. package/dist/web/assets/systemverilog-B9Xyijhd.js +6 -0
  85. package/dist/web/assets/tcl-DnHyzjbg.js +6 -0
  86. package/dist/web/assets/tsMode-C9sm5xMG.js +16 -0
  87. package/dist/web/assets/twig-CPajHgWi.js +6 -0
  88. package/dist/web/assets/typescript-Ctr2X0xG.js +6 -0
  89. package/dist/web/assets/typespec-D-MeaMDU.js +6 -0
  90. package/dist/web/assets/vb-DgyLZaXg.js +6 -0
  91. package/dist/web/assets/wgsl-BIv9DU6q.js +303 -0
  92. package/dist/web/assets/xml-DGpUXjuk.js +6 -0
  93. package/dist/web/assets/yaml-6Dpo9WGU.js +6 -0
  94. package/dist/web/index.html +13 -0
  95. package/package.json +74 -0
@@ -0,0 +1,1889 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/server/cli.ts
4
+ import { fileURLToPath } from "url";
5
+ import { serve } from "@hono/node-server";
6
+
7
+ // src/server/app.ts
8
+ import { existsSync as existsSync3 } from "fs";
9
+ import { Hono as Hono7 } from "hono";
10
+ import { logger } from "hono/logger";
11
+ import { serveStatic } from "@hono/node-server/serve-static";
12
+
13
+ // src/server/routes/connections.ts
14
+ import { Hono } from "hono";
15
+
16
+ // src/server/store/config.ts
17
+ import { Low } from "lowdb";
18
+ import { JSONFile } from "lowdb/node";
19
+ import { nanoid } from "nanoid";
20
+
21
+ // src/server/store/paths.ts
22
+ import { mkdirSync } from "fs";
23
+ import { resolve } from "path";
24
+ var dataDir = resolve(process.cwd(), "data");
25
+ function setDataDir(dir) {
26
+ dataDir = resolve(dir);
27
+ ensureDirs();
28
+ }
29
+ function sessionsDir() {
30
+ return resolve(dataDir, "sessions");
31
+ }
32
+ function attachmentsDir() {
33
+ return resolve(dataDir, "attachments");
34
+ }
35
+ function configPath() {
36
+ return resolve(dataDir, "config.json");
37
+ }
38
+ function sessionFile(sessionId) {
39
+ const safe = sessionId.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 200) || "unknown";
40
+ return resolve(sessionsDir(), `${safe}.jsonl`);
41
+ }
42
+ function ensureDirs() {
43
+ mkdirSync(dataDir, { recursive: true });
44
+ mkdirSync(sessionsDir(), { recursive: true });
45
+ mkdirSync(attachmentsDir(), { recursive: true });
46
+ }
47
+
48
+ // src/server/store/config.ts
49
+ var db = null;
50
+ async function initConfig() {
51
+ ensureDirs();
52
+ const adapter = new JSONFile(configPath());
53
+ db = new Low(adapter, { connections: [] });
54
+ await db.read();
55
+ db.data ||= { connections: [] };
56
+ await db.write();
57
+ }
58
+ function requireDb() {
59
+ if (!db) throw new Error("config store not initialized \u2014 call initConfig() first");
60
+ return db;
61
+ }
62
+ function listConnections() {
63
+ return requireDb().data.connections;
64
+ }
65
+ function getConnection(id) {
66
+ return requireDb().data.connections.find((c) => c.id === id);
67
+ }
68
+ async function createConnection(input) {
69
+ const now = (/* @__PURE__ */ new Date()).toISOString();
70
+ const conn = {
71
+ ...input,
72
+ id: input.id ?? nanoid(8),
73
+ createdAt: now,
74
+ updatedAt: now
75
+ };
76
+ const d = requireDb();
77
+ d.data.connections.push(conn);
78
+ await d.write();
79
+ return conn;
80
+ }
81
+ async function updateConnection(id, patch) {
82
+ const d = requireDb();
83
+ const existing = d.data.connections.find((c) => c.id === id);
84
+ if (!existing) return void 0;
85
+ Object.assign(existing, patch, { id, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
86
+ await d.write();
87
+ return existing;
88
+ }
89
+ async function deleteConnection(id) {
90
+ const d = requireDb();
91
+ const before = d.data.connections.length;
92
+ d.data.connections = d.data.connections.filter((c) => c.id !== id);
93
+ const removed = d.data.connections.length < before;
94
+ if (removed) await d.write();
95
+ return removed;
96
+ }
97
+
98
+ // src/server/routes/connections.ts
99
+ var connectionsRoute = new Hono();
100
+ var emptyCredential = () => ({ domain: "", identity: "" });
101
+ function normalize(body) {
102
+ const cred = (c) => c && typeof c === "object" ? { domain: String(c.domain ?? ""), identity: String(c.identity ?? "") } : emptyCredential();
103
+ return {
104
+ name: String(body?.name ?? "Untitled connection"),
105
+ mode: body?.mode === "virtual-supplier" ? "virtual-supplier" : "virtual-buyer",
106
+ from: cred(body?.from),
107
+ to: cred(body?.to),
108
+ sender: cred(body?.sender),
109
+ sharedSecret: String(body?.sharedSecret ?? ""),
110
+ deploymentMode: body?.deploymentMode === "production" ? "production" : "test",
111
+ authStyle: body?.authStyle === "MAC" ? "MAC" : "SharedSecret",
112
+ punchoutUrl: body?.punchoutUrl ? String(body.punchoutUrl) : void 0,
113
+ orderUrl: body?.orderUrl ? String(body.orderUrl) : void 0,
114
+ catalog: Array.isArray(body?.catalog) ? body.catalog : void 0
115
+ };
116
+ }
117
+ function validateConnection(input) {
118
+ const errors = [];
119
+ if (!input.name.trim()) errors.push("name is required");
120
+ if (input.mode === "virtual-buyer") {
121
+ if (!input.punchoutUrl) errors.push("punchoutUrl is required for virtual-buyer");
122
+ if (!input.orderUrl) errors.push("orderUrl is required for virtual-buyer");
123
+ }
124
+ return errors;
125
+ }
126
+ connectionsRoute.get("/", (c) => c.json(listConnections()));
127
+ connectionsRoute.post("/", async (c) => {
128
+ const input = normalize(await c.req.json().catch(() => ({})));
129
+ const errors = validateConnection(input);
130
+ if (errors.length) return c.json({ errors }, 400);
131
+ const created = await createConnection(input);
132
+ return c.json(created, 201);
133
+ });
134
+ connectionsRoute.get("/:id", (c) => {
135
+ const conn = getConnection(c.req.param("id"));
136
+ return conn ? c.json(conn) : c.json({ error: "not found" }, 404);
137
+ });
138
+ connectionsRoute.put("/:id", async (c) => {
139
+ const id = c.req.param("id");
140
+ const existing = getConnection(id);
141
+ if (!existing) return c.json({ error: "not found" }, 404);
142
+ const merged = { ...existing, ...await c.req.json().catch(() => ({})) };
143
+ const input = normalize(merged);
144
+ const errors = validateConnection(input);
145
+ if (errors.length) return c.json({ errors }, 400);
146
+ const updated = await updateConnection(id, input);
147
+ return c.json(updated);
148
+ });
149
+ connectionsRoute.delete("/:id", async (c) => {
150
+ const ok = await deleteConnection(c.req.param("id"));
151
+ return ok ? c.json({ ok: true }) : c.json({ error: "not found" }, 404);
152
+ });
153
+
154
+ // src/server/routes/flow.ts
155
+ import { Hono as Hono2 } from "hono";
156
+ import { nanoid as nanoid5 } from "nanoid";
157
+
158
+ // src/server/store/log.ts
159
+ import { appendFileSync, existsSync, readdirSync, readFileSync } from "fs";
160
+ import { nanoid as nanoid2 } from "nanoid";
161
+
162
+ // src/server/bus.ts
163
+ import { EventEmitter } from "events";
164
+ var Bus = class extends EventEmitter {
165
+ emitLog(record) {
166
+ this.emit("event", { type: "log", record });
167
+ }
168
+ emitCart(connectionId, cart) {
169
+ this.emit("event", { type: "cart", connectionId, cart });
170
+ }
171
+ onEvent(fn) {
172
+ this.on("event", fn);
173
+ return () => this.off("event", fn);
174
+ }
175
+ };
176
+ var bus = new Bus();
177
+ bus.setMaxListeners(0);
178
+
179
+ // src/server/store/log.ts
180
+ function appendLog(input) {
181
+ ensureDirs();
182
+ const record = {
183
+ ...input,
184
+ id: input.id ?? nanoid2(12),
185
+ ts: input.ts ?? (/* @__PURE__ */ new Date()).toISOString()
186
+ };
187
+ appendFileSync(sessionFile(record.sessionId), JSON.stringify(record) + "\n", "utf8");
188
+ bus.emitLog(record);
189
+ return record;
190
+ }
191
+ function readSession(sessionId) {
192
+ const file = sessionFile(sessionId);
193
+ if (!existsSync(file)) return [];
194
+ return readFileSync(file, "utf8").split("\n").filter((l) => l.trim().length > 0).map((l) => {
195
+ try {
196
+ return JSON.parse(l);
197
+ } catch {
198
+ return null;
199
+ }
200
+ }).filter((r) => r !== null);
201
+ }
202
+ function listSessions() {
203
+ ensureDirs();
204
+ const files = readdirSync(sessionsDir()).filter((f) => f.endsWith(".jsonl"));
205
+ const summaries = [];
206
+ for (const file of files) {
207
+ const sessionId = file.replace(/\.jsonl$/, "");
208
+ const records = readSession(sessionId);
209
+ if (records.length === 0) continue;
210
+ summaries.push({
211
+ sessionId: records[0].sessionId ?? sessionId,
212
+ connectionId: records[0].connectionId,
213
+ count: records.length,
214
+ firstTs: records[0].ts,
215
+ lastTs: records[records.length - 1].ts,
216
+ docTypes: [...new Set(records.map((r) => r.docType))],
217
+ hasErrors: records.some((r) => r.validation && !r.validation.ok)
218
+ });
219
+ }
220
+ return summaries.sort((a, b) => (b.lastTs ?? "").localeCompare(a.lastTs ?? ""));
221
+ }
222
+ function readAllRecent(limit = 200) {
223
+ return listSessions().flatMap((s) => readSession(s.sessionId)).sort((a, b) => a.ts.localeCompare(b.ts)).slice(-limit);
224
+ }
225
+
226
+ // src/server/store/attachments.ts
227
+ import { createHash } from "crypto";
228
+ import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync } from "fs";
229
+ import { resolve as resolve2 } from "path";
230
+
231
+ // src/server/cxml/multipart.ts
232
+ import { nanoid as nanoid3 } from "nanoid";
233
+ function normalizeContentId(cid) {
234
+ if (!cid) return "";
235
+ return cid.trim().replace(/^<+/, "").replace(/>+$/, "").trim();
236
+ }
237
+ function buildMultipartRelated(cxml, attachments, opts = {}) {
238
+ const boundary = `cxml-${nanoid3(20)}`;
239
+ const mainCid = opts.mainContentId ?? `cxml-main@punchout-simulator`;
240
+ const CRLF = "\r\n";
241
+ const parts = [];
242
+ const pushPart = (headers, data) => {
243
+ parts.push(Buffer.from(`--${boundary}${CRLF}`, "utf8"));
244
+ parts.push(Buffer.from(headers.join(CRLF) + CRLF + CRLF, "utf8"));
245
+ parts.push(data);
246
+ parts.push(Buffer.from(CRLF, "utf8"));
247
+ };
248
+ pushPart(
249
+ [
250
+ `Content-Type: application/xml; charset=UTF-8`,
251
+ `Content-Transfer-Encoding: binary`,
252
+ `Content-ID: <${mainCid}>`
253
+ ],
254
+ Buffer.from(cxml, "utf8")
255
+ );
256
+ for (const att of attachments) {
257
+ const disposition = att.filename ? `Content-Disposition: attachment; filename="${att.filename}"` : `Content-Disposition: attachment`;
258
+ pushPart(
259
+ [
260
+ `Content-Type: ${att.contentType}`,
261
+ `Content-Transfer-Encoding: binary`,
262
+ `Content-ID: <${normalizeContentId(att.contentId)}>`,
263
+ disposition
264
+ ],
265
+ att.data
266
+ );
267
+ }
268
+ parts.push(Buffer.from(`--${boundary}--${CRLF}`, "utf8"));
269
+ return {
270
+ body: Buffer.concat(parts),
271
+ boundary,
272
+ contentType: `multipart/related; boundary="${boundary}"; type="application/xml"; start="<${mainCid}>"`
273
+ };
274
+ }
275
+ function getBoundary(contentType) {
276
+ const m = /boundary="?([^";]+)"?/i.exec(contentType);
277
+ return m?.[1];
278
+ }
279
+ function isMultipart(contentType) {
280
+ return !!contentType && /^multipart\//i.test(contentType.trim());
281
+ }
282
+ function parseMultipartRelated(body, contentType) {
283
+ const boundary = getBoundary(contentType);
284
+ if (!boundary) {
285
+ return { parts: [], byContentId: /* @__PURE__ */ new Map() };
286
+ }
287
+ const delimiter = Buffer.from(`--${boundary}`, "utf8");
288
+ const segments = splitBuffer(body, delimiter);
289
+ const parts = [];
290
+ for (const seg of segments) {
291
+ const trimmed = trimLeadingCrlf(seg);
292
+ if (trimmed.length === 0) continue;
293
+ if (trimmed.length >= 2 && trimmed[0] === 45 && trimmed[1] === 45) {
294
+ continue;
295
+ }
296
+ const splitIdx = findHeaderBodySplit(trimmed);
297
+ if (splitIdx < 0) continue;
298
+ const headerText = trimmed.subarray(0, splitIdx).toString("utf8");
299
+ let bodyBuf = trimmed.subarray(splitIdx);
300
+ bodyBuf = stripBoundaryTrailingCrlf(bodyBuf);
301
+ const headers = {};
302
+ for (const line of headerText.split(/\r?\n/)) {
303
+ const idx = line.indexOf(":");
304
+ if (idx > 0) {
305
+ headers[line.slice(0, idx).trim().toLowerCase()] = line.slice(idx + 1).trim();
306
+ }
307
+ }
308
+ parts.push({
309
+ headers,
310
+ contentId: normalizeContentId(headers["content-id"]),
311
+ contentType: headers["content-type"],
312
+ body: bodyBuf
313
+ });
314
+ }
315
+ const start = normalizeContentId(/start="?<?([^">]+)>?"?/i.exec(contentType)?.[1]);
316
+ const byContentId = /* @__PURE__ */ new Map();
317
+ for (const p of parts) if (p.contentId) byContentId.set(p.contentId, p);
318
+ const root2 = start && byContentId.get(start) || parts.find((p) => /xml/i.test(p.contentType ?? "")) || parts[0];
319
+ return { parts, root: root2, byContentId };
320
+ }
321
+ function splitBuffer(buf, delimiter) {
322
+ const out = [];
323
+ let start = 0;
324
+ let idx = buf.indexOf(delimiter, start);
325
+ while (idx !== -1) {
326
+ out.push(buf.subarray(start, idx));
327
+ start = idx + delimiter.length;
328
+ idx = buf.indexOf(delimiter, start);
329
+ }
330
+ out.push(buf.subarray(start));
331
+ return out;
332
+ }
333
+ function trimLeadingCrlf(buf) {
334
+ let i = 0;
335
+ while (i < buf.length && (buf[i] === 13 || buf[i] === 10)) i++;
336
+ return buf.subarray(i);
337
+ }
338
+ function stripBoundaryTrailingCrlf(buf) {
339
+ let end = buf.length;
340
+ while (end > 0 && (buf[end - 1] === 13 || buf[end - 1] === 10)) end--;
341
+ return buf.subarray(0, end);
342
+ }
343
+ function findHeaderBodySplit(buf) {
344
+ const crlfcrlf = buf.indexOf("\r\n\r\n");
345
+ const lflf = buf.indexOf("\n\n");
346
+ if (crlfcrlf !== -1 && (lflf === -1 || crlfcrlf < lflf)) return crlfcrlf + 4;
347
+ if (lflf !== -1) return lflf + 2;
348
+ return -1;
349
+ }
350
+
351
+ // src/server/store/attachments.ts
352
+ function saveAttachment(data, meta) {
353
+ ensureDirs();
354
+ const hash = createHash("sha256").update(data).digest("hex");
355
+ const path = resolve2(attachmentsDir(), hash);
356
+ if (!existsSync2(path)) writeFileSync(path, data);
357
+ return {
358
+ contentId: normalizeContentId(meta.contentId),
359
+ filename: meta.filename,
360
+ contentType: meta.contentType,
361
+ hash,
362
+ size: data.length,
363
+ referenced: meta.referenced
364
+ };
365
+ }
366
+ function readAttachment(hash) {
367
+ const safe = hash.replace(/[^a-f0-9]/gi, "");
368
+ if (!safe) return void 0;
369
+ const path = resolve2(attachmentsDir(), safe);
370
+ if (!existsSync2(path)) return void 0;
371
+ return readFileSync2(path);
372
+ }
373
+
374
+ // src/server/cart-store.ts
375
+ var carts = /* @__PURE__ */ new Map();
376
+ var connectionBySession = /* @__PURE__ */ new Map();
377
+ function setCart(cart) {
378
+ carts.set(cart.sessionId, cart);
379
+ }
380
+ function getCart(sessionId) {
381
+ return carts.get(sessionId);
382
+ }
383
+ function rememberSessionConnection(sessionId, connectionId) {
384
+ connectionBySession.set(sessionId, connectionId);
385
+ }
386
+ function connectionForSession(sessionId) {
387
+ return connectionBySession.get(sessionId);
388
+ }
389
+
390
+ // src/server/http.ts
391
+ async function sendCxml(url, body, contentType = "text/xml; charset=UTF-8", timeoutMs = 3e4) {
392
+ const controller = new AbortController();
393
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
394
+ try {
395
+ const res = await fetch(url, {
396
+ method: "POST",
397
+ headers: { "Content-Type": contentType },
398
+ body,
399
+ signal: controller.signal
400
+ });
401
+ const headers = {};
402
+ res.headers.forEach((v, k) => headers[k] = v);
403
+ const buf = Buffer.from(await res.arrayBuffer());
404
+ return {
405
+ status: res.status,
406
+ headers,
407
+ body: buf.toString("utf8"),
408
+ rawBody: buf,
409
+ contentType: res.headers.get("content-type") ?? void 0
410
+ };
411
+ } catch (e) {
412
+ return {
413
+ status: 0,
414
+ headers: {},
415
+ body: "",
416
+ rawBody: Buffer.alloc(0),
417
+ error: e instanceof Error ? e.message : String(e)
418
+ };
419
+ } finally {
420
+ clearTimeout(timer);
421
+ }
422
+ }
423
+
424
+ // src/server/runtime.ts
425
+ var runtime = {
426
+ port: 8080,
427
+ publicUrl: "http://localhost:8080"
428
+ };
429
+ function setRuntime(r) {
430
+ Object.assign(runtime, r);
431
+ }
432
+ function getPublicUrl() {
433
+ return runtime.publicUrl.replace(/\/$/, "");
434
+ }
435
+ function browserFormPostUrl() {
436
+ return `${getPublicUrl()}/punchout/return`;
437
+ }
438
+
439
+ // src/server/cxml/build.ts
440
+ import { nanoid as nanoid4 } from "nanoid";
441
+ function escapeXml(value) {
442
+ if (value == null) return "";
443
+ return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
444
+ }
445
+ function makePayloadId(host3, nowIso) {
446
+ return `${nowIso}.${nanoid4(10)}@${host3}`;
447
+ }
448
+ function credentialBlock(tag, c) {
449
+ return ` <${tag}>
450
+ <Credential domain="${escapeXml(c.domain)}">
451
+ <Identity>${escapeXml(c.identity)}</Identity>
452
+ </Credential>
453
+ </${tag}>`;
454
+ }
455
+ function senderBlock(c, sharedSecret, userAgent) {
456
+ const secret = sharedSecret != null && sharedSecret !== "" ? `
457
+ <SharedSecret>${escapeXml(sharedSecret)}</SharedSecret>` : "";
458
+ return ` <Sender>
459
+ <Credential domain="${escapeXml(c.domain)}">
460
+ <Identity>${escapeXml(c.identity)}</Identity>${secret}
461
+ </Credential>
462
+ <UserAgent>${escapeXml(userAgent ?? "punchout-simulator")}</UserAgent>
463
+ </Sender>`;
464
+ }
465
+ function header(p) {
466
+ return ` <Header>
467
+ ${credentialBlock("From", p.from)}
468
+ ${credentialBlock("To", p.to)}
469
+ ${senderBlock(p.sender, p.sharedSecret, p.userAgent)}
470
+ </Header>`;
471
+ }
472
+ var DECLARATION = `<?xml version="1.0" encoding="UTF-8"?>
473
+ <!DOCTYPE cXML SYSTEM "http://xml.cxml.org/schemas/cXML/1.2.045/cXML.dtd">`;
474
+ function envelope(payloadId, timestamp, lang, inner) {
475
+ return `${DECLARATION}
476
+ <cXML payloadID="${escapeXml(payloadId)}" timestamp="${escapeXml(timestamp)}" xml:lang="${escapeXml(lang)}">
477
+ ${inner}
478
+ </cXML>`;
479
+ }
480
+ function buildSetupRequest(o) {
481
+ const lang = o.lang ?? "en-US";
482
+ const deployment = o.deploymentMode ? ` deploymentMode="${escapeXml(o.deploymentMode)}"` : "";
483
+ const inner = `${header({
484
+ from: o.from,
485
+ to: o.to,
486
+ sender: o.sender,
487
+ sharedSecret: o.sharedSecret
488
+ })}
489
+ <Request${deployment}>
490
+ <PunchOutSetupRequest operation="${escapeXml(o.operation ?? "create")}">
491
+ <BuyerCookie>${escapeXml(o.buyerCookie)}</BuyerCookie>
492
+ <BrowserFormPost>
493
+ <URL>${escapeXml(o.browserFormPostUrl)}</URL>
494
+ </BrowserFormPost>
495
+ </PunchOutSetupRequest>
496
+ </Request>`;
497
+ return envelope(o.payloadId, o.timestamp, lang, inner);
498
+ }
499
+ function addressBlock(tag, a) {
500
+ return ` <${tag}>
501
+ <Address${a.addressId ? ` addressID="${escapeXml(a.addressId)}"` : ""}>
502
+ <Name xml:lang="en">${escapeXml(a.name ?? "")}</Name>
503
+ <PostalAddress>
504
+ ${a.deliverTo ? ` <DeliverTo>${escapeXml(a.deliverTo)}</DeliverTo>
505
+ ` : ""} <Street>${escapeXml(a.street ?? "")}</Street>
506
+ <City>${escapeXml(a.city ?? "")}</City>
507
+ <State>${escapeXml(a.state ?? "")}</State>
508
+ <PostalCode>${escapeXml(a.postalCode ?? "")}</PostalCode>
509
+ <Country isoCountryCode="${escapeXml(a.countryIsoCode ?? "US")}">${escapeXml(a.countryName ?? "United States")}</Country>
510
+ </PostalAddress>
511
+ </Address>
512
+ </${tag}>`;
513
+ }
514
+ function commentsWithAttachments(cids, indent) {
515
+ if (cids.length === 0) return "";
516
+ const atts = cids.map(
517
+ (cid) => `${indent} <Attachment>
518
+ ${indent} <URL>cid:${escapeXml(cid)}</URL>
519
+ ${indent} </Attachment>`
520
+ ).join("\n");
521
+ return `
522
+ ${indent}<Comments>
523
+ ${atts}
524
+ ${indent}</Comments>`;
525
+ }
526
+ function buildOrderRequest(o) {
527
+ const lang = o.lang ?? "en-US";
528
+ const deployment = o.deploymentMode ? ` deploymentMode="${escapeXml(o.deploymentMode)}"` : "";
529
+ const orderLevelCids = (o.attachments ?? []).filter((a) => a.scope === "order").map((a) => a.contentId);
530
+ const itemCids = (idx) => (o.attachments ?? []).filter((a) => typeof a.scope === "object" && a.scope.itemIndex === idx).map((a) => a.contentId);
531
+ const items = o.items.map((it, i) => {
532
+ const idx = i + 1;
533
+ const cids = itemCids(idx);
534
+ return ` <ItemOut quantity="${escapeXml(it.quantity)}" lineNumber="${idx}">
535
+ <ItemID>
536
+ <SupplierPartID>${escapeXml(it.supplierPartId ?? "")}</SupplierPartID>${it.supplierPartAuxiliaryId ? `
537
+ <SupplierPartAuxiliaryID>${escapeXml(it.supplierPartAuxiliaryId)}</SupplierPartAuxiliaryID>` : ""}
538
+ </ItemID>
539
+ <ItemDetail>
540
+ <UnitPrice>
541
+ <Money currency="${escapeXml(it.currency ?? o.currency)}">${escapeXml(
542
+ it.unitPriceAmount ?? 0
543
+ )}</Money>
544
+ </UnitPrice>
545
+ <Description xml:lang="en">${escapeXml(it.description ?? "")}</Description>
546
+ <UnitOfMeasure>${escapeXml(it.uom ?? "EA")}</UnitOfMeasure>
547
+ <Classification domain="${escapeXml(it.classificationDomain ?? "UNSPSC")}">${escapeXml(
548
+ it.classification ?? ""
549
+ )}</Classification>${it.manufacturerPartId ? `
550
+ <ManufacturerPartID>${escapeXml(it.manufacturerPartId)}</ManufacturerPartID>` : ""}${it.manufacturerName ? `
551
+ <ManufacturerName>${escapeXml(it.manufacturerName)}</ManufacturerName>` : ""}${commentsWithAttachments(cids, " ")}
552
+ </ItemDetail>
553
+ </ItemOut>`;
554
+ }).join("\n");
555
+ const inner = `${header({
556
+ from: o.from,
557
+ to: o.to,
558
+ sender: o.sender,
559
+ sharedSecret: o.sharedSecret
560
+ })}
561
+ <Request${deployment}>
562
+ <OrderRequest>
563
+ <OrderRequestHeader orderID="${escapeXml(o.orderId)}" orderDate="${escapeXml(
564
+ o.orderDate
565
+ )}" type="new">
566
+ <Total>
567
+ <Money currency="${escapeXml(o.currency)}">${escapeXml(o.total)}</Money>
568
+ </Total>
569
+ ${addressBlock("ShipTo", o.shipTo ?? {})}
570
+ ${addressBlock("BillTo", o.billTo ?? {})}${commentsWithAttachments(orderLevelCids, " ")}
571
+ </OrderRequestHeader>
572
+ ${items}
573
+ </OrderRequest>
574
+ </Request>`;
575
+ return envelope(o.payloadId, o.timestamp, lang, inner);
576
+ }
577
+ function optionalHeader(p) {
578
+ return p ? `${header({ from: p.from, to: p.to, sender: p.sender })}
579
+ ` : "";
580
+ }
581
+ function buildSetupResponse(o) {
582
+ const head = o.from && o.to && o.sender ? optionalHeader({ from: o.from, to: o.to, sender: o.sender }) : "";
583
+ const inner = `${head} <Response>
584
+ <Status code="${escapeXml(o.statusCode ?? "200")}" text="${escapeXml(
585
+ o.statusText ?? "OK"
586
+ )}"/>
587
+ <PunchOutSetupResponse>
588
+ <StartPage>
589
+ <URL>${escapeXml(o.startPageUrl)}</URL>
590
+ </StartPage>
591
+ </PunchOutSetupResponse>
592
+ </Response>`;
593
+ return envelope(o.payloadId, o.timestamp, o.lang ?? "en-US", inner);
594
+ }
595
+ function buildResponseStatus(o) {
596
+ const head = o.from && o.to && o.sender ? optionalHeader({ from: o.from, to: o.to, sender: o.sender }) : "";
597
+ const inner = `${head} <Response>
598
+ <Status code="${escapeXml(o.statusCode ?? "200")}" text="${escapeXml(
599
+ o.statusText ?? "OK"
600
+ )}">${escapeXml(o.statusText === "OK" || !o.statusText ? "" : o.statusText)}</Status>
601
+ </Response>`;
602
+ return envelope(o.payloadId, o.timestamp, o.lang ?? "en-US", inner);
603
+ }
604
+ function buildPunchOutOrderMessage(o) {
605
+ const total = o.items.reduce(
606
+ (sum, it) => sum + (it.unitPriceAmount ?? 0) * it.quantity,
607
+ 0
608
+ );
609
+ const items = o.items.map(
610
+ (it) => ` <ItemIn quantity="${escapeXml(it.quantity)}">
611
+ <ItemID>
612
+ <SupplierPartID>${escapeXml(it.supplierPartId ?? "")}</SupplierPartID>${it.supplierPartAuxiliaryId ? `
613
+ <SupplierPartAuxiliaryID>${escapeXml(it.supplierPartAuxiliaryId)}</SupplierPartAuxiliaryID>` : ""}
614
+ </ItemID>
615
+ <ItemDetail>
616
+ <UnitPrice>
617
+ <Money currency="${escapeXml(it.currency ?? o.currency)}">${escapeXml(
618
+ it.unitPriceAmount ?? 0
619
+ )}</Money>
620
+ </UnitPrice>
621
+ <Description xml:lang="en">${escapeXml(it.description ?? "")}</Description>
622
+ <UnitOfMeasure>${escapeXml(it.uom ?? "EA")}</UnitOfMeasure>
623
+ <Classification domain="${escapeXml(it.classificationDomain ?? "UNSPSC")}">${escapeXml(
624
+ it.classification ?? ""
625
+ )}</Classification>${it.manufacturerPartId ? `
626
+ <ManufacturerPartID>${escapeXml(it.manufacturerPartId)}</ManufacturerPartID>` : ""}
627
+ </ItemDetail>
628
+ </ItemIn>`
629
+ ).join("\n");
630
+ const inner = `${header({ from: o.from, to: o.to, sender: o.sender })}
631
+ <Message>
632
+ <PunchOutOrderMessage>
633
+ <BuyerCookie>${escapeXml(o.buyerCookie)}</BuyerCookie>
634
+ <PunchOutOrderMessageHeader operationAllowed="create">
635
+ <Total>
636
+ <Money currency="${escapeXml(o.currency)}">${escapeXml(total.toFixed(2))}</Money>
637
+ </Total>
638
+ </PunchOutOrderMessageHeader>
639
+ ${items}
640
+ </PunchOutOrderMessage>
641
+ </Message>`;
642
+ return envelope(o.payloadId, o.timestamp, o.lang ?? "en-US", inner);
643
+ }
644
+
645
+ // src/server/cxml/parse.ts
646
+ import { XMLParser, XMLValidator } from "fast-xml-parser";
647
+ var ATTR = "@_";
648
+ var parser = new XMLParser({
649
+ ignoreAttributes: false,
650
+ attributeNamePrefix: ATTR,
651
+ parseTagValue: false,
652
+ parseAttributeValue: false,
653
+ trimValues: true,
654
+ // Always treat the repeated cXML containers as arrays so callers don't have
655
+ // to special-case "one item vs many".
656
+ isArray: (name) => ["ItemIn", "ItemOut", "Attachment", "Comments", "Extrinsic"].includes(name)
657
+ });
658
+ function parseXml(raw) {
659
+ const check = XMLValidator.validate(raw, { allowBooleanAttributes: true });
660
+ if (check !== true) {
661
+ return {
662
+ raw,
663
+ tree: null,
664
+ wellFormed: false,
665
+ wellFormedError: check?.err?.msg ?? "not well-formed"
666
+ };
667
+ }
668
+ try {
669
+ return { raw, tree: parser.parse(raw), wellFormed: true };
670
+ } catch (e) {
671
+ return {
672
+ raw,
673
+ tree: null,
674
+ wellFormed: false,
675
+ wellFormedError: e instanceof Error ? e.message : String(e)
676
+ };
677
+ }
678
+ }
679
+ function text(node) {
680
+ if (node == null) return void 0;
681
+ if (typeof node === "string") return node;
682
+ if (typeof node === "number" || typeof node === "boolean") return String(node);
683
+ if (typeof node === "object" && "#text" in node) {
684
+ const t = node["#text"];
685
+ return t == null ? void 0 : String(t);
686
+ }
687
+ return void 0;
688
+ }
689
+ function attr(node, name) {
690
+ if (node == null || typeof node !== "object") return void 0;
691
+ const v = node[ATTR + name];
692
+ return v == null ? void 0 : String(v);
693
+ }
694
+ function asArray(node) {
695
+ if (node == null) return [];
696
+ return Array.isArray(node) ? node : [node];
697
+ }
698
+ function root(doc) {
699
+ return doc.tree?.cXML;
700
+ }
701
+ function getDocType(doc) {
702
+ const r = root(doc);
703
+ if (!r) return "Unknown";
704
+ const req = r.Request;
705
+ const resp = r.Response;
706
+ const msg = r.Message;
707
+ if (req && typeof req === "object") {
708
+ if ("PunchOutSetupRequest" in req) return "SetupRequest";
709
+ if ("OrderRequest" in req) return "OrderRequest";
710
+ }
711
+ if (resp && typeof resp === "object") {
712
+ if ("PunchOutSetupResponse" in resp) return "SetupResponse";
713
+ return "OrderResponse";
714
+ }
715
+ if (msg && typeof msg === "object" && "PunchOutOrderMessage" in msg) {
716
+ return "PunchOutOrderMessage";
717
+ }
718
+ return "Unknown";
719
+ }
720
+ function credentialOf(node) {
721
+ const cred = node?.Credential;
722
+ if (!cred) return void 0;
723
+ const first = Array.isArray(cred) ? cred[0] : cred;
724
+ return {
725
+ domain: attr(first, "domain") ?? "",
726
+ identity: text(first?.Identity) ?? ""
727
+ };
728
+ }
729
+ function getHeaderCredentials(doc) {
730
+ const header2 = root(doc)?.Header;
731
+ if (!header2) return {};
732
+ const senderCred = header2.Sender?.Credential;
733
+ const senderFirst = Array.isArray(senderCred) ? senderCred[0] : senderCred;
734
+ return {
735
+ from: credentialOf(header2.From),
736
+ to: credentialOf(header2.To),
737
+ sender: credentialOf(header2.Sender),
738
+ sharedSecret: text(senderFirst?.SharedSecret)
739
+ };
740
+ }
741
+ function getPayloadId(doc) {
742
+ return attr(root(doc), "payloadID");
743
+ }
744
+ function getTimestamp(doc) {
745
+ return attr(root(doc), "timestamp");
746
+ }
747
+ function getStatus(doc) {
748
+ const status = root(doc)?.Response?.Status;
749
+ if (!status) return {};
750
+ return { code: attr(status, "code"), text: attr(status, "text") };
751
+ }
752
+ function getStartPage(doc) {
753
+ const sr = root(doc)?.Response?.PunchOutSetupResponse;
754
+ return text(sr?.StartPage?.URL);
755
+ }
756
+ function money(node) {
757
+ const m = node?.Money;
758
+ if (!m) return {};
759
+ const raw = text(m);
760
+ const amount = raw != null && raw !== "" ? Number(raw) : void 0;
761
+ return {
762
+ amount: Number.isFinite(amount) ? amount : void 0,
763
+ currency: attr(m, "currency")
764
+ };
765
+ }
766
+ function stripCid(url) {
767
+ const trimmed = url.trim();
768
+ if (/^cid:/i.test(trimmed)) {
769
+ const cid = trimmed.replace(/^cid:/i, "").trim().replace(/^<+/, "").replace(/>+$/, "");
770
+ return { cid, external: false };
771
+ }
772
+ return { cid: trimmed, external: true };
773
+ }
774
+ function attachmentsInComments(commentsNode) {
775
+ const urls = [];
776
+ for (const comments of asArray(commentsNode)) {
777
+ for (const att of asArray(comments?.Attachment)) {
778
+ const url = text(att?.URL);
779
+ if (url) urls.push(url);
780
+ }
781
+ }
782
+ return urls;
783
+ }
784
+ function collectCidReferences(doc) {
785
+ const orderReq = root(doc)?.Request?.OrderRequest;
786
+ if (!orderReq) return [];
787
+ const refs = [];
788
+ for (const rawUrl of attachmentsInComments(orderReq?.OrderRequestHeader?.Comments)) {
789
+ const { cid, external } = stripCid(rawUrl);
790
+ refs.push({ cid, rawUrl, level: "order", external });
791
+ }
792
+ asArray(orderReq?.ItemOut).forEach((item, i) => {
793
+ for (const rawUrl of attachmentsInComments(item?.ItemDetail?.Comments)) {
794
+ const { cid, external } = stripCid(rawUrl);
795
+ refs.push({ cid, rawUrl, level: "item", itemIndex: i + 1, external });
796
+ }
797
+ for (const rawUrl of attachmentsInComments(item?.Comments)) {
798
+ const { cid, external } = stripCid(rawUrl);
799
+ refs.push({ cid, rawUrl, level: "item", itemIndex: i + 1, external });
800
+ }
801
+ });
802
+ return refs;
803
+ }
804
+ function parseCart(doc) {
805
+ const pom = root(doc)?.Message?.PunchOutOrderMessage;
806
+ const sessionId = text(pom?.BuyerCookie) ?? "";
807
+ const headerNode = pom?.PunchOutOrderMessageHeader;
808
+ const total = money(headerNode?.Total);
809
+ const items = asArray(pom?.ItemIn).map((it) => {
810
+ const detail = it?.ItemDetail;
811
+ const up = money(detail?.UnitPrice);
812
+ const classification = detail?.Classification;
813
+ const classFirst = Array.isArray(classification) ? classification[0] : classification;
814
+ return {
815
+ quantity: Number(attr(it, "quantity") ?? "1") || 1,
816
+ supplierPartId: text(it?.ItemID?.SupplierPartID),
817
+ supplierPartAuxiliaryId: text(it?.ItemID?.SupplierPartAuxiliaryID),
818
+ description: text(detail?.Description),
819
+ uom: text(detail?.UnitOfMeasure),
820
+ unitPriceAmount: up.amount,
821
+ currency: up.currency,
822
+ classificationDomain: attr(classFirst, "domain"),
823
+ classification: text(classFirst),
824
+ manufacturerPartId: text(detail?.ManufacturerPartID),
825
+ manufacturerName: text(detail?.ManufacturerName)
826
+ };
827
+ });
828
+ return {
829
+ sessionId,
830
+ operationAllowed: attr(headerNode, "operationAllowed"),
831
+ total: total.amount != null && total.currency ? { amount: total.amount, currency: total.currency } : void 0,
832
+ items
833
+ };
834
+ }
835
+
836
+ // src/server/cxml/validate.ts
837
+ var Issues = class {
838
+ list = [];
839
+ error(code, message, path) {
840
+ this.list.push({ severity: "error", code, message, path });
841
+ }
842
+ warn(code, message, path) {
843
+ this.list.push({ severity: "warning", code, message, path });
844
+ }
845
+ info(code, message, path) {
846
+ this.list.push({ severity: "info", code, message, path });
847
+ }
848
+ };
849
+ function isValidUrl(s) {
850
+ if (!s) return false;
851
+ try {
852
+ const u = new URL(s);
853
+ return u.protocol === "http:" || u.protocol === "https:";
854
+ } catch {
855
+ return false;
856
+ }
857
+ }
858
+ function credEq(a, b) {
859
+ if (!a || !b) return false;
860
+ return a.domain === b.domain && a.identity === b.identity;
861
+ }
862
+ function credKnown(c, conn) {
863
+ if (!c) return false;
864
+ return [conn.from, conn.to, conn.sender].some((k) => credEq(c, k));
865
+ }
866
+ function checkGeneral(doc, ctx, issues) {
867
+ const payloadId = getPayloadId(doc);
868
+ if (!payloadId) {
869
+ issues.error("missing-payloadID", "cXML/@payloadID is missing", "cXML/@payloadID");
870
+ } else if (!payloadId.includes("@")) {
871
+ issues.warn(
872
+ "payloadID-format",
873
+ "payloadID should have the form <unique>@<host>",
874
+ "cXML/@payloadID"
875
+ );
876
+ }
877
+ if (!getTimestamp(doc)) {
878
+ issues.error("missing-timestamp", "cXML/@timestamp is missing", "cXML/@timestamp");
879
+ }
880
+ if (!ctx.connection) return;
881
+ const conn = ctx.connection;
882
+ const creds = getHeaderCredentials(doc);
883
+ for (const [name, c] of [
884
+ ["From", creds.from],
885
+ ["To", creds.to],
886
+ ["Sender", creds.sender]
887
+ ]) {
888
+ if (!c) {
889
+ issues.warn("missing-credential", `Header/${name} credential is missing`, `cXML/Header/${name}`);
890
+ continue;
891
+ }
892
+ if (!c.domain) {
893
+ issues.warn("credential-domain", `Header/${name}/Credential/@domain is empty`, `cXML/Header/${name}`);
894
+ }
895
+ if (!c.identity) {
896
+ issues.warn("credential-identity", `Header/${name}/Credential/Identity is empty`, `cXML/Header/${name}`);
897
+ }
898
+ if (c.domain && c.identity && !credKnown(c, conn)) {
899
+ issues.warn(
900
+ "credential-mismatch",
901
+ `Header/${name} (${c.domain}/${c.identity}) does not match any identity configured on the connection`,
902
+ `cXML/Header/${name}`
903
+ );
904
+ }
905
+ }
906
+ }
907
+ function checkSharedSecret(doc, ctx, issues) {
908
+ if (!ctx.connection) return;
909
+ const conn = ctx.connection;
910
+ if (conn.authStyle !== "SharedSecret") return;
911
+ const creds = getHeaderCredentials(doc);
912
+ if (!creds.sharedSecret) {
913
+ issues.warn(
914
+ "missing-sharedsecret",
915
+ "Sender/Credential/SharedSecret is absent (required for SharedSecret auth)",
916
+ "cXML/Header/Sender/Credential/SharedSecret"
917
+ );
918
+ } else if (conn.sharedSecret && creds.sharedSecret !== conn.sharedSecret) {
919
+ issues.error(
920
+ "sharedsecret-mismatch",
921
+ "Sender SharedSecret does not match the connection's configured shared secret",
922
+ "cXML/Header/Sender/Credential/SharedSecret"
923
+ );
924
+ }
925
+ }
926
+ function checkSetupResponse(doc, issues) {
927
+ const status = getStatus(doc);
928
+ if (!status.code) {
929
+ issues.error("missing-status", "Response/Status/@code is missing", "cXML/Response/Status");
930
+ } else if (status.code !== "200") {
931
+ issues.error(
932
+ "status-not-200",
933
+ `PunchOutSetupResponse Status is ${status.code} (expected 200)`,
934
+ "cXML/Response/Status"
935
+ );
936
+ }
937
+ const startPage = getStartPage(doc);
938
+ if (!startPage) {
939
+ issues.error(
940
+ "missing-startpage",
941
+ "PunchOutSetupResponse/StartPage/URL is missing",
942
+ "cXML/Response/PunchOutSetupResponse/StartPage/URL"
943
+ );
944
+ } else if (!isValidUrl(startPage)) {
945
+ issues.error(
946
+ "invalid-startpage-url",
947
+ `StartPage/URL is not a valid http(s) URL: ${startPage}`,
948
+ "cXML/Response/PunchOutSetupResponse/StartPage/URL"
949
+ );
950
+ }
951
+ }
952
+ function num(s) {
953
+ if (s == null || s === "") return void 0;
954
+ const n = Number(s);
955
+ return Number.isFinite(n) ? n : void 0;
956
+ }
957
+ function checkPunchback(doc, ctx, issues) {
958
+ const pom = root(doc)?.Message?.PunchOutOrderMessage;
959
+ if (!pom) {
960
+ issues.error("missing-pom", "Message/PunchOutOrderMessage is missing", "cXML/Message");
961
+ return;
962
+ }
963
+ const buyerCookie = text(pom.BuyerCookie);
964
+ if (!buyerCookie) {
965
+ issues.error("missing-buyercookie", "BuyerCookie is missing", "cXML/Message/PunchOutOrderMessage/BuyerCookie");
966
+ } else if (ctx.expectedBuyerCookie && buyerCookie !== ctx.expectedBuyerCookie) {
967
+ issues.error(
968
+ "buyercookie-mismatch",
969
+ `BuyerCookie "${buyerCookie}" does not match the session "${ctx.expectedBuyerCookie}"`,
970
+ "cXML/Message/PunchOutOrderMessage/BuyerCookie"
971
+ );
972
+ }
973
+ const headerNode = pom.PunchOutOrderMessageHeader;
974
+ const totalMoney = headerNode?.Total?.Money;
975
+ const totalAmount = num(text(totalMoney));
976
+ const totalCurrency = attr(totalMoney, "currency");
977
+ if (totalMoney == null) {
978
+ issues.error("missing-total", "PunchOutOrderMessageHeader/Total/Money is missing", "cXML/.../PunchOutOrderMessageHeader/Total");
979
+ } else if (!totalCurrency) {
980
+ issues.error("missing-total-currency", "Total/Money/@currency is missing", "cXML/.../Total/Money");
981
+ }
982
+ const items = asArray(pom.ItemIn);
983
+ if (items.length === 0) {
984
+ issues.warn("empty-cart", "PunchOutOrderMessage contains no ItemIn elements", "cXML/.../PunchOutOrderMessage");
985
+ }
986
+ let lineSum = 0;
987
+ items.forEach((it, i) => {
988
+ const base = `cXML/.../ItemIn[${i + 1}]`;
989
+ const qty = num(attr(it, "quantity"));
990
+ if (qty == null) {
991
+ issues.error("item-missing-quantity", `ItemIn[${i + 1}] is missing @quantity`, base);
992
+ }
993
+ if (!text(it?.ItemID?.SupplierPartID)) {
994
+ issues.error("item-missing-supplierpartid", `ItemIn[${i + 1}] is missing ItemID/SupplierPartID`, `${base}/ItemID`);
995
+ }
996
+ const detail = it?.ItemDetail;
997
+ if (!detail) {
998
+ issues.error("item-missing-detail", `ItemIn[${i + 1}] is missing ItemDetail`, base);
999
+ return;
1000
+ }
1001
+ const up = detail.UnitPrice?.Money;
1002
+ const upAmount = num(text(up));
1003
+ if (up == null) {
1004
+ issues.error("item-missing-unitprice", `ItemIn[${i + 1}] is missing ItemDetail/UnitPrice/Money`, `${base}/ItemDetail/UnitPrice`);
1005
+ } else if (!attr(up, "currency")) {
1006
+ issues.error("item-missing-currency", `ItemIn[${i + 1}] UnitPrice/Money is missing @currency`, `${base}/ItemDetail/UnitPrice/Money`);
1007
+ }
1008
+ if (!text(detail.Description)) {
1009
+ issues.error("item-missing-description", `ItemIn[${i + 1}] is missing ItemDetail/Description`, `${base}/ItemDetail/Description`);
1010
+ }
1011
+ if (!text(detail.UnitOfMeasure)) {
1012
+ issues.error("item-missing-uom", `ItemIn[${i + 1}] is missing ItemDetail/UnitOfMeasure`, `${base}/ItemDetail/UnitOfMeasure`);
1013
+ }
1014
+ const classification = Array.isArray(detail.Classification) ? detail.Classification[0] : detail.Classification;
1015
+ if (classification == null) {
1016
+ issues.error("item-missing-classification", `ItemIn[${i + 1}] is missing ItemDetail/Classification`, `${base}/ItemDetail/Classification`);
1017
+ } else if (!attr(classification, "domain")) {
1018
+ issues.error("item-classification-domain", `ItemIn[${i + 1}] Classification is missing @domain`, `${base}/ItemDetail/Classification`);
1019
+ }
1020
+ if (qty != null && upAmount != null) lineSum += qty * upAmount;
1021
+ });
1022
+ if (totalAmount != null && items.length > 0) {
1023
+ const diff = Math.abs(totalAmount - lineSum);
1024
+ if (diff > 0.01) {
1025
+ issues.warn(
1026
+ "total-mismatch",
1027
+ `Header Total (${totalAmount.toFixed(2)}) does not equal the sum of line totals (${lineSum.toFixed(2)})`,
1028
+ "cXML/.../PunchOutOrderMessageHeader/Total"
1029
+ );
1030
+ }
1031
+ }
1032
+ }
1033
+ function checkOrderRequest(doc, ctx, issues) {
1034
+ const orderReq = root(doc)?.Request?.OrderRequest;
1035
+ if (!orderReq) {
1036
+ issues.error("missing-orderrequest", "Request/OrderRequest is missing", "cXML/Request");
1037
+ return;
1038
+ }
1039
+ const header2 = orderReq.OrderRequestHeader;
1040
+ if (!attr(header2, "orderID")) {
1041
+ issues.error("missing-orderid", "OrderRequestHeader/@orderID is missing", "cXML/.../OrderRequestHeader");
1042
+ }
1043
+ if (!attr(header2, "orderDate")) {
1044
+ issues.error("missing-orderdate", "OrderRequestHeader/@orderDate is missing", "cXML/.../OrderRequestHeader");
1045
+ }
1046
+ if (header2?.Total?.Money == null) {
1047
+ issues.error("missing-order-total", "OrderRequestHeader/Total/Money is missing", "cXML/.../OrderRequestHeader/Total");
1048
+ }
1049
+ if (!header2?.ShipTo) {
1050
+ issues.warn("missing-shipto", "OrderRequestHeader/ShipTo is missing", "cXML/.../OrderRequestHeader/ShipTo");
1051
+ }
1052
+ if (!header2?.BillTo) {
1053
+ issues.warn("missing-billto", "OrderRequestHeader/BillTo is missing", "cXML/.../OrderRequestHeader/BillTo");
1054
+ }
1055
+ const items = asArray(orderReq.ItemOut);
1056
+ if (items.length === 0) {
1057
+ issues.error("no-itemout", "OrderRequest contains no ItemOut elements", "cXML/.../OrderRequest");
1058
+ }
1059
+ items.forEach((it, i) => {
1060
+ const base = `cXML/.../ItemOut[${i + 1}]`;
1061
+ if (!text(it?.ItemID?.SupplierPartID)) {
1062
+ issues.error("itemout-missing-id", `ItemOut[${i + 1}] is missing ItemID/SupplierPartID`, `${base}/ItemID`);
1063
+ }
1064
+ if (it?.ItemDetail?.UnitPrice?.Money == null) {
1065
+ issues.error("itemout-missing-unitprice", `ItemOut[${i + 1}] is missing ItemDetail/UnitPrice/Money`, `${base}/ItemDetail/UnitPrice`);
1066
+ }
1067
+ if (num(attr(it, "quantity")) == null) {
1068
+ issues.error("itemout-missing-quantity", `ItemOut[${i + 1}] is missing @quantity`, base);
1069
+ }
1070
+ });
1071
+ const refs = collectCidReferences(doc);
1072
+ const available = ctx.availableContentIds;
1073
+ const referenced = /* @__PURE__ */ new Set();
1074
+ for (const ref of refs) {
1075
+ if (ref.external) {
1076
+ issues.info(
1077
+ "external-attachment",
1078
+ `Attachment URL is an external reference (not a cid:): ${ref.rawUrl}`,
1079
+ ref.level === "item" ? `cXML/.../ItemOut[${ref.itemIndex}]` : "cXML/.../OrderRequestHeader/Comments"
1080
+ );
1081
+ continue;
1082
+ }
1083
+ referenced.add(ref.cid);
1084
+ if (available && !available.has(ref.cid)) {
1085
+ issues.error(
1086
+ "dangling-cid",
1087
+ `Attachment references cid:"${ref.cid}" but no multipart part has that Content-ID (dangling attachment)`,
1088
+ ref.level === "item" ? `cXML/.../ItemOut[${ref.itemIndex}]/Comments/Attachment` : "cXML/.../OrderRequestHeader/Comments/Attachment"
1089
+ );
1090
+ }
1091
+ }
1092
+ if (available) {
1093
+ for (const cid of available) {
1094
+ if (!referenced.has(cid)) {
1095
+ issues.warn(
1096
+ "unreferenced-attachment",
1097
+ `Multipart part Content-ID "${cid}" is not referenced by any <Attachment><URL>cid:...`,
1098
+ "multipart"
1099
+ );
1100
+ }
1101
+ }
1102
+ }
1103
+ }
1104
+ function checkOrderResponse(doc, issues) {
1105
+ const status = getStatus(doc);
1106
+ if (!status.code) {
1107
+ issues.error("missing-status", "Response/Status/@code is missing", "cXML/Response/Status");
1108
+ } else {
1109
+ const code = Number(status.code);
1110
+ if (Number.isFinite(code) && code >= 400) {
1111
+ issues.error("status-error", `Response Status is ${status.code} ${status.text ?? ""}`.trim(), "cXML/Response/Status");
1112
+ }
1113
+ }
1114
+ }
1115
+ function validateDocument(raw, ctx = {}) {
1116
+ const doc = parseXml(raw);
1117
+ const issues = new Issues();
1118
+ if (!doc.wellFormed) {
1119
+ issues.error("not-well-formed", `XML is not well-formed: ${doc.wellFormedError}`);
1120
+ return { docType: "Unknown", wellFormed: false, ok: false, issues: issues.list };
1121
+ }
1122
+ const docType = ctx.forceDocType ?? getDocType(doc);
1123
+ checkGeneral(doc, ctx, issues);
1124
+ switch (docType) {
1125
+ case "SetupRequest":
1126
+ checkSharedSecret(doc, ctx, issues);
1127
+ if (!root(doc)?.Request?.PunchOutSetupRequest?.BrowserFormPost?.URL) {
1128
+ issues.warn("missing-browserformpost", "PunchOutSetupRequest/BrowserFormPost/URL is missing", "cXML/.../PunchOutSetupRequest/BrowserFormPost/URL");
1129
+ }
1130
+ break;
1131
+ case "SetupResponse":
1132
+ checkSetupResponse(doc, issues);
1133
+ break;
1134
+ case "PunchOutOrderMessage":
1135
+ checkPunchback(doc, ctx, issues);
1136
+ break;
1137
+ case "OrderRequest":
1138
+ checkSharedSecret(doc, ctx, issues);
1139
+ checkOrderRequest(doc, ctx, issues);
1140
+ break;
1141
+ case "OrderResponse":
1142
+ checkOrderResponse(doc, issues);
1143
+ break;
1144
+ default:
1145
+ issues.warn("unknown-doctype", "Could not classify the cXML document type");
1146
+ }
1147
+ const hasError = issues.list.some((i) => i.severity === "error");
1148
+ return { docType, wellFormed: true, ok: !hasError, issues: issues.list };
1149
+ }
1150
+
1151
+ // src/server/routes/flow.ts
1152
+ var flowRoute = new Hono2();
1153
+ function host() {
1154
+ try {
1155
+ return new URL(getPublicUrl()).host;
1156
+ } catch {
1157
+ return "punchout-simulator";
1158
+ }
1159
+ }
1160
+ function requireVirtualBuyer(conn) {
1161
+ if (!conn) return "connection not found";
1162
+ if (conn.mode !== "virtual-buyer") return "connection is not in virtual-buyer mode";
1163
+ return null;
1164
+ }
1165
+ flowRoute.get("/:id/setup/preview", (c) => {
1166
+ const conn = getConnection(c.req.param("id"));
1167
+ const err = requireVirtualBuyer(conn);
1168
+ if (err) return c.json({ error: err }, 400);
1169
+ const buyerCookie = c.req.query("buyerCookie") || `pos-${nanoid5(16)}`;
1170
+ const xml = buildSetupRequest({
1171
+ from: conn.from,
1172
+ to: conn.to,
1173
+ sender: conn.sender,
1174
+ sharedSecret: conn.sharedSecret,
1175
+ buyerCookie,
1176
+ browserFormPostUrl: browserFormPostUrl(),
1177
+ payloadId: makePayloadId(host(), (/* @__PURE__ */ new Date()).toISOString()),
1178
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1179
+ deploymentMode: conn.deploymentMode
1180
+ });
1181
+ return c.json({ buyerCookie, xml, browserFormPostUrl: browserFormPostUrl() });
1182
+ });
1183
+ flowRoute.post("/:id/setup", async (c) => {
1184
+ const conn = getConnection(c.req.param("id"));
1185
+ const err = requireVirtualBuyer(conn);
1186
+ if (err) return c.json({ error: err }, 400);
1187
+ const body = await c.req.json().catch(() => ({}));
1188
+ const buyerCookie = body.buyerCookie || `pos-${nanoid5(16)}`;
1189
+ const xml = body.xml || buildSetupRequest({
1190
+ from: conn.from,
1191
+ to: conn.to,
1192
+ sender: conn.sender,
1193
+ sharedSecret: conn.sharedSecret,
1194
+ buyerCookie,
1195
+ browserFormPostUrl: browserFormPostUrl(),
1196
+ payloadId: makePayloadId(host(), (/* @__PURE__ */ new Date()).toISOString()),
1197
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1198
+ deploymentMode: conn.deploymentMode
1199
+ });
1200
+ rememberSessionConnection(buyerCookie, conn.id);
1201
+ const reqValidation = validateDocument(xml, {
1202
+ connection: conn,
1203
+ forceDocType: "SetupRequest"
1204
+ });
1205
+ const reqLog = appendLog({
1206
+ sessionId: buyerCookie,
1207
+ connectionId: conn.id,
1208
+ direction: "out",
1209
+ docType: "SetupRequest",
1210
+ headers: { "Content-Type": "text/xml; charset=UTF-8" },
1211
+ body: xml,
1212
+ contentType: "text/xml",
1213
+ validation: reqValidation
1214
+ });
1215
+ const res = await sendCxml(conn.punchoutUrl, xml);
1216
+ const respValidation = res.error ? void 0 : validateDocument(res.body, { connection: conn, forceDocType: "SetupResponse" });
1217
+ const respLog = appendLog({
1218
+ sessionId: buyerCookie,
1219
+ connectionId: conn.id,
1220
+ direction: "in",
1221
+ docType: "SetupResponse",
1222
+ status: res.status,
1223
+ headers: res.headers,
1224
+ body: res.error ? `<!-- transport error: ${res.error} -->` : res.body,
1225
+ contentType: res.contentType,
1226
+ validation: respValidation
1227
+ });
1228
+ const startPage = res.error ? void 0 : getStartPage(parseXml(res.body));
1229
+ const status = res.error ? void 0 : getStatus(parseXml(res.body));
1230
+ return c.json({
1231
+ buyerCookie,
1232
+ transportError: res.error,
1233
+ httpStatus: res.status,
1234
+ startPage,
1235
+ statusCode: status?.code,
1236
+ request: reqLog,
1237
+ response: respLog
1238
+ });
1239
+ });
1240
+ flowRoute.post("/:id/order", async (c) => {
1241
+ const conn = getConnection(c.req.param("id"));
1242
+ const err = requireVirtualBuyer(conn);
1243
+ if (err) return c.json({ error: err }, 400);
1244
+ const body = await c.req.json().catch(() => ({}));
1245
+ const sessionId = body.sessionId || `pos-${nanoid5(16)}`;
1246
+ const items = body.items ?? [];
1247
+ const currency = body.currency || items[0]?.currency || "USD";
1248
+ const total = body.total ?? items.reduce((s, it) => s + (it.unitPriceAmount ?? 0) * it.quantity, 0);
1249
+ const orderId = body.orderId || `PO-${nanoid5(8)}`;
1250
+ const dangling = !!body.danglingCid;
1251
+ const inputAtts = body.attachments ?? [];
1252
+ const attMeta = inputAtts.map((a) => ({
1253
+ contentId: a.contentId,
1254
+ scope: a.scope === "order" || a.scope == null ? "order" : { itemIndex: Number(a.scope) }
1255
+ }));
1256
+ const xml = body.xml || buildOrderRequest({
1257
+ from: conn.from,
1258
+ to: conn.to,
1259
+ sender: conn.sender,
1260
+ sharedSecret: conn.sharedSecret,
1261
+ orderId,
1262
+ orderDate: (/* @__PURE__ */ new Date()).toISOString(),
1263
+ payloadId: makePayloadId(host(), (/* @__PURE__ */ new Date()).toISOString()),
1264
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1265
+ deploymentMode: conn.deploymentMode,
1266
+ currency,
1267
+ total,
1268
+ items,
1269
+ shipTo: body.shipTo,
1270
+ billTo: body.billTo,
1271
+ attachments: attMeta
1272
+ });
1273
+ let wireBody = xml;
1274
+ let wireContentType = "text/xml; charset=UTF-8";
1275
+ const availableContentIds = /* @__PURE__ */ new Set();
1276
+ const savedRefs = [];
1277
+ if (inputAtts.length > 0) {
1278
+ const parts = inputAtts.map((a) => {
1279
+ const actualCid = dangling ? `${a.contentId}-MISSING` : a.contentId;
1280
+ availableContentIds.add(actualCid);
1281
+ const data = Buffer.from(a.dataBase64, "base64");
1282
+ savedRefs.push(
1283
+ saveAttachment(data, {
1284
+ contentId: actualCid,
1285
+ filename: a.filename,
1286
+ contentType: a.contentType || "application/octet-stream"
1287
+ })
1288
+ );
1289
+ return {
1290
+ contentId: actualCid,
1291
+ filename: a.filename,
1292
+ contentType: a.contentType || "application/octet-stream",
1293
+ data
1294
+ };
1295
+ });
1296
+ const built = buildMultipartRelated(xml, parts);
1297
+ wireBody = built.body;
1298
+ wireContentType = built.contentType;
1299
+ }
1300
+ const reqValidation = validateDocument(xml, {
1301
+ connection: conn,
1302
+ forceDocType: "OrderRequest",
1303
+ availableContentIds: inputAtts.length > 0 ? availableContentIds : void 0
1304
+ });
1305
+ const reqLog = appendLog({
1306
+ sessionId,
1307
+ connectionId: conn.id,
1308
+ direction: "out",
1309
+ docType: "OrderRequest",
1310
+ headers: { "Content-Type": wireContentType },
1311
+ body: xml,
1312
+ contentType: wireContentType,
1313
+ validation: reqValidation,
1314
+ attachments: savedRefs,
1315
+ note: dangling ? "dangling-cid test" : void 0
1316
+ });
1317
+ const res = await sendCxml(conn.orderUrl, wireBody, wireContentType);
1318
+ const respValidation = res.error ? void 0 : validateDocument(res.body, { connection: conn, forceDocType: "OrderResponse" });
1319
+ const respLog = appendLog({
1320
+ sessionId,
1321
+ connectionId: conn.id,
1322
+ direction: "in",
1323
+ docType: "OrderResponse",
1324
+ status: res.status,
1325
+ headers: res.headers,
1326
+ body: res.error ? `<!-- transport error: ${res.error} -->` : res.body,
1327
+ contentType: res.contentType,
1328
+ validation: respValidation
1329
+ });
1330
+ const status = res.error ? void 0 : getStatus(parseXml(res.body));
1331
+ return c.json({
1332
+ transportError: res.error,
1333
+ httpStatus: res.status,
1334
+ statusCode: status?.code,
1335
+ statusText: status?.text,
1336
+ request: reqLog,
1337
+ response: respLog
1338
+ });
1339
+ });
1340
+
1341
+ // src/server/routes/punchout-return.ts
1342
+ import { Hono as Hono3 } from "hono";
1343
+ var punchoutReturnRoute = new Hono3();
1344
+ async function extractCxml(c) {
1345
+ const ct = (c.req.header("content-type") ?? "").toLowerCase();
1346
+ if (ct.includes("application/x-www-form-urlencoded") || ct.includes("multipart/form-data")) {
1347
+ const form = await c.req.parseBody();
1348
+ if (typeof form["cxml-urlencoded"] === "string") return form["cxml-urlencoded"];
1349
+ if (typeof form["cxml-base64"] === "string") {
1350
+ return Buffer.from(form["cxml-base64"], "base64").toString("utf8");
1351
+ }
1352
+ const firstString = Object.values(form).find((v) => typeof v === "string");
1353
+ if (typeof firstString === "string" && firstString.includes("<cXML")) return firstString;
1354
+ }
1355
+ return c.req.text();
1356
+ }
1357
+ function htmlReceipt(ok, itemCount) {
1358
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8">
1359
+ <title>punchout-simulator \u2014 cart received</title>
1360
+ <style>
1361
+ body{font-family:system-ui,sans-serif;background:#0f172a;color:#e2e8f0;display:flex;
1362
+ min-height:100vh;align-items:center;justify-content:center;margin:0}
1363
+ .card{background:#1e293b;padding:2.5rem 3rem;border-radius:14px;text-align:center;
1364
+ box-shadow:0 10px 40px rgba(0,0,0,.4);max-width:420px}
1365
+ .icon{font-size:3rem}
1366
+ h1{font-size:1.25rem;margin:.5rem 0}
1367
+ p{color:#94a3b8;line-height:1.5}
1368
+ .ok{color:#34d399}.bad{color:#f87171}
1369
+ </style></head><body><div class="card">
1370
+ <div class="icon ${ok ? "ok" : "bad"}">${ok ? "\u2713" : "\u26A0"}</div>
1371
+ <h1>Cart returned to punchout-simulator</h1>
1372
+ <p>${ok ? `Received ${itemCount} item(s).` : "The punchback had validation issues."}
1373
+ Switch back to the punchout-simulator tab to inspect the cart and build the OrderRequest.
1374
+ You can close this tab.</p>
1375
+ </div><script>setTimeout(()=>{try{window.close()}catch(e){}},1500)</script></body></html>`;
1376
+ }
1377
+ punchoutReturnRoute.post("/return", async (c) => {
1378
+ const xml = await extractCxml(c);
1379
+ const doc = parseXml(xml);
1380
+ const cart = parseCart(doc);
1381
+ const sessionId = cart.sessionId || text(root(doc)?.Message?.PunchOutOrderMessage?.BuyerCookie) || "unknown";
1382
+ const connectionId = connectionForSession(sessionId);
1383
+ const conn = connectionId ? getConnection(connectionId) : void 0;
1384
+ const validation = validateDocument(xml, {
1385
+ connection: conn,
1386
+ expectedBuyerCookie: sessionId,
1387
+ forceDocType: "PunchOutOrderMessage"
1388
+ });
1389
+ if (connectionId) rememberSessionConnection(sessionId, connectionId);
1390
+ appendLog({
1391
+ sessionId,
1392
+ connectionId: connectionId ?? "",
1393
+ direction: "in",
1394
+ docType: "PunchOutOrderMessage",
1395
+ headers: { "Content-Type": c.req.header("content-type") ?? "" },
1396
+ body: xml,
1397
+ contentType: c.req.header("content-type") ?? void 0,
1398
+ validation
1399
+ });
1400
+ setCart(cart);
1401
+ bus.emitCart(connectionId ?? "", cart);
1402
+ return c.html(htmlReceipt(validation.ok, cart.items.length));
1403
+ });
1404
+
1405
+ // src/server/routes/stream.ts
1406
+ import { Hono as Hono4 } from "hono";
1407
+ import { streamSSE } from "hono/streaming";
1408
+ var streamRoute = new Hono4();
1409
+ streamRoute.get("/stream", (c) => {
1410
+ return streamSSE(c, async (stream) => {
1411
+ let open = true;
1412
+ let id = 0;
1413
+ const unsubscribe = bus.onEvent((event) => {
1414
+ if (!open) return;
1415
+ void stream.writeSSE({
1416
+ id: String(id++),
1417
+ event: event.type,
1418
+ data: JSON.stringify(event)
1419
+ });
1420
+ });
1421
+ stream.onAbort(() => {
1422
+ open = false;
1423
+ unsubscribe();
1424
+ });
1425
+ await stream.writeSSE({ event: "ready", data: JSON.stringify({ ts: Date.now() }) });
1426
+ while (open) {
1427
+ await stream.sleep(15e3);
1428
+ if (!open) break;
1429
+ await stream.writeSSE({ event: "ping", data: JSON.stringify({ ts: Date.now() }) });
1430
+ }
1431
+ });
1432
+ });
1433
+
1434
+ // src/server/routes/data.ts
1435
+ import { Hono as Hono5 } from "hono";
1436
+ var dataRoute = new Hono5();
1437
+ dataRoute.get("/health", (c) => c.json({ ok: true }));
1438
+ dataRoute.get(
1439
+ "/runtime",
1440
+ (c) => c.json({ publicUrl: getPublicUrl(), callbackUrl: `${getPublicUrl()}/punchout/return` })
1441
+ );
1442
+ dataRoute.get("/sessions", (c) => c.json(listSessions()));
1443
+ dataRoute.get("/sessions/:id", (c) => c.json(readSession(c.req.param("id"))));
1444
+ dataRoute.get("/recent", (c) => {
1445
+ const limit = Number(c.req.query("limit") ?? "200");
1446
+ return c.json(readAllRecent(Number.isFinite(limit) ? limit : 200));
1447
+ });
1448
+ dataRoute.get("/cart/:sessionId", (c) => {
1449
+ const cart = getCart(c.req.param("sessionId"));
1450
+ return cart ? c.json(cart) : c.json({ error: "no cart for session" }, 404);
1451
+ });
1452
+ dataRoute.get("/attachments/:hash", (c) => {
1453
+ const data = readAttachment(c.req.param("hash"));
1454
+ if (!data) return c.json({ error: "not found" }, 404);
1455
+ c.header("Content-Type", "application/octet-stream");
1456
+ return c.body(data);
1457
+ });
1458
+
1459
+ // src/server/routes/sim.ts
1460
+ import { Hono as Hono6 } from "hono";
1461
+ import { nanoid as nanoid6 } from "nanoid";
1462
+ var simRoute = new Hono6();
1463
+ var DEMO_CATALOG = [
1464
+ {
1465
+ supplierPartId: "WIDGET-001",
1466
+ description: "Premium Steel Widget",
1467
+ unitPrice: 12.5,
1468
+ currency: "USD",
1469
+ uom: "EA",
1470
+ unspsc: "31161500",
1471
+ manufacturerPartId: "MFR-W001",
1472
+ manufacturerName: "Acme Manufacturing"
1473
+ },
1474
+ {
1475
+ supplierPartId: "BOLT-250",
1476
+ description: "M8 Hex Bolt (pack of 250)",
1477
+ unitPrice: 34,
1478
+ currency: "USD",
1479
+ uom: "PK",
1480
+ unspsc: "31161600",
1481
+ manufacturerPartId: "MFR-B250",
1482
+ manufacturerName: "Acme Manufacturing"
1483
+ },
1484
+ {
1485
+ supplierPartId: "TAPE-RED",
1486
+ description: "Industrial Marking Tape, Red",
1487
+ unitPrice: 5.75,
1488
+ currency: "USD",
1489
+ uom: "RL",
1490
+ unspsc: "31201500"
1491
+ }
1492
+ ];
1493
+ function host2() {
1494
+ try {
1495
+ return new URL(getPublicUrl()).host;
1496
+ } catch {
1497
+ return "punchout-simulator";
1498
+ }
1499
+ }
1500
+ function safeHttpUrl(u) {
1501
+ if (!u) return "";
1502
+ try {
1503
+ const parsed = new URL(u.trim());
1504
+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? u.trim() : "";
1505
+ } catch {
1506
+ return "";
1507
+ }
1508
+ }
1509
+ function catalogOf(conn) {
1510
+ return conn.catalog && conn.catalog.length > 0 ? conn.catalog : DEMO_CATALOG;
1511
+ }
1512
+ function requireSupplier(conn) {
1513
+ if (!conn) return "connection not found";
1514
+ if (conn.mode !== "virtual-supplier") return "connection is not in virtual-supplier mode";
1515
+ return null;
1516
+ }
1517
+ simRoute.post("/:id/punchout", async (c) => {
1518
+ const conn = getConnection(c.req.param("id"));
1519
+ const err = requireSupplier(conn);
1520
+ if (err) return c.text(err, 400);
1521
+ const reqXml = await c.req.text();
1522
+ const doc = parseXml(reqXml);
1523
+ const reqRoot = root(doc)?.Request?.PunchOutSetupRequest;
1524
+ const buyerCookie = text(reqRoot?.BuyerCookie) || `pos-${nanoid6(16)}`;
1525
+ const formPost = safeHttpUrl(text(reqRoot?.BrowserFormPost?.URL));
1526
+ const reqValidation = validateDocument(reqXml, {
1527
+ connection: conn,
1528
+ forceDocType: "SetupRequest"
1529
+ });
1530
+ appendLog({
1531
+ sessionId: buyerCookie,
1532
+ connectionId: conn.id,
1533
+ direction: "in",
1534
+ docType: "SetupRequest",
1535
+ headers: { "Content-Type": c.req.header("content-type") ?? "" },
1536
+ body: reqXml,
1537
+ validation: reqValidation
1538
+ });
1539
+ const startPageUrl = `${getPublicUrl()}/sim/${conn.id}/catalog?cookie=${encodeURIComponent(
1540
+ buyerCookie
1541
+ )}&formpost=${encodeURIComponent(formPost)}`;
1542
+ const respXml = buildSetupResponse({
1543
+ payloadId: makePayloadId(host2(), (/* @__PURE__ */ new Date()).toISOString()),
1544
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1545
+ startPageUrl,
1546
+ from: conn.from,
1547
+ to: conn.to,
1548
+ sender: conn.sender
1549
+ });
1550
+ appendLog({
1551
+ sessionId: buyerCookie,
1552
+ connectionId: conn.id,
1553
+ direction: "out",
1554
+ docType: "SetupResponse",
1555
+ headers: { "Content-Type": "text/xml; charset=UTF-8" },
1556
+ body: respXml,
1557
+ validation: validateDocument(respXml, { forceDocType: "SetupResponse" })
1558
+ });
1559
+ c.header("Content-Type", "text/xml; charset=UTF-8");
1560
+ return c.body(respXml);
1561
+ });
1562
+ simRoute.get("/:id/catalog", (c) => {
1563
+ const conn = getConnection(c.req.param("id"));
1564
+ const err = requireSupplier(conn);
1565
+ if (err) return c.text(err, 400);
1566
+ const cookie = c.req.query("cookie") ?? "";
1567
+ const formpost = c.req.query("formpost") ?? "";
1568
+ const items = catalogOf(conn);
1569
+ const rows = items.map(
1570
+ (it, i) => `<tr>
1571
+ <td><strong>${escapeHtml(it.description)}</strong><br><small>${escapeHtml(
1572
+ it.supplierPartId
1573
+ )} \xB7 ${escapeHtml(it.uom)} \xB7 UNSPSC ${escapeHtml(it.unspsc)}</small></td>
1574
+ <td class="price">${it.currency} ${it.unitPrice.toFixed(2)}</td>
1575
+ <td><input type="number" name="q_${i}" value="0" min="0" step="1" inputmode="numeric"></td>
1576
+ </tr>`
1577
+ ).join("\n");
1578
+ return c.html(`<!doctype html><html lang="en"><head><meta charset="utf-8">
1579
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1580
+ <title>${escapeHtml(conn.name)} \u2014 mock catalog</title>
1581
+ <style>
1582
+ body{font-family:system-ui,sans-serif;background:#0f172a;color:#e2e8f0;margin:0;padding:2rem}
1583
+ .wrap{max-width:720px;margin:0 auto}
1584
+ h1{font-size:1.4rem}.sub{color:#94a3b8;margin-bottom:1.5rem}
1585
+ table{width:100%;border-collapse:collapse;background:#1e293b;border-radius:12px;overflow:hidden}
1586
+ th,td{padding:.75rem 1rem;text-align:left;border-bottom:1px solid #334155}
1587
+ th{background:#0b1220;font-size:.8rem;text-transform:uppercase;letter-spacing:.05em;color:#94a3b8}
1588
+ .price{white-space:nowrap;color:#fbbf24}
1589
+ input[type=number]{width:5rem;background:#0b1220;border:1px solid #334155;color:#e2e8f0;
1590
+ border-radius:6px;padding:.35rem .5rem}
1591
+ button{margin-top:1.5rem;background:#6366f1;color:#fff;border:0;border-radius:8px;
1592
+ padding:.7rem 1.4rem;font-size:1rem;cursor:pointer}
1593
+ button:hover{background:#4f46e5}
1594
+ </style></head><body><div class="wrap">
1595
+ <h1>${escapeHtml(conn.name)} <small>(virtual supplier)</small></h1>
1596
+ <div class="sub">Mock catalog served by punchout-simulator. Set quantities and return the cart.</div>
1597
+ <form method="post" action="${getPublicUrl()}/sim/${conn.id}/checkout">
1598
+ <input type="hidden" name="cookie" value="${escapeHtml(cookie)}">
1599
+ <input type="hidden" name="formpost" value="${escapeHtml(formpost)}">
1600
+ <table><thead><tr><th>Item</th><th>Price</th><th>Qty</th></tr></thead>
1601
+ <tbody>${rows}</tbody></table>
1602
+ <button type="submit">Return cart to buyer \u2192</button>
1603
+ </form>
1604
+ </div></body></html>`);
1605
+ });
1606
+ simRoute.post("/:id/checkout", async (c) => {
1607
+ const conn = getConnection(c.req.param("id"));
1608
+ const err = requireSupplier(conn);
1609
+ if (err) return c.text(err, 400);
1610
+ const form = await c.req.parseBody();
1611
+ const cookie = String(form.cookie ?? `pos-${nanoid6(16)}`);
1612
+ const formpost = safeHttpUrl(String(form.formpost ?? ""));
1613
+ const catalog = catalogOf(conn);
1614
+ const items = [];
1615
+ catalog.forEach((it, i) => {
1616
+ const qty = Number(form[`q_${i}`] ?? 0);
1617
+ if (qty > 0) {
1618
+ items.push({
1619
+ quantity: qty,
1620
+ supplierPartId: it.supplierPartId,
1621
+ description: it.description,
1622
+ uom: it.uom,
1623
+ unitPriceAmount: it.unitPrice,
1624
+ currency: it.currency,
1625
+ classificationDomain: "UNSPSC",
1626
+ classification: it.unspsc,
1627
+ manufacturerPartId: it.manufacturerPartId,
1628
+ manufacturerName: it.manufacturerName
1629
+ });
1630
+ }
1631
+ });
1632
+ const currency = items[0]?.currency ?? "USD";
1633
+ const xml = buildPunchOutOrderMessage({
1634
+ from: conn.from,
1635
+ to: conn.to,
1636
+ sender: conn.sender,
1637
+ buyerCookie: cookie,
1638
+ payloadId: makePayloadId(host2(), (/* @__PURE__ */ new Date()).toISOString()),
1639
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1640
+ currency,
1641
+ items
1642
+ });
1643
+ appendLog({
1644
+ sessionId: cookie,
1645
+ connectionId: conn.id,
1646
+ direction: "out",
1647
+ docType: "PunchOutOrderMessage",
1648
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1649
+ body: xml,
1650
+ validation: validateDocument(xml, { connection: conn, forceDocType: "PunchOutOrderMessage" })
1651
+ });
1652
+ return c.html(`<!doctype html><html lang="en"><head><meta charset="utf-8">
1653
+ <title>Returning cart\u2026</title></head>
1654
+ <body onload="document.forms[0].submit()" style="font-family:system-ui;background:#0f172a;color:#e2e8f0">
1655
+ <p style="padding:2rem">Returning cart to the buyer\u2026</p>
1656
+ <form method="post" action="${escapeHtml(formpost)}">
1657
+ <input type="hidden" name="cxml-urlencoded" value="${escapeHtml(xml)}">
1658
+ <noscript><button type="submit">Continue</button></noscript>
1659
+ </form>
1660
+ </body></html>`);
1661
+ });
1662
+ simRoute.post("/:id/order", async (c) => {
1663
+ const conn = getConnection(c.req.param("id"));
1664
+ const err = requireSupplier(conn);
1665
+ if (err) return c.text(err, 400);
1666
+ const ct = c.req.header("content-type") ?? "";
1667
+ const raw = Buffer.from(await c.req.arrayBuffer());
1668
+ let xml;
1669
+ const availableContentIds = /* @__PURE__ */ new Set();
1670
+ const savedRefs = [];
1671
+ if (isMultipart(ct)) {
1672
+ const mp = parseMultipartRelated(raw, ct);
1673
+ xml = mp.root?.body.toString("utf8") ?? "";
1674
+ for (const part of mp.parts) {
1675
+ if (part === mp.root) continue;
1676
+ const cid = normalizeContentId(part.contentId);
1677
+ if (cid) availableContentIds.add(cid);
1678
+ savedRefs.push(
1679
+ saveAttachment(part.body, {
1680
+ contentId: cid,
1681
+ contentType: part.contentType ?? "application/octet-stream"
1682
+ })
1683
+ );
1684
+ }
1685
+ } else {
1686
+ xml = raw.toString("utf8");
1687
+ }
1688
+ const doc = parseXml(xml);
1689
+ const sessionId = findSessionForOrder(doc) ?? `order-${nanoid6(8)}`;
1690
+ const validation = validateDocument(xml, {
1691
+ connection: conn,
1692
+ forceDocType: "OrderRequest",
1693
+ availableContentIds: isMultipart(ct) ? availableContentIds : void 0
1694
+ });
1695
+ appendLog({
1696
+ sessionId,
1697
+ connectionId: conn.id,
1698
+ direction: "in",
1699
+ docType: "OrderRequest",
1700
+ headers: { "Content-Type": ct },
1701
+ body: xml,
1702
+ contentType: ct,
1703
+ validation,
1704
+ attachments: savedRefs
1705
+ });
1706
+ const ok = validation.ok;
1707
+ const respXml = buildResponseStatus({
1708
+ payloadId: makePayloadId(host2(), (/* @__PURE__ */ new Date()).toISOString()),
1709
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1710
+ statusCode: ok ? "200" : "400",
1711
+ statusText: ok ? "OK" : "Bad Request",
1712
+ from: conn.from,
1713
+ to: conn.to,
1714
+ sender: conn.sender
1715
+ });
1716
+ appendLog({
1717
+ sessionId,
1718
+ connectionId: conn.id,
1719
+ direction: "out",
1720
+ docType: "OrderResponse",
1721
+ headers: { "Content-Type": "text/xml; charset=UTF-8" },
1722
+ body: respXml,
1723
+ validation: validateDocument(respXml, { forceDocType: "OrderResponse" })
1724
+ });
1725
+ c.header("Content-Type", "text/xml; charset=UTF-8");
1726
+ return c.body(respXml, ok ? 200 : 400);
1727
+ });
1728
+ function findSessionForOrder(doc) {
1729
+ const header2 = root(doc)?.Request?.OrderRequest?.OrderRequestHeader;
1730
+ const orderId = header2 ? attrOf(header2, "orderID") : void 0;
1731
+ return orderId ? `order-${orderId}` : void 0;
1732
+ }
1733
+ function attrOf(node, name) {
1734
+ const v = node?.[`@_${name}`];
1735
+ return v == null ? void 0 : String(v);
1736
+ }
1737
+ function escapeHtml(s) {
1738
+ return escapeXml(s);
1739
+ }
1740
+
1741
+ // src/server/app.ts
1742
+ import { relative } from "path";
1743
+ function createApp(opts = {}) {
1744
+ const app = new Hono7();
1745
+ if (!opts.quiet) app.use("*", logger());
1746
+ app.route("/api/connections", connectionsRoute);
1747
+ app.route("/api/connections", flowRoute);
1748
+ app.route("/api", dataRoute);
1749
+ app.route("/api", streamRoute);
1750
+ app.route("/punchout", punchoutReturnRoute);
1751
+ app.route("/sim", simRoute);
1752
+ if (opts.webRoot && existsSync3(opts.webRoot)) {
1753
+ app.use(
1754
+ "/*",
1755
+ serveStatic({
1756
+ root: relativeToCwd(opts.webRoot),
1757
+ // SPA fallback: unknown non-API routes return index.html.
1758
+ rewriteRequestPath: (path) => path
1759
+ })
1760
+ );
1761
+ app.get("/*", serveStatic({ root: relativeToCwd(opts.webRoot), path: "index.html" }));
1762
+ }
1763
+ return app;
1764
+ }
1765
+ function relativeToCwd(abs) {
1766
+ const rel = relative(process.cwd(), abs);
1767
+ return rel === "" ? "." : rel;
1768
+ }
1769
+
1770
+ // src/server/seed.ts
1771
+ async function seedDemoIfEmpty() {
1772
+ if (listConnections().length > 0) return;
1773
+ const supplier = await createConnection({
1774
+ id: "demo-supplier",
1775
+ name: "Demo Supplier (built-in mock)",
1776
+ mode: "virtual-supplier",
1777
+ from: { domain: "DUNS", identity: "987654321" },
1778
+ // supplier identity (the tool)
1779
+ to: { domain: "DUNS", identity: "123456789" },
1780
+ // buyer identity (counterparty)
1781
+ sender: { domain: "DUNS", identity: "987654321" },
1782
+ sharedSecret: "demo-secret",
1783
+ deploymentMode: "test",
1784
+ authStyle: "SharedSecret",
1785
+ catalog: []
1786
+ });
1787
+ await createConnection({
1788
+ id: "demo-buyer",
1789
+ name: "Demo Buyer \u2192 built-in supplier",
1790
+ mode: "virtual-buyer",
1791
+ from: { domain: "DUNS", identity: "123456789" },
1792
+ // buyer identity (the tool)
1793
+ to: { domain: "DUNS", identity: "987654321" },
1794
+ // supplier identity (counterparty)
1795
+ sender: { domain: "DUNS", identity: "123456789" },
1796
+ sharedSecret: "demo-secret",
1797
+ deploymentMode: "test",
1798
+ authStyle: "SharedSecret",
1799
+ punchoutUrl: `${getPublicUrl()}/sim/${supplier.id}/punchout`,
1800
+ orderUrl: `${getPublicUrl()}/sim/${supplier.id}/order`
1801
+ });
1802
+ }
1803
+
1804
+ // src/server/cli.ts
1805
+ function parseFlags(argv) {
1806
+ const flags = {
1807
+ port: Number(process.env.PORT ?? 8080),
1808
+ dataDir: process.env.DATA_DIR ?? "./data",
1809
+ open: true,
1810
+ dev: false,
1811
+ seed: true
1812
+ };
1813
+ for (let i = 0; i < argv.length; i++) {
1814
+ const a = argv[i];
1815
+ const next = () => argv[++i];
1816
+ switch (a) {
1817
+ case "--port":
1818
+ case "-p":
1819
+ flags.port = Number(next());
1820
+ break;
1821
+ case "--data-dir":
1822
+ case "-d":
1823
+ flags.dataDir = next();
1824
+ break;
1825
+ case "--public-url":
1826
+ flags.publicUrl = next();
1827
+ break;
1828
+ case "--no-open":
1829
+ flags.open = false;
1830
+ break;
1831
+ case "--dev":
1832
+ flags.dev = true;
1833
+ flags.open = false;
1834
+ break;
1835
+ case "--no-seed":
1836
+ flags.seed = false;
1837
+ break;
1838
+ case "--help":
1839
+ case "-h":
1840
+ printHelp();
1841
+ process.exit(0);
1842
+ }
1843
+ }
1844
+ return flags;
1845
+ }
1846
+ function printHelp() {
1847
+ console.log(`punchout-simulator \u2014 test cXML PunchOut integrations as a virtual counterparty
1848
+
1849
+ Usage: punchout-simulator [options]
1850
+
1851
+ Options:
1852
+ -p, --port <n> Port to listen on (default 8080)
1853
+ -d, --data-dir <path> Where to store config + logs (default ./data)
1854
+ --public-url <url> Externally reachable base URL (default http://localhost:<port>)
1855
+ Set this when fronting the tool with ngrok/cloudflared.
1856
+ --no-open Do not open a browser on start
1857
+ --no-seed Do not seed the built-in demo connections on first run
1858
+ --dev Dev mode (do not serve SPA, do not open browser)
1859
+ -h, --help Show this help
1860
+ `);
1861
+ }
1862
+ async function main() {
1863
+ const flags = parseFlags(process.argv.slice(2));
1864
+ const publicUrl = flags.publicUrl ?? `http://localhost:${flags.port}`;
1865
+ setDataDir(flags.dataDir);
1866
+ setRuntime({ port: flags.port, publicUrl });
1867
+ await initConfig();
1868
+ if (flags.seed) await seedDemoIfEmpty();
1869
+ const webRoot = flags.dev ? void 0 : fileURLToPath(new URL("../web", import.meta.url));
1870
+ const app = createApp({ webRoot, quiet: false });
1871
+ serve({ fetch: app.fetch, port: flags.port }, (info) => {
1872
+ const url = `http://localhost:${info.port}`;
1873
+ console.log(`
1874
+ punchout-simulator listening on ${url}`);
1875
+ if (getPublicUrl() !== url) console.log(` public URL: ${getPublicUrl()}`);
1876
+ console.log(` data dir: ${flags.dataDir}`);
1877
+ console.log(` callback: ${getPublicUrl()}/punchout/return
1878
+ `);
1879
+ if (flags.open) {
1880
+ import("open").then((m) => m.default(url)).catch(() => {
1881
+ });
1882
+ }
1883
+ });
1884
+ }
1885
+ main().catch((e) => {
1886
+ console.error(e);
1887
+ process.exit(1);
1888
+ });
1889
+ //# sourceMappingURL=cli.js.map