punchout-simulator 0.2.0 → 0.3.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 (23) hide show
  1. package/README.md +86 -14
  2. package/dist/server/cli.js +534 -93
  3. package/dist/web/assets/{cssMode-B-got2hv.js → cssMode-UKotGf5X.js} +1 -1
  4. package/dist/web/assets/{freemarker2-RnkeBrE4.js → freemarker2-0DRATxLT.js} +1 -1
  5. package/dist/web/assets/{handlebars-54KQc4Ip.js → handlebars-U7ip3Hjn.js} +1 -1
  6. package/dist/web/assets/{html-BHfOj4V7.js → html-pMwW-Db1.js} +1 -1
  7. package/dist/web/assets/{htmlMode-Chd2dG7N.js → htmlMode-BZiR_DER.js} +1 -1
  8. package/dist/web/assets/{index-CdJNNMRn.js → index-fwAo3vG0.js} +197 -197
  9. package/dist/web/assets/{index-9LlIENcD.css → index-sN4D-IAg.css} +1 -1
  10. package/dist/web/assets/{javascript-DfxvokuB.js → javascript-BNQzFVRF.js} +1 -1
  11. package/dist/web/assets/{jsonMode-VBut9FUK.js → jsonMode-CSAIHDlB.js} +1 -1
  12. package/dist/web/assets/{liquid-BnObGKeE.js → liquid--vvS9GUJ.js} +1 -1
  13. package/dist/web/assets/{mdx-DugOiDtO.js → mdx-BJDQflEF.js} +1 -1
  14. package/dist/web/assets/{python-BctDjJVU.js → python-CepodNht.js} +1 -1
  15. package/dist/web/assets/{razor-wtD6sxeJ.js → razor-BQksENSs.js} +1 -1
  16. package/dist/web/assets/{tsMode-DcIWDCPt.js → tsMode-BRKq1Rx4.js} +1 -1
  17. package/dist/web/assets/{typescript-amfHwAlg.js → typescript-V-bP8GlZ.js} +1 -1
  18. package/dist/web/assets/{xml-CRuxB8WJ.js → xml-Bgme81_M.js} +1 -1
  19. package/dist/web/assets/{yaml-D_zXW3Py.js → yaml-CkpLYd3y.js} +1 -1
  20. package/dist/web/favicon.svg +8 -0
  21. package/dist/web/index.html +12 -2
  22. package/package.json +1 -1
  23. package/dist/server/cli.js.map +0 -1
@@ -3,23 +3,149 @@
3
3
  // src/server/cli.ts
4
4
  import { fileURLToPath } from "url";
5
5
  import { serve } from "@hono/node-server";
6
+ import { nanoid as nanoid7 } from "nanoid";
6
7
 
7
8
  // src/server/app.ts
8
9
  import { existsSync as existsSync3 } from "fs";
9
- import { Hono as Hono8 } from "hono";
10
+ import { Hono as Hono9 } from "hono";
10
11
  import { logger } from "hono/logger";
12
+ import { bodyLimit } from "hono/body-limit";
13
+ import { getCookie } from "hono/cookie";
11
14
  import { serveStatic } from "@hono/node-server/serve-static";
12
15
 
16
+ // src/server/runtime.ts
17
+ var runtime = {
18
+ port: 8080,
19
+ publicUrl: "http://localhost:8080"
20
+ };
21
+ function setRuntime(r) {
22
+ Object.assign(runtime, r);
23
+ }
24
+ function getToken() {
25
+ return runtime.token;
26
+ }
27
+ function getPublicUrl() {
28
+ return runtime.publicUrl.replace(/\/$/, "");
29
+ }
30
+ function browserFormPostUrl() {
31
+ return `${getPublicUrl()}/punchout/return`;
32
+ }
33
+
13
34
  // src/server/routes/connections.ts
14
35
  import { Hono } from "hono";
15
36
 
16
37
  // src/server/store/config.ts
38
+ import { chmodSync as chmodSync2 } from "fs";
17
39
  import { Low } from "lowdb";
18
40
  import { JSONFile } from "lowdb/node";
19
41
  import { nanoid } from "nanoid";
20
42
 
43
+ // src/server/cxml/profile-presets.ts
44
+ var PROFILE_PRESETS = [
45
+ {
46
+ id: "generic",
47
+ name: "Generic cXML",
48
+ platform: "Generic",
49
+ builtin: true,
50
+ dtdVersions: { default: "1.2.045" },
51
+ userAgent: "punchout-simulator",
52
+ setupOperation: "create",
53
+ attachmentEncoding: "binary",
54
+ cartReturnTransport: "cxml-urlencoded",
55
+ extrinsics: []
56
+ },
57
+ {
58
+ id: "ariba",
59
+ name: "SAP Ariba",
60
+ platform: "Ariba",
61
+ builtin: true,
62
+ dtdVersions: { default: "1.2.045", PunchOutOrderMessage: "1.2.045" },
63
+ userAgent: "Ariba Network/1.0",
64
+ setupOperation: "create",
65
+ attachmentEncoding: "base64",
66
+ cartReturnTransport: "cxml-urlencoded",
67
+ extrinsics: [{ name: "User", value: "${buyerCookie}", scope: "setup" }]
68
+ },
69
+ {
70
+ id: "coupa",
71
+ name: "Coupa",
72
+ platform: "Coupa",
73
+ builtin: true,
74
+ // Coupa publishes specific DTD versions per document type.
75
+ dtdVersions: { default: "1.2.014", PunchOutOrderMessage: "1.2.023" },
76
+ userAgent: "Coupa Procurement",
77
+ setupOperation: "create",
78
+ attachmentEncoding: "base64",
79
+ cartReturnTransport: "cxml-urlencoded",
80
+ extrinsics: []
81
+ },
82
+ {
83
+ id: "jaggaer",
84
+ name: "JAGGAER",
85
+ platform: "Jaggaer",
86
+ builtin: true,
87
+ dtdVersions: { default: "1.2.021" },
88
+ userAgent: "JAGGAER Procurement",
89
+ setupOperation: "create",
90
+ attachmentEncoding: "binary",
91
+ cartReturnTransport: "cxml-urlencoded",
92
+ extrinsics: []
93
+ },
94
+ {
95
+ id: "oracle",
96
+ name: "Oracle iProcurement",
97
+ platform: "Oracle",
98
+ builtin: true,
99
+ dtdVersions: { default: "1.2.008" },
100
+ userAgent: "Oracle iProcurement",
101
+ setupOperation: "create",
102
+ attachmentEncoding: "binary",
103
+ cartReturnTransport: "cxml-urlencoded",
104
+ extrinsics: []
105
+ },
106
+ {
107
+ id: "sap-srm",
108
+ name: "SAP SRM / Business Network",
109
+ platform: "SAP",
110
+ builtin: true,
111
+ // NOTE: real SAP SRM speaks OCI (form parameters), which this cXML-only tool
112
+ // cannot emit. This profile models the cXML/Business-Network side: base64
113
+ // attachments and a base64 cart return.
114
+ dtdVersions: { default: "1.2.040" },
115
+ userAgent: "SAP Business Network",
116
+ setupOperation: "create",
117
+ attachmentEncoding: "base64",
118
+ cartReturnTransport: "cxml-base64",
119
+ extrinsics: []
120
+ },
121
+ {
122
+ id: "workday",
123
+ name: "Workday",
124
+ platform: "Workday",
125
+ builtin: true,
126
+ dtdVersions: { default: "1.2.045" },
127
+ userAgent: "Workday Strategic Sourcing",
128
+ setupOperation: "create",
129
+ attachmentEncoding: "base64",
130
+ cartReturnTransport: "cxml-urlencoded",
131
+ extrinsics: []
132
+ }
133
+ ];
134
+ var GENERIC_PROFILE = {
135
+ ...PROFILE_PRESETS[0],
136
+ createdAt: "",
137
+ updatedAt: ""
138
+ };
139
+ function seedBuiltinProfiles(data, now2) {
140
+ for (const preset of PROFILE_PRESETS) {
141
+ if (!data.profiles.some((p) => p.id === preset.id)) {
142
+ data.profiles.push({ ...preset, createdAt: now2, updatedAt: now2 });
143
+ }
144
+ }
145
+ }
146
+
21
147
  // src/server/store/paths.ts
