punchout-simulator 0.1.5 → 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 +95 -12
  2. package/dist/server/cli.js +931 -297
  3. package/dist/web/assets/{cssMode-WA7HerFP.js → cssMode-UKotGf5X.js} +1 -1
  4. package/dist/web/assets/{freemarker2-BTOdUHiQ.js → freemarker2-0DRATxLT.js} +1 -1
  5. package/dist/web/assets/{handlebars-DdTiW10M.js → handlebars-U7ip3Hjn.js} +1 -1
  6. package/dist/web/assets/{html-CP7qpuUD.js → html-pMwW-Db1.js} +1 -1
  7. package/dist/web/assets/{htmlMode-BG3HQ1Sh.js → htmlMode-BZiR_DER.js} +1 -1
  8. package/dist/web/assets/{index-DQ_OSoxK.js → index-fwAo3vG0.js} +204 -204
  9. package/dist/web/assets/{index-CRWJsM13.css → index-sN4D-IAg.css} +1 -1
  10. package/dist/web/assets/{javascript-IQKq-LZB.js → javascript-BNQzFVRF.js} +1 -1
  11. package/dist/web/assets/{jsonMode-B1p3z-UY.js → jsonMode-CSAIHDlB.js} +1 -1
  12. package/dist/web/assets/{liquid-4Fxw7q97.js → liquid--vvS9GUJ.js} +1 -1
  13. package/dist/web/assets/{mdx-00dxYvhf.js → mdx-BJDQflEF.js} +1 -1
  14. package/dist/web/assets/{python-BI6CPDlf.js → python-CepodNht.js} +1 -1
  15. package/dist/web/assets/{razor-DGRBM6nC.js → razor-BQksENSs.js} +1 -1
  16. package/dist/web/assets/{tsMode-BVtwLDYC.js → tsMode-BRKq1Rx4.js} +1 -1
  17. package/dist/web/assets/{typescript-Dgh1yWP4.js → typescript-V-bP8GlZ.js} +1 -1
  18. package/dist/web/assets/{xml-BOb15_Cq.js → xml-Bgme81_M.js} +1 -1
  19. package/dist/web/assets/{yaml-Dv2aUhkS.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 Hono7 } 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,29 +180,86 @@ var db = null;
50
180
  async function initConfig() {
51
181
  ensureDirs();
52
182
  const adapter = new JSONFile(configPath());
53
- db = new Low(adapter, { connections: [] });
183
+ db = new Low(adapter, { buyers: [], suppliers: [], connections: [], profiles: [] });
54
184
  await db.read();
55
- db.data ||= { connections: [] };
185
+ db.data ||= { buyers: [], suppliers: [], connections: [], profiles: [] };
186
+ db.data.buyers ||= [];
187
+ db.data.suppliers ||= [];
188
+ db.data.connections ||= [];
189
+ db.data.profiles ||= [];
190
+ seedBuiltinProfiles(db.data, now());
191
+ migrateLegacy(db.data);
56
192
  await db.write();
193
+ try {
194
+ chmodSync2(configPath(), 384);
195
+ } catch {
196
+ }
57
197
  }
58
198
  function requireDb() {
59
199
  if (!db) throw new Error("config store not initialized \u2014 call initConfig() first");
60
200
  return db;
61
201
  }
