punchout-simulator 0.5.2 → 0.6.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.
package/README.md CHANGED
@@ -18,6 +18,13 @@ It is role-neutral and runs in two modes (mirror images of each other):
18
18
 
19
19
  Protocol scope: **cXML only**.
20
20
 
21
+ > **New to PunchOut, or want the full picture?** Read the in-depth reports at
22
+ > [**slawomir-szostak.github.io/punchout-simulator**](https://slawomir-szostak.github.io/punchout-simulator/):
23
+ > the [PunchOut business primer](https://slawomir-szostak.github.io/punchout-simulator/punchout-business-primer_en.html),
24
+ > the [Architecture reference](https://slawomir-szostak.github.io/punchout-simulator/architecture_en.html),
25
+ > and the [Operations & usage guide](https://slawomir-szostak.github.io/punchout-simulator/operations-guide_en.html).
26
+ > (Markdown sources live in [`docs/`](docs/).)
27
+
21
28
  ---
22
29
 
23
30
  ## Quick start
@@ -30,15 +37,17 @@ That's it — no install, no build. It boots a local server, opens your browser,
30
37
  and seeds a **built-in demo**: a virtual buyer wired to a built-in mock supplier,
31
38
  so you can run the entire roundtrip immediately:
32
39
 
33
- 1. Open the **Demo Buyer → Demo Supplier** connection.
40
+ 1. On the **Sessions** tab, click **+ New session**, pick the **Demo Buyer → Demo
41
+ Supplier** connection and the **create** operation.
34
42
  2. **Send SetupRequest** → the mock supplier replies with a StartPage.
35
43
  3. **Open the catalog**, set quantities, **return the cart** — the punchback
36
44
  lands back in the app live.
37
45
  4. **Build the OrderRequest**, edit the cXML if you want (tweak `<Comments>`,
38
46
  addresses, attachment refs), optionally attach files at the **order or item
39
47
  level**, optionally flip on the **dangling-`cid` test**, then **send it** and
40
- inspect the supplier's response.
41
- 5. If validation fails, **edit and re-send** — the flow session and cart persist.
48
+ inspect the supplier's response. (A **Validate** button lints the cXML before
49
+ you send.)
50
+ 5. If validation fails, **edit and re-send** — the session keeps its log and cart.
42
51
 
43
52
  Every document — inbound and outbound — is validated and logged.
44
53
 
@@ -142,6 +151,27 @@ receiver detects the missing attachment. `<Comments>` (and therefore
142
151
 
143
152
  ---
144
153
 
154
+ ## Sessions
155
+
156
+ A **session** is one PunchOut conversation, keyed by its `BuyerCookie`:
157
+ SetupRequest → catalog → cart → OrderRequest → OrderResponse. The **Sessions** tab
158
+ (under **Run**, separate from the **Configure** sections) is the workspace:
159
+
160
+ - A live **session list** — Mode-A sessions you start *and* Mode-B sessions an
161
+ external buyer initiates against the tool's endpoints (those show as **inbound**).
162
+ - A per-session **message log** (each session keeps its own; no global firehose).
163
+ - **+ New session** picks a connection and the SetupRequest **operation**:
164
+ - **create** — a fresh, empty cart (the default).
165
+ - **edit** — reopen a previous session's returned cart for modification; its
166
+ items are sent as `ItemOut` inside the SetupRequest (`operation="edit"`).
167
+ - **inspect** — view a previously ordered item read-only (`operation="inspect"`),
168
+ carrying that item (or the whole source cart) as `ItemOut`.
169
+
170
+ Connections/Buyers/Suppliers/Products/Profiles remain under **Configure** — you set
171
+ them up there, then run them from **Sessions**.
172
+
173
+ ---
174
+
145
175
  ## Network reachability
146
176
 
147
177
  - **Mode A rarely needs a tunnel.** The punchback is a browser auto-submit from
@@ -176,6 +206,16 @@ src/
176
206
  └─ cxml/ build · parse · validate · multipart · types
177
207
  ```
178
208
 
209
+ For a deeper treatment — component breakdown, the full data model, and Mode A /
210
+ Mode B sequence diagrams — see the
211
+ [**Architecture reference**](https://slawomir-szostak.github.io/punchout-simulator/architecture_en.html).
212
+ Alongside it: a [**PunchOut business primer**](https://slawomir-szostak.github.io/punchout-simulator/punchout-business-primer_en.html)
213
+ (the business process and where this tool fits) and an
214
+ [**Operations & usage guide**](https://slawomir-szostak.github.io/punchout-simulator/operations-guide_en.html)
215
+ (sessions, operations, profiles, product lists, validation). These render at
216
+ [slawomir-szostak.github.io/punchout-simulator](https://slawomir-szostak.github.io/punchout-simulator/);
217
+ their canonical Markdown sources live in [`docs/`](docs/).
218
+
179
219
  ### Data model
180
220
 
181
221
  The config is normalized into five entities:
@@ -869,7 +869,8 @@ import { Hono as Hono5 } from "hono";
869
869
  import { nanoid as nanoid5 } from "nanoid";
870
870
 
871
871
  // src/server/store/log.ts
872
- import { appendFileSync, existsSync, readdirSync, readFileSync } from "fs";
872
+ import { appendFileSync, existsSync, readdirSync, readFileSync, rmSync } from "fs";
873
+ import { resolve as resolve2, sep } from "path";
873
874
  import { nanoid as nanoid2 } from "nanoid";
874
875
 
875
876
  // src/server/bus.ts
@@ -917,6 +918,13 @@ function readSession(sessionId) {
917
918
  }
918
919
  }).filter((r) => r !== null);
919
920
  }
921
+ function deleteSession(sessionId) {
922
+ const file = resolve2(sessionFile(sessionId));
923
+ if (!file.startsWith(resolve2(sessionsDir()) + sep)) return false;
924
+ if (!existsSync(file)) return false;
925
+ rmSync(file);
926
+ return true;
927
+ }
920
928
  function listSessions() {
921
929
  ensureDirs();
922
930
  const files = readdirSync(sessionsDir()).filter((f) => f.endsWith(".jsonl"));
@@ -925,6 +933,8 @@ function listSessions() {
925
933
  const sessionId = file.replace(/\.jsonl$/, "");
926
934
  const records = readSession(sessionId);
927
935
  if (records.length === 0) continue;
936
+ const setup = records.find((r) => r.docType === "SetupRequest");
937
+ const operation = setup ? /<PunchOutSetupRequest[^>]*\boperation="([^"]+)"/.exec(setup.body)?.[1] : void 0;
928
938
  summaries.push({
929
939
  sessionId: records[0].sessionId ?? sessionId,
930
940
  connectionId: records[0].connectionId,
@@ -932,7 +942,8 @@ function listSessions() {
932
942
  firstTs: records[0].ts,
933
943
  lastTs: records[records.length - 1].ts,
934
944
  docTypes: [...new Set(records.map((r) => r.docType))],
935
- hasErrors: records.some((r) => r.validation && !r.validation.ok)
945
+ hasErrors: records.some((r) => r.validation && !r.validation.ok),
946
+ operation
936
947
  });
937
948
  }
938
949
  return summaries.sort((a, b) => (b.lastTs ?? "").localeCompare(a.lastTs ?? ""));
@@ -944,7 +955,7 @@ function readAllRecent(limit = 200) {
944
955
  // src/server/store/attachments.ts
945
956
  import { createHash } from "crypto";
946
957
  import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync } from "fs";
947
- import { resolve as resolve2 } from "path";
958
+ import { resolve as resolve3 } from "path";
948
959
 
949
960
  // src/server/cxml/multipart.ts
950
961
  import { nanoid as nanoid3 } from "nanoid";
@@ -1082,7 +1093,7 @@ function findHeaderBodySplit(buf) {
1082
1093
  function saveAttachment(data, meta) {
1083
1094
  ensureDirs();
1084
1095
  const hash = createHash("sha256").update(data).digest("hex");
1085
- const path = resolve2(attachmentsDir(), hash);
1096
+ const path = resolve3(attachmentsDir(), hash);
1086
1097
  if (!existsSync2(path)) writeFileSync(path, data, { mode: 384 });
1087
1098
  return {
1088
1099
  contentId: normalizeContentId(meta.contentId),
@@ -1096,7 +1107,7 @@ function saveAttachment(data, meta) {
1096
1107
  function readAttachment(hash) {
1097
1108
  const safe = hash.replace(/[^a-f0-9]/gi, "");
1098
1109
  if (!safe) return void 0;
1099
- const path = resolve2(attachmentsDir(), safe);
1110
+ const path = resolve3(attachmentsDir(), safe);
1100
1111
  if (!existsSync2(path)) return void 0;
1101
1112
  return readFileSync2(path);
1102
1113
  }
@@ -1116,6 +1127,10 @@ function rememberSessionConnection(sessionId, connectionId) {
1116
1127
  function connectionForSession(sessionId) {
1117
1128
  return connectionBySession.get(sessionId);
1118
1129
  }
1130
+ function forgetSession(sessionId) {
1131
+ carts.delete(sessionId);
1132
+ connectionBySession.delete(sessionId);
1133
+ }
1119
1134
 
1120
1135
  // src/server/http.ts
1121
1136
  function assertSafeOutboundUrl(url) {
@@ -1229,6 +1244,22 @@ function extrinsicBlock(items, indent) {
1229
1244
  if (!items || items.length === 0) return "";
1230
1245
  return "\n" + items.map((e) => `${indent}<Extrinsic name="${escapeXml(e.name)}">${escapeXml(e.value)}</Extrinsic>`).join("\n");
1231
1246
  }
1247
+ function setupItemOut(it, idx) {
1248
+ return ` <ItemOut quantity="${escapeXml(it.quantity)}" lineNumber="${idx}">
1249
+ <ItemID>
1250
+ <SupplierPartID>${escapeXml(it.supplierPartId ?? "")}</SupplierPartID>${it.supplierPartAuxiliaryId ? `
1251
+ <SupplierPartAuxiliaryID>${escapeXml(it.supplierPartAuxiliaryId)}</SupplierPartAuxiliaryID>` : ""}
1252
+ </ItemID>
1253
+ <ItemDetail>
1254
+ <UnitPrice>
1255
+ <Money currency="${escapeXml(it.currency ?? "USD")}">${escapeXml(it.unitPriceAmount ?? 0)}</Money>
1256
+ </UnitPrice>
1257
+ <Description xml:lang="en">${escapeXml(it.description ?? "")}</Description>
1258
+ <UnitOfMeasure>${escapeXml(it.uom ?? "EA")}</UnitOfMeasure>
1259
+ ${classificationBlock(it, " ")}
1260
+ </ItemDetail>
1261
+ </ItemOut>`;
1262
+ }
1232
1263
  function buildSetupRequest(o) {
1233
1264
  const lang = o.lang ?? "en-US";
1234
1265
  const deployment = o.deploymentMode ? ` deploymentMode="${escapeXml(o.deploymentMode)}"` : "";
@@ -1237,6 +1268,8 @@ function buildSetupRequest(o) {
1237
1268
  ${addressBlock("ShipTo", o.shipTo, mode)}` : "";
1238
1269
  const contact2 = o.contact ? `
1239
1270
  ${contactBlock(o.contact, mode, " ")}` : "";
1271
+ const items = o.items && o.items.length > 0 ? `
1272
+ ${o.items.map((it, i) => setupItemOut(it, i + 1)).join("\n")}` : "";
1240
1273
  const inner = `${header({
1241
1274
  from: o.from,
1242
1275
  to: o.to,
@@ -1249,7 +1282,7 @@ ${contactBlock(o.contact, mode, " ")}` : "";
1249
1282
  <BuyerCookie>${escapeXml(o.buyerCookie)}</BuyerCookie>
1250
1283
  <BrowserFormPost>
1251
1284
  <URL>${escapeXml(o.browserFormPostUrl)}</URL>
1252
- </BrowserFormPost>${extrinsicBlock(o.extrinsics, " ")}${shipTo}${contact2}
1285
+ </BrowserFormPost>${extrinsicBlock(o.extrinsics, " ")}${shipTo}${contact2}${items}
1253
1286
  </PunchOutSetupRequest>
1254
1287
  </Request>`;
1255
1288
  return envelope(o.payloadId, o.timestamp, lang, inner, o.dtdVersion);
@@ -1971,12 +2004,22 @@ function validateDocument(raw, ctx = {}) {
1971
2004
  const docType = ctx.forceDocType ?? getDocType(doc);
1972
2005
  checkGeneral(doc, ctx, issues, docType);
1973
2006
  switch (docType) {
1974
- case "SetupRequest":
2007
+ case "SetupRequest": {
1975
2008
  checkSharedSecret(doc, ctx, issues);
1976
- if (!root(doc)?.Request?.PunchOutSetupRequest?.BrowserFormPost?.URL) {
2009
+ const setup = root(doc)?.Request?.PunchOutSetupRequest;
2010
+ if (!setup?.BrowserFormPost?.URL) {
1977
2011
  issues.warn("missing-browserformpost", "PunchOutSetupRequest/BrowserFormPost/URL is missing", "cXML/.../PunchOutSetupRequest/BrowserFormPost/URL");
1978
2012
  }
2013
+ const op = attr(setup, "operation") ?? "create";
2014
+ const hasItems = asArray(setup?.ItemOut).length > 0;
2015
+ if ((op === "edit" || op === "inspect") && !hasItems) {
2016
+ issues.warn("setup-missing-items", `operation="${op}" usually carries the prior item(s) as ItemOut, but none are present`, "cXML/.../PunchOutSetupRequest");
2017
+ }
2018
+ if (op === "create" && hasItems) {
2019
+ issues.warn("setup-unexpected-items", 'operation="create" starts an empty cart, but ItemOut elements are present', "cXML/.../PunchOutSetupRequest");
2020
+ }
1979
2021
  break;
2022
+ }
1980
2023
  case "SetupResponse":
1981
2024
  checkSetupResponse(doc, issues);
1982
2025
  break;
@@ -1998,6 +2041,8 @@ function validateDocument(raw, ctx = {}) {
1998
2041
  }
1999
2042
 
2000
2043
  // src/server/routes/flow.ts
2044
+ var coerceOperation = (v) => v === "create" || v === "edit" || v === "inspect" ? v : void 0;
2045
+ var setupItems = (body) => Array.isArray(body?.items) && body.items.length > 0 ? body.items : void 0;
2001
2046
  var flowRoute = new Hono5();
2002
2047
  function host() {
2003
2048
  try {
@@ -2049,11 +2094,12 @@ function resolveVirtualBuyer(id) {
2049
2094
  if (r.connection.mode !== "virtual-buyer") return { error: "connection is not in virtual-buyer mode" };
2050
2095
  return { ctx: buyerContext(r) };
2051
2096
  }
2052
- flowRoute.get("/:id/setup/preview", (c) => {
2097
+ flowRoute.post("/:id/setup/preview", async (c) => {
2053
2098
  const r = resolveVirtualBuyer(c.req.param("id"));
2054
2099
  if ("error" in r) return c.json({ error: r.error }, 400);
2055
2100
  const { ctx } = r;
2056
- const buyerCookie = c.req.query("buyerCookie") || `pos-${nanoid5(16)}`;
2101
+ const body = await c.req.json().catch(() => ({}));
2102
+ const buyerCookie = body.buyerCookie || `pos-${nanoid5(16)}`;
2057
2103
  const xml = buildSetupRequest({
2058
2104
  from: ctx.from,
2059
2105
  to: ctx.to,
@@ -2064,11 +2110,12 @@ flowRoute.get("/:id/setup/preview", (c) => {
2064
2110
  payloadId: makePayloadId(host(), (/* @__PURE__ */ new Date()).toISOString()),
2065
2111
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2066
2112
  deploymentMode: ctx.deploymentMode,
2067
- operation: ctx.eff.setupOperation,
2113
+ operation: coerceOperation(body.operation) ?? ctx.eff.setupOperation,
2068
2114
  dtdVersion: dtdVersionFor(ctx.eff, "SetupRequest"),
2069
2115
  userAgent: ctx.eff.userAgent,
2070
2116
  extrinsics: setupExtrinsics(ctx, buyerCookie),
2071
- ...setupAddresses(ctx)
2117
+ ...setupAddresses(ctx),
2118
+ items: setupItems(body)
2072
2119
  });
2073
2120
  return c.json({ buyerCookie, xml, browserFormPostUrl: browserFormPostUrl() });
2074
2121
  });
@@ -2088,11 +2135,12 @@ flowRoute.post("/:id/setup", async (c) => {
2088
2135
  payloadId: makePayloadId(host(), (/* @__PURE__ */ new Date()).toISOString()),
2089
2136
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2090
2137
  deploymentMode: ctx.deploymentMode,
2091
- operation: ctx.eff.setupOperation,
2138
+ operation: coerceOperation(body.operation) ?? ctx.eff.setupOperation,
2092
2139
  dtdVersion: dtdVersionFor(ctx.eff, "SetupRequest"),
2093
2140
  userAgent: ctx.eff.userAgent,
2094
2141
  extrinsics: setupExtrinsics(ctx, buyerCookie),
2095
- ...setupAddresses(ctx)
2142
+ ...setupAddresses(ctx),
2143
+ items: setupItems(body)
2096
2144
  });
2097
2145
  rememberSessionConnection(buyerCookie, ctx.connectionId);
2098
2146
  const reqValidation = validateDocument(xml, { expected: ctx.expected, forceDocType: "SetupRequest" });
@@ -2402,8 +2450,37 @@ dataRoute.get(
2402
2450
  "/runtime",
2403
2451
  (c) => c.json({ publicUrl: getPublicUrl(), callbackUrl: `${getPublicUrl()}/punchout/return`, version: getVersion() })
2404
2452
  );
2405
- dataRoute.get("/sessions", (c) => c.json(listSessions()));
2453
+ dataRoute.get("/sessions", (c) => {
2454
+ const enriched = listSessions().map((s) => {
2455
+ const conn = s.connectionId ? getConnection(s.connectionId) : void 0;
2456
+ if (conn) {
2457
+ return {
2458
+ ...s,
2459
+ connectionName: conn.name,
2460
+ mode: conn.mode,
2461
+ buyerName: getBuyer(conn.buyerId)?.name,
2462
+ supplierName: getSupplier(conn.supplierId)?.name,
2463
+ inbound: false
2464
+ };
2465
+ }
2466
+ const supplier = s.connectionId ? getSupplier(s.connectionId) : void 0;
2467
+ return {
2468
+ ...s,
2469
+ supplierName: supplier?.name,
2470
+ mode: supplier ? "virtual-supplier" : void 0,
2471
+ inbound: !!supplier
2472
+ // an external buyer hit our /sim
2473
+ };
2474
+ });
2475
+ return c.json(enriched);
2476
+ });
2406
2477
  dataRoute.get("/sessions/:id", (c) => c.json(readSession(c.req.param("id"))));
2478
+ dataRoute.delete("/sessions/:id", (c) => {
2479
+ const id = c.req.param("id");
2480
+ const removed = deleteSession(id);
2481
+ forgetSession(id);
2482
+ return removed ? c.json({ ok: true }) : c.json({ error: "not found" }, 404);
2483
+ });
2407
2484
  dataRoute.get("/sessions/:sessionId/records/:recordId/raw", (c) => {
2408
2485
  const record = readSession(c.req.param("sessionId")).find((r) => r.id === c.req.param("recordId"));
2409
2486
  if (!record) return c.json({ error: "not found" }, 404);
@@ -2580,7 +2657,10 @@ simRoute.post("/:id/checkout", async (c) => {
2580
2657
  const supplier = getSupplier(c.req.param("id"));
2581
2658
  if (!supplier) return c.text("supplier not found", 404);
2582
2659
  const form = await c.req.parseBody();
2583
- const cookie = String(form.cookie ?? `pos-${nanoid6(16)}`);
2660
+ const cookie = typeof form.cookie === "string" ? form.cookie.trim() : "";
2661
+ if (!cookie) {
2662
+ return c.text("missing BuyerCookie \u2014 open the catalog from a SetupResponse StartPage", 400);
2663
+ }
2584
2664
  const formpost = safeHttpUrl(String(form.formpost ?? ""));
2585
2665
  const buyerCred = { domain: String(form.bd ?? ""), identity: String(form.bi ?? "") };
2586
2666
  const catalog = catalogOf(supplier);
@@ -1,4 +1,4 @@
1
- import{m as et}from"./index-CR8XUPcx.js";/*!-----------------------------------------------------------------------------
1
+ import{m as et}from"./index-ByIxIbJu.js";/*!-----------------------------------------------------------------------------
2
2
  * Copyright (c) Microsoft Corporation. All rights reserved.
3
3
  * Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
4
4
  * Released under the MIT license
@@ -1,4 +1,4 @@
1
- import{m as f}from"./index-CR8XUPcx.js";/*!-----------------------------------------------------------------------------
1
+ import{m as f}from"./index-ByIxIbJu.js";/*!-----------------------------------------------------------------------------
2
2
  * Copyright (c) Microsoft Corporation. All rights reserved.
3
3
  * Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
4
4
  * Released under the MIT license
@@ -1,4 +1,4 @@
1
- import{m as l}from"./index-CR8XUPcx.js";/*!-----------------------------------------------------------------------------
1
+ import{m as l}from"./index-ByIxIbJu.js";/*!-----------------------------------------------------------------------------
2
2
  * Copyright (c) Microsoft Corporation. All rights reserved.
3
3
  * Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
4
4
  * Released under the MIT license
@@ -1,4 +1,4 @@
1
- import{m as s}from"./index-CR8XUPcx.js";/*!-----------------------------------------------------------------------------
1
+ import{m as s}from"./index-ByIxIbJu.js";/*!-----------------------------------------------------------------------------
2
2
  * Copyright (c) Microsoft Corporation. All rights reserved.
3
3
  * Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
4
4
  * Released under the MIT license
@@ -1,4 +1,4 @@
1
- import{m as lt}from"./index-CR8XUPcx.js";/*!-----------------------------------------------------------------------------
1
+ import{m as lt}from"./index-ByIxIbJu.js";/*!-----------------------------------------------------------------------------
2
2
  * Copyright (c) Microsoft Corporation. All rights reserved.
3
3
  * Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
4
4
  * Released under the MIT license