22
- import { mkdirSync } from "fs";
148
+ import { chmodSync, mkdirSync } from "fs";
23
149
  import { resolve } from "path";
24
150
  var dataDir = resolve(process.cwd(), "data");
25
151
  function setDataDir(dir) {
@@ -40,9 +166,13 @@ function sessionFile(sessionId) {
40
166
  return resolve(sessionsDir(), `${safe}.jsonl`);
41
167
  }
42
168
  function ensureDirs() {
43
- mkdirSync(dataDir, { recursive: true });
44
- mkdirSync(sessionsDir(), { recursive: true });
45
- mkdirSync(attachmentsDir(), { recursive: true });
169
+ for (const d of [dataDir, sessionsDir(), attachmentsDir()]) {
170
+ mkdirSync(d, { recursive: true, mode: 448 });
171
+ try {
172
+ chmodSync(d, 448);
173
+ } catch {
174
+ }
175
+ }
46
176
  }
47
177
 
48
178
  // src/server/store/config.ts
@@ -50,14 +180,20 @@ var db = null;
50
180
  async function initConfig() {
51
181
  ensureDirs();
52
182
  const adapter = new JSONFile(configPath());
53
- db = new Low(adapter, { buyers: [], suppliers: [], connections: [] });
183
+ db = new Low(adapter, { buyers: [], suppliers: [], connections: [], profiles: [] });
54
184
  await db.read();
55
- db.data ||= { buyers: [], suppliers: [], connections: [] };
185
+ db.data ||= { buyers: [], suppliers: [], connections: [], profiles: [] };
56
186
  db.data.buyers ||= [];
57
187
  db.data.suppliers ||= [];
58
188
  db.data.connections ||= [];
189
+ db.data.profiles ||= [];
190
+ seedBuiltinProfiles(db.data, now());
59
191
  migrateLegacy(db.data);
60
192
  await db.write();
193
+ try {
194
+ chmodSync2(configPath(), 384);
195
+ } catch {
196
+ }
61
197
  }
62
198
  function requireDb() {
63
199
  if (!db) throw new Error("config store not initialized \u2014 call initConfig() first");
@@ -166,6 +302,53 @@ function findConnectionBySupplierAndBuyerIdentity(supplierId, from) {
166
302
  }
167
303
  return void 0;
168
304
  }
305
+ var listProfiles = () => requireDb().data.profiles;
306
+ var getProfile = (id) => requireDb().data.profiles.find((p) => p.id === id);
307
+ async function createProfile(input) {
308
+ const profile = { ...input, id: input.id ?? nanoid(8), createdAt: now(), updatedAt: now() };
309
+ const d = requireDb();
310
+ d.data.profiles.push(profile);
311
+ await d.write();
312
+ return profile;
313
+ }
314
+ async function updateProfile(id, patch) {
315
+ const d = requireDb();
316
+ const existing = d.data.profiles.find((p) => p.id === id);
317
+ if (!existing) return void 0;
318
+ Object.assign(existing, patch, { id, updatedAt: now() });
319
+ await d.write();
320
+ return existing;
321
+ }
322
+ async function deleteProfile(id) {
323
+ const d = requireDb();
324
+ if (d.data.buyers.some((b) => b.profileId === id)) {
325
+ throw new Error("profile is referenced by a buyer");
326
+ }
327
+ const before = d.data.profiles.length;
328
+ d.data.profiles = d.data.profiles.filter((p) => p.id !== id);
329
+ const removed = d.data.profiles.length < before;
330
+ if (removed) await d.write();
331
+ return removed;
332
+ }
333
+ function profileForBuyer(buyer) {
334
+ const p = buyer.profileId ? getProfile(buyer.profileId) : void 0;
335
+ return p ?? getProfile("generic") ?? GENERIC_PROFILE;
336
+ }
337
+ function effectiveProfile(connection, buyer) {
338
+ const p = profileForBuyer(buyer);
339
+ return {
340
+ dtdVersions: p.dtdVersions,
341
+ userAgent: p.userAgent,
342
+ setupOperation: p.setupOperation,
343
+ // Connection attachmentEncoding is concrete by construction → explicit override.
344
+ attachmentEncoding: connection.attachmentEncoding ?? p.attachmentEncoding,
345
+ cartReturnTransport: p.cartReturnTransport,
346
+ extrinsics: p.extrinsics
347
+ };
348
+ }
349
+ function dtdVersionFor(eff, docType) {
350
+ return eff.dtdVersions[docType] ?? eff.dtdVersions.default;
351
+ }
169
352
  function migrateLegacy(data) {
170
353
  const legacy = data.connections.filter((c) => "from" in c || "to" in c);
171
354
  if (legacy.length === 0) return;
@@ -219,7 +402,6 @@ function migrateLegacy(data) {
219
402
  sharedSecret: c.sharedSecret ?? "",
220
403
  senderIdentity: c.sender,
221
404
  deploymentMode: c.deploymentMode ?? "test",
222
- authStyle: c.authStyle ?? "SharedSecret",
223
405
  createdAt: c.createdAt ?? now(),
224
406
  updatedAt: now()
225
407
  });
@@ -239,7 +421,7 @@ function normalize(body) {
239
421
  sharedSecret: String(body?.sharedSecret ?? ""),
240
422
  senderIdentity: sender,
241
423
  deploymentMode: body?.deploymentMode === "production" ? "production" : "test",
242
- authStyle: body?.authStyle === "MAC" ? "MAC" : "SharedSecret"
424
+ attachmentEncoding: body?.attachmentEncoding === "base64" ? "base64" : "binary"
243
425
  };
244
426
  }