62
- function listConnections() {
63
- return requireDb().data.connections;
202
+ var now = () => (/* @__PURE__ */ new Date()).toISOString();
203
+ var listBuyers = () => requireDb().data.buyers;
204
+ var getBuyer = (id) => requireDb().data.buyers.find((b) => b.id === id);
205
+ async function createBuyer(input) {
206
+ const buyer = { ...input, id: input.id ?? nanoid(8), createdAt: now(), updatedAt: now() };
207
+ const d = requireDb();
208
+ d.data.buyers.push(buyer);
209
+ await d.write();
210
+ return buyer;
64
211
  }
65
- function getConnection(id) {
66
- return requireDb().data.connections.find((c) => c.id === id);
212
+ async function updateBuyer(id, patch) {
213
+ const d = requireDb();
214
+ const existing = d.data.buyers.find((b) => b.id === id);
215
+ if (!existing) return void 0;
216
+ Object.assign(existing, patch, { id, updatedAt: now() });
217
+ await d.write();
218
+ return existing;
219
+ }
220
+ async function deleteBuyer(id) {
221
+ const d = requireDb();
222
+ if (d.data.connections.some((c) => c.buyerId === id)) {
223
+ throw new Error("buyer is referenced by a connection");
224
+ }
225
+ const before = d.data.buyers.length;
226
+ d.data.buyers = d.data.buyers.filter((b) => b.id !== id);
227
+ const removed = d.data.buyers.length < before;
228
+ if (removed) await d.write();
229
+ return removed;
230
+ }
231
+ var listSuppliers = () => requireDb().data.suppliers;
232
+ var getSupplier = (id) => requireDb().data.suppliers.find((s) => s.id === id);
233
+ async function createSupplier(input) {
234
+ const supplier = { ...input, id: input.id ?? nanoid(8), createdAt: now(), updatedAt: now() };
235
+ const d = requireDb();
236
+ d.data.suppliers.push(supplier);
237
+ await d.write();
238
+ return supplier;
239
+ }
240
+ async function updateSupplier(id, patch) {
241
+ const d = requireDb();
242
+ const existing = d.data.suppliers.find((s) => s.id === id);
243
+ if (!existing) return void 0;
244
+ Object.assign(existing, patch, { id, updatedAt: now() });
245
+ await d.write();
246
+ return existing;
67
247
  }
248
+ async function deleteSupplier(id) {
249
+ const d = requireDb();
250
+ if (d.data.connections.some((c) => c.supplierId === id)) {
251
+ throw new Error("supplier is referenced by a connection");
252
+ }
253
+ const before = d.data.suppliers.length;
254
+ d.data.suppliers = d.data.suppliers.filter((s) => s.id !== id);
255
+ const removed = d.data.suppliers.length < before;
256
+ if (removed) await d.write();
257
+ return removed;
258
+ }
259
+ var listConnections = () => requireDb().data.connections;
260
+ var getConnection = (id) => requireDb().data.connections.find((c) => c.id === id);
68
261
  async function createConnection(input) {
69
- const now = (/* @__PURE__ */ new Date()).toISOString();
70
- const conn = {
71
- ...input,
72
- id: input.id ?? nanoid(8),
73
- createdAt: now,
74
- updatedAt: now
75
- };
262
+ const conn = { ...input, id: input.id ?? nanoid(8), createdAt: now(), updatedAt: now() };
76
263
  const d = requireDb();
77
264
  d.data.connections.push(conn);
78
265
  await d.write();
@@ -82,7 +269,7 @@ async function updateConnection(id, patch) {
82
269
  const d = requireDb();
83
270
  const existing = d.data.connections.find((c) => c.id === id);
84
271
  if (!existing) return void 0;
85
- Object.assign(existing, patch, { id, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
272
+ Object.assign(existing, patch, { id, updatedAt: now() });
86
273
  await d.write();
87
274
  return existing;
88
275
  }
@@ -94,65 +281,353 @@ async function deleteConnection(id) {
94
281
  if (removed) await d.write();
95
282
  return removed;
96
283
  }
284
+ function resolveConnection(id) {
285
+ const connection = getConnection(id);
286
+ if (!connection) return void 0;
287
+ const buyer = getBuyer(connection.buyerId);
288
+ const supplier = getSupplier(connection.supplierId);
289
+ if (!buyer || !supplier) return void 0;
290
+ return { connection, buyer, supplier };
291
+ }
292
+ function findConnectionBySupplierAndBuyerIdentity(supplierId, from) {
293
+ const d = requireDb();
294
+ for (const connection of d.data.connections) {
295
+ if (connection.supplierId !== supplierId) continue;
296
+ const buyer = getBuyer(connection.buyerId);
297
+ if (!buyer) continue;
298
+ if (from && buyer.identity.domain === from.domain && buyer.identity.identity === from.identity) {
299
+ const supplier = getSupplier(supplierId);
300
+ if (supplier) return { connection, buyer, supplier };
301
+ }
302
+ }
303
+ return void 0;
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
+ }
352
+ function migrateLegacy(data) {
353
+ const legacy = data.connections.filter((c) => "from" in c || "to" in c);
354
+ if (legacy.length === 0) return;
355
+ const buyerKey = (c) => `${c.domain}|${c.identity}`;
356
+ const findOrAddBuyer = (name, identity) => {
357
+ const found = data.buyers.find((b) => buyerKey(b.identity) === buyerKey(identity));
358
+ if (found) return found.id;
359
+ const buyer = { id: nanoid(8), name, identity, createdAt: now(), updatedAt: now() };
360
+ data.buyers.push(buyer);
361
+ return buyer.id;
362
+ };
363
+ const findOrAddSupplier = (s) => {
364
+ const found = data.suppliers.find((x) => buyerKey(x.identity) === buyerKey(s.identity));
365
+ if (found) return found.id;
366
+ const supplier = {
367
+ id: nanoid(8),
368
+ name: s.name ?? "Supplier",
369
+ identity: s.identity,
370
+ punchoutUrl: s.punchoutUrl,
371
+ orderUrl: s.orderUrl,
372
+ catalog: s.catalog,
373
+ createdAt: now(),
374
+ updatedAt: now()
375
+ };
376
+ data.suppliers.push(supplier);
377
+ return supplier.id;
378
+ };
379
+ const migrated = [];
380
+ for (const c of data.connections) {
381
+ if (!("from" in c) && !("to" in c)) {
382
+ migrated.push(c);
383
+ continue;
384
+ }
385
+ const isSupplierMode = c.mode === "virtual-supplier";
386
+ const buyerCred = isSupplierMode ? c.to : c.from;
387
+ const supplierCred = isSupplierMode ? c.from : c.to;
388
+ const buyerId = findOrAddBuyer(isSupplierMode ? "Buyer" : c.name, buyerCred);
389
+ const supplierId = findOrAddSupplier({
390
+ name: isSupplierMode ? c.name : "Supplier",
391
+ identity: supplierCred,
392
+ punchoutUrl: c.punchoutUrl,
393
+ orderUrl: c.orderUrl,
394
+ catalog: c.catalog
395
+ });
396
+ migrated.push({
397
+ id: c.id ?? nanoid(8),
398
+ name: c.name ?? "Connection",
399
+ buyerId,
400
+ supplierId,
401
+ mode: c.mode ?? "virtual-buyer",
402
+ sharedSecret: c.sharedSecret ?? "",
403
+ senderIdentity: c.sender,
404
+ deploymentMode: c.deploymentMode ?? "test",
405
+ createdAt: c.createdAt ?? now(),
406
+ updatedAt: now()
407
+ });
408
+ }
409
+ data.connections = migrated;
410
+ }
97
411
 
98
412
  // src/server/routes/connections.ts
99
413
  var connectionsRoute = new Hono();
100
- var emptyCredential = () => ({ domain: "", identity: "" });
101
414
  function normalize(body) {
102
- const cred = (c) => c && typeof c === "object" ? { domain: String(c.domain ?? ""), identity: String(c.identity ?? "") } : emptyCredential();
415
+ const sender = body?.senderIdentity && (body.senderIdentity.domain || body.senderIdentity.identity) ? { domain: String(body.senderIdentity.domain ?? ""), identity: String(body.senderIdentity.identity ?? "") } : void 0;
103
416
  return {
104
- name: String(body?.name ?? "Untitled connection"),
417
+ name: String(body?.name ?? ""),
418
+ buyerId: String(body?.buyerId ?? ""),
419
+ supplierId: String(body?.supplierId ?? ""),
105
420
  mode: body?.mode === "virtual-supplier" ? "virtual-supplier" : "virtual-buyer",
106
- from: cred(body?.from),
107
- to: cred(body?.to),
108
- sender: cred(body?.sender),
109
421
  sharedSecret: String(body?.sharedSecret ?? ""),
422
+ senderIdentity: sender,
110
423
  deploymentMode: body?.deploymentMode === "production" ? "production" : "test",
111
- authStyle: body?.authStyle === "MAC" ? "MAC" : "SharedSecret",
112
- punchoutUrl: body?.punchoutUrl ? String(body.punchoutUrl) : void 0,
113
- orderUrl: body?.orderUrl ? String(body.orderUrl) : void 0,
114
- catalog: Array.isArray(body?.catalog) ? body.catalog : void 0
424
+ attachmentEncoding: body?.attachmentEncoding === "base64" ? "base64" : "binary"
115
425
  };
116
426
  }
117
- function validateConnection(input) {
427
+ function validate(input) {
118
428
  const errors = [];
119
- if (!input.name.trim()) errors.push("name is required");
120
- if (input.mode === "virtual-buyer") {
121
- if (!input.punchoutUrl) errors.push("punchoutUrl is required for virtual-buyer");
122
- if (!input.orderUrl) errors.push("orderUrl is required for virtual-buyer");
123
- }
429
+ if (!input.buyerId || !getBuyer(input.buyerId)) errors.push("a valid buyer is required");
430
+ if (!input.supplierId || !getSupplier(input.supplierId)) errors.push("a valid supplier is required");
124
431
  return errors;
125
432
  }
126
- connectionsRoute.get("/", (c) => c.json(listConnections()));
433
+ function withLabel(input) {
434
+ if (input.name.trim()) return input;
435
+ const buyer = getBuyer(input.buyerId);
436
+ const supplier = getSupplier(input.supplierId);
437
+ return { ...input, name: `${buyer?.name ?? "Buyer"} \u2192 ${supplier?.name ?? "Supplier"}` };
438
+ }
439
+ function maskSecret(conn) {
440
+ return { ...conn, sharedSecret: "", hasSharedSecret: !!conn.sharedSecret };
441
+ }
442
+ connectionsRoute.get(
443
+ "/",
444
+ (c) => c.json(
445
+ listConnections().map((conn) => ({
446
+ ...maskSecret(conn),
447
+ buyer: getBuyer(conn.buyerId),
448
+ supplier: getSupplier(conn.supplierId)
449
+ }))
450
+ )
451
+ );
127
452
  connectionsRoute.post("/", async (c) => {
128
453
  const input = normalize(await c.req.json().catch(() => ({})));
129
- const errors = validateConnection(input);
454
+ const errors = validate(input);
130
455
  if (errors.length) return c.json({ errors }, 400);
131
- const created = await createConnection(input);
132
- return c.json(created, 201);
456
+ return c.json(maskSecret(await createConnection(withLabel(input))), 201);
133
457
  });
134
458
  connectionsRoute.get("/:id", (c) => {
459
+ const resolved = resolveConnection(c.req.param("id"));
460
+ if (resolved) return c.json({ ...maskSecret(resolved.connection), buyer: resolved.buyer, supplier: resolved.supplier });
135
461
  const conn = getConnection(c.req.param("id"));
136
- return conn ? c.json(conn) : c.json({ error: "not found" }, 404);
462
+ return conn ? c.json(maskSecret(conn)) : c.json({ error: "not found" }, 404);
137
463
  });
138
464
  connectionsRoute.put("/:id", async (c) => {
139
- const id = c.req.param("id");
140
- const existing = getConnection(id);
465
+ const existing = getConnection(c.req.param("id"));
141
466
  if (!existing) return c.json({ error: "not found" }, 404);
142
- const merged = { ...existing, ...await c.req.json().catch(() => ({})) };
143
- const input = normalize(merged);
144
- const errors = validateConnection(input);
467
+ const body = await c.req.json().catch(() => ({}));
468
+ if (!body || !body.sharedSecret) delete body.sharedSecret;
469
+ const input = normalize({ ...existing, ...body });
470
+ const errors = validate(input);
145
471
  if (errors.length) return c.json({ errors }, 400);
146
- const updated = await updateConnection(id, input);
147
- return c.json(updated);
472
+ return c.json(maskSecret(await updateConnection(c.req.param("id"), withLabel(input))));
148
473
  });
149
474
  connectionsRoute.delete("/:id", async (c) => {
150
475
  const ok = await deleteConnection(c.req.param("id"));
151
476
  return ok ? c.json({ ok: true }) : c.json({ error: "not found" }, 404);
152
477
  });
153
478
 
154
- // src/server/routes/flow.ts
479
+ // src/server/routes/parties.ts
155
480
  import { Hono as Hono2 } from "hono";
481
+ var cred = (c) => ({
482
+ domain: String(c?.domain ?? ""),
483
+ identity: String(c?.identity ?? "")
484
+ });
485
+ var buyersRoute = new Hono2();
486
+ function normalizeBuyer(body) {
487
+ return {
488
+ name: String(body?.name ?? "Untitled buyer"),
489
+ identity: cred(body?.identity),
490
+ profileId: body?.profileId ? String(body.profileId) : void 0
491
+ };
492
+ }
493
+ buyersRoute.get("/", (c) => c.json(listBuyers()));
494
+ buyersRoute.post("/", async (c) => {
495
+ const input = normalizeBuyer(await c.req.json().catch(() => ({})));
496
+ if (!input.name.trim()) return c.json({ errors: ["name is required"] }, 400);
497
+ return c.json(await createBuyer(input), 201);
498
+ });
499
+ buyersRoute.get("/:id", (c) => {
500
+ const b = getBuyer(c.req.param("id"));
501
+ return b ? c.json(b) : c.json({ error: "not found" }, 404);
502
+ });
503
+ buyersRoute.put("/:id", async (c) => {
504
+ if (!getBuyer(c.req.param("id"))) return c.json({ error: "not found" }, 404);
505
+ const input = normalizeBuyer(await c.req.json().catch(() => ({})));
506
+ return c.json(await updateBuyer(c.req.param("id"), input));
507
+ });
508
+ buyersRoute.delete("/:id", async (c) => {
509
+ try {
510
+ const ok = await deleteBuyer(c.req.param("id"));
511
+ return ok ? c.json({ ok: true }) : c.json({ error: "not found" }, 404);
512
+ } catch (e) {
513
+ return c.json({ error: e instanceof Error ? e.message : String(e) }, 409);
514
+ }
515
+ });
516
+ var suppliersRoute = new Hono2();
517
+ function normalizeSupplier(body) {
518
+ const catalog = Array.isArray(body?.catalog) ? body.catalog.map((it) => ({
519
+ supplierPartId: String(it?.supplierPartId ?? ""),
520
+ description: String(it?.description ?? ""),
521
+ unitPrice: Number(it?.unitPrice ?? 0) || 0,
522
+ currency: String(it?.currency ?? "USD"),
523
+ uom: String(it?.uom ?? "EA"),
524
+ unspsc: String(it?.unspsc ?? ""),
525
+ manufacturerPartId: it?.manufacturerPartId ? String(it.manufacturerPartId) : void 0,
526
+ manufacturerName: it?.manufacturerName ? String(it.manufacturerName) : void 0
527
+ })) : void 0;
528
+ return {
529
+ name: String(body?.name ?? "Untitled supplier"),
530
+ identity: cred(body?.identity),
531
+ punchoutUrl: body?.punchoutUrl ? String(body.punchoutUrl) : void 0,
532
+ orderUrl: body?.orderUrl ? String(body.orderUrl) : void 0,
533
+ catalog
534
+ };
535
+ }
536
+ suppliersRoute.get("/", (c) => c.json(listSuppliers()));
537
+ suppliersRoute.post("/", async (c) => {
538
+ const input = normalizeSupplier(await c.req.json().catch(() => ({})));
539
+ if (!input.name.trim()) return c.json({ errors: ["name is required"] }, 400);
540
+ return c.json(await createSupplier(input), 201);
541
+ });
542
+ suppliersRoute.get("/:id", (c) => {
543
+ const s = getSupplier(c.req.param("id"));
544
+ return s ? c.json(s) : c.json({ error: "not found" }, 404);
545
+ });
546
+ suppliersRoute.put("/:id", async (c) => {
547
+ if (!getSupplier(c.req.param("id"))) return c.json({ error: "not found" }, 404);
548
+ const input = normalizeSupplier(await c.req.json().catch(() => ({})));
549
+ return c.json(await updateSupplier(c.req.param("id"), input));
550
+ });
551
+ suppliersRoute.delete("/:id", async (c) => {
552
+ try {
553
+ const ok = await deleteSupplier(c.req.param("id"));
554
+ return ok ? c.json({ ok: true }) : c.json({ error: "not found" }, 404);
555
+ } catch (e) {
556
+ return c.json({ error: e instanceof Error ? e.message : String(e) }, 409);
557
+ }
558
+ });
559
+
560
+ // src/server/routes/profiles.ts
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";
156
631
  import { nanoid as nanoid5 } from "nanoid";
157
632
 
158
633
  // src/server/store/log.ts
@@ -177,14 +652,19 @@ var bus = new Bus();
177
652
  bus.setMaxListeners(0);
178
653
 
179
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
+ }
180
659
  function appendLog(input) {
181
660
  ensureDirs();
182
661
  const record = {
183
662
  ...input,
663
+ body: redactSecrets(input.body),
184
664
  id: input.id ?? nanoid2(12),
185
665
  ts: input.ts ?? (/* @__PURE__ */ new Date()).toISOString()
186
666
  };
187
- appendFileSync(sessionFile(record.sessionId), JSON.stringify(record) + "\n", "utf8");
667
+ appendFileSync(sessionFile(record.sessionId), JSON.stringify(record) + "\n", { encoding: "utf8", mode: 384 });
188
668
  bus.emitLog(record);
189
669
  return record;
190
670
  }
@@ -234,8 +714,13 @@ function normalizeContentId(cid) {
234
714
  if (!cid) return "";
235
715
  return cid.trim().replace(/^<+/, "").replace(/>+$/, "").trim();
236
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
+ }
237
721
  function buildMultipartRelated(cxml, attachments, opts = {}) {
238
- const boundary = `cxml-${nanoid3(20)}`;
722
+ const boundary = opts.boundary || `cxml-${nanoid3(20)}`;
723
+ const useBase64 = opts.attachmentEncoding === "base64";
239
724
  const mainCid = opts.mainContentId ?? `cxml-main@punchout-simulator`;
240
725
  const CRLF = "\r\n";
241
726
  const parts = [];
@@ -258,11 +743,11 @@ function buildMultipartRelated(cxml, attachments, opts = {}) {
258
743
  pushPart(
259
744
  [
260
745
  `Content-Type: ${att.contentType}`,
261
- `Content-Transfer-Encoding: binary`,
746
+ `Content-Transfer-Encoding: ${useBase64 ? "base64" : "binary"}`,
262
747
  `Content-ID: <${normalizeContentId(att.contentId)}>`,
263
748
  disposition
264
749
  ],
265
- att.data
750
+ useBase64 ? base64Wrapped(att.data) : att.data
266
751
  );
267
752
  }
268
753
  parts.push(Buffer.from(`--${boundary}--${CRLF}`, "utf8"));
@@ -276,6 +761,10 @@ function getBoundary(contentType) {
276
761
  const m = /boundary="?([^";]+)"?/i.exec(contentType);
277
762
  return m?.[1];
278
763
  }
764
+ function getStartCid(contentType) {
765
+ const m = /start="?<?([^">]+)>?"?/i.exec(contentType);
766
+ return m ? normalizeContentId(m[1]) : void 0;
767
+ }
279
768
  function isMultipart(contentType) {
280
769
  return !!contentType && /^multipart\//i.test(contentType.trim());
281
770
  }
@@ -305,6 +794,9 @@ function parseMultipartRelated(body, contentType) {
305
794
  headers[line.slice(0, idx).trim().toLowerCase()] = line.slice(idx + 1).trim();
306
795
  }
307
796
  }
797
+ if (headers["content-transfer-encoding"]?.toLowerCase() === "base64") {
798
+ bodyBuf = Buffer.from(bodyBuf.toString("utf8"), "base64");
799
+ }
308
800
  parts.push({
309
801
  headers,
310
802
  contentId: normalizeContentId(headers["content-id"]),
@@ -353,7 +845,7 @@ function saveAttachment(data, meta) {
353
845
  ensureDirs();
354
846
  const hash = createHash("sha256").update(data).digest("hex");
355
847
  const path = resolve2(attachmentsDir(), hash);
356
- if (!existsSync2(path)) writeFileSync(path, data);
848
+ if (!existsSync2(path)) writeFileSync(path, data, { mode: 384 });
357
849
  return {
358
850
  contentId: normalizeContentId(meta.contentId),
359
851
  filename: meta.filename,
@@ -388,14 +880,32 @@ function connectionForSession(sessionId) {
388
880
  }
389
881
 
390
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
+ }
391
897
  async function sendCxml(url, body, contentType = "text/xml; charset=UTF-8", timeoutMs = 3e4) {
392
898
  const controller = new AbortController();
393
899
  const timer = setTimeout(() => controller.abort(), timeoutMs);
394
900
  try {
901
+ assertSafeOutboundUrl(url);
395
902
  const res = await fetch(url, {
396
903
  method: "POST",
397
904
  headers: { "Content-Type": contentType },
398
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",
399
909
  signal: controller.signal
400
910
  });
401
911
  const headers = {};
@@ -421,21 +931,6 @@ async function sendCxml(url, body, contentType = "text/xml; charset=UTF-8", time
421
931
  }
422
932
  }
423
933
 
424
- // src/server/runtime.ts
425
- var runtime = {
426
- port: 8080,
427
- publicUrl: "http://localhost:8080"
428
- };
429
- function setRuntime(r) {
430
- Object.assign(runtime, r);
431
- }
432
- function getPublicUrl() {
433
- return runtime.publicUrl.replace(/\/$/, "");
434
- }
435
- function browserFormPostUrl() {
436
- return `${getPublicUrl()}/punchout/return`;
437
- }
438
-
439
934
  // src/server/cxml/build.ts
440
935
  import { nanoid as nanoid4 } from "nanoid";
441
936
  function escapeXml(value) {
@@ -469,14 +964,24 @@ ${credentialBlock("To", p.to)}
469
964
  ${senderBlock(p.sender, p.sharedSecret, p.userAgent)}
470
965
  </Header>`;
471
966
  }
472
- var DECLARATION = `<?xml version="1.0" encoding="UTF-8"?>
473
- <!DOCTYPE cXML SYSTEM "http://xml.cxml.org/schemas/cXML/1.2.045/cXML.dtd">`;
474
- function envelope(payloadId, timestamp, lang, inner) {
475
- return `${DECLARATION}
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)}
476
974
  <cXML payloadID="${escapeXml(payloadId)}" timestamp="${escapeXml(timestamp)}" xml:lang="${escapeXml(lang)}">
477
975
  ${inner}
478
976
  </cXML>`;
479
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
+ }
480
985
  function buildSetupRequest(o) {
481
986
  const lang = o.lang ?? "en-US";
482
987
  const deployment = o.deploymentMode ? ` deploymentMode="${escapeXml(o.deploymentMode)}"` : "";
@@ -484,17 +989,18 @@ function buildSetupRequest(o) {
484
989
  from: o.from,
485
990
  to: o.to,
486
991
  sender: o.sender,
487
- sharedSecret: o.sharedSecret
992
+ sharedSecret: o.sharedSecret,
993
+ userAgent: o.userAgent
488
994
  })}
489
995
  <Request${deployment}>
490
996
  <PunchOutSetupRequest operation="${escapeXml(o.operation ?? "create")}">
491
997
  <BuyerCookie>${escapeXml(o.buyerCookie)}</BuyerCookie>
492
998
  <BrowserFormPost>
493
999
  <URL>${escapeXml(o.browserFormPostUrl)}</URL>
494
- </BrowserFormPost>
1000
+ </BrowserFormPost>${extrinsicBlock(o.extrinsics, " ")}
495
1001
  </PunchOutSetupRequest>
496
1002
  </Request>`;
497
- return envelope(o.payloadId, o.timestamp, lang, inner);
1003
+ return envelope(o.payloadId, o.timestamp, lang, inner, o.dtdVersion);
498
1004
  }
499
1005
  function addressBlock(tag, a) {
500
1006
  return ` <${tag}>
@@ -556,7 +1062,8 @@ function buildOrderRequest(o) {
556
1062
  from: o.from,
557
1063
  to: o.to,
558
1064
  sender: o.sender,
559
- sharedSecret: o.sharedSecret
1065
+ sharedSecret: o.sharedSecret,
1066
+ userAgent: o.userAgent
560
1067
  })}
561
1068
  <Request${deployment}>
562
1069
  <OrderRequest>
@@ -567,12 +1074,15 @@ function buildOrderRequest(o) {
567
1074
  <Money currency="${escapeXml(o.currency)}">${escapeXml(o.total)}</Money>
568
1075
  </Total>
569
1076
  ${addressBlock("ShipTo", o.shipTo ?? {})}
570
- ${addressBlock("BillTo", o.billTo ?? {})}${commentsWithAttachments(orderLevelCids, " ")}
1077
+ ${addressBlock("BillTo", o.billTo ?? {})}${commentsWithAttachments(orderLevelCids, " ")}${extrinsicBlock(
1078
+ o.extrinsics,
1079
+ " "
1080
+ )}
571
1081
  </OrderRequestHeader>
572
1082
  ${items}
573
1083
  </OrderRequest>
574
1084
  </Request>`;
575
- return envelope(o.payloadId, o.timestamp, lang, inner);
1085
+ return envelope(o.payloadId, o.timestamp, lang, inner, o.dtdVersion);
576
1086
  }
577
1087
  function optionalHeader(p) {
578
1088
  return p ? `${header({ from: p.from, to: p.to, sender: p.sender })}
@@ -590,7 +1100,7 @@ function buildSetupResponse(o) {
590
1100
  </StartPage>
591
1101
  </PunchOutSetupResponse>
592
1102
  </Response>`;
593
- 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);
594
1104
  }
595
1105
  function buildResponseStatus(o) {
596
1106
  const head = o.from && o.to && o.sender ? optionalHeader({ from: o.from, to: o.to, sender: o.sender }) : "";
@@ -599,7 +1109,7 @@ function buildResponseStatus(o) {
599
1109
  o.statusText ?? "OK"
600
1110
  )}">${escapeXml(o.statusText === "OK" || !o.statusText ? "" : o.statusText)}</Status>
601
1111
  </Response>`;
602
- 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);
603
1113
  }
604
1114
  function buildPunchOutOrderMessage(o) {
605
1115
  const total = o.items.reduce(
@@ -631,7 +1141,7 @@ function buildPunchOutOrderMessage(o) {
631
1141
  <Message>
632
1142
  <PunchOutOrderMessage>
633
1143
  <BuyerCookie>${escapeXml(o.buyerCookie)}</BuyerCookie>
634
- <PunchOutOrderMessageHeader operationAllowed="create">
1144
+ <PunchOutOrderMessageHeader operationAllowed="${escapeXml(o.operationAllowed ?? "create")}">
635
1145
  <Total>
636
1146
  <Money currency="${escapeXml(o.currency)}">${escapeXml(total.toFixed(2))}</Money>
637
1147
  </Total>
@@ -639,7 +1149,7 @@ function buildPunchOutOrderMessage(o) {
639
1149
  ${items}
640
1150
  </PunchOutOrderMessage>
641
1151
  </Message>`;
642
- 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);
643
1153
  }
