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.
- package/README.md +95 -12
- package/dist/server/cli.js +931 -297
- package/dist/web/assets/{cssMode-WA7HerFP.js → cssMode-UKotGf5X.js} +1 -1
- package/dist/web/assets/{freemarker2-BTOdUHiQ.js → freemarker2-0DRATxLT.js} +1 -1
- package/dist/web/assets/{handlebars-DdTiW10M.js → handlebars-U7ip3Hjn.js} +1 -1
- package/dist/web/assets/{html-CP7qpuUD.js → html-pMwW-Db1.js} +1 -1
- package/dist/web/assets/{htmlMode-BG3HQ1Sh.js → htmlMode-BZiR_DER.js} +1 -1
- package/dist/web/assets/{index-DQ_OSoxK.js → index-fwAo3vG0.js} +204 -204
- package/dist/web/assets/{index-CRWJsM13.css → index-sN4D-IAg.css} +1 -1
- package/dist/web/assets/{javascript-IQKq-LZB.js → javascript-BNQzFVRF.js} +1 -1
- package/dist/web/assets/{jsonMode-B1p3z-UY.js → jsonMode-CSAIHDlB.js} +1 -1
- package/dist/web/assets/{liquid-4Fxw7q97.js → liquid--vvS9GUJ.js} +1 -1
- package/dist/web/assets/{mdx-00dxYvhf.js → mdx-BJDQflEF.js} +1 -1
- package/dist/web/assets/{python-BI6CPDlf.js → python-CepodNht.js} +1 -1
- package/dist/web/assets/{razor-DGRBM6nC.js → razor-BQksENSs.js} +1 -1
- package/dist/web/assets/{tsMode-BVtwLDYC.js → tsMode-BRKq1Rx4.js} +1 -1
- package/dist/web/assets/{typescript-Dgh1yWP4.js → typescript-V-bP8GlZ.js} +1 -1
- package/dist/web/assets/{xml-BOb15_Cq.js → xml-Bgme81_M.js} +1 -1
- package/dist/web/assets/{yaml-Dv2aUhkS.js → yaml-CkpLYd3y.js} +1 -1
- package/dist/web/favicon.svg +8 -0
- package/dist/web/index.html +12 -2
- package/package.json +1 -1
- package/dist/server/cli.js.map +0 -1
package/dist/server/cli.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
|
66
|
-
|
|
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
|
|
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: (
|
|
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
|
|
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 ?? "
|
|
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
|
-
|
|
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
|
|
427
|
+
function validate(input) {
|
|
118
428
|
const errors = [];
|
|
119
|
-
if (!input.
|
|
120
|
-
if (input.
|
|
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
|
-
|
|
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 =
|
|
454
|
+
const errors = validate(input);
|
|
130
455
|
if (errors.length) return c.json({ errors }, 400);
|
|
131
|
-
|
|
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
|
|
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
|
|
143
|
-
|
|
144
|
-
const
|
|
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
|
-
|
|
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/
|
|
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
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
|
722
|
-
if (!
|
|
723
|
-
const first = Array.isArray(
|
|
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,
|
|
1375
|
+
function credKnown(c, exp) {
|
|
863
1376
|
if (!c) return false;
|
|
864
|
-
return [
|
|
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.
|
|
881
|
-
const
|
|
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,
|
|
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
|
-
|
|
909
|
-
|
|
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 (
|
|
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
|
|
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
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
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
|
|
1167
|
-
|
|
1168
|
-
|
|
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:
|
|
1172
|
-
to:
|
|
1173
|
-
sender:
|
|
1174
|
-
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:
|
|
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
|
|
1185
|
-
|
|
1186
|
-
|
|
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:
|
|
1191
|
-
to:
|
|
1192
|
-
sender:
|
|
1193
|
-
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:
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
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:
|
|
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
|
-
|
|
1216
|
-
const
|
|
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:
|
|
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(
|
|
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:
|
|
1251
|
-
to:
|
|
1252
|
-
sender:
|
|
1253
|
-
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:
|
|
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
|
|
1270
|
-
|
|
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(
|
|
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
|
|
1278
|
-
|
|
1279
|
-
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
1330
|
-
const
|
|
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:
|
|
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
|
|
1355
|
-
var punchoutReturnRoute = new
|
|
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
|
|
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
|
-
|
|
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
|
|
1972
|
+
import { Hono as Hono6 } from "hono";
|
|
1419
1973
|
import { streamSSE } from "hono/streaming";
|
|
1420
|
-
var streamRoute = new
|
|
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
|
|
1448
|
-
var dataRoute = new
|
|
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
|
|
2059
|
+
import { Hono as Hono8 } from "hono";
|
|
1473
2060
|
import { nanoid as nanoid6 } from "nanoid";
|
|
1474
|
-
var simRoute = new
|
|
2061
|
+
var simRoute = new Hono8();
|
|
1475
2062
|
var DEMO_CATALOG = [
|
|
1476
|
-
{
|
|
1477
|
-
|
|
1478
|
-
|
|
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
|
|
1516
|
-
return
|
|
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
|
|
1522
|
-
return
|
|
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
|
|
1525
|
-
|
|
1526
|
-
|
|
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
|
|
1531
|
-
|
|
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
|
|
1539
|
-
connection: conn,
|
|
1540
|
-
forceDocType: "SetupRequest"
|
|
1541
|
-
});
|
|
2109
|
+
const from = getHeaderCredentials(doc).from;
|
|
1542
2110
|
appendLog({
|
|
1543
2111
|
sessionId: buyerCookie,
|
|
1544
|
-
connectionId:
|
|
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:
|
|
2117
|
+
validation: validateDocument(reqXml, { expected: expectedFor(supplier.id, from), forceDocType: "SetupRequest" })
|
|
1550
2118
|
});
|
|
1551
|
-
const
|
|
1552
|
-
|
|
1553
|
-
|
|
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:
|
|
1559
|
-
to:
|
|
1560
|
-
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:
|
|
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
|
|
1576
|
-
|
|
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
|
|
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>${
|
|
1584
|
-
it.
|
|
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>${
|
|
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
|
-
|
|
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>${
|
|
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/${
|
|
1610
|
-
<input type="hidden" name="cookie" value="${
|
|
1611
|
-
<input type="hidden" name="formpost" value="${
|
|
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
|
|
1620
|
-
|
|
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
|
|
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:
|
|
1647
|
-
to:
|
|
1648
|
-
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:
|
|
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, {
|
|
2232
|
+
validation: validateDocument(xml, { forceDocType: "PunchOutOrderMessage" })
|
|
1663
2233
|
});
|
|
1664
|
-
|
|
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
|
|
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
|
-
|
|
1669
|
-
|
|
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
|
-
|
|
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
|
|
1676
|
-
|
|
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
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
1725
|
-
to:
|
|
1726
|
-
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
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
|
|
1801
|
-
name: "Demo Buyer \u2192
|
|
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
|
|
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 ${
|
|
1887
|
-
if (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(
|
|
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
|