punchout-simulator 0.2.0 → 0.3.1

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