245
427
  function validate(input) {
@@ -254,11 +436,14 @@ function withLabel(input) {
254
436
  const supplier = getSupplier(input.supplierId);
255
437
  return { ...input, name: `${buyer?.name ?? "Buyer"} \u2192 ${supplier?.name ?? "Supplier"}` };
256
438
  }
439
+ function maskSecret(conn) {
440
+ return { ...conn, sharedSecret: "", hasSharedSecret: !!conn.sharedSecret };
441
+ }
257
442
  connectionsRoute.get(
258
443
  "/",
259
444
  (c) => c.json(
260
445
  listConnections().map((conn) => ({
261
- ...conn,
446
+ ...maskSecret(conn),
262
447
  buyer: getBuyer(conn.buyerId),
263
448
  supplier: getSupplier(conn.supplierId)
264
449
  }))
@@ -268,21 +453,23 @@ connectionsRoute.post("/", async (c) => {
268
453
  const input = normalize(await c.req.json().catch(() => ({})));
269
454
  const errors = validate(input);
270
455
  if (errors.length) return c.json({ errors }, 400);
271
- return c.json(await createConnection(withLabel(input)), 201);
456
+ return c.json(maskSecret(await createConnection(withLabel(input))), 201);
272
457
  });
273
458
  connectionsRoute.get("/:id", (c) => {
274
459
  const resolved = resolveConnection(c.req.param("id"));
275
- if (resolved) return c.json({ ...resolved.connection, buyer: resolved.buyer, supplier: resolved.supplier });
460
+ if (resolved) return c.json({ ...maskSecret(resolved.connection), buyer: resolved.buyer, supplier: resolved.supplier });
276
461
  const conn = getConnection(c.req.param("id"));
277
- return conn ? c.json(conn) : c.json({ error: "not found" }, 404);
462
+ return conn ? c.json(maskSecret(conn)) : c.json({ error: "not found" }, 404);
278
463
  });
279
464
  connectionsRoute.put("/:id", async (c) => {
280
465
  const existing = getConnection(c.req.param("id"));
281
466
  if (!existing) return c.json({ error: "not found" }, 404);
282
- const input = normalize({ ...existing, ...await c.req.json().catch(() => ({})) });
467
+ const body = await c.req.json().catch(() => ({}));
468
+ if (!body || !body.sharedSecret) delete body.sharedSecret;
469
+ const input = normalize({ ...existing, ...body });
283
470
  const errors = validate(input);
284
471
  if (errors.length) return c.json({ errors }, 400);
285
- return c.json(await updateConnection(c.req.param("id"), withLabel(input)));
472
+ return c.json(maskSecret(await updateConnection(c.req.param("id"), withLabel(input))));
286
473
  });
287
474
  connectionsRoute.delete("/:id", async (c) => {
288
475
  const ok = await deleteConnection(c.req.param("id"));
@@ -297,7 +484,11 @@ var cred = (c) => ({
297
484
  });
298
485
  var buyersRoute = new Hono2();
299
486
  function normalizeBuyer(body) {
300
- return { name: String(body?.name ?? "Untitled buyer"), identity: cred(body?.identity) };
487
+ return {
488
+ name: String(body?.name ?? "Untitled buyer"),
489
+ identity: cred(body?.identity),
490
+ profileId: body?.profileId ? String(body.profileId) : void 0
491
+ };
301
492
  }
302
493
  buyersRoute.get("/", (c) => c.json(listBuyers()));
303
494
  buyersRoute.post("/", async (c) => {
@@ -366,8 +557,77 @@ suppliersRoute.delete("/:id", async (c) => {
366
557
  }
367
558
  });
368
559
 
369
- // src/server/routes/flow.ts
560
+ // src/server/routes/profiles.ts
370
561
  import { Hono as Hono3 } from "hono";
562
+ var profilesRoute = new Hono3();
563
+ var profilePresetsRoute = new Hono3();
564
+ var VERSION_KEYS = [
565
+ "SetupRequest",
566
+ "SetupResponse",
567
+ "PunchOutOrderMessage",
568
+ "OrderRequest",
569
+ "OrderResponse"
570
+ ];
571
+ function normalizeDtdVersions(v) {
572
+ const out = { default: String(v?.default ?? "1.2.045") };
573
+ for (const k of VERSION_KEYS) {
574
+ if (v?.[k]) out[k] = String(v[k]);
575
+ }
576
+ return out;
577
+ }
578
+ function normalizeExtrinsics(v) {
579
+ if (!Array.isArray(v)) return [];
580
+ return v.map((e) => ({
581
+ name: String(e?.name ?? ""),
582
+ value: String(e?.value ?? ""),
583
+ scope: e?.scope === "order" ? "order" : "setup"
584
+ })).filter((e) => e.name.trim().length > 0);
585
+ }
586
+ function normalizeProfile(body) {
587
+ const setupOperation = ["create", "edit", "inspect"].includes(body?.setupOperation) ? body.setupOperation : "create";
588
+ const attachmentEncoding = body?.attachmentEncoding === "base64" ? "base64" : "binary";
589
+ const cartReturnTransport = ["cxml-urlencoded", "cxml-base64", "raw"].includes(
590
+ body?.cartReturnTransport
591
+ ) ? body.cartReturnTransport : "cxml-urlencoded";
592
+ return {
593
+ name: String(body?.name ?? "Untitled profile"),
594
+ platform: body?.platform ? String(body.platform) : void 0,
595
+ dtdVersions: normalizeDtdVersions(body?.dtdVersions),
596
+ userAgent: String(body?.userAgent ?? "punchout-simulator"),
597
+ setupOperation,
598
+ attachmentEncoding,
599
+ cartReturnTransport,
600
+ extrinsics: normalizeExtrinsics(body?.extrinsics)
601
+ // `builtin` is never set from the wire — only code-seeded presets carry it.
602
+ };
603
+ }
604
+ profilesRoute.get("/", (c) => c.json(listProfiles()));
605
+ profilesRoute.post("/", async (c) => {
606
+ const input = normalizeProfile(await c.req.json().catch(() => ({})));
607
+ if (!input.name.trim()) return c.json({ errors: ["name is required"] }, 400);
608
+ return c.json(await createProfile(input), 201);
609
+ });
610
+ profilesRoute.get("/:id", (c) => {
611
+ const p = getProfile(c.req.param("id"));
612
+ return p ? c.json(p) : c.json({ error: "not found" }, 404);
613
+ });
614
+ profilesRoute.put("/:id", async (c) => {
615
+ if (!getProfile(c.req.param("id"))) return c.json({ error: "not found" }, 404);
616
+ const input = normalizeProfile(await c.req.json().catch(() => ({})));
617
+ return c.json(await updateProfile(c.req.param("id"), input));
618
+ });
619
+ profilesRoute.delete("/:id", async (c) => {
620
+ try {
621
+ const ok = await deleteProfile(c.req.param("id"));
622
+ return ok ? c.json({ ok: true }) : c.json({ error: "not found" }, 404);
623
+ } catch (e) {
624
+ return c.json({ error: e instanceof Error ? e.message : String(e) }, 409);
625
+ }
626
+ });
627
+ profilePresetsRoute.get("/", (c) => c.json(PROFILE_PRESETS));
628
+
629
+ // src/server/routes/flow.ts
630
+ import { Hono as Hono4 } from "hono";
371
631
  import { nanoid as nanoid5 } from "nanoid";
372
632
 
373
633
  // src/server/store/log.ts
@@ -392,14 +652,19 @@ var bus = new Bus();
392
652
  bus.setMaxListeners(0);
393
653
 
394
654
  // src/server/store/log.ts
655
+ function redactSecrets(body) {
656
+ if (!body) return body ?? "";
657
+ return body.replace(/(<SharedSecret>)[\s\S]*?(<\/SharedSecret>)/g, "$1***$2");
658
+ }
395
659
  function appendLog(input) {
396
660
  ensureDirs();
397
661
  const record = {
398
662
  ...input,
663
+ body: redactSecrets(input.body),
399
664
  id: input.id ?? nanoid2(12),
400
665
  ts: input.ts ?? (/* @__PURE__ */ new Date()).toISOString()
401
666
  };
402
- appendFileSync(sessionFile(record.sessionId), JSON.stringify(record) + "\n", "utf8");
667
+ appendFileSync(sessionFile(record.sessionId), JSON.stringify(record) + "\n", { encoding: "utf8", mode: 384 });
403
668
  bus.emitLog(record);
404
669
  return record;
405
670
  }
@@ -449,8 +714,13 @@ function normalizeContentId(cid) {
449
714
  if (!cid) return "";
450
715
  return cid.trim().replace(/^<+/, "").replace(/>+$/, "").trim();
451
716
  }
717
+ function base64Wrapped(data) {
718
+ const lines = data.toString("base64").match(/.{1,76}/g) ?? [];
719
+ return Buffer.from(lines.join("\r\n"), "utf8");
720
+ }
452
721
  function buildMultipartRelated(cxml, attachments, opts = {}) {
453
- const boundary = `cxml-${nanoid3(20)}`;
722
+ const boundary = opts.boundary || `cxml-${nanoid3(20)}`;
723
+ const useBase64 = opts.attachmentEncoding === "base64";
454
724
  const mainCid = opts.mainContentId ?? `cxml-main@punchout-simulator`;
455
725
  const CRLF = "\r\n";
456
726
  const parts = [];
@@ -473,11 +743,11 @@ function buildMultipartRelated(cxml, attachments, opts = {}) {
473
743
  pushPart(
474
744
  [
475
745
  `Content-Type: ${att.contentType}`,
476
- `Content-Transfer-Encoding: binary`,
746
+ `Content-Transfer-Encoding: ${useBase64 ? "base64" : "binary"}`,
477
747
  `Content-ID: <${normalizeContentId(att.contentId)}>`,
478
748
  disposition
479
749
  ],
480
- att.data
750
+ useBase64 ? base64Wrapped(att.data) : att.data
481
751
  );
482
752
  }
483
753
  parts.push(Buffer.from(`--${boundary}--${CRLF}`, "utf8"));
@@ -491,6 +761,10 @@ function getBoundary(contentType) {
491
761
  const m = /boundary="?([^";]+)"?/i.exec(contentType);
492
762
  return m?.[1];
493
763
  }
764
+ function getStartCid(contentType) {
765
+ const m = /start="?<?([^">]+)>?"?/i.exec(contentType);
766
+ return m ? normalizeContentId(m[1]) : void 0;
767
+ }
494
768
  function isMultipart(contentType) {
495
769
  return !!contentType && /^multipart\//i.test(contentType.trim());
496
770
  }
@@ -520,6 +794,9 @@ function parseMultipartRelated(body, contentType) {
520
794
  headers[line.slice(0, idx).trim().toLowerCase()] = line.slice(idx + 1).trim();
521
795
  }
522
796
  }
797
+ if (headers["content-transfer-encoding"]?.toLowerCase() === "base64") {
798
+ bodyBuf = Buffer.from(bodyBuf.toString("utf8"), "base64");
799
+ }
523
800
  parts.push({
524
801
  headers,
525
802
  contentId: normalizeContentId(headers["content-id"]),
@@ -568,7 +845,7 @@ function saveAttachment(data, meta) {
568
845
  ensureDirs();
569
846
  const hash = createHash("sha256").update(data).digest("hex");
570
847
  const path = resolve2(attachmentsDir(), hash);
571
- if (!existsSync2(path)) writeFileSync(path, data);
848
+ if (!existsSync2(path)) writeFileSync(path, data, { mode: 384 });
572
849
  return {
573
850
  contentId: normalizeContentId(meta.contentId),
574
851
  filename: meta.filename,
@@ -603,14 +880,32 @@ function connectionForSession(sessionId) {
603
880
  }
604
881
 
605
882
  // src/server/http.ts
883
+ function assertSafeOutboundUrl(url) {
884
+ let u;
885
+ try {
886
+ u = new URL(url);
887
+ } catch {
888
+ throw new Error(`invalid URL: ${url}`);
889
+ }
890
+ if (u.protocol !== "http:" && u.protocol !== "https:") {
891
+ throw new Error(`unsupported URL scheme "${u.protocol}" (only http/https allowed)`);
892
+ }
893
+ if (u.username || u.password) {
894
+ throw new Error("credentials embedded in the URL are not allowed");
895
+ }
896
+ }
606
897
  async function sendCxml(url, body, contentType = "text/xml; charset=UTF-8", timeoutMs = 3e4) {
607
898
  const controller = new AbortController();
608
899
  const timer = setTimeout(() => controller.abort(), timeoutMs);
609
900
  try {
901
+ assertSafeOutboundUrl(url);
610
902
  const res = await fetch(url, {
611
903
  method: "POST",
612
904
  headers: { "Content-Type": contentType },
613
905
  body,
906
+ // Do not transparently follow redirects to a different host (SSRF pivot);
907
+ // a 3xx is surfaced to the caller as the response instead.
908
+ redirect: "manual",
614
909
  signal: controller.signal
615
910
  });
616
911
  const headers = {};
@@ -636,21 +931,6 @@ async function sendCxml(url, body, contentType = "text/xml; charset=UTF-8", time
636
931
  }
637
932
  }
638
933
 
639
- // src/server/runtime.ts
640
- var runtime = {
641
- port: 8080,
642
- publicUrl: "http://localhost:8080"
643
- };
644
- function setRuntime(r) {
645
- Object.assign(runtime, r);
646
- }
647
- function getPublicUrl() {
648
- return runtime.publicUrl.replace(/\/$/, "");
649
- }
650
- function browserFormPostUrl() {
651
- return `${getPublicUrl()}/punchout/return`;
652
- }
653
-
654
934
  // src/server/cxml/build.ts
655
935
  import { nanoid as nanoid4 } from "nanoid";
656
936
  function escapeXml(value) {
@@ -684,14 +964,24 @@ ${credentialBlock("To", p.to)}
684
964
  ${senderBlock(p.sender, p.sharedSecret, p.userAgent)}
685
965
  </Header>`;
686
966
  }
687
- var DECLARATION = `<?xml version="1.0" encoding="UTF-8"?>
688
- <!DOCTYPE cXML SYSTEM "http://xml.cxml.org/schemas/cXML/1.2.045/cXML.dtd">`;
689
- function envelope(payloadId, timestamp, lang, inner) {
690
- return `${DECLARATION}
967
+ var DEFAULT_DTD_VERSION = "1.2.045";
968
+ function doctype(version) {
969
+ return `<?xml version="1.0" encoding="UTF-8"?>
970
+ <!DOCTYPE cXML SYSTEM "http://xml.cxml.org/schemas/cXML/${escapeXml(version)}/cXML.dtd">`;
971
+ }
972
+ function envelope(payloadId, timestamp, lang, inner, dtdVersion = DEFAULT_DTD_VERSION) {
973
+ return `${doctype(dtdVersion)}
691
974
  <cXML payloadID="${escapeXml(payloadId)}" timestamp="${escapeXml(timestamp)}" xml:lang="${escapeXml(lang)}">
692
975
  ${inner}
693
976
  </cXML>`;
694
977
  }
978
+ function applyExtrinsicTokens(value, tokens) {
979
+ return value.replace(/\$\{(\w+)\}/g, (_, k) => tokens[k] ?? "");
980
+ }
981
+ function extrinsicBlock(items, indent) {
982
+ if (!items || items.length === 0) return "";
983
+ return "\n" + items.map((e) => `${indent}<Extrinsic name="${escapeXml(e.name)}">${escapeXml(e.value)}</Extrinsic>`).join("\n");
984
+ }
695
985
  function buildSetupRequest(o) {
696
986
  const lang = o.lang ?? "en-US";
697
987
  const deployment = o.deploymentMode ? ` deploymentMode="${escapeXml(o.deploymentMode)}"` : "";
@@ -699,17 +989,18 @@ function buildSetupRequest(o) {
699
989
  from: o.from,
700
990
  to: o.to,
701
991
  sender: o.sender,
702
- sharedSecret: o.sharedSecret
992
+ sharedSecret: o.sharedSecret,
993
+ userAgent: o.userAgent
703
994
  })}
704
995
  <Request${deployment}>
705
996
  <PunchOutSetupRequest operation="${escapeXml(o.operation ?? "create")}">
706
997
  <BuyerCookie>${escapeXml(o.buyerCookie)}</BuyerCookie>
707
998
  <BrowserFormPost>
708
999
  <URL>${escapeXml(o.browserFormPostUrl)}</URL>
709
- </BrowserFormPost>
1000
+ </BrowserFormPost>${extrinsicBlock(o.extrinsics, " ")}
710
1001
  </PunchOutSetupRequest>
711
1002
  </Request>`;
712
- return envelope(o.payloadId, o.timestamp, lang, inner);
1003
+ return envelope(o.payloadId, o.timestamp, lang, inner, o.dtdVersion);
713
1004
  }
714
1005
  function addressBlock(tag, a) {
715
1006
  return ` <${tag}>
@@ -771,7 +1062,8 @@ function buildOrderRequest(o) {
771
1062
  from: o.from,
772
1063
  to: o.to,
773
1064
  sender: o.sender,
774
- sharedSecret: o.sharedSecret
1065
+ sharedSecret: o.sharedSecret,
1066
+ userAgent: o.userAgent
775
1067
  })}
776
1068
  <Request${deployment}>
777
1069
  <OrderRequest>
@@ -782,12 +1074,15 @@ function buildOrderRequest(o) {
782
1074
  <Money currency="${escapeXml(o.currency)}">${escapeXml(o.total)}</Money>
783
1075
  </Total>
784
1076
  ${addressBlock("ShipTo", o.shipTo ?? {})}
785
- ${addressBlock("BillTo", o.billTo ?? {})}${commentsWithAttachments(orderLevelCids, " ")}
1077
+ ${addressBlock("BillTo", o.billTo ?? {})}${commentsWithAttachments(orderLevelCids, " ")}${extrinsicBlock(
1078
+ o.extrinsics,
1079
+ " "
1080
+ )}
786
1081
  </OrderRequestHeader>
787
1082
  ${items}
788
1083
  </OrderRequest>
789
1084
  </Request>`;
790
- return envelope(o.payloadId, o.timestamp, lang, inner);
1085
+ return envelope(o.payloadId, o.timestamp, lang, inner, o.dtdVersion);
791
1086
  }
792
1087
  function optionalHeader(p) {
793
1088
  return p ? `${header({ from: p.from, to: p.to, sender: p.sender })}
@@ -805,7 +1100,7 @@ function buildSetupResponse(o) {
805
1100
  </StartPage>
806
1101
  </PunchOutSetupResponse>
807
1102
  </Response>`;
808
- return envelope(o.payloadId, o.timestamp, o.lang ?? "en-US", inner);
1103
+ return envelope(o.payloadId, o.timestamp, o.lang ?? "en-US", inner, o.dtdVersion);
809
1104
  }
810
1105
  function buildResponseStatus(o) {
811
1106
  const head = o.from && o.to && o.sender ? optionalHeader({ from: o.from, to: o.to, sender: o.sender }) : "";
@@ -814,7 +1109,7 @@ function buildResponseStatus(o) {
814
1109
  o.statusText ?? "OK"
815
1110
  )}">${escapeXml(o.statusText === "OK" || !o.statusText ? "" : o.statusText)}</Status>
816
1111
  </Response>`;
817
- return envelope(o.payloadId, o.timestamp, o.lang ?? "en-US", inner);
1112
+ return envelope(o.payloadId, o.timestamp, o.lang ?? "en-US", inner, o.dtdVersion);
818
1113
  }
819
1114
  function buildPunchOutOrderMessage(o) {
820
1115
  const total = o.items.reduce(
@@ -846,7 +1141,7 @@ function buildPunchOutOrderMessage(o) {
846
1141
  <Message>
847
1142
  <PunchOutOrderMessage>
848
1143
  <BuyerCookie>${escapeXml(o.buyerCookie)}</BuyerCookie>
849
- <PunchOutOrderMessageHeader operationAllowed="create">
1144
+ <PunchOutOrderMessageHeader operationAllowed="${escapeXml(o.operationAllowed ?? "create")}">
850
1145
  <Total>
851
1146
  <Money currency="${escapeXml(o.currency)}">${escapeXml(total.toFixed(2))}</Money>
852
1147
  </Total>
@@ -854,7 +1149,7 @@ function buildPunchOutOrderMessage(o) {
854
1149
  ${items}
855
1150
  </PunchOutOrderMessage>
856
1151
  </Message>`;
857
- return envelope(o.payloadId, o.timestamp, o.lang ?? "en-US", inner);
1152
+ return envelope(o.payloadId, o.timestamp, o.lang ?? "en-US", inner, o.dtdVersion);
858
1153
  }
859
1154
 
860
1155
  // src/server/cxml/parse.ts
@@ -871,6 +1166,9 @@ var parser = new XMLParser({
871
1166
  isArray: (name) => ["ItemIn", "ItemOut", "Attachment", "Comments", "Extrinsic"].includes(name)
872
1167
  });
873
1168
  function parseXml(raw) {
1169
+ if (/<!ENTITY/i.test(raw)) {
1170
+ return { raw, tree: null, wellFormed: false, wellFormedError: "DTD entity definitions are not allowed" };
1171
+ }
874
1172
  const check = XMLValidator.validate(raw, { allowBooleanAttributes: true });
875
1173
  if (check !== true) {
876
1174
  return {
@@ -1121,7 +1419,7 @@ function checkGeneral(doc, ctx, issues) {
1121
1419
  }
1122
1420
  function checkSharedSecret(doc, ctx, issues) {
1123
1421
  const exp = ctx.expected;
1124
- if (!exp || exp.authStyle !== "SharedSecret") return;
1422
+ if (!exp) return;
1125
1423
  const creds = getHeaderCredentials(doc);
1126
1424
  if (!creds.sharedSecret) {
1127
1425
  issues.warn(
@@ -1363,7 +1661,7 @@ function validateDocument(raw, ctx = {}) {
1363
1661
  }
1364
1662
 
1365
1663
  // src/server/routes/flow.ts
1366
- var flowRoute = new Hono3();
1664
+ var flowRoute = new Hono4();
1367
1665
  function host() {
1368
1666
  try {
1369
1667
  return new URL(getPublicUrl()).host;
@@ -1376,6 +1674,7 @@ function buyerContext(r) {
1376
1674
  const from = buyer.identity;
1377
1675
  const to = supplier.identity;
1378
1676
  const sender = connection.senderIdentity ?? buyer.identity;
1677
+ const eff = effectiveProfile(connection, buyer);
1379
1678
  return {
1380
1679
  from,
1381
1680
  to,
@@ -1385,9 +1684,17 @@ function buyerContext(r) {
1385
1684
  orderUrl: supplier.orderUrl,
1386
1685
  deploymentMode: connection.deploymentMode,
1387
1686
  connectionId: connection.id,
1388
- expected: { from, to, sender, sharedSecret: connection.sharedSecret, authStyle: connection.authStyle }
1687
+ attachmentEncoding: eff.attachmentEncoding,
1688
+ eff,
1689
+ expected: { from, to, sender, sharedSecret: connection.sharedSecret }
1389
1690
  };
1390
1691
  }
1692
+ function setupExtrinsics(ctx, buyerCookie) {
1693
+ return ctx.eff.extrinsics.filter((e) => e.scope === "setup").map((e) => ({ name: e.name, value: applyExtrinsicTokens(e.value, { buyerCookie }) }));
1694
+ }
1695
+ function orderExtrinsics(ctx, orderId) {
1696
+ return ctx.eff.extrinsics.filter((e) => e.scope === "order").map((e) => ({ name: e.name, value: applyExtrinsicTokens(e.value, { orderId }) }));
1697
+ }
1391
1698
  function resolveVirtualBuyer(id) {
1392
1699
  const r = resolveConnection(id);
1393
1700
  if (!r) return { error: "connection not found (or its buyer/supplier is missing)" };
@@ -1408,7 +1715,11 @@ flowRoute.get("/:id/setup/preview", (c) => {
1408
1715
  browserFormPostUrl: browserFormPostUrl(),
1409
1716
  payloadId: makePayloadId(host(), (/* @__PURE__ */ new Date()).toISOString()),
1410
1717
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1411
- deploymentMode: ctx.deploymentMode
1718
+ deploymentMode: ctx.deploymentMode,
1719
+ operation: ctx.eff.setupOperation,
1720
+ dtdVersion: dtdVersionFor(ctx.eff, "SetupRequest"),
1721
+ userAgent: ctx.eff.userAgent,
1722
+ extrinsics: setupExtrinsics(ctx, buyerCookie)
1412
1723
  });
1413
1724
  return c.json({ buyerCookie, xml, browserFormPostUrl: browserFormPostUrl() });
1414
1725
  });
@@ -1427,7 +1738,11 @@ flowRoute.post("/:id/setup", async (c) => {
1427
1738
  browserFormPostUrl: browserFormPostUrl(),
1428
1739
  payloadId: makePayloadId(host(), (/* @__PURE__ */ new Date()).toISOString()),
1429
1740
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1430
- deploymentMode: ctx.deploymentMode
1741
+ deploymentMode: ctx.deploymentMode,
1742
+ operation: ctx.eff.setupOperation,
1743
+ dtdVersion: dtdVersionFor(ctx.eff, "SetupRequest"),
1744
+ userAgent: ctx.eff.userAgent,
1745
+ extrinsics: setupExtrinsics(ctx, buyerCookie)
1431
1746
  });
1432
1747
  rememberSessionConnection(buyerCookie, ctx.connectionId);
1433
1748
  const reqValidation = validateDocument(xml, { expected: ctx.expected, forceDocType: "SetupRequest" });
@@ -1491,7 +1806,10 @@ function buildOrderXml(ctx, body) {
1491
1806
  items,
1492
1807
  shipTo: body.shipTo,
1493
1808
  billTo: body.billTo,
1494
- attachments: attMeta
1809
+ attachments: attMeta,
1810
+ dtdVersion: dtdVersionFor(ctx.eff, "OrderRequest"),
1811
+ userAgent: ctx.eff.userAgent,
1812
+ extrinsics: orderExtrinsics(ctx, orderId)
1495
1813
  });
1496
1814
  return { xml, orderId };
1497
1815
  }
@@ -1534,7 +1852,7 @@ flowRoute.post("/:id/order", async (c) => {
1534
1852
  data
1535
1853
  };
1536
1854
  });
1537
- const built = buildMultipartRelated(xml, parts);
1855
+ const built = buildMultipartRelated(xml, parts, { attachmentEncoding: ctx.attachmentEncoding });
1538
1856
  wireBody = built.body;
1539
1857
  wireContentType = built.contentType;
1540
1858
  }
@@ -1553,6 +1871,7 @@ flowRoute.post("/:id/order", async (c) => {
1553
1871
  contentType: wireContentType,
1554
1872
  validation: reqValidation,
1555
1873
  attachments: savedRefs,
1874
+ attachmentEncoding: inputAtts.length > 0 ? ctx.attachmentEncoding : void 0,
1556
1875
  note: dangling ? "dangling-cid test" : void 0
1557
1876
  });
1558
1877
  if (!ctx.orderUrl) return c.json({ error: "supplier has no orderUrl configured" }, 400);
@@ -1581,8 +1900,8 @@ flowRoute.post("/:id/order", async (c) => {
1581
1900
  });
1582
1901
 
1583
1902
  // src/server/routes/punchout-return.ts
1584
- import { Hono as Hono4 } from "hono";
1585
- var punchoutReturnRoute = new Hono4();
1903
+ import { Hono as Hono5 } from "hono";
1904
+ var punchoutReturnRoute = new Hono5();
1586
1905
  async function extractCxml(c) {
1587
1906
  const ct = (c.req.header("content-type") ?? "").toLowerCase();
1588
1907
  if (ct.includes("application/x-www-form-urlencoded") || ct.includes("multipart/form-data")) {
@@ -1626,8 +1945,7 @@ punchoutReturnRoute.post("/return", async (c) => {
1626
1945
  const expected = resolved ? {
1627
1946
  from: resolved.buyer.identity,
1628
1947
  to: resolved.supplier.identity,
1629
- sender: resolved.connection.senderIdentity ?? resolved.supplier.identity,
1630
- authStyle: resolved.connection.authStyle
1948
+ sender: resolved.connection.senderIdentity ?? resolved.supplier.identity
1631
1949
  } : void 0;
1632
1950
  const validation = validateDocument(xml, {
1633
1951
  expected,
@@ -1651,10 +1969,14 @@ punchoutReturnRoute.post("/return", async (c) => {
1651
1969
  });
1652
1970
 
1653
1971
  // src/server/routes/stream.ts
1654
- import { Hono as Hono5 } from "hono";
1972
+ import { Hono as Hono6 } from "hono";
1655
1973
  import { streamSSE } from "hono/streaming";
1656
- var streamRoute = new Hono5();
1974
+ var streamRoute = new Hono6();
1975
+ var MAX_STREAMS = 64;
1976
+ var activeStreams = 0;
1657
1977
  streamRoute.get("/stream", (c) => {
1978
+ if (activeStreams >= MAX_STREAMS) return c.text("too many live-log connections", 503);
1979
+ activeStreams++;
1658
1980
  return streamSSE(c, async (stream) => {
1659
1981
  let open = true;
1660
1982
  let id = 0;
@@ -1668,6 +1990,7 @@ streamRoute.get("/stream", (c) => {
1668
1990
  });
1669
1991
  stream.onAbort(() => {
1670
1992
  open = false;
1993
+ activeStreams = Math.max(0, activeStreams - 1);
1671
1994
  unsubscribe();
1672
1995
  });
1673
1996
  await stream.writeSSE({ event: "ready", data: JSON.stringify({ ts: Date.now() }) });
@@ -1680,8 +2003,30 @@ streamRoute.get("/stream", (c) => {
1680
2003
  });
1681
2004
 
1682
2005
  // src/server/routes/data.ts
1683
- import { Hono as Hono6 } from "hono";
1684
- var dataRoute = new Hono6();
2006
+ import { Hono as Hono7 } from "hono";
2007
+ var dataRoute = new Hono7();
2008
+ function rawMessage(record) {
2009
+ const ct = record.contentType ?? record.headers?.["Content-Type"] ?? record.headers?.["content-type"];
2010
+ let body = record.body;
2011
+ if (ct && isMultipart(ct) && record.attachments && record.attachments.length > 0) {
2012
+ const parts = record.attachments.map((a) => ({
2013
+ contentId: a.contentId,
2014
+ filename: a.filename,
2015
+ contentType: a.contentType,
2016
+ data: readAttachment(a.hash) ?? Buffer.alloc(0)
2017
+ }));
2018
+ const built = buildMultipartRelated(record.body, parts, {
2019
+ boundary: getBoundary(ct),
2020
+ mainContentId: getStartCid(ct),
2021
+ attachmentEncoding: record.attachmentEncoding
2022
+ });
2023
+ body = built.body.toString("utf8");
2024
+ }
2025
+ const headerLines = Object.entries(record.headers ?? {}).map(([k, v]) => `${k}: ${v}`);
2026
+ return headerLines.length > 0 ? `${headerLines.join("\r\n")}\r
2027
+ \r
2028
+ ${body}` : body;
2029
+ }
1685
2030
  dataRoute.get("/health", (c) => c.json({ ok: true }));
1686
2031
  dataRoute.get(
1687
2032
  "/runtime",
@@ -1689,6 +2034,12 @@ dataRoute.get(
1689
2034
  );
1690
2035
  dataRoute.get("/sessions", (c) => c.json(listSessions()));
1691
2036
  dataRoute.get("/sessions/:id", (c) => c.json(readSession(c.req.param("id"))));
2037
+ dataRoute.get("/sessions/:sessionId/records/:recordId/raw", (c) => {
2038
+ const record = readSession(c.req.param("sessionId")).find((r) => r.id === c.req.param("recordId"));
2039
+ if (!record) return c.json({ error: "not found" }, 404);
2040
+ c.header("Content-Type", "text/plain; charset=utf-8");
2041
+ return c.body(rawMessage(record));
2042
+ });
1692
2043
  dataRoute.get("/recent", (c) => {
1693
2044
  const limit = Number(c.req.query("limit") ?? "200");
1694
2045
  return c.json(readAllRecent(Number.isFinite(limit) ? limit : 200));
@@ -1705,9 +2056,9 @@ dataRoute.get("/attachments/:hash", (c) => {
1705
2056
  });
1706
2057
 
1707
2058
  // src/server/routes/sim.ts
1708
- import { Hono as Hono7 } from "hono";
2059
+ import { Hono as Hono8 } from "hono";
1709
2060
  import { nanoid as nanoid6 } from "nanoid";
1710
- var simRoute = new Hono7();
2061
+ var simRoute = new Hono8();
1711
2062
  var DEMO_CATALOG = [
1712
2063
  { supplierPartId: "WIDGET-001", description: "Premium Steel Widget", unitPrice: 12.5, currency: "USD", uom: "EA", unspsc: "31161500", manufacturerPartId: "MFR-W001", manufacturerName: "Acme Manufacturing" },
1713
2064
  { supplierPartId: "BOLT-250", description: "M8 Hex Bolt (pack of 250)", unitPrice: 34, currency: "USD", uom: "PK", unspsc: "31161600", manufacturerPartId: "MFR-B250", manufacturerName: "Acme Manufacturing" },
@@ -1725,11 +2076,14 @@ function safeHttpUrl(u) {
1725
2076
  if (!u) return "";
1726
2077
  try {
1727
2078
  const p = new URL(u.trim());
1728
- return p.protocol === "http:" || p.protocol === "https:" ? u.trim() : "";
2079
+ return p.protocol === "http:" || p.protocol === "https:" ? p.href : "";
1729
2080
  } catch {
1730
2081
  return "";
1731
2082
  }
1732
2083
  }
2084
+ function jsonForScript(value) {
2085
+ return JSON.stringify(value).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
2086
+ }
1733
2087
  function expectedFor(supplierId, from) {
1734
2088
  const r = findConnectionBySupplierAndBuyerIdentity(supplierId, from);
1735
2089
  if (!r) return void 0;
@@ -1737,10 +2091,13 @@ function expectedFor(supplierId, from) {
1737
2091
  from: r.buyer.identity,
1738
2092
  to: r.supplier.identity,
1739
2093
  sender: r.connection.senderIdentity ?? r.buyer.identity,
1740
- sharedSecret: r.connection.sharedSecret,
1741
- authStyle: r.connection.authStyle
2094
+ sharedSecret: r.connection.sharedSecret
1742
2095
  };
1743
2096
  }
2097
+ function effFor(supplierId, from) {
2098
+ const r = findConnectionBySupplierAndBuyerIdentity(supplierId, from);
2099
+ return r ? effectiveProfile(r.connection, r.buyer) : void 0;
2100
+ }
1744
2101
  simRoute.post("/:id/punchout", async (c) => {
1745
2102
  const supplier = getSupplier(c.req.param("id"));
1746
2103
  if (!supplier) return c.text("supplier not found", 404);
@@ -1761,13 +2118,15 @@ simRoute.post("/:id/punchout", async (c) => {
1761
2118
  });
1762
2119
  const buyerCred = from ?? { domain: "", identity: "" };
1763
2120
  const startPageUrl = `${getPublicUrl()}/sim/${supplier.id}/catalog?cookie=${encodeURIComponent(buyerCookie)}&formpost=${encodeURIComponent(formPost)}&bd=${encodeURIComponent(buyerCred.domain)}&bi=${encodeURIComponent(buyerCred.identity)}`;
2121
+ const eff = effFor(supplier.id, from);
1764
2122
  const respXml = buildSetupResponse({
1765
2123
  payloadId: makePayloadId(host2(), (/* @__PURE__ */ new Date()).toISOString()),
1766
2124
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1767
2125
  startPageUrl,
1768
2126
  from: supplier.identity,
1769
2127
  to: buyerCred,
1770
- sender: supplier.identity
2128
+ sender: supplier.identity,
2129
+ dtdVersion: eff ? dtdVersionFor(eff, "SetupResponse") : void 0
1771
2130
  });
1772
2131
  appendLog({
1773
2132
  sessionId: buyerCookie,
@@ -1851,6 +2210,7 @@ simRoute.post("/:id/checkout", async (c) => {
1851
2210
  }
1852
2211
  });
1853
2212
  const currency = items[0]?.currency ?? "USD";
2213
+ const eff = effFor(supplier.id, buyerCred);
1854
2214
  const xml = buildPunchOutOrderMessage({
1855
2215
  from: supplier.identity,
1856
2216
  to: buyerCred,
@@ -1859,7 +2219,8 @@ simRoute.post("/:id/checkout", async (c) => {
1859
2219
  payloadId: makePayloadId(host2(), (/* @__PURE__ */ new Date()).toISOString()),
1860
2220
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1861
2221
  currency,
1862
- items
2222
+ items,
2223
+ dtdVersion: eff ? dtdVersionFor(eff, "PunchOutOrderMessage") : void 0
1863
2224
  });
1864
2225
  appendLog({
1865
2226
  sessionId: cookie,
@@ -1870,16 +2231,43 @@ simRoute.post("/:id/checkout", async (c) => {
1870
2231
  body: xml,
1871
2232
  validation: validateDocument(xml, { forceDocType: "PunchOutOrderMessage" })
1872
2233
  });
1873
- return c.html(`<!doctype html><html lang="en"><head><meta charset="utf-8">
2234
+ const transport = eff?.cartReturnTransport ?? "cxml-urlencoded";
2235
+ return c.html(cartReturnPage(formpost, xml, transport));
2236
+ });
2237
+ function cartReturnPage(formpost, xml, transport) {
2238
+ const shell = (inner) => `<!doctype html><html lang="en"><head><meta charset="utf-8">
1874
2239
  <title>Returning cart\u2026</title></head>
1875
- <body onload="document.forms[0].submit()" style="font-family:system-ui;background:#0f172a;color:#e2e8f0">
2240
+ <body style="font-family:system-ui;background:#0f172a;color:#e2e8f0">
1876
2241
  <p style="padding:2rem">Returning cart to the buyer\u2026</p>
1877
- <form method="post" action="${escapeXml(formpost)}">
2242
+ ${inner}
2243
+ </body></html>`;
2244
+ if (transport === "raw") {
2245
+ return shell(` <form method="post" action="${escapeXml(formpost)}">
1878
2246
  <input type="hidden" name="cxml-urlencoded" value="${escapeXml(xml)}">
1879
2247
  <noscript><button type="submit">Continue</button></noscript>
1880
2248
  </form>
1881
- </body></html>`);
1882
- });
2249
+ <script>
2250
+ fetch(${jsonForScript(formpost)}, { method: "POST",
2251
+ headers: { "Content-Type": "text/xml; charset=UTF-8" },
2252
+ body: ${jsonForScript(xml)} })
2253
+ .then(function () {
2254
+ document.body.replaceChildren();
2255
+ var p = document.createElement("p");
2256
+ p.style.padding = "2rem";
2257
+ p.textContent = "Cart returned to the buyer. You can close this tab.";
2258
+ document.body.appendChild(p);
2259
+ })
2260
+ .catch(function () { document.forms[0].submit(); });
2261
+ </script>`);
2262
+ }
2263
+ const field = transport === "cxml-base64" ? "cxml-base64" : "cxml-urlencoded";
2264
+ const value = transport === "cxml-base64" ? Buffer.from(xml, "utf8").toString("base64") : xml;
2265
+ return shell(` <form method="post" action="${escapeXml(formpost)}">
2266
+ <input type="hidden" name="${field}" value="${escapeXml(value)}">
2267
+ <noscript><button type="submit">Continue</button></noscript>
2268
+ </form>
2269
+ <script>document.forms[0].submit()</script>`);
2270
+ }
1883
2271
  simRoute.post("/:id/order", async (c) => {
1884
2272
  const supplier = getSupplier(c.req.param("id"));
1885
2273
  if (!supplier) return c.text("supplier not found", 404);
@@ -1888,6 +2276,7 @@ simRoute.post("/:id/order", async (c) => {
1888
2276
  let xml;
1889
2277
  const availableContentIds = /* @__PURE__ */ new Set();
1890
2278
  const savedRefs = [];
2279
+ let attachmentEncoding;
1891
2280
  if (isMultipart(ct)) {
1892
2281
  const mp = parseMultipartRelated(raw, ct);
1893
2282
  xml = mp.root?.body.toString("utf8") ?? "";
@@ -1895,8 +2284,12 @@ simRoute.post("/:id/order", async (c) => {
1895
2284
  if (part === mp.root) continue;
1896
2285
  const cid = normalizeContentId(part.contentId);
1897
2286
  if (cid) availableContentIds.add(cid);
2287
+ if ((part.headers["content-transfer-encoding"] ?? "").toLowerCase() === "base64") {
2288
+ attachmentEncoding = "base64";
2289
+ }
1898
2290
  savedRefs.push(saveAttachment(part.body, { contentId: cid, contentType: part.contentType ?? "application/octet-stream" }));
1899
2291
  }
2292
+ if (savedRefs.length > 0 && !attachmentEncoding) attachmentEncoding = "binary";
1900
2293
  } else {
1901
2294
  xml = raw.toString("utf8");
1902
2295
  }
@@ -1917,9 +2310,11 @@ simRoute.post("/:id/order", async (c) => {
1917
2310
  body: xml,
1918
2311
  contentType: ct,
1919
2312
  validation,
1920
- attachments: savedRefs
2313
+ attachments: savedRefs,
2314
+ attachmentEncoding
1921
2315
  });
1922
2316
  const ok = validation.ok;
2317
+ const eff = effFor(supplier.id, from);
1923
2318
  const respXml = buildResponseStatus({
1924
2319
  payloadId: makePayloadId(host2(), (/* @__PURE__ */ new Date()).toISOString()),
1925
2320
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
@@ -1927,7 +2322,8 @@ simRoute.post("/:id/order", async (c) => {
1927
2322
  statusText: ok ? "OK" : "Bad Request",
1928
2323
  from: supplier.identity,
1929
2324
  to: from ?? { domain: "", identity: "" },
1930
- sender: supplier.identity
2325
+ sender: supplier.identity,
2326
+ dtdVersion: eff ? dtdVersionFor(eff, "OrderResponse") : void 0
1931
2327
  });
1932
2328
  appendLog({
1933
2329
  sessionId,
@@ -1950,10 +2346,25 @@ function findSessionForOrder(doc) {
1950
2346
  // src/server/app.ts
1951
2347
  import { relative } from "path";
1952
2348
  function createApp(opts = {}) {
1953
- const app = new Hono8();
2349
+ const app = new Hono9();
1954
2350
  if (!opts.quiet) app.use("*", logger());
2351
+ app.use(
2352
+ "*",
2353
+ bodyLimit({ maxSize: 24 * 1024 * 1024, onError: (c) => c.json({ error: "payload too large" }, 413) })
2354
+ );
2355
+ app.use("/api/*", async (c, next) => {
2356
+ const token = getToken();
2357
+ if (!token) return next();
2358
+ if (c.req.path === "/api/health") return next();
2359
+ const auth = c.req.header("authorization") ?? "";
2360
+ const provided = (auth.toLowerCase().startsWith("bearer ") ? auth.slice(7).trim() : "") || c.req.header("x-pos-token") || c.req.query("token") || getCookie(c, "pos-api-token") || "";
2361
+ if (provided === token) return next();
2362
+ return c.json({ error: "unauthorized" }, 401);
2363
+ });
1955
2364
  app.route("/api/buyers", buyersRoute);
1956
2365
  app.route("/api/suppliers", suppliersRoute);
2366
+ app.route("/api/profiles", profilesRoute);
2367
+ app.route("/api/profile-presets", profilePresetsRoute);
1957
2368
  app.route("/api/connections", connectionsRoute);
1958
2369
  app.route("/api/connections", flowRoute);
1959
2370
  app.route("/api", dataRoute);
@@ -1984,7 +2395,10 @@ async function seedDemoIfEmpty() {
1984
2395
  const buyer = await createBuyer({
1985
2396
  id: "demo-buyer",
1986
2397
  name: "Demo Buyer",
1987
- identity: { domain: "DUNS", identity: "123456789" }
2398
+ identity: { domain: "DUNS", identity: "123456789" },
2399
+ // Exercise a non-default platform profile end-to-end (Coupa: per-doc-type
2400
+ // DTD versions, base64 attachments). Built-in profiles are seeded by initConfig.
2401
+ profileId: "coupa"
1988
2402
  });
1989
2403
  const supplier = await createSupplier({
1990
2404
  id: "demo-supplier",
@@ -2001,8 +2415,7 @@ async function seedDemoIfEmpty() {
2001
2415
  supplierId: supplier.id,
2002
2416
  mode: "virtual-buyer",
2003
2417
  sharedSecret: "demo-secret",
2004
- deploymentMode: "test",
2005
- authStyle: "SharedSecret"
2418
+ deploymentMode: "test"
2006
2419
  });
2007
2420
  }
2008
2421
 
@@ -2011,6 +2424,8 @@ function parseFlags(argv) {
2011
2424
  const flags = {
2012
2425
  port: Number(process.env.PORT ?? 8080),
2013
2426
  dataDir: process.env.DATA_DIR ?? "./data",
2427
+ host: process.env.HOST,
2428
+ token: process.env.POS_TOKEN,
2014
2429
  open: true,
2015
2430
  dev: false,
2016
2431
  seed: true
@@ -2030,6 +2445,12 @@ function parseFlags(argv) {
2030
2445
  case "--public-url":
2031
2446
  flags.publicUrl = next();
2032
2447
  break;
2448
+ case "--host":
2449
+ flags.host = next();
2450
+ break;
2451
+ case "--token":
2452
+ flags.token = next();
2453
+ break;
2033
2454
  case "--no-open":
2034
2455
  flags.open = false;
2035
2456
  break;
@@ -2048,6 +2469,14 @@ function parseFlags(argv) {
2048
2469
  }
2049
2470
  return flags;
2050
2471
  }
2472
+ var LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1", "[::1]", ""]);
2473
+ function hostnameOf(url) {
2474
+ try {
2475
+ return new URL(url).hostname;
2476
+ } catch {
2477
+ return "";
2478
+ }
2479
+ }
2051
2480
  function printHelp() {
2052
2481
  console.log(`punchout-simulator \u2014 test cXML PunchOut integrations as a virtual counterparty
2053
2482
 
@@ -2058,6 +2487,9 @@ Options:
2058
2487
  -d, --data-dir <path> Where to store config + logs (default ./data)
2059
2488
  --public-url <url> Externally reachable base URL (default http://localhost:<port>)
2060
2489
  Set this when fronting the tool with ngrok/cloudflared.
2490
+ --host <addr> Bind address (default 127.0.0.1; use 0.0.0.0 for LAN)
2491
+ --token <secret> Require this token on /api (auto-generated when exposed
2492
+ and not provided; or set POS_TOKEN)
2061
2493
  --no-open Do not open a browser on start
2062
2494
  --no-seed Do not seed the built-in demo connections on first run
2063
2495
  --dev Dev mode (do not serve SPA, do not open browser)
@@ -2067,22 +2499,32 @@ Options:
2067
2499
  async function main() {
2068
2500
  const flags = parseFlags(process.argv.slice(2));
2069
2501
  const publicUrl = flags.publicUrl ?? `http://localhost:${flags.port}`;
2502
+ const bindHost = flags.host ?? "127.0.0.1";
2503
+ const exposed = !LOOPBACK.has(hostnameOf(publicUrl)) || !LOOPBACK.has(bindHost);
2504
+ const token = flags.token || (exposed ? nanoid7(24) : void 0);
2070
2505
  setDataDir(flags.dataDir);
2071
- setRuntime({ port: flags.port, publicUrl });
2506
+ setRuntime({ port: flags.port, publicUrl, token });
2072
2507
  await initConfig();
2073
2508
  if (flags.seed) await seedDemoIfEmpty();
2074
2509
  const webRoot = flags.dev ? void 0 : fileURLToPath(new URL("../web", import.meta.url));
2075
2510
  const app = createApp({ webRoot, quiet: false });
2076
- serve({ fetch: app.fetch, port: flags.port }, (info) => {
2077
- const url = `http://localhost:${info.port}`;
2511
+ serve({ fetch: app.fetch, port: flags.port, hostname: bindHost }, (info) => {
2512
+ const local = `http://${bindHost}:${info.port}`;
2078
2513
  console.log(`
2079
- punchout-simulator listening on ${url}`);
2080
- if (getPublicUrl() !== url) console.log(` public URL: ${getPublicUrl()}`);
2514
+ punchout-simulator listening on ${local}`);
2515
+ if (getPublicUrl() !== local) console.log(` public URL: ${getPublicUrl()}`);
2081
2516
  console.log(` data dir: ${flags.dataDir}`);
2082
- console.log(` callback: ${getPublicUrl()}/punchout/return
2083
- `);
2517
+ console.log(` callback: ${getPublicUrl()}/punchout/return`);
2518
+ const openUrl = token ? `${getPublicUrl()}/?token=${token}` : local;
2519
+ if (token) {
2520
+ console.log(`
2521
+ \u26A0 EXPOSED: /api requires a token. Open the UI with the token:`);
2522
+ console.log(` ${openUrl}`);
2523
+ console.log(` (inbound /sim and /punchout stay open for buyer traffic)`);
2524
+ }
2525
+ console.log("");
2084
2526
  if (flags.open) {
2085
- import("open").then((m) => m.default(url)).catch(() => {
2527
+ import("open").then((m) => m.default(token ? `http://localhost:${info.port}/?token=${token}` : local)).catch(() => {
2086
2528
  });
2087
2529
  }
2088
2530
  });
@@ -2091,4 +2533,3 @@ main().catch((e) => {
2091
2533
  console.error(e);
2092
2534
  process.exit(1);
2093
2535
  });
2094
- //# sourceMappingURL=cli.js.map