punchout-simulator 0.5.2 → 0.6.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.
package/README.md CHANGED
@@ -18,6 +18,11 @@ 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?** See the in-depth reports in
22
+ > [`docs/`](docs/): the [PunchOut business primer](docs/punchout-business-primer_en.html),
23
+ > the [Architecture reference](docs/architecture_en.html), and the
24
+ > [Operations & usage guide](docs/operations-guide_en.html).
25
+
21
26
  ---
22
27
 
23
28
  ## Quick start
@@ -30,15 +35,17 @@ That's it — no install, no build. It boots a local server, opens your browser,
30
35
  and seeds a **built-in demo**: a virtual buyer wired to a built-in mock supplier,
31
36
  so you can run the entire roundtrip immediately:
32
37
 
33
- 1. Open the **Demo Buyer → Demo Supplier** connection.
38
+ 1. On the **Sessions** tab, click **+ New session**, pick the **Demo Buyer → Demo
39
+ Supplier** connection and the **create** operation.
34
40
  2. **Send SetupRequest** → the mock supplier replies with a StartPage.
35
41
  3. **Open the catalog**, set quantities, **return the cart** — the punchback
36
42
  lands back in the app live.
37
43
  4. **Build the OrderRequest**, edit the cXML if you want (tweak `<Comments>`,
38
44
  addresses, attachment refs), optionally attach files at the **order or item
39
45
  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.
46
+ inspect the supplier's response. (A **Validate** button lints the cXML before
47
+ you send.)
48
+ 5. If validation fails, **edit and re-send** — the session keeps its log and cart.
42
49
 
43
50
  Every document — inbound and outbound — is validated and logged.
44
51
 
@@ -142,6 +149,27 @@ receiver detects the missing attachment. `<Comments>` (and therefore
142
149
 
143
150
  ---
144
151
 
152
+ ## Sessions
153
+
154
+ A **session** is one PunchOut conversation, keyed by its `BuyerCookie`:
155
+ SetupRequest → catalog → cart → OrderRequest → OrderResponse. The **Sessions** tab
156
+ (under **Run**, separate from the **Configure** sections) is the workspace:
157
+
158
+ - A live **session list** — Mode-A sessions you start *and* Mode-B sessions an
159
+ external buyer initiates against the tool's endpoints (those show as **inbound**).
160
+ - A per-session **message log** (each session keeps its own; no global firehose).
161
+ - **+ New session** picks a connection and the SetupRequest **operation**:
162
+ - **create** — a fresh, empty cart (the default).
163
+ - **edit** — reopen a previous session's returned cart for modification; its
164
+ items are sent as `ItemOut` inside the SetupRequest (`operation="edit"`).
165
+ - **inspect** — view a previously ordered item read-only (`operation="inspect"`),
166
+ carrying that item (or the whole source cart) as `ItemOut`.
167
+
168
+ Connections/Buyers/Suppliers/Products/Profiles remain under **Configure** — you set
169
+ them up there, then run them from **Sessions**.
170
+
171
+ ---
172
+
145
173
  ## Network reachability
146
174
 
147
175
  - **Mode A rarely needs a tunnel.** The punchback is a browser auto-submit from
@@ -176,6 +204,15 @@ src/
176
204
  └─ cxml/ build · parse · validate · multipart · types
177
205
  ```
178
206
 
207
+ For a deeper treatment — component breakdown, the full data model, and Mode A /
208
+ Mode B sequence diagrams — see the
209
+ [**Architecture reference**](docs/architecture_en.html) in [`docs/`](docs/).
210
+ The same folder holds a [**PunchOut business primer**](docs/punchout-business-primer_en.html)
211
+ (the business process and where this tool fits) and an
212
+ [**Operations & usage guide**](docs/operations-guide_en.html) (sessions,
213
+ operations, profiles, product lists, validation). The reports are self-contained
214
+ HTML; their canonical Markdown sources sit alongside them.
215
+
179
216
  ### Data model
180
217
 
181
218
  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