644
1154
 
645
1155
  // src/server/cxml/parse.ts
@@ -656,6 +1166,9 @@ var parser = new XMLParser({
656
1166
  isArray: (name) => ["ItemIn", "ItemOut", "Attachment", "Comments", "Extrinsic"].includes(name)
657
1167
  });
658
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
+ }
659
1172
  const check = XMLValidator.validate(raw, { allowBooleanAttributes: true });
660
1173
  if (check !== true) {
661
1174
  return {
@@ -718,9 +1231,9 @@ function getDocType(doc) {
718
1231
  return "Unknown";
719
1232
  }
720
1233
  function credentialOf(node) {
721
- const cred = node?.Credential;
722
- if (!cred) return void 0;
723
- const first = Array.isArray(cred) ? cred[0] : cred;
1234
+ const cred2 = node?.Credential;
1235
+ if (!cred2) return void 0;
1236
+ const first = Array.isArray(cred2) ? cred2[0] : cred2;
724
1237
  return {
725
1238
  domain: attr(first, "domain") ?? "",
726
1239
  identity: text(first?.Identity) ?? ""
@@ -859,9 +1372,9 @@ function credEq(a, b) {
859
1372
  if (!a || !b) return false;
860
1373
  return a.domain === b.domain && a.identity === b.identity;
861
1374
  }
862
- function credKnown(c, conn) {
1375
+ function credKnown(c, exp) {
863
1376
  if (!c) return false;
864
- return [conn.from, conn.to, conn.sender].some((k) => credEq(c, k));
1377
+ return [exp.from, exp.to, exp.sender].some((k) => credEq(c, k));
865
1378
  }
866
1379
  function checkGeneral(doc, ctx, issues) {
867
1380
  const payloadId = getPayloadId(doc);
@@ -877,8 +1390,8 @@ function checkGeneral(doc, ctx, issues) {
877
1390
  if (!getTimestamp(doc)) {
878
1391
  issues.error("missing-timestamp", "cXML/@timestamp is missing", "cXML/@timestamp");
879
1392
  }
880
- if (!ctx.connection) return;
881
- const conn = ctx.connection;
1393
+ if (!ctx.expected) return;
1394
+ const exp = ctx.expected;
882
1395
  const creds = getHeaderCredentials(doc);
883
1396
  for (const [name, c] of [
884
1397
  ["From", creds.from],
@@ -895,7 +1408,7 @@ function checkGeneral(doc, ctx, issues) {
895
1408
  if (!c.identity) {
896
1409
  issues.warn("credential-identity", `Header/${name}/Credential/Identity is empty`, `cXML/Header/${name}`);
897
1410
  }
898
- if (c.domain && c.identity && !credKnown(c, conn)) {
1411
+ if (c.domain && c.identity && !credKnown(c, exp)) {
899
1412
  issues.warn(
900
1413
  "credential-mismatch",
901
1414
  `Header/${name} (${c.domain}/${c.identity}) does not match any identity configured on the connection`,
@@ -905,9 +1418,8 @@ function checkGeneral(doc, ctx, issues) {
905
1418
  }
906
1419
  }
907
1420
  function checkSharedSecret(doc, ctx, issues) {
908
- if (!ctx.connection) return;
909
- const conn = ctx.connection;
910
- if (conn.authStyle !== "SharedSecret") return;
1421
+ const exp = ctx.expected;
1422
+ if (!exp) return;
911
1423
  const creds = getHeaderCredentials(doc);
912
1424
  if (!creds.sharedSecret) {
913
1425
  issues.warn(
@@ -915,7 +1427,7 @@ function checkSharedSecret(doc, ctx, issues) {
915
1427
  "Sender/Credential/SharedSecret is absent (required for SharedSecret auth)",
916
1428
  "cXML/Header/Sender/Credential/SharedSecret"
917
1429
  );
918
- } else if (conn.sharedSecret && creds.sharedSecret !== conn.sharedSecret) {
1430
+ } else if (exp.sharedSecret && creds.sharedSecret !== exp.sharedSecret) {
919
1431
  issues.error(
920
1432
  "sharedsecret-mismatch",
921
1433
  "Sender SharedSecret does not match the connection's configured shared secret",
@@ -1149,7 +1661,7 @@ function validateDocument(raw, ctx = {}) {
1149
1661
  }
1150
1662
 
1151
1663
  // src/server/routes/flow.ts
1152
- var flowRoute = new Hono2();
1664
+ var flowRoute = new Hono4();
1153
1665
  function host() {
1154
1666
  try {
1155
1667
  return new URL(getPublicUrl()).host;
@@ -1157,54 +1669,86 @@ function host() {
1157
1669
  return "punchout-simulator";
1158
1670
  }
1159
1671
  }
1160
- function requireVirtualBuyer(conn) {
1161
- if (!conn) return "connection not found";
1162
- if (conn.mode !== "virtual-buyer") return "connection is not in virtual-buyer mode";
1163
- return null;
1672
+ function buyerContext(r) {
1673
+ const { connection, buyer, supplier } = r;
1674
+ const from = buyer.identity;
1675
+ const to = supplier.identity;
1676
+ const sender = connection.senderIdentity ?? buyer.identity;
1677
+ const eff = effectiveProfile(connection, buyer);
1678
+ return {
1679
+ from,
1680
+ to,
1681
+ sender,
1682
+ sharedSecret: connection.sharedSecret,
1683
+ punchoutUrl: supplier.punchoutUrl,
1684
+ orderUrl: supplier.orderUrl,
1685
+ deploymentMode: connection.deploymentMode,
1686
+ connectionId: connection.id,
1687
+ attachmentEncoding: eff.attachmentEncoding,
1688
+ eff,
1689
+ expected: { from, to, sender, sharedSecret: connection.sharedSecret }
1690
+ };
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
+ }
1698
+ function resolveVirtualBuyer(id) {
1699
+ const r = resolveConnection(id);
1700
+ if (!r) return { error: "connection not found (or its buyer/supplier is missing)" };
1701
+ if (r.connection.mode !== "virtual-buyer") return { error: "connection is not in virtual-buyer mode" };
1702
+ return { ctx: buyerContext(r) };
1164
1703
  }
1165
1704
  flowRoute.get("/:id/setup/preview", (c) => {
1166
- const conn = getConnection(c.req.param("id"));
1167
- const err = requireVirtualBuyer(conn);
1168
- if (err) return c.json({ error: err }, 400);
1705
+ const r = resolveVirtualBuyer(c.req.param("id"));
1706
+ if ("error" in r) return c.json({ error: r.error }, 400);
1707
+ const { ctx } = r;
1169
1708
  const buyerCookie = c.req.query("buyerCookie") || `pos-${nanoid5(16)}`;
1170
1709
  const xml = buildSetupRequest({
1171
- from: conn.from,
1172
- to: conn.to,
1173
- sender: conn.sender,
1174
- sharedSecret: conn.sharedSecret,
1710
+ from: ctx.from,
1711
+ to: ctx.to,
1712
+ sender: ctx.sender,
1713
+ sharedSecret: ctx.sharedSecret,
1175
1714
  buyerCookie,
1176
1715
  browserFormPostUrl: browserFormPostUrl(),
1177
1716
  payloadId: makePayloadId(host(), (/* @__PURE__ */ new Date()).toISOString()),
1178
1717
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1179
- deploymentMode: conn.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)
1180
1723
  });
1181
1724
  return c.json({ buyerCookie, xml, browserFormPostUrl: browserFormPostUrl() });
1182
1725
  });
1183
1726
  flowRoute.post("/:id/setup", async (c) => {
1184
- const conn = getConnection(c.req.param("id"));
1185
- const err = requireVirtualBuyer(conn);
1186
- if (err) return c.json({ error: err }, 400);
1727
+ const r = resolveVirtualBuyer(c.req.param("id"));
1728
+ if ("error" in r) return c.json({ error: r.error }, 400);
1729
+ const { ctx } = r;
1187
1730
  const body = await c.req.json().catch(() => ({}));
1188
1731
  const buyerCookie = body.buyerCookie || `pos-${nanoid5(16)}`;
1189
1732
  const xml = body.xml || buildSetupRequest({
1190
- from: conn.from,
1191
- to: conn.to,
1192
- sender: conn.sender,
1193
- sharedSecret: conn.sharedSecret,
1733
+ from: ctx.from,
1734
+ to: ctx.to,
1735
+ sender: ctx.sender,
1736
+ sharedSecret: ctx.sharedSecret,
1194
1737
  buyerCookie,
1195
1738
  browserFormPostUrl: browserFormPostUrl(),
1196
1739
  payloadId: makePayloadId(host(), (/* @__PURE__ */ new Date()).toISOString()),
1197
1740
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1198
- deploymentMode: conn.deploymentMode
1199
- });
1200
- rememberSessionConnection(buyerCookie, conn.id);
1201
- const reqValidation = validateDocument(xml, {
1202
- connection: conn,
1203
- forceDocType: "SetupRequest"
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)
1204
1746
  });
1747
+ rememberSessionConnection(buyerCookie, ctx.connectionId);
1748
+ const reqValidation = validateDocument(xml, { expected: ctx.expected, forceDocType: "SetupRequest" });
1205
1749
  const reqLog = appendLog({
1206
1750
  sessionId: buyerCookie,
1207
- connectionId: conn.id,
1751
+ connectionId: ctx.connectionId,
1208
1752
  direction: "out",
1209
1753
  docType: "SetupRequest",
1210
1754
  headers: { "Content-Type": "text/xml; charset=UTF-8" },
@@ -1212,11 +1756,12 @@ flowRoute.post("/:id/setup", async (c) => {
1212
1756
  contentType: "text/xml",
1213
1757
  validation: reqValidation
1214
1758
  });
1215
- const res = await sendCxml(conn.punchoutUrl, xml);
1216
- const respValidation = res.error ? void 0 : validateDocument(res.body, { connection: conn, forceDocType: "SetupResponse" });
1759
+ if (!ctx.punchoutUrl) return c.json({ error: "supplier has no punchoutUrl configured" }, 400);
1760
+ const res = await sendCxml(ctx.punchoutUrl, xml);
1761
+ const respValidation = res.error ? void 0 : validateDocument(res.body, { expected: ctx.expected, forceDocType: "SetupResponse" });
1217
1762
  const respLog = appendLog({
1218
1763
  sessionId: buyerCookie,
1219
- connectionId: conn.id,
1764
+ connectionId: ctx.connectionId,
1220
1765
  direction: "in",
1221
1766
  docType: "SetupResponse",
1222
1767
  status: res.status,
@@ -1237,7 +1782,7 @@ flowRoute.post("/:id/setup", async (c) => {
1237
1782
  response: respLog
1238
1783
  });
1239
1784
  });
1240
- function buildOrderXml(conn, body) {
1785
+ function buildOrderXml(ctx, body) {
1241
1786
  const items = body.items ?? [];
1242
1787
  const currency = body.currency || items[0]?.currency || "USD";
1243
1788
  const total = body.total ?? items.reduce((s, it) => s + (it.unitPriceAmount ?? 0) * it.quantity, 0);
@@ -1247,41 +1792,43 @@ function buildOrderXml(conn, body) {
1247
1792
  scope: a.scope === "order" || a.scope == null ? "order" : { itemIndex: Number(a.scope) }
1248
1793
  }));
1249
1794
  const xml = buildOrderRequest({
1250
- from: conn.from,
1251
- to: conn.to,
1252
- sender: conn.sender,
1253
- sharedSecret: conn.sharedSecret,
1795
+ from: ctx.from,
1796
+ to: ctx.to,
1797
+ sender: ctx.sender,
1798
+ sharedSecret: ctx.sharedSecret,
1254
1799
  orderId,
1255
1800
  orderDate: (/* @__PURE__ */ new Date()).toISOString(),
1256
1801
  payloadId: makePayloadId(host(), (/* @__PURE__ */ new Date()).toISOString()),
1257
1802
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1258
- deploymentMode: conn.deploymentMode,
1803
+ deploymentMode: ctx.deploymentMode,
1259
1804
  currency,
1260
1805
  total,
1261
1806
  items,
1262
1807
  shipTo: body.shipTo,
1263
1808
  billTo: body.billTo,
1264
- attachments: attMeta
1809
+ attachments: attMeta,
1810
+ dtdVersion: dtdVersionFor(ctx.eff, "OrderRequest"),
1811
+ userAgent: ctx.eff.userAgent,
1812
+ extrinsics: orderExtrinsics(ctx, orderId)
1265
1813
  });
1266
1814
  return { xml, orderId };
1267
1815
  }
1268
1816
  flowRoute.post("/:id/order/preview", async (c) => {
1269
- const conn = getConnection(c.req.param("id"));
1270
- const err = requireVirtualBuyer(conn);
1271
- if (err) return c.json({ error: err }, 400);
1817
+ const r = resolveVirtualBuyer(c.req.param("id"));
1818
+ if ("error" in r) return c.json({ error: r.error }, 400);
1272
1819
  const body = await c.req.json().catch(() => ({}));
1273
- const { xml, orderId } = buildOrderXml(conn, body);
1820
+ const { xml, orderId } = buildOrderXml(r.ctx, body);
1274
1821
  return c.json({ xml, orderId });
1275
1822
  });
1276
1823
  flowRoute.post("/:id/order", async (c) => {
1277
- const conn = getConnection(c.req.param("id"));
1278
- const err = requireVirtualBuyer(conn);
1279
- if (err) return c.json({ error: err }, 400);
1824
+ const r = resolveVirtualBuyer(c.req.param("id"));
1825
+ if ("error" in r) return c.json({ error: r.error }, 400);
1826
+ const { ctx } = r;
1280
1827
  const body = await c.req.json().catch(() => ({}));
1281
1828
  const sessionId = body.sessionId || `pos-${nanoid5(16)}`;
1282
1829
  const dangling = !!body.danglingCid;
1283
1830
  const inputAtts = body.attachments ?? [];
1284
- const xml = body.xml || buildOrderXml(conn, body).xml;
1831
+ const xml = body.xml || buildOrderXml(ctx, body).xml;
1285
1832
  let wireBody = xml;
1286
1833
  let wireContentType = "text/xml; charset=UTF-8";
1287
1834
  const availableContentIds = /* @__PURE__ */ new Set();
@@ -1305,18 +1852,18 @@ flowRoute.post("/:id/order", async (c) => {
1305
1852
  data
1306
1853
  };
1307
1854
  });
1308
- const built = buildMultipartRelated(xml, parts);
1855
+ const built = buildMultipartRelated(xml, parts, { attachmentEncoding: ctx.attachmentEncoding });
1309
1856
  wireBody = built.body;
1310
1857
  wireContentType = built.contentType;
1311
1858
  }
1312
1859
  const reqValidation = validateDocument(xml, {
1313
- connection: conn,
1860
+ expected: ctx.expected,
1314
1861
  forceDocType: "OrderRequest",
1315
1862
  availableContentIds: inputAtts.length > 0 ? availableContentIds : void 0
1316
1863
  });
1317
1864
  const reqLog = appendLog({
1318
1865
  sessionId,
1319
- connectionId: conn.id,
1866
+ connectionId: ctx.connectionId,
1320
1867
  direction: "out",
1321
1868
  docType: "OrderRequest",
1322
1869
  headers: { "Content-Type": wireContentType },
@@ -1324,13 +1871,15 @@ flowRoute.post("/:id/order", async (c) => {
1324
1871
  contentType: wireContentType,
1325
1872
  validation: reqValidation,
1326
1873
  attachments: savedRefs,
1874
+ attachmentEncoding: inputAtts.length > 0 ? ctx.attachmentEncoding : void 0,
1327
1875
  note: dangling ? "dangling-cid test" : void 0
1328
1876
  });
1329
- const res = await sendCxml(conn.orderUrl, wireBody, wireContentType);
1330
- const respValidation = res.error ? void 0 : validateDocument(res.body, { connection: conn, forceDocType: "OrderResponse" });
1877
+ if (!ctx.orderUrl) return c.json({ error: "supplier has no orderUrl configured" }, 400);
1878
+ const res = await sendCxml(ctx.orderUrl, wireBody, wireContentType);
1879
+ const respValidation = res.error ? void 0 : validateDocument(res.body, { expected: ctx.expected, forceDocType: "OrderResponse" });
1331
1880
  const respLog = appendLog({
1332
1881
  sessionId,
1333
- connectionId: conn.id,
1882
+ connectionId: ctx.connectionId,
1334
1883
  direction: "in",
1335
1884
  docType: "OrderResponse",
1336
1885
  status: res.status,
@@ -1351,8 +1900,8 @@ flowRoute.post("/:id/order", async (c) => {
1351
1900
  });
1352
1901
 
1353
1902
  // src/server/routes/punchout-return.ts
1354
- import { Hono as Hono3 } from "hono";
1355
- var punchoutReturnRoute = new Hono3();
1903
+ import { Hono as Hono5 } from "hono";
1904
+ var punchoutReturnRoute = new Hono5();
1356
1905
  async function extractCxml(c) {
1357
1906
  const ct = (c.req.header("content-type") ?? "").toLowerCase();
1358
1907
  if (ct.includes("application/x-www-form-urlencoded") || ct.includes("multipart/form-data")) {
@@ -1392,9 +1941,14 @@ punchoutReturnRoute.post("/return", async (c) => {
1392
1941
  const cart = parseCart(doc);
1393
1942
  const sessionId = cart.sessionId || text(root(doc)?.Message?.PunchOutOrderMessage?.BuyerCookie) || "unknown";
1394
1943
  const connectionId = connectionForSession(sessionId);
1395
- const conn = connectionId ? getConnection(connectionId) : void 0;
1944
+ const resolved = connectionId ? resolveConnection(connectionId) : void 0;
1945
+ const expected = resolved ? {
1946
+ from: resolved.buyer.identity,
1947
+ to: resolved.supplier.identity,
1948
+ sender: resolved.connection.senderIdentity ?? resolved.supplier.identity
1949
+ } : void 0;
1396
1950
  const validation = validateDocument(xml, {
1397
- connection: conn,
1951
+ expected,
1398
1952
  expectedBuyerCookie: sessionId,
1399
1953
  forceDocType: "PunchOutOrderMessage"
1400
1954
  });
@@ -1415,10 +1969,14 @@ punchoutReturnRoute.post("/return", async (c) => {
1415
1969
  });
1416
1970
 
1417
1971
  // src/server/routes/stream.ts
1418
- import { Hono as Hono4 } from "hono";
1972
+ import { Hono as Hono6 } from "hono";
1419
1973
  import { streamSSE } from "hono/streaming";
1420
- var streamRoute = new Hono4();
1974
+ var streamRoute = new Hono6();
1975
+ var MAX_STREAMS = 64;
1976
+ var activeStreams = 0;
1421
1977
  streamRoute.get("/stream", (c) => {
1978
+ if (activeStreams >= MAX_STREAMS) return c.text("too many live-log connections", 503);
1979
+ activeStreams++;
1422
1980
  return streamSSE(c, async (stream) => {
1423
1981
  let open = true;
1424
1982
  let id = 0;
@@ -1432,6 +1990,7 @@ streamRoute.get("/stream", (c) => {
1432
1990
  });
1433
1991
  stream.onAbort(() => {
1434
1992
  open = false;
1993
+ activeStreams = Math.max(0, activeStreams - 1);
1435
1994
  unsubscribe();
1436
1995
  });
1437
1996
  await stream.writeSSE({ event: "ready", data: JSON.stringify({ ts: Date.now() }) });
@@ -1444,8 +2003,30 @@ streamRoute.get("/stream", (c) => {
1444
2003
  });
1445
2004
 
1446
2005
  // src/server/routes/data.ts
1447
- import { Hono as Hono5 } from "hono";
1448
- var dataRoute = new Hono5();
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
+ }
1449
2030
  dataRoute.get("/health", (c) => c.json({ ok: true }));
1450
2031
  dataRoute.get(
1451
2032
  "/runtime",
@@ -1453,6 +2034,12 @@ dataRoute.get(
1453
2034
  );
1454
2035
  dataRoute.get("/sessions", (c) => c.json(listSessions()));
1455
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
+ });
1456
2043
  dataRoute.get("/recent", (c) => {
1457
2044
  const limit = Number(c.req.query("limit") ?? "200");
1458
2045
  return c.json(readAllRecent(Number.isFinite(limit) ? limit : 200));
@@ -1469,38 +2056,13 @@ dataRoute.get("/attachments/:hash", (c) => {
1469
2056
  });
1470
2057
 
1471
2058
  // src/server/routes/sim.ts
1472
- import { Hono as Hono6 } from "hono";
2059
+ import { Hono as Hono8 } from "hono";
1473
2060
  import { nanoid as nanoid6 } from "nanoid";
1474
- var simRoute = new Hono6();
2061
+ var simRoute = new Hono8();
1475
2062
  var DEMO_CATALOG = [
1476
- {
1477
- supplierPartId: "WIDGET-001",
1478
- description: "Premium Steel Widget",
1479
- unitPrice: 12.5,
1480
- currency: "USD",
1481
- uom: "EA",
1482
- unspsc: "31161500",
1483
- manufacturerPartId: "MFR-W001",
1484
- manufacturerName: "Acme Manufacturing"
1485
- },
1486
- {
1487
- supplierPartId: "BOLT-250",
1488
- description: "M8 Hex Bolt (pack of 250)",
1489
- unitPrice: 34,
1490
- currency: "USD",
1491
- uom: "PK",
1492
- unspsc: "31161600",
1493
- manufacturerPartId: "MFR-B250",
1494
- manufacturerName: "Acme Manufacturing"
1495
- },
1496
- {
1497
- supplierPartId: "TAPE-RED",
1498
- description: "Industrial Marking Tape, Red",
1499
- unitPrice: 5.75,
1500
- currency: "USD",
1501
- uom: "RL",
1502
- unspsc: "31201500"
1503
- }
2063
+ { supplierPartId: "WIDGET-001", description: "Premium Steel Widget", unitPrice: 12.5, currency: "USD", uom: "EA", unspsc: "31161500", manufacturerPartId: "MFR-W001", manufacturerName: "Acme Manufacturing" },
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" },
2065
+ { supplierPartId: "TAPE-RED", description: "Industrial Marking Tape, Red", unitPrice: 5.75, currency: "USD", uom: "RL", unspsc: "31201500" }
1504
2066
  ];
1505
2067
  function host2() {
1506
2068
  try {
@@ -1509,59 +2071,66 @@ function host2() {
1509
2071
  return "punchout-simulator";
1510
2072
  }
1511
2073
  }
2074
+ var catalogOf = (s) => s.catalog && s.catalog.length > 0 ? s.catalog : DEMO_CATALOG;
1512
2075
  function safeHttpUrl(u) {
1513
2076
  if (!u) return "";
1514
2077
  try {
1515
- const parsed = new URL(u.trim());
1516
- return parsed.protocol === "http:" || parsed.protocol === "https:" ? u.trim() : "";
2078
+ const p = new URL(u.trim());
2079
+ return p.protocol === "http:" || p.protocol === "https:" ? p.href : "";
1517
2080
  } catch {
1518
2081
  return "";
1519
2082
  }
1520
2083
  }
1521
- function catalogOf(conn) {
1522
- return conn.catalog && conn.catalog.length > 0 ? conn.catalog : DEMO_CATALOG;
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
+ }
2087
+ function expectedFor(supplierId, from) {
2088
+ const r = findConnectionBySupplierAndBuyerIdentity(supplierId, from);
2089
+ if (!r) return void 0;
2090
+ return {
2091
+ from: r.buyer.identity,
2092
+ to: r.supplier.identity,
2093
+ sender: r.connection.senderIdentity ?? r.buyer.identity,
2094
+ sharedSecret: r.connection.sharedSecret
2095
+ };
1523
2096
  }
1524
- function requireSupplier(conn) {
1525
- if (!conn) return "connection not found";
1526
- if (conn.mode !== "virtual-supplier") return "connection is not in virtual-supplier mode";
1527
- return null;
2097
+ function effFor(supplierId, from) {
2098
+ const r = findConnectionBySupplierAndBuyerIdentity(supplierId, from);
2099
+ return r ? effectiveProfile(r.connection, r.buyer) : void 0;
1528
2100
  }
1529
2101
  simRoute.post("/:id/punchout", async (c) => {
1530
- const conn = getConnection(c.req.param("id"));
1531
- const err = requireSupplier(conn);
1532
- if (err) return c.text(err, 400);
2102
+ const supplier = getSupplier(c.req.param("id"));
2103
+ if (!supplier) return c.text("supplier not found", 404);
1533
2104
  const reqXml = await c.req.text();
1534
2105
  const doc = parseXml(reqXml);
1535
2106
  const reqRoot = root(doc)?.Request?.PunchOutSetupRequest;
1536
2107
  const buyerCookie = text(reqRoot?.BuyerCookie) || `pos-${nanoid6(16)}`;
1537
2108
  const formPost = safeHttpUrl(text(reqRoot?.BrowserFormPost?.URL));
1538
- const reqValidation = validateDocument(reqXml, {
1539
- connection: conn,
1540
- forceDocType: "SetupRequest"
1541
- });
2109
+ const from = getHeaderCredentials(doc).from;
1542
2110
  appendLog({
1543
2111
  sessionId: buyerCookie,
1544
- connectionId: conn.id,
2112
+ connectionId: supplier.id,
1545
2113
  direction: "in",
1546
2114
  docType: "SetupRequest",
1547
2115
  headers: { "Content-Type": c.req.header("content-type") ?? "" },
1548
2116
  body: reqXml,
1549
- validation: reqValidation
2117
+ validation: validateDocument(reqXml, { expected: expectedFor(supplier.id, from), forceDocType: "SetupRequest" })
1550
2118
  });
1551
- const startPageUrl = `${getPublicUrl()}/sim/${conn.id}/catalog?cookie=${encodeURIComponent(
1552
- buyerCookie
1553
- )}&formpost=${encodeURIComponent(formPost)}`;
2119
+ const buyerCred = from ?? { domain: "", identity: "" };
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);
1554
2122
  const respXml = buildSetupResponse({
1555
2123
  payloadId: makePayloadId(host2(), (/* @__PURE__ */ new Date()).toISOString()),
1556
2124
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1557
2125
  startPageUrl,
1558
- from: conn.from,
1559
- to: conn.to,
1560
- sender: conn.sender
2126
+ from: supplier.identity,
2127
+ to: buyerCred,
2128
+ sender: supplier.identity,
2129
+ dtdVersion: eff ? dtdVersionFor(eff, "SetupResponse") : void 0
1561
2130
  });
1562
2131
  appendLog({
1563
2132
  sessionId: buyerCookie,
1564
- connectionId: conn.id,
2133
+ connectionId: supplier.id,
1565
2134
  direction: "out",
1566
2135
  docType: "SetupResponse",
1567
2136
  headers: { "Content-Type": "text/xml; charset=UTF-8" },
@@ -1572,24 +2141,23 @@ simRoute.post("/:id/punchout", async (c) => {
1572
2141
  return c.body(respXml);
1573
2142
  });
1574
2143
  simRoute.get("/:id/catalog", (c) => {
1575
- const conn = getConnection(c.req.param("id"));
1576
- const err = requireSupplier(conn);
1577
- if (err) return c.text(err, 400);
2144
+ const supplier = getSupplier(c.req.param("id"));
2145
+ if (!supplier) return c.text("supplier not found", 404);
1578
2146
  const cookie = c.req.query("cookie") ?? "";
1579
- const formpost = c.req.query("formpost") ?? "";
1580
- const items = catalogOf(conn);
2147
+ const formpost = safeHttpUrl(c.req.query("formpost") ?? "");
2148
+ const bd = c.req.query("bd") ?? "";
2149
+ const bi = c.req.query("bi") ?? "";
2150
+ const items = catalogOf(supplier);
1581
2151
  const rows = items.map(
1582
2152
  (it, i) => `<tr>
1583
- <td><strong>${escapeHtml(it.description)}</strong><br><small>${escapeHtml(
1584
- it.supplierPartId
1585
- )} \xB7 ${escapeHtml(it.uom)} \xB7 UNSPSC ${escapeHtml(it.unspsc)}</small></td>
1586
- <td class="price">${it.currency} ${it.unitPrice.toFixed(2)}</td>
2153
+ <td><strong>${escapeXml(it.description)}</strong><br><small>${escapeXml(it.supplierPartId)} \xB7 ${escapeXml(it.uom)} \xB7 UNSPSC ${escapeXml(it.unspsc)}</small></td>
2154
+ <td class="price">${escapeXml(it.currency)} ${it.unitPrice.toFixed(2)}</td>
1587
2155
  <td><input type="number" name="q_${i}" value="0" min="0" step="1" inputmode="numeric"></td>
1588
2156
  </tr>`
1589
2157
  ).join("\n");
1590
2158
  return c.html(`<!doctype html><html lang="en"><head><meta charset="utf-8">
1591
2159
  <meta name="viewport" content="width=device-width, initial-scale=1">
1592
- <title>${escapeHtml(conn.name)} \u2014 mock catalog</title>
2160
+ <title>${escapeXml(supplier.name)} \u2014 mock catalog</title>
1593
2161
  <style>
1594
2162
  body{font-family:system-ui,sans-serif;background:#0f172a;color:#e2e8f0;margin:0;padding:2rem}
1595
2163
  .wrap{max-width:720px;margin:0 auto}
@@ -1598,17 +2166,17 @@ simRoute.get("/:id/catalog", (c) => {
1598
2166
  th,td{padding:.75rem 1rem;text-align:left;border-bottom:1px solid #334155}
1599
2167
  th{background:#0b1220;font-size:.8rem;text-transform:uppercase;letter-spacing:.05em;color:#94a3b8}
1600
2168
  .price{white-space:nowrap;color:#fbbf24}
1601
- input[type=number]{width:5rem;background:#0b1220;border:1px solid #334155;color:#e2e8f0;
1602
- border-radius:6px;padding:.35rem .5rem}
1603
- button{margin-top:1.5rem;background:#6366f1;color:#fff;border:0;border-radius:8px;
1604
- padding:.7rem 1.4rem;font-size:1rem;cursor:pointer}
2169
+ input[type=number]{width:5rem;background:#0b1220;border:1px solid #334155;color:#e2e8f0;border-radius:6px;padding:.35rem .5rem}
2170
+ button{margin-top:1.5rem;background:#6366f1;color:#fff;border:0;border-radius:8px;padding:.7rem 1.4rem;font-size:1rem;cursor:pointer}
1605
2171
  button:hover{background:#4f46e5}
1606
2172
  </style></head><body><div class="wrap">
1607
- <h1>${escapeHtml(conn.name)} <small>(virtual supplier)</small></h1>
2173
+ <h1>${escapeXml(supplier.name)} <small>(virtual supplier)</small></h1>
1608
2174
  <div class="sub">Mock catalog served by punchout-simulator. Set quantities and return the cart.</div>
1609
- <form method="post" action="${getPublicUrl()}/sim/${conn.id}/checkout">
1610
- <input type="hidden" name="cookie" value="${escapeHtml(cookie)}">
1611
- <input type="hidden" name="formpost" value="${escapeHtml(formpost)}">
2175
+ <form method="post" action="${getPublicUrl()}/sim/${supplier.id}/checkout">
2176
+ <input type="hidden" name="cookie" value="${escapeXml(cookie)}">
2177
+ <input type="hidden" name="formpost" value="${escapeXml(formpost)}">
2178
+ <input type="hidden" name="bd" value="${escapeXml(bd)}">
2179
+ <input type="hidden" name="bi" value="${escapeXml(bi)}">
1612
2180
  <table><thead><tr><th>Item</th><th>Price</th><th>Qty</th></tr></thead>
1613
2181
  <tbody>${rows}</tbody></table>
1614
2182
  <button type="submit">Return cart to buyer \u2192</button>
@@ -1616,13 +2184,13 @@ simRoute.get("/:id/catalog", (c) => {
1616
2184
  </div></body></html>`);
1617
2185
  });
1618
2186
  simRoute.post("/:id/checkout", async (c) => {
1619
- const conn = getConnection(c.req.param("id"));
1620
- const err = requireSupplier(conn);
1621
- if (err) return c.text(err, 400);
2187
+ const supplier = getSupplier(c.req.param("id"));
2188
+ if (!supplier) return c.text("supplier not found", 404);
1622
2189
  const form = await c.req.parseBody();
1623
2190
  const cookie = String(form.cookie ?? `pos-${nanoid6(16)}`);
1624
2191
  const formpost = safeHttpUrl(String(form.formpost ?? ""));
1625
- const catalog = catalogOf(conn);
2192
+ const buyerCred = { domain: String(form.bd ?? ""), identity: String(form.bi ?? "") };
2193
+ const catalog = catalogOf(supplier);
1626
2194
  const items = [];
1627
2195
  catalog.forEach((it, i) => {
1628
2196
  const qty = Number(form[`q_${i}`] ?? 0);
@@ -1642,44 +2210,73 @@ simRoute.post("/:id/checkout", async (c) => {
1642
2210
  }
1643
2211
  });
1644
2212
  const currency = items[0]?.currency ?? "USD";
2213
+ const eff = effFor(supplier.id, buyerCred);
1645
2214
  const xml = buildPunchOutOrderMessage({
1646
- from: conn.from,
1647
- to: conn.to,
1648
- sender: conn.sender,
2215
+ from: supplier.identity,
2216
+ to: buyerCred,
2217
+ sender: supplier.identity,
1649
2218
  buyerCookie: cookie,
1650
2219
  payloadId: makePayloadId(host2(), (/* @__PURE__ */ new Date()).toISOString()),
1651
2220
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1652
2221
  currency,
1653
- items
2222
+ items,
2223
+ dtdVersion: eff ? dtdVersionFor(eff, "PunchOutOrderMessage") : void 0
1654
2224
  });
1655
2225
  appendLog({
1656
2226
  sessionId: cookie,
1657
- connectionId: conn.id,
2227
+ connectionId: supplier.id,
1658
2228
  direction: "out",
1659
2229
  docType: "PunchOutOrderMessage",
1660
2230
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
1661
2231
  body: xml,
1662
- validation: validateDocument(xml, { connection: conn, forceDocType: "PunchOutOrderMessage" })
2232
+ validation: validateDocument(xml, { forceDocType: "PunchOutOrderMessage" })
1663
2233
  });
1664
- 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">
1665
2239
  <title>Returning cart\u2026</title></head>
1666
- <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">
1667
2241
  <p style="padding:2rem">Returning cart to the buyer\u2026</p>
1668
- <form method="post" action="${escapeHtml(formpost)}">
1669
- <input type="hidden" name="cxml-urlencoded" value="${escapeHtml(xml)}">
2242
+ ${inner}
2243
+ </body></html>`;
2244
+ if (transport === "raw") {
2245
+ return shell(` <form method="post" action="${escapeXml(formpost)}">
2246
+ <input type="hidden" name="cxml-urlencoded" value="${escapeXml(xml)}">
1670
2247
  <noscript><button type="submit">Continue</button></noscript>
1671
2248
  </form>
1672
- </body></html>`);
1673
- });
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
+ }
1674
2271
  simRoute.post("/:id/order", async (c) => {
1675
- const conn = getConnection(c.req.param("id"));
1676
- const err = requireSupplier(conn);
1677
- if (err) return c.text(err, 400);
2272
+ const supplier = getSupplier(c.req.param("id"));
2273
+ if (!supplier) return c.text("supplier not found", 404);
1678
2274
  const ct = c.req.header("content-type") ?? "";
1679
2275
  const raw = Buffer.from(await c.req.arrayBuffer());
1680
2276
  let xml;
1681
2277
  const availableContentIds = /* @__PURE__ */ new Set();
1682
2278
  const savedRefs = [];
2279
+ let attachmentEncoding;
1683
2280
  if (isMultipart(ct)) {
1684
2281
  const mp = parseMultipartRelated(raw, ct);
1685
2282
  xml = mp.root?.body.toString("utf8") ?? "";
@@ -1687,47 +2284,50 @@ simRoute.post("/:id/order", async (c) => {
1687
2284
  if (part === mp.root) continue;
1688
2285
  const cid = normalizeContentId(part.contentId);
1689
2286
  if (cid) availableContentIds.add(cid);
1690
- savedRefs.push(
1691
- saveAttachment(part.body, {
1692
- contentId: cid,
1693
- contentType: part.contentType ?? "application/octet-stream"
1694
- })
1695
- );
2287
+ if ((part.headers["content-transfer-encoding"] ?? "").toLowerCase() === "base64") {
2288
+ attachmentEncoding = "base64";
2289
+ }
2290
+ savedRefs.push(saveAttachment(part.body, { contentId: cid, contentType: part.contentType ?? "application/octet-stream" }));
1696
2291
  }
2292
+ if (savedRefs.length > 0 && !attachmentEncoding) attachmentEncoding = "binary";
1697
2293
  } else {
1698
2294
  xml = raw.toString("utf8");
1699
2295
  }
1700
2296
  const doc = parseXml(xml);
2297
+ const from = getHeaderCredentials(doc).from;
1701
2298
  const sessionId = findSessionForOrder(doc) ?? `order-${nanoid6(8)}`;
1702
2299
  const validation = validateDocument(xml, {
1703
- connection: conn,
2300
+ expected: expectedFor(supplier.id, from),
1704
2301
  forceDocType: "OrderRequest",
1705
2302
  availableContentIds: isMultipart(ct) ? availableContentIds : void 0
1706
2303
  });
1707
2304
  appendLog({
1708
2305
  sessionId,
1709
- connectionId: conn.id,
2306
+ connectionId: supplier.id,
1710
2307
  direction: "in",
1711
2308
  docType: "OrderRequest",
1712
2309
  headers: { "Content-Type": ct },
1713
2310
  body: xml,
1714
2311
  contentType: ct,
1715
2312
  validation,
1716
- attachments: savedRefs
2313
+ attachments: savedRefs,
2314
+ attachmentEncoding
1717
2315
  });
1718
2316
  const ok = validation.ok;
2317
+ const eff = effFor(supplier.id, from);
1719
2318
  const respXml = buildResponseStatus({
1720
2319
  payloadId: makePayloadId(host2(), (/* @__PURE__ */ new Date()).toISOString()),
1721
2320
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1722
2321
  statusCode: ok ? "200" : "400",
1723
2322
  statusText: ok ? "OK" : "Bad Request",
1724
- from: conn.from,
1725
- to: conn.to,
1726
- sender: conn.sender
2323
+ from: supplier.identity,
2324
+ to: from ?? { domain: "", identity: "" },
2325
+ sender: supplier.identity,
2326
+ dtdVersion: eff ? dtdVersionFor(eff, "OrderResponse") : void 0
1727
2327
  });
1728
2328
  appendLog({
1729
2329
  sessionId,
1730
- connectionId: conn.id,
2330
+ connectionId: supplier.id,
1731
2331
  direction: "out",
1732
2332
  docType: "OrderResponse",
1733
2333
  headers: { "Content-Type": "text/xml; charset=UTF-8" },
@@ -1739,22 +2339,32 @@ simRoute.post("/:id/order", async (c) => {
1739
2339
  });
1740
2340
  function findSessionForOrder(doc) {
1741
2341
  const header2 = root(doc)?.Request?.OrderRequest?.OrderRequestHeader;
1742
- const orderId = header2 ? attrOf(header2, "orderID") : void 0;
1743
- return orderId ? `order-${orderId}` : void 0;
1744
- }
1745
- function attrOf(node, name) {
1746
- const v = node?.[`@_${name}`];
1747
- return v == null ? void 0 : String(v);
1748
- }
1749
- function escapeHtml(s) {
1750
- return escapeXml(s);
2342
+ const orderId = header2?.["@_orderID"];
2343
+ return orderId ? `order-${String(orderId)}` : void 0;
1751
2344
  }
1752
2345
 
1753
2346
  // src/server/app.ts
1754
2347
  import { relative } from "path";
1755
2348
  function createApp(opts = {}) {
1756
- const app = new Hono7();
2349
+ const app = new Hono9();
1757
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
+ });
2364
+ app.route("/api/buyers", buyersRoute);
2365
+ app.route("/api/suppliers", suppliersRoute);
2366
+ app.route("/api/profiles", profilesRoute);
2367
+ app.route("/api/profile-presets", profilePresetsRoute);
1758
2368
  app.route("/api/connections", connectionsRoute);
1759
2369
  app.route("/api/connections", flowRoute);
1760
2370
  app.route("/api", dataRoute);
@@ -1782,34 +2392,30 @@ function relativeToCwd(abs) {
1782
2392
  // src/server/seed.ts
1783
2393
  async function seedDemoIfEmpty() {
1784
2394
  if (listConnections().length > 0) return;
1785
- const supplier = await createConnection({
2395
+ const buyer = await createBuyer({
2396
+ id: "demo-buyer",
2397
+ name: "Demo Buyer",
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"
2402
+ });
2403
+ const supplier = await createSupplier({
1786
2404
  id: "demo-supplier",
1787
2405
  name: "Demo Supplier (built-in mock)",
1788
- mode: "virtual-supplier",
1789
- from: { domain: "DUNS", identity: "987654321" },
1790
- // supplier identity (the tool)
1791
- to: { domain: "DUNS", identity: "123456789" },
1792
- // buyer identity (counterparty)
1793
- sender: { domain: "DUNS", identity: "987654321" },
1794
- sharedSecret: "demo-secret",
1795
- deploymentMode: "test",
1796
- authStyle: "SharedSecret",
2406
+ identity: { domain: "DUNS", identity: "987654321" },
2407
+ punchoutUrl: `${getPublicUrl()}/sim/demo-supplier/punchout`,
2408
+ orderUrl: `${getPublicUrl()}/sim/demo-supplier/order`,
1797
2409
  catalog: []
1798
2410
  });
1799
2411
  await createConnection({
1800
- id: "demo-buyer",
1801
- name: "Demo Buyer \u2192 built-in supplier",
2412
+ id: "demo",
2413
+ name: "Demo Buyer \u2192 Demo Supplier",
2414
+ buyerId: buyer.id,
2415
+ supplierId: supplier.id,
1802
2416
  mode: "virtual-buyer",
1803
- from: { domain: "DUNS", identity: "123456789" },
1804
- // buyer identity (the tool)
1805
- to: { domain: "DUNS", identity: "987654321" },
1806
- // supplier identity (counterparty)
1807
- sender: { domain: "DUNS", identity: "123456789" },
1808
2417
  sharedSecret: "demo-secret",
1809
- deploymentMode: "test",
1810
- authStyle: "SharedSecret",
1811
- punchoutUrl: `${getPublicUrl()}/sim/${supplier.id}/punchout`,
1812
- orderUrl: `${getPublicUrl()}/sim/${supplier.id}/order`
2418
+ deploymentMode: "test"
1813
2419
  });
1814
2420
  }
1815
2421
 
@@ -1818,6 +2424,8 @@ function parseFlags(argv) {
1818
2424
  const flags = {
1819
2425
  port: Number(process.env.PORT ?? 8080),
1820
2426
  dataDir: process.env.DATA_DIR ?? "./data",
2427
+ host: process.env.HOST,
2428
+ token: process.env.POS_TOKEN,
1821
2429
  open: true,
1822
2430
  dev: false,
1823
2431
  seed: true
@@ -1837,6 +2445,12 @@ function parseFlags(argv) {
1837
2445
  case "--public-url":
1838
2446
  flags.publicUrl = next();
1839
2447
  break;
2448
+ case "--host":
2449
+ flags.host = next();
2450
+ break;
2451
+ case "--token":
2452
+ flags.token = next();
2453
+ break;
1840
2454
  case "--no-open":
1841
2455
  flags.open = false;
1842
2456
  break;
@@ -1855,6 +2469,14 @@ function parseFlags(argv) {
1855
2469
  }
1856
2470
  return flags;
1857
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
+ }
1858
2480
  function printHelp() {
1859
2481
  console.log(`punchout-simulator \u2014 test cXML PunchOut integrations as a virtual counterparty
1860
2482
 
@@ -1865,6 +2487,9 @@ Options:
1865
2487
  -d, --data-dir <path> Where to store config + logs (default ./data)
1866
2488
  --public-url <url> Externally reachable base URL (default http://localhost:<port>)
1867
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)
1868
2493
  --no-open Do not open a browser on start
1869
2494
  --no-seed Do not seed the built-in demo connections on first run
1870
2495
  --dev Dev mode (do not serve SPA, do not open browser)
@@ -1874,22 +2499,32 @@ Options:
1874
2499
  async function main() {
1875
2500
  const flags = parseFlags(process.argv.slice(2));
1876
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);
1877
2505
  setDataDir(flags.dataDir);
1878
- setRuntime({ port: flags.port, publicUrl });
2506
+ setRuntime({ port: flags.port, publicUrl, token });
1879
2507
  await initConfig();
1880
2508
  if (flags.seed) await seedDemoIfEmpty();
1881
2509
  const webRoot = flags.dev ? void 0 : fileURLToPath(new URL("../web", import.meta.url));
1882
2510
  const app = createApp({ webRoot, quiet: false });
1883
- serve({ fetch: app.fetch, port: flags.port }, (info) => {
1884
- const url = `http://localhost:${info.port}`;
2511
+ serve({ fetch: app.fetch, port: flags.port, hostname: bindHost }, (info) => {
2512
+ const local = `http://${bindHost}:${info.port}`;
1885
2513
  console.log(`
1886
- punchout-simulator listening on ${url}`);
1887
- if (getPublicUrl() !== url) console.log(` public URL: ${getPublicUrl()}`);
2514
+ punchout-simulator listening on ${local}`);
2515
+ if (getPublicUrl() !== local) console.log(` public URL: ${getPublicUrl()}`);
1888
2516
  console.log(` data dir: ${flags.dataDir}`);
1889
- console.log(` callback: ${getPublicUrl()}/punchout/return
1890
- `);
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("");
1891
2526
  if (flags.open) {
1892
- import("open").then((m) => m.default(url)).catch(() => {
2527
+ import("open").then((m) => m.default(token ? `http://localhost:${info.port}/?token=${token}` : local)).catch(() => {
1893
2528
  });
1894
2529
  }
1895
2530
  });
@@ -1898,4 +2533,3 @@ main().catch((e) => {
1898
2533
  console.error(e);
1899
2534
  process.exit(1);
1900
2535
  });
1901
- //# sourceMappingURL=cli.js.map