@devlab.group/agent-commerce 0.1.0-alpha.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/LICENSE +17 -0
- package/README.md +324 -0
- package/dist/chunk-CZ6ZDAYD.js +192 -0
- package/dist/chunk-CZ6ZDAYD.js.map +1 -0
- package/dist/cli/index.js +2396 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/index.d.ts +412 -0
- package/dist/index.js +2930 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp.d.ts +68 -0
- package/dist/mcp.js +400 -0
- package/dist/mcp.js.map +1 -0
- package/dist/protocol-adapter-C0goMgLK.d.ts +223 -0
- package/dist/runtime-LCk2TzGN.d.ts +277 -0
- package/dist/x402.d.ts +77 -0
- package/dist/x402.js +650 -0
- package/dist/x402.js.map +1 -0
- package/package.json +136 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2930 @@
|
|
|
1
|
+
import { CommerceError, PAYMENT_INPUT_FIELD, systemClock, NOOP_LOGGER, PACKAGE_VERSION, toCommerceError, DEFAULT_BACKEND_TIMEOUT_MS, toErrorEnvelope, isHttpProtocolAdapter, isCommerceError, PAYMENT_HEADER, toPaymentRequiredEnvelope, PAYMENT_RESPONSE_HEADER } from './chunk-CZ6ZDAYD.js';
|
|
2
|
+
export { COMMERCE_ERROR_CODES, COMMERCE_ERROR_HTTP_STATUS, COMMERCE_EVENT_TYPES, CommerceError, DEFAULT_BACKEND_TIMEOUT_MS, DELIVERY_SUMMARY_META_KEY, NOOP_LOGGER, PAYMENT_HEADER, PAYMENT_INPUT_FIELD, PAYMENT_RESPONSE_HEADER, RETRYABLE_ERROR_CODES, isCommerceError, isHttpProtocolAdapter, isPaymentRequiredEnvelope, systemClock, toCommerceError, toDeliverySummary, toErrorEnvelope, toPaymentRequiredEnvelope } from './chunk-CZ6ZDAYD.js';
|
|
3
|
+
import { promises, existsSync, mkdirSync, accessSync, constants, chmodSync } from 'fs';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import { dirname } from 'path';
|
|
6
|
+
import { parse, YAMLError } from 'yaml';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { randomUUID, timingSafeEqual, createHash } from 'crypto';
|
|
9
|
+
import { createRequire } from 'module';
|
|
10
|
+
import pino from 'pino';
|
|
11
|
+
import Fastify from 'fastify';
|
|
12
|
+
import Database from 'better-sqlite3';
|
|
13
|
+
|
|
14
|
+
// src/config/env.ts
|
|
15
|
+
var PLACEHOLDER_PATTERN = /\$\{([A-Za-z_][A-Za-z0-9_]*)(:-([^}]*))?\}/g;
|
|
16
|
+
function substituteEnv(value, env, path2 = "$") {
|
|
17
|
+
if (typeof value === "string") {
|
|
18
|
+
return substituteString(value, env, path2);
|
|
19
|
+
}
|
|
20
|
+
if (Array.isArray(value)) {
|
|
21
|
+
return value.map((entry, index) => substituteEnv(entry, env, `${path2}[${index}]`));
|
|
22
|
+
}
|
|
23
|
+
if (isPlainObject(value)) {
|
|
24
|
+
const result = {};
|
|
25
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
26
|
+
result[key] = substituteEnv(entry, env, `${path2}.${key}`);
|
|
27
|
+
}
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
function substituteString(value, env, path2) {
|
|
33
|
+
const substituted = substituteOnce(value, env, path2);
|
|
34
|
+
PLACEHOLDER_PATTERN.lastIndex = 0;
|
|
35
|
+
if (PLACEHOLDER_PATTERN.test(substituted)) {
|
|
36
|
+
PLACEHOLDER_PATTERN.lastIndex = 0;
|
|
37
|
+
const residual = PLACEHOLDER_PATTERN.exec(substituted)?.[0] ?? "${\u2026}";
|
|
38
|
+
PLACEHOLDER_PATTERN.lastIndex = 0;
|
|
39
|
+
throw new CommerceError(
|
|
40
|
+
"CONFIG_INVALID",
|
|
41
|
+
`Configuration value at "${path2}" still contains the unresolved placeholder "${residual}" after substitution \u2014 nested placeholders (e.g. "\${A:-\${B}}") are not supported. Use a plain default, or set the variable.`,
|
|
42
|
+
{ details: { path: path2, residual } }
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return substituted;
|
|
46
|
+
}
|
|
47
|
+
function substituteOnce(value, env, path2) {
|
|
48
|
+
return value.replace(
|
|
49
|
+
PLACEHOLDER_PATTERN,
|
|
50
|
+
(_match, name, hasDefault, def) => {
|
|
51
|
+
const envValue = env[name];
|
|
52
|
+
if (hasDefault !== void 0) {
|
|
53
|
+
if (envValue === void 0 || envValue === "") return def ?? "";
|
|
54
|
+
return envValue;
|
|
55
|
+
}
|
|
56
|
+
if (envValue === void 0) {
|
|
57
|
+
throw new CommerceError(
|
|
58
|
+
"CONFIG_INVALID",
|
|
59
|
+
`Unresolved environment variable "\${${name}}" referenced at config path "${path2}"`,
|
|
60
|
+
{ details: { variable: name, path: path2 } }
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return envValue;
|
|
64
|
+
}
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
function isPlainObject(value) {
|
|
68
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// src/config/filename.ts
|
|
72
|
+
var DEFAULT_CONFIG_FILENAME = "config.yaml";
|
|
73
|
+
|
|
74
|
+
// src/core/execution/backend-http.ts
|
|
75
|
+
var MAX_BODY_SNIPPET_LENGTH = 512;
|
|
76
|
+
var PATH_PARAM_PATTERN = /\{([a-zA-Z0-9_.-]+)\}/g;
|
|
77
|
+
var MAX_RESPONSE_BODY_BYTES = 1024 * 1024;
|
|
78
|
+
var HttpBackendExecutor = class {
|
|
79
|
+
fetchImpl;
|
|
80
|
+
logger;
|
|
81
|
+
constructor(options = {}) {
|
|
82
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
83
|
+
this.logger = options.logger ?? NOOP_LOGGER;
|
|
84
|
+
}
|
|
85
|
+
async call(handler, request) {
|
|
86
|
+
const timeoutMs = handler.timeoutMs ?? DEFAULT_BACKEND_TIMEOUT_MS;
|
|
87
|
+
const inputRecord = isPlainObject2(request.input) ? request.input : {};
|
|
88
|
+
let target;
|
|
89
|
+
let remaining;
|
|
90
|
+
try {
|
|
91
|
+
const templated = applyPathTemplate(handler.url, inputRecord);
|
|
92
|
+
target = new URL(templated.url);
|
|
93
|
+
remaining = templated.remaining;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error instanceof Error && error.message.startsWith("invalid-path-parameter:")) {
|
|
96
|
+
const field = error.message.slice("invalid-path-parameter:".length);
|
|
97
|
+
throw new CommerceError("INPUT_INVALID", `Path parameter "${field}" is not a valid value`, {
|
|
98
|
+
requestId: request.requestId,
|
|
99
|
+
resourceId: request.resourceId,
|
|
100
|
+
details: { field }
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
if (error instanceof Error && error.message.startsWith("missing-path-parameter:")) {
|
|
104
|
+
const field = error.message.slice("missing-path-parameter:".length);
|
|
105
|
+
throw new CommerceError("INPUT_INVALID", `Path parameter "${field}" was not supplied`, {
|
|
106
|
+
requestId: request.requestId,
|
|
107
|
+
resourceId: request.resourceId,
|
|
108
|
+
details: { field }
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
throw new CommerceError("BACKEND_ERROR", "Backend URL could not be constructed from input", {
|
|
112
|
+
requestId: request.requestId,
|
|
113
|
+
resourceId: request.resourceId,
|
|
114
|
+
details: { reason: describeConstructionError(error) },
|
|
115
|
+
cause: error
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
const literalPrefix = handler.url.split("{")[0] ?? handler.url;
|
|
119
|
+
try {
|
|
120
|
+
const literalPrefixPathname = new URL(literalPrefix).pathname;
|
|
121
|
+
if (!target.pathname.startsWith(literalPrefixPathname)) {
|
|
122
|
+
throw new CommerceError(
|
|
123
|
+
"INPUT_INVALID",
|
|
124
|
+
"Path parameters resolved outside the configured backend path",
|
|
125
|
+
{
|
|
126
|
+
requestId: request.requestId,
|
|
127
|
+
resourceId: request.resourceId
|
|
128
|
+
}
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (error instanceof CommerceError) throw error;
|
|
133
|
+
}
|
|
134
|
+
const headers = { ...handler.headers ?? {} };
|
|
135
|
+
let body;
|
|
136
|
+
if (handler.method === "GET" || handler.method === "DELETE") {
|
|
137
|
+
checkQueryCollision(target, remaining, {
|
|
138
|
+
requestId: request.requestId,
|
|
139
|
+
resourceId: request.resourceId
|
|
140
|
+
});
|
|
141
|
+
for (const [key, value] of Object.entries(remaining)) {
|
|
142
|
+
target.searchParams.set(key, stringifyPrimitive(value));
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
body = JSON.stringify(remaining);
|
|
146
|
+
if (!hasHeader(headers, "content-type")) {
|
|
147
|
+
headers["content-type"] = "application/json";
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const started = performance.now();
|
|
151
|
+
let response;
|
|
152
|
+
try {
|
|
153
|
+
response = await this.fetchImpl(target, {
|
|
154
|
+
method: handler.method,
|
|
155
|
+
headers,
|
|
156
|
+
...body !== void 0 ? { body } : {},
|
|
157
|
+
redirect: "manual",
|
|
158
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
159
|
+
});
|
|
160
|
+
} catch (error) {
|
|
161
|
+
const durationMs2 = Math.round(performance.now() - started);
|
|
162
|
+
if (isAbortError(error)) {
|
|
163
|
+
throw new CommerceError(
|
|
164
|
+
"BACKEND_TIMEOUT",
|
|
165
|
+
`Backend request timed out after ${timeoutMs}ms`,
|
|
166
|
+
{
|
|
167
|
+
requestId: request.requestId,
|
|
168
|
+
resourceId: request.resourceId,
|
|
169
|
+
details: { timeoutMs, durationMs: durationMs2 },
|
|
170
|
+
cause: error
|
|
171
|
+
}
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
throw new CommerceError("BACKEND_ERROR", "Backend request failed (transport error)", {
|
|
175
|
+
requestId: request.requestId,
|
|
176
|
+
resourceId: request.resourceId,
|
|
177
|
+
details: { durationMs: durationMs2 },
|
|
178
|
+
cause: error
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
const durationMs = Math.round(performance.now() - started);
|
|
182
|
+
const isRedirect = response.type === "opaqueredirect" || response.status >= 300 && response.status < 400;
|
|
183
|
+
if (isRedirect) {
|
|
184
|
+
throw new CommerceError(
|
|
185
|
+
"BACKEND_ERROR",
|
|
186
|
+
"Backend responded with a redirect, which is not followed",
|
|
187
|
+
{
|
|
188
|
+
requestId: request.requestId,
|
|
189
|
+
resourceId: request.resourceId,
|
|
190
|
+
details: { status: response.status, reason: "redirect-not-followed" }
|
|
191
|
+
}
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
const responseHeaders = headersToRecord(response.headers);
|
|
195
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
196
|
+
let parsedBody;
|
|
197
|
+
try {
|
|
198
|
+
parsedBody = await parseBody(response, contentType);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
const tooLarge = error instanceof Error && error.message.startsWith("response-body-too-large");
|
|
201
|
+
throw new CommerceError(
|
|
202
|
+
"BACKEND_ERROR",
|
|
203
|
+
tooLarge ? "Backend response exceeded the maximum allowed size" : "Backend response could not be read",
|
|
204
|
+
{
|
|
205
|
+
requestId: request.requestId,
|
|
206
|
+
resourceId: request.resourceId,
|
|
207
|
+
details: tooLarge ? { reason: "response-too-large", maxBytes: MAX_RESPONSE_BODY_BYTES } : { reason: "read-error" },
|
|
208
|
+
cause: error
|
|
209
|
+
}
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
if (response.status < 200 || response.status >= 300) {
|
|
213
|
+
this.logger.debug(
|
|
214
|
+
{
|
|
215
|
+
requestId: request.requestId,
|
|
216
|
+
resourceId: request.resourceId,
|
|
217
|
+
status: response.status,
|
|
218
|
+
bodySnippet: truncateSnippet(parsedBody)
|
|
219
|
+
},
|
|
220
|
+
"Backend responded with a non-2xx status"
|
|
221
|
+
);
|
|
222
|
+
throw new CommerceError("BACKEND_ERROR", `Backend responded with status ${response.status}`, {
|
|
223
|
+
requestId: request.requestId,
|
|
224
|
+
resourceId: request.resourceId,
|
|
225
|
+
details: { status: response.status }
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
status: response.status,
|
|
230
|
+
headers: responseHeaders,
|
|
231
|
+
body: parsedBody,
|
|
232
|
+
durationMs
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
function extractPathParameterNames(url) {
|
|
237
|
+
return [...url.matchAll(PATH_PARAM_PATTERN)].map((match) => match[1]);
|
|
238
|
+
}
|
|
239
|
+
function findUnparsedBraceToken(url) {
|
|
240
|
+
const residue = url.replace(PATH_PARAM_PATTERN, "");
|
|
241
|
+
const index = residue.search(/[{}]/);
|
|
242
|
+
if (index === -1) return void 0;
|
|
243
|
+
const token = /\{[^{}]*\}?|\}/.exec(residue.slice(index));
|
|
244
|
+
return token?.[0] ?? residue[index];
|
|
245
|
+
}
|
|
246
|
+
function validateBackendRequestShape(handler, input, context) {
|
|
247
|
+
const inputRecord = isPlainObject2(input) ? input : {};
|
|
248
|
+
let templated;
|
|
249
|
+
try {
|
|
250
|
+
templated = applyPathTemplate(handler.url, inputRecord);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
if (error instanceof Error && error.message.startsWith("invalid-path-parameter:")) {
|
|
253
|
+
const field = error.message.slice("invalid-path-parameter:".length);
|
|
254
|
+
throw new CommerceError("INPUT_INVALID", `Path parameter "${field}" is not a valid value`, {
|
|
255
|
+
requestId: context.requestId,
|
|
256
|
+
resourceId: context.resourceId,
|
|
257
|
+
details: { field }
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
if (error instanceof Error && error.message.startsWith("missing-path-parameter:")) {
|
|
261
|
+
const field = error.message.slice("missing-path-parameter:".length);
|
|
262
|
+
throw new CommerceError("INPUT_INVALID", `Path parameter "${field}" was not supplied`, {
|
|
263
|
+
requestId: context.requestId,
|
|
264
|
+
resourceId: context.resourceId,
|
|
265
|
+
details: { field }
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (handler.method === "GET" || handler.method === "DELETE") {
|
|
271
|
+
let target;
|
|
272
|
+
try {
|
|
273
|
+
target = new URL(templated.url);
|
|
274
|
+
} catch {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
checkQueryCollision(target, templated.remaining, context);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
function checkQueryCollision(target, remaining, context) {
|
|
281
|
+
const templateKeys = new Set(target.searchParams.keys());
|
|
282
|
+
for (const key of Object.keys(remaining)) {
|
|
283
|
+
if (templateKeys.has(key)) {
|
|
284
|
+
throw new CommerceError(
|
|
285
|
+
"INPUT_INVALID",
|
|
286
|
+
`Input key "${key}" collides with a query parameter already set on backend.url`,
|
|
287
|
+
{ requestId: context.requestId, resourceId: context.resourceId, details: { field: key } }
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
function isPlainObject2(value) {
|
|
293
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
294
|
+
}
|
|
295
|
+
var TRAVERSAL_PATH_VALUES = /* @__PURE__ */ new Set(["", ".", ".."]);
|
|
296
|
+
function applyPathTemplate(template, input) {
|
|
297
|
+
const remaining = { ...input };
|
|
298
|
+
let missing;
|
|
299
|
+
let invalid;
|
|
300
|
+
const url = template.replace(PATH_PARAM_PATTERN, (_match, key) => {
|
|
301
|
+
if (!Object.hasOwn(remaining, key)) {
|
|
302
|
+
missing = key;
|
|
303
|
+
return "";
|
|
304
|
+
}
|
|
305
|
+
const value = remaining[key];
|
|
306
|
+
delete remaining[key];
|
|
307
|
+
const raw = stringifyPrimitive(value);
|
|
308
|
+
if (TRAVERSAL_PATH_VALUES.has(raw)) {
|
|
309
|
+
invalid = key;
|
|
310
|
+
return "";
|
|
311
|
+
}
|
|
312
|
+
return encodeURIComponent(raw);
|
|
313
|
+
});
|
|
314
|
+
if (missing !== void 0) {
|
|
315
|
+
throw new Error(`missing-path-parameter:${missing}`);
|
|
316
|
+
}
|
|
317
|
+
if (invalid !== void 0) {
|
|
318
|
+
throw new Error(`invalid-path-parameter:${invalid}`);
|
|
319
|
+
}
|
|
320
|
+
return { url, remaining };
|
|
321
|
+
}
|
|
322
|
+
function describeConstructionError(error) {
|
|
323
|
+
if (error instanceof Error && error.message.startsWith("missing-path-parameter:")) {
|
|
324
|
+
return error.message;
|
|
325
|
+
}
|
|
326
|
+
return "invalid-url";
|
|
327
|
+
}
|
|
328
|
+
function stringifyPrimitive(value) {
|
|
329
|
+
if (value === void 0 || value === null) return "";
|
|
330
|
+
if (typeof value === "string") return value;
|
|
331
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
332
|
+
try {
|
|
333
|
+
return JSON.stringify(value);
|
|
334
|
+
} catch {
|
|
335
|
+
return String(value);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
function hasHeader(headers, name) {
|
|
339
|
+
const target = name.toLowerCase();
|
|
340
|
+
return Object.keys(headers).some((key) => key.toLowerCase() === target);
|
|
341
|
+
}
|
|
342
|
+
function headersToRecord(headers) {
|
|
343
|
+
const record = {};
|
|
344
|
+
headers.forEach((value, key) => {
|
|
345
|
+
record[key] = value;
|
|
346
|
+
});
|
|
347
|
+
return record;
|
|
348
|
+
}
|
|
349
|
+
async function readBodyCapped(response, maxBytes) {
|
|
350
|
+
if (!response.body) return "";
|
|
351
|
+
const reader = response.body.getReader();
|
|
352
|
+
const chunks = [];
|
|
353
|
+
let total = 0;
|
|
354
|
+
try {
|
|
355
|
+
for (; ; ) {
|
|
356
|
+
const { done, value } = await reader.read();
|
|
357
|
+
if (done) break;
|
|
358
|
+
total += value.byteLength;
|
|
359
|
+
if (total > maxBytes) {
|
|
360
|
+
await reader.cancel();
|
|
361
|
+
throw new Error(`response-body-too-large:${maxBytes}`);
|
|
362
|
+
}
|
|
363
|
+
chunks.push(value);
|
|
364
|
+
}
|
|
365
|
+
} finally {
|
|
366
|
+
reader.releaseLock();
|
|
367
|
+
}
|
|
368
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
369
|
+
}
|
|
370
|
+
async function parseBody(response, contentType) {
|
|
371
|
+
const text = await readBodyCapped(response, MAX_RESPONSE_BODY_BYTES);
|
|
372
|
+
if (text.length === 0) return text;
|
|
373
|
+
if (contentType.includes("json")) {
|
|
374
|
+
try {
|
|
375
|
+
return JSON.parse(text);
|
|
376
|
+
} catch {
|
|
377
|
+
return text;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return text;
|
|
381
|
+
}
|
|
382
|
+
function truncateSnippet(body) {
|
|
383
|
+
const text = typeof body === "string" ? body : safeStringify(body);
|
|
384
|
+
return text.length > MAX_BODY_SNIPPET_LENGTH ? `${text.slice(0, MAX_BODY_SNIPPET_LENGTH)}\u2026` : text;
|
|
385
|
+
}
|
|
386
|
+
function safeStringify(value) {
|
|
387
|
+
try {
|
|
388
|
+
return JSON.stringify(value) ?? String(value);
|
|
389
|
+
} catch {
|
|
390
|
+
return String(value);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
function isAbortError(error) {
|
|
394
|
+
return error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// src/core/execution/event-bus.ts
|
|
398
|
+
function createEventBus(options) {
|
|
399
|
+
const subscribers = /* @__PURE__ */ new Set();
|
|
400
|
+
return {
|
|
401
|
+
async emit(event) {
|
|
402
|
+
try {
|
|
403
|
+
await options.store.appendEvent(event);
|
|
404
|
+
} catch (error) {
|
|
405
|
+
options.logger.error(
|
|
406
|
+
{ err: describeError(error), eventType: event.type, requestId: event.requestId },
|
|
407
|
+
"Failed to persist commerce event"
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
for (const subscriber of subscribers) {
|
|
411
|
+
try {
|
|
412
|
+
subscriber(event);
|
|
413
|
+
} catch (error) {
|
|
414
|
+
options.logger.error(
|
|
415
|
+
{ err: describeError(error), eventType: event.type, requestId: event.requestId },
|
|
416
|
+
"Event subscriber threw"
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
},
|
|
421
|
+
subscribe(fn) {
|
|
422
|
+
subscribers.add(fn);
|
|
423
|
+
return () => {
|
|
424
|
+
subscribers.delete(fn);
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
function describeError(error) {
|
|
430
|
+
if (error instanceof Error) {
|
|
431
|
+
return { message: error.message, name: error.name };
|
|
432
|
+
}
|
|
433
|
+
return { message: String(error) };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// src/core/execution/validation.ts
|
|
437
|
+
var SUPPORTED_TYPES = /* @__PURE__ */ new Set([
|
|
438
|
+
"object",
|
|
439
|
+
"string",
|
|
440
|
+
"number",
|
|
441
|
+
"integer",
|
|
442
|
+
"boolean",
|
|
443
|
+
"array",
|
|
444
|
+
"null"
|
|
445
|
+
]);
|
|
446
|
+
function compileJsonSchema(schema) {
|
|
447
|
+
return (input) => {
|
|
448
|
+
if (schema === void 0) return { valid: true, value: input };
|
|
449
|
+
const errors = validateNode(schema, input, "$");
|
|
450
|
+
return errors.length === 0 ? { valid: true, value: input } : { valid: false, errors };
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
function isPlainObject3(value) {
|
|
454
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
455
|
+
}
|
|
456
|
+
function matchesType(type, value) {
|
|
457
|
+
switch (type) {
|
|
458
|
+
case "object":
|
|
459
|
+
return isPlainObject3(value);
|
|
460
|
+
case "array":
|
|
461
|
+
return Array.isArray(value);
|
|
462
|
+
case "string":
|
|
463
|
+
return typeof value === "string";
|
|
464
|
+
case "boolean":
|
|
465
|
+
return typeof value === "boolean";
|
|
466
|
+
case "number":
|
|
467
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
468
|
+
case "integer":
|
|
469
|
+
return typeof value === "number" && Number.isInteger(value);
|
|
470
|
+
case "null":
|
|
471
|
+
return value === null;
|
|
472
|
+
default:
|
|
473
|
+
return true;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
function describeType(value) {
|
|
477
|
+
if (value === null) return "null";
|
|
478
|
+
if (Array.isArray(value)) return "array";
|
|
479
|
+
return typeof value;
|
|
480
|
+
}
|
|
481
|
+
function deepEqual(a, b) {
|
|
482
|
+
if (a === b) return true;
|
|
483
|
+
if (typeof a !== typeof b) return false;
|
|
484
|
+
if (a === null || b === null) return false;
|
|
485
|
+
if (typeof a !== "object") return false;
|
|
486
|
+
try {
|
|
487
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
488
|
+
} catch {
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
function own(obj, key) {
|
|
493
|
+
return Object.hasOwn(obj, key) ? obj[key] : void 0;
|
|
494
|
+
}
|
|
495
|
+
function typeList(raw) {
|
|
496
|
+
if (typeof raw === "string") return [raw];
|
|
497
|
+
if (Array.isArray(raw) && raw.every((entry) => typeof entry === "string")) {
|
|
498
|
+
return raw;
|
|
499
|
+
}
|
|
500
|
+
return void 0;
|
|
501
|
+
}
|
|
502
|
+
function isObjectSchemaNode(schema) {
|
|
503
|
+
const types = typeList(schema["type"]);
|
|
504
|
+
if (types !== void 0) return types.includes("object");
|
|
505
|
+
return Object.hasOwn(schema, "properties") || Object.hasOwn(schema, "required");
|
|
506
|
+
}
|
|
507
|
+
function validateNode(schema, value, path2) {
|
|
508
|
+
const errors = [];
|
|
509
|
+
const types = typeList(own(schema, "type"));
|
|
510
|
+
const recognisedTypes = types?.filter((entry) => SUPPORTED_TYPES.has(entry));
|
|
511
|
+
if (types !== void 0 && recognisedTypes !== void 0 && recognisedTypes.length === types.length && types.length > 0) {
|
|
512
|
+
if (!types.some((entry) => matchesType(entry, value))) {
|
|
513
|
+
errors.push({
|
|
514
|
+
path: path2,
|
|
515
|
+
message: `expected type "${types.join(" | ")}" but got ${describeType(value)}`
|
|
516
|
+
});
|
|
517
|
+
return errors;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
const enumRaw = own(schema, "enum");
|
|
521
|
+
if (Array.isArray(enumRaw)) {
|
|
522
|
+
const allowed = enumRaw;
|
|
523
|
+
if (!allowed.some((candidate) => deepEqual(candidate, value))) {
|
|
524
|
+
errors.push({ path: path2, message: `must be one of ${JSON.stringify(allowed)}` });
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
if (isObjectSchemaNode(schema) && isPlainObject3(value)) {
|
|
528
|
+
errors.push(...validateObject(schema, value, path2));
|
|
529
|
+
} else if ((types?.includes("array") ?? false) && Array.isArray(value)) {
|
|
530
|
+
errors.push(...validateArray(schema, value, path2));
|
|
531
|
+
}
|
|
532
|
+
return errors;
|
|
533
|
+
}
|
|
534
|
+
function validateObject(schema, value, path2) {
|
|
535
|
+
const errors = [];
|
|
536
|
+
const propsRaw = own(schema, "properties");
|
|
537
|
+
const properties = isPlainObject3(propsRaw) ? propsRaw : {};
|
|
538
|
+
const requiredRaw = own(schema, "required");
|
|
539
|
+
const required = Array.isArray(requiredRaw) ? requiredRaw.filter((entry) => typeof entry === "string") : [];
|
|
540
|
+
for (const key of required) {
|
|
541
|
+
if (!Object.hasOwn(value, key)) {
|
|
542
|
+
errors.push({ path: `${path2}.${key}`, message: "is required" });
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
const additional = own(schema, "additionalProperties");
|
|
546
|
+
for (const key of Object.keys(value)) {
|
|
547
|
+
const propSchemaRaw = own(properties, key);
|
|
548
|
+
if (propSchemaRaw !== void 0) {
|
|
549
|
+
if (isPlainObject3(propSchemaRaw)) {
|
|
550
|
+
errors.push(...validateNode(propSchemaRaw, value[key], `${path2}.${key}`));
|
|
551
|
+
}
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
if (additional === false) {
|
|
555
|
+
errors.push({ path: `${path2}.${key}`, message: "additional property not allowed" });
|
|
556
|
+
} else if (isPlainObject3(additional)) {
|
|
557
|
+
errors.push(...validateNode(additional, value[key], `${path2}.${key}`));
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return errors;
|
|
561
|
+
}
|
|
562
|
+
function validateArray(schema, value, path2) {
|
|
563
|
+
const errors = [];
|
|
564
|
+
const itemsRaw = own(schema, "items");
|
|
565
|
+
if (isPlainObject3(itemsRaw)) {
|
|
566
|
+
value.forEach((item, index) => {
|
|
567
|
+
errors.push(...validateNode(itemsRaw, item, `${path2}[${index}]`));
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
return errors;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// src/core/execution/pipeline.ts
|
|
574
|
+
function createExecutionPipeline(options) {
|
|
575
|
+
const validators = /* @__PURE__ */ new Map();
|
|
576
|
+
function getValidator(resource) {
|
|
577
|
+
const cached = validators.get(resource.id);
|
|
578
|
+
if (cached) return cached;
|
|
579
|
+
const compiled = compileJsonSchema(resource.inputSchema);
|
|
580
|
+
validators.set(resource.id, compiled);
|
|
581
|
+
return compiled;
|
|
582
|
+
}
|
|
583
|
+
async function safeEmit(event) {
|
|
584
|
+
try {
|
|
585
|
+
await options.events.emit(event);
|
|
586
|
+
} catch (error) {
|
|
587
|
+
options.logger.error(
|
|
588
|
+
{ err: describeError2(error), eventType: event.type, requestId: event.requestId },
|
|
589
|
+
"Event sink failed; commerce flow continues"
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
async function safePersist(op, label, requestId) {
|
|
594
|
+
try {
|
|
595
|
+
await op();
|
|
596
|
+
} catch (error) {
|
|
597
|
+
options.logger.error(
|
|
598
|
+
{ err: describeError2(error), requestId },
|
|
599
|
+
`Persistence failed: ${label}`
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
function buildEvent(partial) {
|
|
604
|
+
return { id: options.ids.next("evt"), at: options.clock.nowIso(), ...partial };
|
|
605
|
+
}
|
|
606
|
+
async function execute(request) {
|
|
607
|
+
const pipelineStart = options.clock.monotonicMs();
|
|
608
|
+
const resource = options.resources.get(request.resourceId);
|
|
609
|
+
if (!resource || !resource.exposedVia.includes(request.protocol)) {
|
|
610
|
+
throw new CommerceError(
|
|
611
|
+
"RESOURCE_NOT_FOUND",
|
|
612
|
+
`Resource "${request.resourceId}" was not found`,
|
|
613
|
+
{
|
|
614
|
+
requestId: request.requestId,
|
|
615
|
+
resourceId: request.resourceId
|
|
616
|
+
}
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
await safeEmit(
|
|
620
|
+
buildEvent({
|
|
621
|
+
type: "resource.requested",
|
|
622
|
+
requestId: request.requestId,
|
|
623
|
+
resourceId: resource.id,
|
|
624
|
+
adapter: request.protocol
|
|
625
|
+
})
|
|
626
|
+
);
|
|
627
|
+
const strippedInput = stripPaymentField(request.input);
|
|
628
|
+
const validation = getValidator(resource)(strippedInput);
|
|
629
|
+
if (!validation.valid) {
|
|
630
|
+
throw new CommerceError(
|
|
631
|
+
"INPUT_INVALID",
|
|
632
|
+
`Input for resource "${resource.id}" failed validation`,
|
|
633
|
+
{
|
|
634
|
+
requestId: request.requestId,
|
|
635
|
+
resourceId: resource.id,
|
|
636
|
+
details: { errors: validation.errors }
|
|
637
|
+
}
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
const validInput = validation.value;
|
|
641
|
+
validateBackendRequestShape(resource.handler, validInput, {
|
|
642
|
+
requestId: request.requestId,
|
|
643
|
+
resourceId: resource.id
|
|
644
|
+
});
|
|
645
|
+
if (resource.pricing.type === "dynamic") {
|
|
646
|
+
throw new CommerceError(
|
|
647
|
+
"CONFIG_INVALID",
|
|
648
|
+
`Resource "${resource.id}" uses unsupported dynamic pricing`,
|
|
649
|
+
{
|
|
650
|
+
requestId: request.requestId,
|
|
651
|
+
resourceId: resource.id
|
|
652
|
+
}
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
let paymentResult;
|
|
656
|
+
if (resource.pricing.type === "fixed") {
|
|
657
|
+
const pricing = resource.pricing;
|
|
658
|
+
const provider = pickProvider(options.paymentProviders, resource.paymentMethods);
|
|
659
|
+
if (!provider) {
|
|
660
|
+
throw new CommerceError(
|
|
661
|
+
"PAYMENT_PROVIDER_UNAVAILABLE",
|
|
662
|
+
`No enabled payment provider for resource "${resource.id}"`,
|
|
663
|
+
{
|
|
664
|
+
requestId: request.requestId,
|
|
665
|
+
resourceId: resource.id,
|
|
666
|
+
details: { paymentMethods: resource.paymentMethods }
|
|
667
|
+
}
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
let requirement;
|
|
671
|
+
try {
|
|
672
|
+
requirement = await provider.createRequirement({
|
|
673
|
+
requestId: request.requestId,
|
|
674
|
+
resource,
|
|
675
|
+
amount: pricing.amount,
|
|
676
|
+
currency: pricing.currency,
|
|
677
|
+
requestedAt: options.clock.nowIso()
|
|
678
|
+
});
|
|
679
|
+
} catch (error) {
|
|
680
|
+
throw isCommerceError(error) ? error : new CommerceError("PAYMENT_PROVIDER_UNAVAILABLE", "Payment provider is unavailable", {
|
|
681
|
+
requestId: request.requestId,
|
|
682
|
+
resourceId: resource.id,
|
|
683
|
+
cause: error
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
if (!request.payment) {
|
|
687
|
+
await safeEmit(
|
|
688
|
+
buildEvent({
|
|
689
|
+
type: "payment.required",
|
|
690
|
+
requestId: request.requestId,
|
|
691
|
+
resourceId: resource.id,
|
|
692
|
+
adapter: request.protocol,
|
|
693
|
+
paymentProvider: provider.name
|
|
694
|
+
})
|
|
695
|
+
);
|
|
696
|
+
return {
|
|
697
|
+
kind: "payment-required",
|
|
698
|
+
requestId: request.requestId,
|
|
699
|
+
resourceId: resource.id,
|
|
700
|
+
requirement
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
const payment = request.payment;
|
|
704
|
+
let verification;
|
|
705
|
+
try {
|
|
706
|
+
verification = await provider.verify({
|
|
707
|
+
requestId: request.requestId,
|
|
708
|
+
resource,
|
|
709
|
+
requirement,
|
|
710
|
+
submission: payment
|
|
711
|
+
});
|
|
712
|
+
} catch (error) {
|
|
713
|
+
const mapped = isCommerceError(error) ? error : new CommerceError("PAYMENT_INVALID", "Payment verification failed", {
|
|
714
|
+
requestId: request.requestId,
|
|
715
|
+
resourceId: resource.id,
|
|
716
|
+
cause: error
|
|
717
|
+
});
|
|
718
|
+
await safeEmit(
|
|
719
|
+
buildEvent({
|
|
720
|
+
type: "payment.rejected",
|
|
721
|
+
requestId: request.requestId,
|
|
722
|
+
resourceId: resource.id,
|
|
723
|
+
adapter: request.protocol,
|
|
724
|
+
paymentProvider: provider.name,
|
|
725
|
+
status: "error",
|
|
726
|
+
data: { reason: mapped.code }
|
|
727
|
+
})
|
|
728
|
+
);
|
|
729
|
+
throw mapped;
|
|
730
|
+
}
|
|
731
|
+
if (verification.status !== "verified") {
|
|
732
|
+
await safeEmit(
|
|
733
|
+
buildEvent({
|
|
734
|
+
type: "payment.rejected",
|
|
735
|
+
requestId: request.requestId,
|
|
736
|
+
resourceId: resource.id,
|
|
737
|
+
adapter: request.protocol,
|
|
738
|
+
paymentProvider: provider.name,
|
|
739
|
+
status: "error",
|
|
740
|
+
data: verification.rejectionReason ? { reason: verification.rejectionReason } : {}
|
|
741
|
+
})
|
|
742
|
+
);
|
|
743
|
+
throw new CommerceError(
|
|
744
|
+
"PAYMENT_INVALID",
|
|
745
|
+
verification.rejectionReason ?? "Payment was rejected",
|
|
746
|
+
{
|
|
747
|
+
requestId: request.requestId,
|
|
748
|
+
resourceId: resource.id
|
|
749
|
+
}
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
if (!verification.replayKey) {
|
|
753
|
+
await safeEmit(
|
|
754
|
+
buildEvent({
|
|
755
|
+
type: "payment.rejected",
|
|
756
|
+
requestId: request.requestId,
|
|
757
|
+
resourceId: resource.id,
|
|
758
|
+
adapter: request.protocol,
|
|
759
|
+
paymentProvider: provider.name,
|
|
760
|
+
status: "error",
|
|
761
|
+
data: { reason: "missing-replay-key" }
|
|
762
|
+
})
|
|
763
|
+
);
|
|
764
|
+
throw new CommerceError("PAYMENT_INVALID", "Payment provider did not return a replay key", {
|
|
765
|
+
requestId: request.requestId,
|
|
766
|
+
resourceId: resource.id
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
const replayKey = verification.replayKey;
|
|
770
|
+
try {
|
|
771
|
+
await options.store.reservePaymentAttempt({
|
|
772
|
+
requestId: request.requestId,
|
|
773
|
+
resourceId: resource.id,
|
|
774
|
+
provider: provider.name,
|
|
775
|
+
replayKey,
|
|
776
|
+
amount: verification.amount,
|
|
777
|
+
currency: verification.currency,
|
|
778
|
+
...verification.payer !== void 0 ? { payer: verification.payer } : {},
|
|
779
|
+
...verification.payee !== void 0 ? { payee: verification.payee } : {}
|
|
780
|
+
});
|
|
781
|
+
} catch (error) {
|
|
782
|
+
const mapped = toCommerceError(
|
|
783
|
+
error,
|
|
784
|
+
"STORAGE_ERROR",
|
|
785
|
+
"Payment attempt could not be recorded"
|
|
786
|
+
);
|
|
787
|
+
await safeEmit(
|
|
788
|
+
buildEvent({
|
|
789
|
+
type: "payment.rejected",
|
|
790
|
+
requestId: request.requestId,
|
|
791
|
+
resourceId: resource.id,
|
|
792
|
+
adapter: request.protocol,
|
|
793
|
+
paymentProvider: provider.name,
|
|
794
|
+
status: "error",
|
|
795
|
+
data: { reason: mapped.code }
|
|
796
|
+
})
|
|
797
|
+
);
|
|
798
|
+
throw mapped;
|
|
799
|
+
}
|
|
800
|
+
await safeEmit(
|
|
801
|
+
buildEvent({
|
|
802
|
+
type: "payment.verified",
|
|
803
|
+
requestId: request.requestId,
|
|
804
|
+
resourceId: resource.id,
|
|
805
|
+
adapter: request.protocol,
|
|
806
|
+
paymentProvider: provider.name,
|
|
807
|
+
status: "ok"
|
|
808
|
+
})
|
|
809
|
+
);
|
|
810
|
+
let settlement;
|
|
811
|
+
try {
|
|
812
|
+
settlement = await provider.settle({
|
|
813
|
+
requestId: request.requestId,
|
|
814
|
+
resource,
|
|
815
|
+
requirement,
|
|
816
|
+
submission: payment,
|
|
817
|
+
verification
|
|
818
|
+
});
|
|
819
|
+
} catch (error) {
|
|
820
|
+
const uncertainTxHash = uncertainSettlementTxHash(error);
|
|
821
|
+
await safePersist(
|
|
822
|
+
() => options.store.updatePaymentAttempt(
|
|
823
|
+
uncertainTxHash !== void 0 ? { replayKey, status: "settlement-uncertain", externalReference: uncertainTxHash } : {
|
|
824
|
+
replayKey,
|
|
825
|
+
status: "failed",
|
|
826
|
+
...error instanceof Error ? { rejectionReason: error.message } : {}
|
|
827
|
+
}
|
|
828
|
+
),
|
|
829
|
+
uncertainTxHash !== void 0 ? "updatePaymentAttempt(settlement-uncertain)" : "updatePaymentAttempt(failed)",
|
|
830
|
+
request.requestId
|
|
831
|
+
);
|
|
832
|
+
await safeEmit(
|
|
833
|
+
buildEvent({
|
|
834
|
+
type: "payment.rejected",
|
|
835
|
+
requestId: request.requestId,
|
|
836
|
+
resourceId: resource.id,
|
|
837
|
+
adapter: request.protocol,
|
|
838
|
+
paymentProvider: provider.name,
|
|
839
|
+
status: "error",
|
|
840
|
+
data: uncertainTxHash !== void 0 ? { reason: "settlement-uncertain", transactionHash: uncertainTxHash } : { reason: "settlement-threw" }
|
|
841
|
+
})
|
|
842
|
+
);
|
|
843
|
+
throw new CommerceError(
|
|
844
|
+
"PAYMENT_SETTLEMENT_FAILED",
|
|
845
|
+
uncertainTxHash !== void 0 ? "Settlement could not be confirmed" : "Payment settlement failed",
|
|
846
|
+
{
|
|
847
|
+
requestId: request.requestId,
|
|
848
|
+
resourceId: resource.id,
|
|
849
|
+
cause: error,
|
|
850
|
+
...uncertainTxHash !== void 0 ? { details: { settlementUncertain: true, transactionHash: uncertainTxHash } } : {}
|
|
851
|
+
}
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
if (settlement.status !== "settled") {
|
|
855
|
+
await safePersist(
|
|
856
|
+
() => options.store.updatePaymentAttempt({
|
|
857
|
+
replayKey,
|
|
858
|
+
status: "rejected",
|
|
859
|
+
...settlement.rejectionReason !== void 0 ? { rejectionReason: settlement.rejectionReason } : {}
|
|
860
|
+
}),
|
|
861
|
+
"updatePaymentAttempt(rejected)",
|
|
862
|
+
request.requestId
|
|
863
|
+
);
|
|
864
|
+
await safeEmit(
|
|
865
|
+
buildEvent({
|
|
866
|
+
type: "payment.rejected",
|
|
867
|
+
requestId: request.requestId,
|
|
868
|
+
resourceId: resource.id,
|
|
869
|
+
adapter: request.protocol,
|
|
870
|
+
paymentProvider: provider.name,
|
|
871
|
+
status: "error",
|
|
872
|
+
data: settlement.rejectionReason ? { reason: settlement.rejectionReason } : {}
|
|
873
|
+
})
|
|
874
|
+
);
|
|
875
|
+
throw new CommerceError(
|
|
876
|
+
"PAYMENT_SETTLEMENT_FAILED",
|
|
877
|
+
settlement.rejectionReason ?? "Settlement was rejected",
|
|
878
|
+
{
|
|
879
|
+
requestId: request.requestId,
|
|
880
|
+
resourceId: resource.id
|
|
881
|
+
}
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
await safePersist(
|
|
885
|
+
() => options.store.updatePaymentAttempt({
|
|
886
|
+
replayKey,
|
|
887
|
+
status: "settled",
|
|
888
|
+
...settlement.externalReference !== void 0 ? { externalReference: settlement.externalReference } : {}
|
|
889
|
+
}),
|
|
890
|
+
"updatePaymentAttempt(settled)",
|
|
891
|
+
request.requestId
|
|
892
|
+
);
|
|
893
|
+
await safeEmit(
|
|
894
|
+
buildEvent({
|
|
895
|
+
type: "payment.settled",
|
|
896
|
+
requestId: request.requestId,
|
|
897
|
+
resourceId: resource.id,
|
|
898
|
+
adapter: request.protocol,
|
|
899
|
+
paymentProvider: provider.name,
|
|
900
|
+
status: "ok"
|
|
901
|
+
})
|
|
902
|
+
);
|
|
903
|
+
paymentResult = settlement;
|
|
904
|
+
}
|
|
905
|
+
const backendStart = options.clock.monotonicMs();
|
|
906
|
+
let backendResponse;
|
|
907
|
+
try {
|
|
908
|
+
backendResponse = await options.backend.call(resource.handler, {
|
|
909
|
+
requestId: request.requestId,
|
|
910
|
+
resourceId: resource.id,
|
|
911
|
+
input: validInput
|
|
912
|
+
});
|
|
913
|
+
} catch (error) {
|
|
914
|
+
const commerceError = toCommerceError(error, "BACKEND_ERROR", "Backend call failed");
|
|
915
|
+
await safeEmit(
|
|
916
|
+
buildEvent({
|
|
917
|
+
type: "backend.failed",
|
|
918
|
+
requestId: request.requestId,
|
|
919
|
+
resourceId: resource.id,
|
|
920
|
+
adapter: request.protocol,
|
|
921
|
+
status: "error",
|
|
922
|
+
durationMs: Math.round(options.clock.monotonicMs() - backendStart),
|
|
923
|
+
data: { code: commerceError.code }
|
|
924
|
+
})
|
|
925
|
+
);
|
|
926
|
+
if (paymentResult !== void 0) {
|
|
927
|
+
const failedReceipt = {
|
|
928
|
+
id: options.ids.next("receipt"),
|
|
929
|
+
requestId: request.requestId,
|
|
930
|
+
resourceId: resource.id,
|
|
931
|
+
deliveredAt: options.clock.nowIso(),
|
|
932
|
+
backendStatus: backendErrorStatus(commerceError),
|
|
933
|
+
protocol: request.protocol,
|
|
934
|
+
payment: paymentResult,
|
|
935
|
+
metadata: { delivered: false, backendErrorCode: commerceError.code }
|
|
936
|
+
};
|
|
937
|
+
await safePersist(
|
|
938
|
+
() => options.store.saveReceipt(failedReceipt),
|
|
939
|
+
"saveReceipt(backend-failed-after-settlement)",
|
|
940
|
+
request.requestId
|
|
941
|
+
);
|
|
942
|
+
throw new CommerceError(
|
|
943
|
+
"BACKEND_ERROR",
|
|
944
|
+
"Payment settled but the backend call failed; the payment was not refunded automatically",
|
|
945
|
+
{
|
|
946
|
+
requestId: request.requestId,
|
|
947
|
+
resourceId: resource.id,
|
|
948
|
+
cause: commerceError,
|
|
949
|
+
details: {
|
|
950
|
+
payment: {
|
|
951
|
+
status: paymentResult.status,
|
|
952
|
+
provider: paymentResult.provider,
|
|
953
|
+
amount: paymentResult.amount,
|
|
954
|
+
currency: paymentResult.currency,
|
|
955
|
+
...paymentResult.externalReference !== void 0 ? { externalReference: paymentResult.externalReference } : {}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
throw commerceError;
|
|
962
|
+
}
|
|
963
|
+
await safeEmit(
|
|
964
|
+
buildEvent({
|
|
965
|
+
type: "backend.called",
|
|
966
|
+
requestId: request.requestId,
|
|
967
|
+
resourceId: resource.id,
|
|
968
|
+
adapter: request.protocol,
|
|
969
|
+
status: "ok",
|
|
970
|
+
durationMs: backendResponse.durationMs,
|
|
971
|
+
data: { status: backendResponse.status }
|
|
972
|
+
})
|
|
973
|
+
);
|
|
974
|
+
const receipt = {
|
|
975
|
+
id: options.ids.next("receipt"),
|
|
976
|
+
requestId: request.requestId,
|
|
977
|
+
resourceId: resource.id,
|
|
978
|
+
deliveredAt: options.clock.nowIso(),
|
|
979
|
+
backendStatus: backendResponse.status,
|
|
980
|
+
durationMs: backendResponse.durationMs,
|
|
981
|
+
protocol: request.protocol,
|
|
982
|
+
...paymentResult !== void 0 ? { payment: paymentResult } : {}
|
|
983
|
+
};
|
|
984
|
+
await safePersist(() => options.store.saveReceipt(receipt), "saveReceipt", request.requestId);
|
|
985
|
+
const totalDurationMs = Math.round(options.clock.monotonicMs() - pipelineStart);
|
|
986
|
+
await safeEmit(
|
|
987
|
+
buildEvent({
|
|
988
|
+
type: "resource.delivered",
|
|
989
|
+
requestId: request.requestId,
|
|
990
|
+
resourceId: resource.id,
|
|
991
|
+
adapter: request.protocol,
|
|
992
|
+
status: "ok",
|
|
993
|
+
durationMs: totalDurationMs
|
|
994
|
+
})
|
|
995
|
+
);
|
|
996
|
+
return {
|
|
997
|
+
kind: "delivered",
|
|
998
|
+
requestId: request.requestId,
|
|
999
|
+
resourceId: resource.id,
|
|
1000
|
+
backendStatus: backendResponse.status,
|
|
1001
|
+
body: backendResponse.body,
|
|
1002
|
+
headers: backendResponse.headers,
|
|
1003
|
+
...paymentResult !== void 0 ? { payment: paymentResult } : {},
|
|
1004
|
+
receipt,
|
|
1005
|
+
durationMs: totalDurationMs
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
return { execute };
|
|
1009
|
+
}
|
|
1010
|
+
function backendErrorStatus(error) {
|
|
1011
|
+
const status = error.details?.["status"];
|
|
1012
|
+
return typeof status === "number" ? status : 0;
|
|
1013
|
+
}
|
|
1014
|
+
function uncertainSettlementTxHash(error) {
|
|
1015
|
+
if (!isCommerceError(error) || error.code !== "PAYMENT_PROVIDER_UNAVAILABLE") return void 0;
|
|
1016
|
+
const hash = error.details?.["transactionHash"];
|
|
1017
|
+
return typeof hash === "string" ? hash : void 0;
|
|
1018
|
+
}
|
|
1019
|
+
function pickProvider(providers, methods) {
|
|
1020
|
+
for (const method of methods) {
|
|
1021
|
+
const found = providers.find((provider) => provider.name === method);
|
|
1022
|
+
if (found) return found;
|
|
1023
|
+
}
|
|
1024
|
+
return void 0;
|
|
1025
|
+
}
|
|
1026
|
+
var RESERVED_INPUT_KEYS = /* @__PURE__ */ new Set([PAYMENT_INPUT_FIELD, "__proto__"]);
|
|
1027
|
+
function stripPaymentField(input) {
|
|
1028
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) return input;
|
|
1029
|
+
const record = input;
|
|
1030
|
+
const hasReserved = [...RESERVED_INPUT_KEYS].some((key) => Object.hasOwn(record, key));
|
|
1031
|
+
if (!hasReserved) return input;
|
|
1032
|
+
const rest = {};
|
|
1033
|
+
for (const key of Object.keys(record)) {
|
|
1034
|
+
if (!RESERVED_INPUT_KEYS.has(key)) rest[key] = record[key];
|
|
1035
|
+
}
|
|
1036
|
+
return rest;
|
|
1037
|
+
}
|
|
1038
|
+
function describeError2(error) {
|
|
1039
|
+
if (error instanceof Error) return { message: error.message, name: error.name };
|
|
1040
|
+
return { message: String(error) };
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// src/core/execution/registry.ts
|
|
1044
|
+
var ResourceRegistryImpl = class {
|
|
1045
|
+
byId = /* @__PURE__ */ new Map();
|
|
1046
|
+
ordered;
|
|
1047
|
+
constructor(resources) {
|
|
1048
|
+
for (const resource of resources) {
|
|
1049
|
+
if (this.byId.has(resource.id)) {
|
|
1050
|
+
throw new CommerceError("CONFIG_INVALID", `Duplicate resource id "${resource.id}"`, {
|
|
1051
|
+
details: { resourceId: resource.id }
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
this.byId.set(resource.id, resource);
|
|
1055
|
+
}
|
|
1056
|
+
this.ordered = [...resources];
|
|
1057
|
+
}
|
|
1058
|
+
get(id) {
|
|
1059
|
+
return this.byId.get(id);
|
|
1060
|
+
}
|
|
1061
|
+
list() {
|
|
1062
|
+
return this.ordered;
|
|
1063
|
+
}
|
|
1064
|
+
listExposedVia(protocol) {
|
|
1065
|
+
return this.ordered.filter((resource) => resource.exposedVia.includes(protocol));
|
|
1066
|
+
}
|
|
1067
|
+
has(id) {
|
|
1068
|
+
return this.byId.has(id);
|
|
1069
|
+
}
|
|
1070
|
+
};
|
|
1071
|
+
function createResourceRegistry(resources) {
|
|
1072
|
+
return new ResourceRegistryImpl(resources);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// src/config/schema.ts
|
|
1076
|
+
var SUPPORTED_CONFIG_VERSION = 1;
|
|
1077
|
+
var ADDRESS_PATTERN = /^0x[0-9a-fA-F]{40}$/;
|
|
1078
|
+
var ZERO_ADDRESS_PATTERN = /^0x0{40}$/i;
|
|
1079
|
+
var MCP_TOOL_NAME_PATTERN = /^[A-Za-z0-9._-]{1,128}$/;
|
|
1080
|
+
var NumberOrString = z.union([z.number(), z.string()]);
|
|
1081
|
+
var BooleanOrString = z.union([z.boolean(), z.string()]);
|
|
1082
|
+
var MerchantSchema = z.object({
|
|
1083
|
+
id: z.string().min(1),
|
|
1084
|
+
name: z.string().min(1),
|
|
1085
|
+
publicBaseUrl: z.string().min(1)
|
|
1086
|
+
}).strict();
|
|
1087
|
+
var ServerSchema = z.object({
|
|
1088
|
+
port: NumberOrString,
|
|
1089
|
+
host: z.string().min(1),
|
|
1090
|
+
/** Shared secret gating /api/receipts, /api/events, /api/events/stream. No token -> those routes 404. */
|
|
1091
|
+
adminToken: z.string().min(1).optional(),
|
|
1092
|
+
/** Browser origins allowed to read the dashboard-facing routes. Empty by default: closed. */
|
|
1093
|
+
// Entries are matched literally against the browser's
|
|
1094
|
+
// `Origin` header, so `"*"` and a trailing slash match nothing at all —
|
|
1095
|
+
// fail-closed (a lockout, not a bypass), but silently, and a lockout with
|
|
1096
|
+
// no explanation is the kind of thing an operator "fixes" by disabling the
|
|
1097
|
+
// check. Reject those two shapes with a pointer instead.
|
|
1098
|
+
allowedOrigins: z.array(
|
|
1099
|
+
z.string().min(1).refine((origin) => origin !== "*", {
|
|
1100
|
+
message: `wildcard "*" is not supported \u2014 allowedOrigins entries are matched literally against the browser's Origin header, so "*" would match nothing. List each scheme://host[:port] explicitly.`
|
|
1101
|
+
}).refine((origin) => !origin.endsWith("/"), {
|
|
1102
|
+
message: 'must not end with "/" \u2014 a browser Origin header never has a trailing slash, so this entry would match nothing. Use e.g. "http://localhost:5173".'
|
|
1103
|
+
})
|
|
1104
|
+
).optional()
|
|
1105
|
+
}).strict();
|
|
1106
|
+
var StorageSchema = z.object({
|
|
1107
|
+
receipts: z.object({
|
|
1108
|
+
driver: z.literal("sqlite"),
|
|
1109
|
+
path: z.string().min(1)
|
|
1110
|
+
}).strict()
|
|
1111
|
+
}).strict();
|
|
1112
|
+
var ProtocolsSchema = z.object({
|
|
1113
|
+
http: z.object({ enabled: BooleanOrString }).strict(),
|
|
1114
|
+
mcp: z.object({
|
|
1115
|
+
enabled: BooleanOrString,
|
|
1116
|
+
mountPath: z.string().min(1)
|
|
1117
|
+
}).strict()
|
|
1118
|
+
}).strict();
|
|
1119
|
+
var BackendMethodSchema = z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]);
|
|
1120
|
+
var BackendHandlerSchema = z.object({
|
|
1121
|
+
type: z.literal("http"),
|
|
1122
|
+
method: BackendMethodSchema,
|
|
1123
|
+
url: z.string().min(1),
|
|
1124
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
1125
|
+
timeoutMs: NumberOrString.optional()
|
|
1126
|
+
}).strict();
|
|
1127
|
+
var PricingSchema = z.discriminatedUnion("type", [
|
|
1128
|
+
z.object({ type: z.literal("free") }).strict(),
|
|
1129
|
+
z.object({
|
|
1130
|
+
type: z.literal("fixed"),
|
|
1131
|
+
amount: z.string().min(1),
|
|
1132
|
+
currency: z.string().min(1)
|
|
1133
|
+
}).strict(),
|
|
1134
|
+
z.object({
|
|
1135
|
+
type: z.literal("dynamic"),
|
|
1136
|
+
resolver: z.string().min(1)
|
|
1137
|
+
}).strict()
|
|
1138
|
+
]);
|
|
1139
|
+
var JsonSchemaValueSchema = z.record(z.string(), z.unknown());
|
|
1140
|
+
var ResourceEntrySchema = z.object({
|
|
1141
|
+
name: z.string().min(1),
|
|
1142
|
+
description: z.string().optional(),
|
|
1143
|
+
input: JsonSchemaValueSchema.optional(),
|
|
1144
|
+
output: JsonSchemaValueSchema.optional(),
|
|
1145
|
+
backend: BackendHandlerSchema,
|
|
1146
|
+
pricing: PricingSchema,
|
|
1147
|
+
expose: z.array(z.string().min(1)).min(1),
|
|
1148
|
+
payments: z.array(z.string().min(1)).optional()
|
|
1149
|
+
}).strict();
|
|
1150
|
+
var ResourcesMapSchema = z.record(z.string().min(1), ResourceEntrySchema);
|
|
1151
|
+
var FacilitatorSchema = z.discriminatedUnion("mode", [
|
|
1152
|
+
z.object({
|
|
1153
|
+
mode: z.literal("local"),
|
|
1154
|
+
signerPrivateKey: z.string().min(1)
|
|
1155
|
+
}).strict(),
|
|
1156
|
+
z.object({
|
|
1157
|
+
mode: z.literal("remote"),
|
|
1158
|
+
url: z.string().min(1)
|
|
1159
|
+
}).strict()
|
|
1160
|
+
]);
|
|
1161
|
+
var X402Schema = z.object({
|
|
1162
|
+
enabled: BooleanOrString,
|
|
1163
|
+
network: z.string().min(1),
|
|
1164
|
+
rpcUrl: z.string().min(1),
|
|
1165
|
+
asset: z.string().min(1),
|
|
1166
|
+
assetName: z.string().min(1),
|
|
1167
|
+
assetVersion: z.string().min(1),
|
|
1168
|
+
assetDecimals: NumberOrString,
|
|
1169
|
+
payTo: z.string().min(1),
|
|
1170
|
+
maxTimeoutSeconds: NumberOrString,
|
|
1171
|
+
facilitator: FacilitatorSchema
|
|
1172
|
+
}).strict();
|
|
1173
|
+
var PaymentsSchema = z.object({
|
|
1174
|
+
x402: X402Schema.optional()
|
|
1175
|
+
}).strict();
|
|
1176
|
+
var RawConfigSchema = z.object({
|
|
1177
|
+
version: z.literal(SUPPORTED_CONFIG_VERSION),
|
|
1178
|
+
merchant: MerchantSchema,
|
|
1179
|
+
server: ServerSchema,
|
|
1180
|
+
storage: StorageSchema,
|
|
1181
|
+
protocols: ProtocolsSchema,
|
|
1182
|
+
resources: ResourcesMapSchema,
|
|
1183
|
+
payments: PaymentsSchema
|
|
1184
|
+
}).strict();
|
|
1185
|
+
function parseConfig(raw, env) {
|
|
1186
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
1187
|
+
throw new CommerceError(
|
|
1188
|
+
"CONFIG_INVALID",
|
|
1189
|
+
'Configuration root must be a mapping (object) at "$"',
|
|
1190
|
+
{
|
|
1191
|
+
details: { path: "$" }
|
|
1192
|
+
}
|
|
1193
|
+
);
|
|
1194
|
+
}
|
|
1195
|
+
checkVersion(raw);
|
|
1196
|
+
const substituted = substituteEnv(raw, env);
|
|
1197
|
+
const parsed = parseWithZod(RawConfigSchema, substituted);
|
|
1198
|
+
return normalise(parsed);
|
|
1199
|
+
}
|
|
1200
|
+
function checkVersion(raw) {
|
|
1201
|
+
if (!("version" in raw)) {
|
|
1202
|
+
throw new CommerceError("CONFIG_INVALID", 'Configuration is missing required field "version"', {
|
|
1203
|
+
details: { path: "version" }
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
const rawVersion = raw["version"];
|
|
1207
|
+
const version = typeof rawVersion === "string" ? Number(rawVersion) : rawVersion;
|
|
1208
|
+
if (version !== SUPPORTED_CONFIG_VERSION) {
|
|
1209
|
+
throw new CommerceError(
|
|
1210
|
+
"CONFIG_INVALID",
|
|
1211
|
+
`Unsupported config version "${String(rawVersion)}": this gateway only supports version ${SUPPORTED_CONFIG_VERSION}`,
|
|
1212
|
+
{ details: { path: "version", supported: SUPPORTED_CONFIG_VERSION } }
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
function parseWithZod(schema, value) {
|
|
1217
|
+
const result = schema.safeParse(value);
|
|
1218
|
+
if (!result.success) {
|
|
1219
|
+
throw zodErrorToConfigError(result.error);
|
|
1220
|
+
}
|
|
1221
|
+
return result.data;
|
|
1222
|
+
}
|
|
1223
|
+
function zodErrorToConfigError(error) {
|
|
1224
|
+
const issues = error.issues.map(describeIssue);
|
|
1225
|
+
const first = issues[0];
|
|
1226
|
+
const message = first ? `Configuration invalid at "${first.path}": ${first.message}` : "Configuration invalid";
|
|
1227
|
+
return new CommerceError("CONFIG_INVALID", message, { details: { issues } });
|
|
1228
|
+
}
|
|
1229
|
+
function describeIssue(issue) {
|
|
1230
|
+
const path2 = issue.path.length > 0 ? issue.path.join(".") : "$";
|
|
1231
|
+
return { path: path2, message: issue.message, code: issue.code };
|
|
1232
|
+
}
|
|
1233
|
+
function toNumber(value, path2, bounds = {}) {
|
|
1234
|
+
if (typeof value === "string" && !/^\s*\d+\s*$/.test(value)) {
|
|
1235
|
+
throw new CommerceError(
|
|
1236
|
+
"CONFIG_INVALID",
|
|
1237
|
+
`Configuration value at "${path2}" must be an integer written in decimal digits (got ${value === "" ? "an empty string" : `"${value}"`})`,
|
|
1238
|
+
{ details: { path: path2 } }
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1241
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
1242
|
+
if (!Number.isFinite(n) || !Number.isInteger(n)) {
|
|
1243
|
+
throw new CommerceError(
|
|
1244
|
+
"CONFIG_INVALID",
|
|
1245
|
+
`Configuration value at "${path2}" must be an integer`,
|
|
1246
|
+
{
|
|
1247
|
+
details: { path: path2 }
|
|
1248
|
+
}
|
|
1249
|
+
);
|
|
1250
|
+
}
|
|
1251
|
+
if (bounds.min !== void 0 && n < bounds.min) {
|
|
1252
|
+
throw new CommerceError(
|
|
1253
|
+
"CONFIG_INVALID",
|
|
1254
|
+
`Configuration value at "${path2}" must be >= ${bounds.min}`,
|
|
1255
|
+
{
|
|
1256
|
+
details: { path: path2 }
|
|
1257
|
+
}
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1260
|
+
if (bounds.max !== void 0 && n > bounds.max) {
|
|
1261
|
+
throw new CommerceError(
|
|
1262
|
+
"CONFIG_INVALID",
|
|
1263
|
+
`Configuration value at "${path2}" must be <= ${bounds.max}`,
|
|
1264
|
+
{
|
|
1265
|
+
details: { path: path2 }
|
|
1266
|
+
}
|
|
1267
|
+
);
|
|
1268
|
+
}
|
|
1269
|
+
return n;
|
|
1270
|
+
}
|
|
1271
|
+
function toBoolean(value, path2) {
|
|
1272
|
+
if (typeof value === "boolean") return value;
|
|
1273
|
+
const normalized = value.trim().toLowerCase();
|
|
1274
|
+
if (normalized === "true") return true;
|
|
1275
|
+
if (normalized === "false") return false;
|
|
1276
|
+
throw new CommerceError(
|
|
1277
|
+
"CONFIG_INVALID",
|
|
1278
|
+
`Configuration value at "${path2}" must be a boolean ("true" or "false")`,
|
|
1279
|
+
{ details: { path: path2 } }
|
|
1280
|
+
);
|
|
1281
|
+
}
|
|
1282
|
+
var SUPPORTED_PROTOCOLS = /* @__PURE__ */ new Set(["http", "mcp"]);
|
|
1283
|
+
var SUPPORTED_PAYMENT_METHODS = /* @__PURE__ */ new Set(["x402"]);
|
|
1284
|
+
function normalise(raw) {
|
|
1285
|
+
const protocols = {
|
|
1286
|
+
http: { enabled: toBoolean(raw.protocols.http.enabled, "protocols.http.enabled") },
|
|
1287
|
+
mcp: {
|
|
1288
|
+
enabled: toBoolean(raw.protocols.mcp.enabled, "protocols.mcp.enabled"),
|
|
1289
|
+
mountPath: raw.protocols.mcp.mountPath
|
|
1290
|
+
}
|
|
1291
|
+
};
|
|
1292
|
+
const x402Raw = raw.payments.x402;
|
|
1293
|
+
if (x402Raw?.facilitator.mode === "remote") {
|
|
1294
|
+
throw new CommerceError(
|
|
1295
|
+
"CONFIG_INVALID",
|
|
1296
|
+
'payments.x402.facilitator.mode "remote" is not supported in v0.1.0-alpha; use "local" (the deterministic dev-chain facilitator)',
|
|
1297
|
+
{ details: { path: "payments.x402.facilitator.mode" } }
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
const x402 = x402Raw ? {
|
|
1301
|
+
enabled: toBoolean(x402Raw.enabled, "payments.x402.enabled"),
|
|
1302
|
+
network: x402Raw.network,
|
|
1303
|
+
rpcUrl: x402Raw.rpcUrl,
|
|
1304
|
+
asset: x402Raw.asset,
|
|
1305
|
+
assetName: x402Raw.assetName,
|
|
1306
|
+
assetVersion: x402Raw.assetVersion,
|
|
1307
|
+
assetDecimals: toNumber(x402Raw.assetDecimals, "payments.x402.assetDecimals", {
|
|
1308
|
+
min: 0,
|
|
1309
|
+
max: 36
|
|
1310
|
+
}),
|
|
1311
|
+
payTo: x402Raw.payTo,
|
|
1312
|
+
maxTimeoutSeconds: toNumber(x402Raw.maxTimeoutSeconds, "payments.x402.maxTimeoutSeconds", {
|
|
1313
|
+
min: 1
|
|
1314
|
+
}),
|
|
1315
|
+
// Only "local" can reach here — "remote" was rejected above.
|
|
1316
|
+
facilitator: {
|
|
1317
|
+
mode: "local",
|
|
1318
|
+
signerPrivateKey: x402Raw.facilitator.signerPrivateKey
|
|
1319
|
+
}
|
|
1320
|
+
} : void 0;
|
|
1321
|
+
if (x402) {
|
|
1322
|
+
validateAddress("payments.x402.payTo", x402.payTo);
|
|
1323
|
+
validateAddress("payments.x402.asset", x402.asset);
|
|
1324
|
+
}
|
|
1325
|
+
const resources = Object.entries(raw.resources).map(
|
|
1326
|
+
([id, entry]) => normaliseResource(id, entry, protocols, x402)
|
|
1327
|
+
);
|
|
1328
|
+
return {
|
|
1329
|
+
version: SUPPORTED_CONFIG_VERSION,
|
|
1330
|
+
merchant: {
|
|
1331
|
+
id: raw.merchant.id,
|
|
1332
|
+
name: raw.merchant.name,
|
|
1333
|
+
publicBaseUrl: raw.merchant.publicBaseUrl
|
|
1334
|
+
},
|
|
1335
|
+
// min 0, not 1: port 0 is the standard "let the OS pick a free port"
|
|
1336
|
+
// convention, useful for tests and ephemeral dev/demo instances.
|
|
1337
|
+
server: {
|
|
1338
|
+
port: toNumber(raw.server.port, "server.port", { min: 0, max: 65535 }),
|
|
1339
|
+
host: raw.server.host,
|
|
1340
|
+
...raw.server.adminToken !== void 0 ? { adminToken: raw.server.adminToken } : {},
|
|
1341
|
+
allowedOrigins: raw.server.allowedOrigins ?? []
|
|
1342
|
+
},
|
|
1343
|
+
storage: { receipts: { driver: "sqlite", path: raw.storage.receipts.path } },
|
|
1344
|
+
protocols,
|
|
1345
|
+
resources,
|
|
1346
|
+
payments: {
|
|
1347
|
+
...x402 !== void 0 ? { x402 } : {}
|
|
1348
|
+
}
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
function normaliseResource(id, entry, protocols, x402) {
|
|
1352
|
+
if (entry.input !== void 0) validateResourceSchemaKeywords(id, "input", entry.input);
|
|
1353
|
+
const inputProperties = entry.input?.["properties"];
|
|
1354
|
+
if (inputProperties && typeof inputProperties === "object" && PAYMENT_INPUT_FIELD in inputProperties) {
|
|
1355
|
+
throw new CommerceError(
|
|
1356
|
+
"CONFIG_INVALID",
|
|
1357
|
+
`Resource "${id}" declares an input property "${PAYMENT_INPUT_FIELD}", which is reserved for payment proofs`,
|
|
1358
|
+
{
|
|
1359
|
+
details: {
|
|
1360
|
+
path: `resources.${id}.input.properties.${PAYMENT_INPUT_FIELD}`,
|
|
1361
|
+
resourceId: id
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1366
|
+
for (const protocol of entry.expose) {
|
|
1367
|
+
if (!SUPPORTED_PROTOCOLS.has(protocol)) {
|
|
1368
|
+
const hint = protocol === "ucp" ? " (UCP is planned, not supported in v0.1.0-alpha)" : "";
|
|
1369
|
+
throw new CommerceError(
|
|
1370
|
+
"CONFIG_INVALID",
|
|
1371
|
+
`Resource "${id}" exposes unsupported protocol "${protocol}"${hint}. Supported: http, mcp.`,
|
|
1372
|
+
{ details: { path: `resources.${id}.expose`, resourceId: id, protocol } }
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
if (entry.expose.includes("mcp")) {
|
|
1377
|
+
if (!protocols.mcp.enabled) {
|
|
1378
|
+
throw new CommerceError(
|
|
1379
|
+
"CONFIG_INVALID",
|
|
1380
|
+
`Resource "${id}" is exposed via "mcp" but protocols.mcp.enabled is false`,
|
|
1381
|
+
{ details: { path: `resources.${id}.expose`, resourceId: id } }
|
|
1382
|
+
);
|
|
1383
|
+
}
|
|
1384
|
+
if (!MCP_TOOL_NAME_PATTERN.test(id)) {
|
|
1385
|
+
throw new CommerceError(
|
|
1386
|
+
"CONFIG_INVALID",
|
|
1387
|
+
`Resource "${id}" is exposed via "mcp" but its id is not a legal MCP tool name (allowed: A-Z, a-z, 0-9, "_", "-", ".", 1-128 chars)`,
|
|
1388
|
+
{ details: { path: `resources.${id}`, resourceId: id } }
|
|
1389
|
+
);
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
if (entry.expose.includes("http") && !protocols.http.enabled) {
|
|
1393
|
+
throw new CommerceError(
|
|
1394
|
+
"CONFIG_INVALID",
|
|
1395
|
+
`Resource "${id}" is exposed via "http" but protocols.http.enabled is false`,
|
|
1396
|
+
{ details: { path: `resources.${id}.expose`, resourceId: id } }
|
|
1397
|
+
);
|
|
1398
|
+
}
|
|
1399
|
+
validateBackendUrl(id, entry.backend.url);
|
|
1400
|
+
validatePathParametersDeclared(id, entry.backend.url, entry.input);
|
|
1401
|
+
if (entry.pricing.type === "dynamic") {
|
|
1402
|
+
throw new CommerceError(
|
|
1403
|
+
"CONFIG_INVALID",
|
|
1404
|
+
`Resource "${id}" uses pricing.type "dynamic", which is not supported in v0.1.0-alpha`,
|
|
1405
|
+
{ details: { path: `resources.${id}.pricing.type`, resourceId: id } }
|
|
1406
|
+
);
|
|
1407
|
+
}
|
|
1408
|
+
if (entry.pricing.type === "fixed") {
|
|
1409
|
+
validatePricingAmount(id, entry.pricing.amount, x402);
|
|
1410
|
+
}
|
|
1411
|
+
const paymentMethods = entry.payments ?? [];
|
|
1412
|
+
if (entry.pricing.type === "fixed") {
|
|
1413
|
+
if (paymentMethods.length === 0) {
|
|
1414
|
+
throw new CommerceError(
|
|
1415
|
+
"CONFIG_INVALID",
|
|
1416
|
+
`Resource "${id}" has fixed pricing but declares no "payments" (a paid resource must name at least one payment method)`,
|
|
1417
|
+
{ details: { path: `resources.${id}.payments`, resourceId: id } }
|
|
1418
|
+
);
|
|
1419
|
+
}
|
|
1420
|
+
for (const method of paymentMethods) {
|
|
1421
|
+
if (!SUPPORTED_PAYMENT_METHODS.has(method)) {
|
|
1422
|
+
throw new CommerceError(
|
|
1423
|
+
"CONFIG_INVALID",
|
|
1424
|
+
`Resource "${id}" names unsupported payment method "${method}". Supported: x402.`,
|
|
1425
|
+
{ details: { path: `resources.${id}.payments`, resourceId: id, method } }
|
|
1426
|
+
);
|
|
1427
|
+
}
|
|
1428
|
+
if (method === "x402" && (!x402 || !x402.enabled)) {
|
|
1429
|
+
throw new CommerceError(
|
|
1430
|
+
"CONFIG_INVALID",
|
|
1431
|
+
`Resource "${id}" names payment method "x402" which is not configured or not enabled under payments.x402`,
|
|
1432
|
+
{ details: { path: `resources.${id}.payments`, resourceId: id, method } }
|
|
1433
|
+
);
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
const pricing = entry.pricing.type === "free" ? { type: "free" } : { type: "fixed", amount: entry.pricing.amount, currency: entry.pricing.currency };
|
|
1438
|
+
return {
|
|
1439
|
+
id,
|
|
1440
|
+
name: entry.name,
|
|
1441
|
+
...entry.description !== void 0 ? { description: entry.description } : {},
|
|
1442
|
+
inputSchema: entry.input !== void 0 ? defaultClosedObjectSchema(entry.input) : EMPTY_CLOSED_OBJECT_SCHEMA,
|
|
1443
|
+
...entry.output !== void 0 ? { outputSchema: entry.output } : {},
|
|
1444
|
+
handler: {
|
|
1445
|
+
type: "http",
|
|
1446
|
+
method: entry.backend.method,
|
|
1447
|
+
url: entry.backend.url,
|
|
1448
|
+
...entry.backend.headers !== void 0 ? { headers: entry.backend.headers } : {},
|
|
1449
|
+
...entry.backend.timeoutMs !== void 0 ? {
|
|
1450
|
+
timeoutMs: toNumber(entry.backend.timeoutMs, `resources.${id}.backend.timeoutMs`, {
|
|
1451
|
+
min: 1
|
|
1452
|
+
})
|
|
1453
|
+
} : {}
|
|
1454
|
+
},
|
|
1455
|
+
pricing,
|
|
1456
|
+
exposedVia: entry.expose,
|
|
1457
|
+
paymentMethods
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
function defaultClosedObjectSchema(schema) {
|
|
1461
|
+
const isObjectSchema = isObjectSchemaNode(schema);
|
|
1462
|
+
const result = { ...schema };
|
|
1463
|
+
if (isObjectSchema) {
|
|
1464
|
+
const properties = schema["properties"];
|
|
1465
|
+
if (isPlainObject4(properties)) {
|
|
1466
|
+
result["properties"] = Object.fromEntries(
|
|
1467
|
+
Object.entries(properties).map(([key, sub]) => [
|
|
1468
|
+
key,
|
|
1469
|
+
isPlainObject4(sub) ? defaultClosedObjectSchema(sub) : sub
|
|
1470
|
+
])
|
|
1471
|
+
);
|
|
1472
|
+
}
|
|
1473
|
+
if (!Object.hasOwn(schema, "additionalProperties")) {
|
|
1474
|
+
result["additionalProperties"] = false;
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
const items = schema["items"];
|
|
1478
|
+
if (isPlainObject4(items)) {
|
|
1479
|
+
result["items"] = defaultClosedObjectSchema(items);
|
|
1480
|
+
}
|
|
1481
|
+
return result;
|
|
1482
|
+
}
|
|
1483
|
+
var EMPTY_CLOSED_OBJECT_SCHEMA = {
|
|
1484
|
+
type: "object",
|
|
1485
|
+
properties: {},
|
|
1486
|
+
additionalProperties: false
|
|
1487
|
+
};
|
|
1488
|
+
var UNSUPPORTED_SCHEMA_KEYWORDS = [
|
|
1489
|
+
"pattern",
|
|
1490
|
+
"minLength",
|
|
1491
|
+
"maxLength",
|
|
1492
|
+
"format",
|
|
1493
|
+
"minimum",
|
|
1494
|
+
"maximum",
|
|
1495
|
+
"exclusiveMinimum",
|
|
1496
|
+
"exclusiveMaximum",
|
|
1497
|
+
"multipleOf",
|
|
1498
|
+
"oneOf",
|
|
1499
|
+
"anyOf",
|
|
1500
|
+
"allOf",
|
|
1501
|
+
"not",
|
|
1502
|
+
"$ref",
|
|
1503
|
+
"const",
|
|
1504
|
+
"patternProperties",
|
|
1505
|
+
"minItems",
|
|
1506
|
+
"maxItems",
|
|
1507
|
+
"uniqueItems"
|
|
1508
|
+
];
|
|
1509
|
+
function isPlainObject4(value) {
|
|
1510
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1511
|
+
}
|
|
1512
|
+
function excludesObjectType(schema) {
|
|
1513
|
+
const type = schema["type"];
|
|
1514
|
+
if (type === void 0 || type === "object") return false;
|
|
1515
|
+
if (Array.isArray(type)) return !type.includes("object");
|
|
1516
|
+
return true;
|
|
1517
|
+
}
|
|
1518
|
+
function validateResourceSchemaKeywords(id, path2, schema) {
|
|
1519
|
+
for (const keyword of UNSUPPORTED_SCHEMA_KEYWORDS) {
|
|
1520
|
+
if (Object.hasOwn(schema, keyword)) {
|
|
1521
|
+
console.warn(
|
|
1522
|
+
`[agent-commerce] resource "${id}" ${path2} uses JSON Schema keyword "${keyword}", which this gateway does not enforce (see src/core/execution/validation.ts for the supported subset). Remove it or treat it as documentation only.`
|
|
1523
|
+
);
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
if ((Object.hasOwn(schema, "properties") || Object.hasOwn(schema, "required")) && excludesObjectType(schema)) {
|
|
1527
|
+
throw new CommerceError(
|
|
1528
|
+
"CONFIG_INVALID",
|
|
1529
|
+
`Resource "${id}" ${path2} declares "properties" and/or "required" but its "type" (${JSON.stringify(schema["type"])}) does not include "object" \u2014 the validator will never route a value through either check under that type, so this schema claims to constrain input it does not actually enforce. Remove "properties"/"required", or include "object" in "type".`,
|
|
1530
|
+
{ details: { path: `resources.${id}.${path2}`, resourceId: id } }
|
|
1531
|
+
);
|
|
1532
|
+
}
|
|
1533
|
+
const requiredRaw = schema["required"];
|
|
1534
|
+
const closed = !Object.hasOwn(schema, "additionalProperties") ? true : schema["additionalProperties"] === false;
|
|
1535
|
+
if (Array.isArray(requiredRaw) && closed && isObjectSchemaNode(schema)) {
|
|
1536
|
+
const declared = isPlainObject4(schema["properties"]) ? schema["properties"] : {};
|
|
1537
|
+
const undeclared = requiredRaw.filter(
|
|
1538
|
+
(name) => typeof name === "string" && !Object.hasOwn(declared, name)
|
|
1539
|
+
);
|
|
1540
|
+
if (undeclared.length > 0) {
|
|
1541
|
+
throw new CommerceError(
|
|
1542
|
+
"CONFIG_INVALID",
|
|
1543
|
+
`Resource "${id}" ${path2} lists ${undeclared.map((n) => `"${n}"`).join(", ")} in "required" but does not declare ${undeclared.length === 1 ? "it" : "them"} in "properties". The schema is closed (additionalProperties: false), so ${undeclared.length === 1 ? "that name is" : "those names are"} rejected as an unknown key and no input can ever satisfy this schema. Declare ${undeclared.length === 1 ? "it" : "them"} in "properties".`,
|
|
1544
|
+
{ details: { path: `resources.${id}.${path2}`, resourceId: id, undeclared } }
|
|
1545
|
+
);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
const properties = schema["properties"];
|
|
1549
|
+
if (isPlainObject4(properties)) {
|
|
1550
|
+
for (const [key, sub] of Object.entries(properties)) {
|
|
1551
|
+
if (isPlainObject4(sub)) validateResourceSchemaKeywords(id, `${path2}.properties.${key}`, sub);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
const items = schema["items"];
|
|
1555
|
+
if (isPlainObject4(items)) {
|
|
1556
|
+
validateResourceSchemaKeywords(id, `${path2}.items`, items);
|
|
1557
|
+
} else if (Array.isArray(items)) {
|
|
1558
|
+
console.warn(
|
|
1559
|
+
`[agent-commerce] resource "${id}" ${path2}.items is a tuple (an array of schemas), which this gateway does not enforce \u2014 only a single schema applied to every array element is supported (see src/core/execution/validation.ts). Each position's schema is unenforced; treat it as documentation only.`
|
|
1560
|
+
);
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
var PRICING_AMOUNT_PATTERN = /^\d+(?:\.\d+)?$/;
|
|
1564
|
+
var ZERO_AMOUNT_PATTERN = /^0(?:\.0+)?$/;
|
|
1565
|
+
function validatePricingAmount(id, amount, x402) {
|
|
1566
|
+
const path2 = `resources.${id}.pricing.amount`;
|
|
1567
|
+
if (!PRICING_AMOUNT_PATTERN.test(amount)) {
|
|
1568
|
+
throw new CommerceError(
|
|
1569
|
+
"CONFIG_INVALID",
|
|
1570
|
+
`Resource "${id}" has pricing.amount "${amount}", which is not a plain positive decimal (no currency symbol, no thousands separator, no exponent \u2014 e.g. "0.01")`,
|
|
1571
|
+
{ details: { path: path2, resourceId: id } }
|
|
1572
|
+
);
|
|
1573
|
+
}
|
|
1574
|
+
if (ZERO_AMOUNT_PATTERN.test(amount)) {
|
|
1575
|
+
throw new CommerceError(
|
|
1576
|
+
"CONFIG_INVALID",
|
|
1577
|
+
`Resource "${id}" has pricing.amount "0" \u2014 a paid resource cannot cost zero; use "pricing: { type: free }" instead`,
|
|
1578
|
+
{ details: { path: path2, resourceId: id } }
|
|
1579
|
+
);
|
|
1580
|
+
}
|
|
1581
|
+
if (x402 !== void 0) {
|
|
1582
|
+
const fractionalDigits = amount.includes(".") ? amount.split(".")[1]?.length ?? 0 : 0;
|
|
1583
|
+
if (fractionalDigits > x402.assetDecimals) {
|
|
1584
|
+
throw new CommerceError(
|
|
1585
|
+
"CONFIG_INVALID",
|
|
1586
|
+
`Resource "${id}" has pricing.amount "${amount}" with more precision (${fractionalDigits} fractional digits) than the configured asset supports (${x402.assetDecimals} decimals)`,
|
|
1587
|
+
{ details: { path: path2, resourceId: id } }
|
|
1588
|
+
);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
function validatePathParametersDeclared(id, url, input) {
|
|
1593
|
+
const stray = findUnparsedBraceToken(url);
|
|
1594
|
+
if (stray !== void 0) {
|
|
1595
|
+
throw new CommerceError(
|
|
1596
|
+
"CONFIG_INVALID",
|
|
1597
|
+
`Resource "${id}" has backend.url containing "${stray}", which is not a valid path parameter (allowed characters: A-Z a-z 0-9 _ . -). It would be sent to the backend literally, so a paid resource would settle payment and then never reach the backend`,
|
|
1598
|
+
{ details: { path: `resources.${id}.backend.url`, resourceId: id, token: stray } }
|
|
1599
|
+
);
|
|
1600
|
+
}
|
|
1601
|
+
const params = extractPathParameterNames(url);
|
|
1602
|
+
if (params.length === 0) return;
|
|
1603
|
+
const propertiesRaw = input?.["properties"];
|
|
1604
|
+
const properties = isPlainObject4(propertiesRaw) ? propertiesRaw : {};
|
|
1605
|
+
const requiredRaw = input?.["required"];
|
|
1606
|
+
const required = new Set(
|
|
1607
|
+
Array.isArray(requiredRaw) ? requiredRaw.filter((entry) => typeof entry === "string") : []
|
|
1608
|
+
);
|
|
1609
|
+
for (const param of params) {
|
|
1610
|
+
const path2 = `resources.${id}.backend.url`;
|
|
1611
|
+
if (!Object.hasOwn(properties, param)) {
|
|
1612
|
+
throw new CommerceError(
|
|
1613
|
+
"CONFIG_INVALID",
|
|
1614
|
+
`Resource "${id}" has backend.url path parameter "{${param}}" which is not declared in its input schema \u2014 the caller has no way to supply it, so every call would settle payment (if priced) and then fail to reach the backend`,
|
|
1615
|
+
{ details: { path: path2, resourceId: id, param } }
|
|
1616
|
+
);
|
|
1617
|
+
}
|
|
1618
|
+
if (!required.has(param)) {
|
|
1619
|
+
throw new CommerceError(
|
|
1620
|
+
"CONFIG_INVALID",
|
|
1621
|
+
`Resource "${id}" has backend.url path parameter "{${param}}" declared in its input schema but not listed in "required" \u2014 a caller that omits it hits the same unservable-request problem as an undeclared parameter`,
|
|
1622
|
+
{ details: { path: path2, resourceId: id, param } }
|
|
1623
|
+
);
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
function validateBackendUrl(id, url) {
|
|
1628
|
+
let parsed;
|
|
1629
|
+
try {
|
|
1630
|
+
parsed = new URL(url);
|
|
1631
|
+
} catch {
|
|
1632
|
+
throw new CommerceError(
|
|
1633
|
+
"CONFIG_INVALID",
|
|
1634
|
+
`Resource "${id}" has an invalid backend.url "${url}": must be an absolute http:// or https:// URL`,
|
|
1635
|
+
{ details: { path: `resources.${id}.backend.url`, resourceId: id } }
|
|
1636
|
+
);
|
|
1637
|
+
}
|
|
1638
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
1639
|
+
throw new CommerceError(
|
|
1640
|
+
"CONFIG_INVALID",
|
|
1641
|
+
`Resource "${id}" has a backend.url with scheme "${parsed.protocol}": must be http:// or https://`,
|
|
1642
|
+
{ details: { path: `resources.${id}.backend.url`, resourceId: id } }
|
|
1643
|
+
);
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
function validateAddress(path2, value) {
|
|
1647
|
+
if (!ADDRESS_PATTERN.test(value)) {
|
|
1648
|
+
throw new CommerceError(
|
|
1649
|
+
"CONFIG_INVALID",
|
|
1650
|
+
`Configuration value at "${path2}" is not a plausible address (expected "0x" followed by 40 hex characters)`,
|
|
1651
|
+
{ details: { path: path2 } }
|
|
1652
|
+
);
|
|
1653
|
+
}
|
|
1654
|
+
if (ZERO_ADDRESS_PATTERN.test(value)) {
|
|
1655
|
+
throw new CommerceError(
|
|
1656
|
+
"CONFIG_INVALID",
|
|
1657
|
+
`Configuration value at "${path2}" must not be the zero address`,
|
|
1658
|
+
{
|
|
1659
|
+
details: { path: path2 }
|
|
1660
|
+
}
|
|
1661
|
+
);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
// src/config/load.ts
|
|
1666
|
+
var CONFIG_PATH_ENV_VAR = "AGENT_COMMERCE_CONFIG";
|
|
1667
|
+
async function loadConfig(options = {}) {
|
|
1668
|
+
const env = options.env ?? process.env;
|
|
1669
|
+
const cwd = options.cwd ?? process.cwd();
|
|
1670
|
+
const configPath = resolveConfigPath(options.path, env, cwd);
|
|
1671
|
+
let text;
|
|
1672
|
+
try {
|
|
1673
|
+
text = await promises.readFile(configPath, "utf8");
|
|
1674
|
+
} catch (error) {
|
|
1675
|
+
throw new CommerceError("CONFIG_INVALID", `Could not read configuration file "${configPath}"`, {
|
|
1676
|
+
details: { path: configPath },
|
|
1677
|
+
cause: error
|
|
1678
|
+
});
|
|
1679
|
+
}
|
|
1680
|
+
let raw;
|
|
1681
|
+
try {
|
|
1682
|
+
raw = parse(text);
|
|
1683
|
+
} catch (error) {
|
|
1684
|
+
const { reason, details } = describeYamlError(error);
|
|
1685
|
+
throw new CommerceError(
|
|
1686
|
+
"CONFIG_INVALID",
|
|
1687
|
+
`Configuration file "${configPath}" is not valid YAML: ${reason}`,
|
|
1688
|
+
{
|
|
1689
|
+
details: { path: configPath, ...details },
|
|
1690
|
+
// Retained for programmatic callers. Nothing in the CLI prints a
|
|
1691
|
+
// `cause`, and nothing should start: this is the object whose
|
|
1692
|
+
// `.message` carries the source excerpt (see below).
|
|
1693
|
+
cause: error
|
|
1694
|
+
}
|
|
1695
|
+
);
|
|
1696
|
+
}
|
|
1697
|
+
return parseConfig(raw, env);
|
|
1698
|
+
}
|
|
1699
|
+
var YAML_ERROR_EXPLANATIONS = {
|
|
1700
|
+
DUPLICATE_KEY: "map keys must be unique",
|
|
1701
|
+
BAD_INDENT: "bad indentation",
|
|
1702
|
+
TAB_AS_INDENT: "tabs cannot be used for indentation",
|
|
1703
|
+
MISSING_CHAR: "a required character is missing",
|
|
1704
|
+
BLOCK_AS_IMPLICIT_KEY: "a block value cannot be used as a key (missing quotes, or a stray colon)",
|
|
1705
|
+
MULTILINE_IMPLICIT_KEY: "a key spans multiple lines (missing quotes, or a stray colon)",
|
|
1706
|
+
UNEXPECTED_TOKEN: "unexpected token"
|
|
1707
|
+
};
|
|
1708
|
+
function describeYamlError(error) {
|
|
1709
|
+
if (!(error instanceof YAMLError)) {
|
|
1710
|
+
return { reason: "unknown YAML parse error", details: {} };
|
|
1711
|
+
}
|
|
1712
|
+
const start = error.linePos?.[0];
|
|
1713
|
+
const where = start === void 0 ? "" : ` at line ${String(start.line)}, column ${String(start.col)}`;
|
|
1714
|
+
const explanation = YAML_ERROR_EXPLANATIONS[error.code];
|
|
1715
|
+
const what = explanation === void 0 ? error.code : `${error.code} (${explanation})`;
|
|
1716
|
+
return {
|
|
1717
|
+
reason: `${what}${where}`,
|
|
1718
|
+
details: {
|
|
1719
|
+
yamlErrorCode: error.code,
|
|
1720
|
+
...start === void 0 ? {} : { line: start.line, column: start.col }
|
|
1721
|
+
}
|
|
1722
|
+
};
|
|
1723
|
+
}
|
|
1724
|
+
function resolveConfigPath(explicitPath, env, cwd) {
|
|
1725
|
+
if (explicitPath) return path.resolve(cwd, explicitPath);
|
|
1726
|
+
const fromEnv = env[CONFIG_PATH_ENV_VAR];
|
|
1727
|
+
if (fromEnv) return path.resolve(cwd, fromEnv);
|
|
1728
|
+
return path.resolve(cwd, DEFAULT_CONFIG_FILENAME);
|
|
1729
|
+
}
|
|
1730
|
+
function createDefaultIdGenerator() {
|
|
1731
|
+
return {
|
|
1732
|
+
next(prefix) {
|
|
1733
|
+
const id = randomUUID();
|
|
1734
|
+
return prefix ? `${prefix}_${id}` : id;
|
|
1735
|
+
}
|
|
1736
|
+
};
|
|
1737
|
+
}
|
|
1738
|
+
var PINO_PRETTY_PATH = (() => {
|
|
1739
|
+
try {
|
|
1740
|
+
return createRequire(import.meta.url).resolve("pino-pretty");
|
|
1741
|
+
} catch {
|
|
1742
|
+
return void 0;
|
|
1743
|
+
}
|
|
1744
|
+
})();
|
|
1745
|
+
var SECRET_FIELD_NAMES = [
|
|
1746
|
+
"privateKey",
|
|
1747
|
+
"signerPrivateKey",
|
|
1748
|
+
"signature",
|
|
1749
|
+
"seed",
|
|
1750
|
+
"mnemonic",
|
|
1751
|
+
"secret",
|
|
1752
|
+
"apiKey",
|
|
1753
|
+
"adminToken",
|
|
1754
|
+
"token"
|
|
1755
|
+
];
|
|
1756
|
+
var REDACT_PATHS = [
|
|
1757
|
+
"req.headers.authorization",
|
|
1758
|
+
'req.headers["x-payment"]',
|
|
1759
|
+
...SECRET_FIELD_NAMES,
|
|
1760
|
+
...SECRET_FIELD_NAMES.map((name) => `*.${name}`)
|
|
1761
|
+
];
|
|
1762
|
+
function baseLoggerOptions(options) {
|
|
1763
|
+
const nodeEnv = options.nodeEnv ?? process.env["NODE_ENV"] ?? "development";
|
|
1764
|
+
const wantPretty = options.prettyPrint ?? (nodeEnv !== "production" && nodeEnv !== "test");
|
|
1765
|
+
const usePretty = wantPretty && PINO_PRETTY_PATH !== void 0;
|
|
1766
|
+
return {
|
|
1767
|
+
level: options.level ?? process.env["LOG_LEVEL"] ?? (nodeEnv === "test" ? "silent" : "info"),
|
|
1768
|
+
...options.name !== void 0 ? { name: options.name } : {},
|
|
1769
|
+
redact: { paths: [...REDACT_PATHS], censor: "[REDACTED]" },
|
|
1770
|
+
...usePretty ? { transport: { target: PINO_PRETTY_PATH } } : {}
|
|
1771
|
+
};
|
|
1772
|
+
}
|
|
1773
|
+
function sanitizeUrl(url) {
|
|
1774
|
+
if (url === void 0) return void 0;
|
|
1775
|
+
const queryIndex = url.indexOf("?");
|
|
1776
|
+
return queryIndex === -1 ? url : `${url.slice(0, queryIndex)}?[REDACTED]`;
|
|
1777
|
+
}
|
|
1778
|
+
function redactedReqSerializer(req) {
|
|
1779
|
+
return {
|
|
1780
|
+
method: req.method,
|
|
1781
|
+
url: sanitizeUrl(req.url),
|
|
1782
|
+
host: req.host,
|
|
1783
|
+
remoteAddress: req.ip,
|
|
1784
|
+
remotePort: req.socket?.remotePort
|
|
1785
|
+
};
|
|
1786
|
+
}
|
|
1787
|
+
function fastifyLoggerOptions(options = {}) {
|
|
1788
|
+
return {
|
|
1789
|
+
...baseLoggerOptions(options),
|
|
1790
|
+
serializers: { req: redactedReqSerializer }
|
|
1791
|
+
};
|
|
1792
|
+
}
|
|
1793
|
+
function buildNotFoundHandler() {
|
|
1794
|
+
return (request, reply) => {
|
|
1795
|
+
const safeUrl = sanitizeUrl(request.raw.url) ?? "";
|
|
1796
|
+
const message = `Route ${request.method}:${safeUrl} not found`;
|
|
1797
|
+
request.log.info(message);
|
|
1798
|
+
reply.code(404).send({ message, error: "Not Found", statusCode: 404 });
|
|
1799
|
+
};
|
|
1800
|
+
}
|
|
1801
|
+
function createGatewayLogger(options = {}) {
|
|
1802
|
+
const instance = pino(baseLoggerOptions(options));
|
|
1803
|
+
return { pino: instance, core: wrapPino(instance) };
|
|
1804
|
+
}
|
|
1805
|
+
function wrapPino(instance) {
|
|
1806
|
+
const logger = {
|
|
1807
|
+
debug: (obj, msg) => instance.debug(obj, msg),
|
|
1808
|
+
info: (obj, msg) => instance.info(obj, msg),
|
|
1809
|
+
warn: (obj, msg) => instance.warn(obj, msg),
|
|
1810
|
+
error: (obj, msg) => instance.error(obj, msg),
|
|
1811
|
+
child: (bindings) => wrapPino(instance.child(bindings))
|
|
1812
|
+
};
|
|
1813
|
+
return logger;
|
|
1814
|
+
}
|
|
1815
|
+
var CORS_METHODS = "GET,POST,DELETE,OPTIONS";
|
|
1816
|
+
var CORS_HEADERS = "content-type,x-payment,x-request-id,authorization";
|
|
1817
|
+
function buildAccessControlHook(options) {
|
|
1818
|
+
const allowedOrigins = new Set(options.allowedOrigins.map((origin) => origin.toLowerCase()));
|
|
1819
|
+
const publicHostname = hostnameOf(options.publicBaseUrl);
|
|
1820
|
+
return async (request, reply) => {
|
|
1821
|
+
const host = request.headers.host;
|
|
1822
|
+
if (!isAllowedHost(host, publicHostname)) {
|
|
1823
|
+
reply.status(403).send({ status: "error", code: "FORBIDDEN", message: "Host not allowed" });
|
|
1824
|
+
return;
|
|
1825
|
+
}
|
|
1826
|
+
const origin = request.headers.origin;
|
|
1827
|
+
if (origin !== void 0) {
|
|
1828
|
+
if (!allowedOrigins.has(origin.toLowerCase())) {
|
|
1829
|
+
reply.status(403).send({ status: "error", code: "FORBIDDEN", message: "Origin not allowed" });
|
|
1830
|
+
return;
|
|
1831
|
+
}
|
|
1832
|
+
reply.header("access-control-allow-origin", origin);
|
|
1833
|
+
reply.header("access-control-allow-methods", CORS_METHODS);
|
|
1834
|
+
reply.header("access-control-allow-headers", CORS_HEADERS);
|
|
1835
|
+
reply.header("vary", "Origin");
|
|
1836
|
+
if (request.method === "OPTIONS") {
|
|
1837
|
+
reply.status(204).send();
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
};
|
|
1841
|
+
}
|
|
1842
|
+
function buildOperatorTokenHook(adminToken) {
|
|
1843
|
+
return async (request, reply) => {
|
|
1844
|
+
if (!adminToken) {
|
|
1845
|
+
reply.status(404).send();
|
|
1846
|
+
return;
|
|
1847
|
+
}
|
|
1848
|
+
if (!isValidAdminToken(request.headers.authorization, adminToken)) {
|
|
1849
|
+
reply.status(401).send({ status: "error", code: "UNAUTHORIZED", message: "Admin token required" });
|
|
1850
|
+
}
|
|
1851
|
+
};
|
|
1852
|
+
}
|
|
1853
|
+
function hostnameOf(value) {
|
|
1854
|
+
try {
|
|
1855
|
+
const hostname = new URL(
|
|
1856
|
+
value.includes("://") ? value : `http://${value}`
|
|
1857
|
+
).hostname.toLowerCase();
|
|
1858
|
+
return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
1859
|
+
} catch {
|
|
1860
|
+
return "";
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
var LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1"]);
|
|
1864
|
+
function isAllowedHost(hostHeader, publicHostname) {
|
|
1865
|
+
if (!hostHeader) return false;
|
|
1866
|
+
const hostname = hostnameOf(hostHeader);
|
|
1867
|
+
return hostname !== "" && (hostname === publicHostname || LOOPBACK_HOSTNAMES.has(hostname));
|
|
1868
|
+
}
|
|
1869
|
+
function isValidAdminToken(authHeader, adminToken) {
|
|
1870
|
+
const provided = authHeader?.startsWith("Bearer ") ? authHeader.slice("Bearer ".length) : void 0;
|
|
1871
|
+
if (provided === void 0) return false;
|
|
1872
|
+
return timingSafeEqual(hashToken(provided), hashToken(adminToken));
|
|
1873
|
+
}
|
|
1874
|
+
function hashToken(value) {
|
|
1875
|
+
return createHash("sha256").update(value, "utf8").digest();
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
// src/gateway/adapters.ts
|
|
1879
|
+
var MOUNT_BODY_LIMIT_BYTES = 256 * 1024;
|
|
1880
|
+
async function startAndMountAdapters(options) {
|
|
1881
|
+
const runtimes = [];
|
|
1882
|
+
for (const adapter of options.adapters) {
|
|
1883
|
+
const adapterLogger = options.logger.child({ adapter: adapter.name });
|
|
1884
|
+
const adapterContext = { ...options.context, logger: adapterLogger };
|
|
1885
|
+
try {
|
|
1886
|
+
await adapter.start(adapterContext);
|
|
1887
|
+
} catch (error) {
|
|
1888
|
+
const detail = describeError3(error);
|
|
1889
|
+
adapterLogger.error(
|
|
1890
|
+
{ err: detail },
|
|
1891
|
+
"Protocol adapter failed to start; gateway continues without it"
|
|
1892
|
+
);
|
|
1893
|
+
runtimes.push({
|
|
1894
|
+
adapter,
|
|
1895
|
+
startFailure: { status: "fail", detail, checkedAt: options.clock.nowIso() }
|
|
1896
|
+
});
|
|
1897
|
+
continue;
|
|
1898
|
+
}
|
|
1899
|
+
if (isHttpProtocolAdapter(adapter)) {
|
|
1900
|
+
mountHttpAdapter(options.server, adapter, adapterLogger);
|
|
1901
|
+
}
|
|
1902
|
+
runtimes.push({ adapter });
|
|
1903
|
+
}
|
|
1904
|
+
return runtimes;
|
|
1905
|
+
}
|
|
1906
|
+
var MOUNT_MAX_CONCURRENT_REQUESTS = 20;
|
|
1907
|
+
var MOUNT_BUSY_RETRY_AFTER_SECONDS = 1;
|
|
1908
|
+
function mountHttpAdapter(server, adapter, logger) {
|
|
1909
|
+
const mountPath = adapter.mountPath;
|
|
1910
|
+
const wildcard = mountPath.endsWith("/") ? `${mountPath}*` : `${mountPath}/*`;
|
|
1911
|
+
let inFlight = 0;
|
|
1912
|
+
void server.register(async (instance) => {
|
|
1913
|
+
instance.addContentTypeParser(
|
|
1914
|
+
["application/json", "text/plain"],
|
|
1915
|
+
(_request, _payload, done) => {
|
|
1916
|
+
done(null);
|
|
1917
|
+
}
|
|
1918
|
+
);
|
|
1919
|
+
instance.addContentTypeParser("*", (_request, _payload, done) => {
|
|
1920
|
+
done(null);
|
|
1921
|
+
});
|
|
1922
|
+
const handler = async (request, reply) => {
|
|
1923
|
+
if (inFlight >= MOUNT_MAX_CONCURRENT_REQUESTS) {
|
|
1924
|
+
const error = new CommerceError(
|
|
1925
|
+
"GATEWAY_BUSY",
|
|
1926
|
+
"Too many requests already parsing on this mount; retry shortly."
|
|
1927
|
+
);
|
|
1928
|
+
reply.status(error.httpStatus).header("retry-after", String(MOUNT_BUSY_RETRY_AFTER_SECONDS)).send(toErrorEnvelope(error));
|
|
1929
|
+
return;
|
|
1930
|
+
}
|
|
1931
|
+
inFlight += 1;
|
|
1932
|
+
try {
|
|
1933
|
+
const stopEnforcing = enforceMountBodyLimit(
|
|
1934
|
+
request.raw,
|
|
1935
|
+
reply.raw,
|
|
1936
|
+
MOUNT_BODY_LIMIT_BYTES,
|
|
1937
|
+
logger
|
|
1938
|
+
);
|
|
1939
|
+
try {
|
|
1940
|
+
await adapter.handleHttp(request.raw, reply.raw);
|
|
1941
|
+
} catch (error) {
|
|
1942
|
+
logger.error({ err: describeError3(error) }, "Protocol adapter request handler threw");
|
|
1943
|
+
if (!reply.raw.headersSent) {
|
|
1944
|
+
reply.raw.statusCode = 500;
|
|
1945
|
+
reply.raw.end();
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
stopEnforcing();
|
|
1949
|
+
reply.hijack();
|
|
1950
|
+
} finally {
|
|
1951
|
+
inFlight -= 1;
|
|
1952
|
+
}
|
|
1953
|
+
};
|
|
1954
|
+
instance.all(mountPath, handler);
|
|
1955
|
+
instance.all(wildcard, handler);
|
|
1956
|
+
});
|
|
1957
|
+
}
|
|
1958
|
+
function enforceMountBodyLimit(req, res, maxBytes, logger) {
|
|
1959
|
+
const socket = req.socket;
|
|
1960
|
+
let total = 0;
|
|
1961
|
+
let stopped = false;
|
|
1962
|
+
const onData = (chunk) => {
|
|
1963
|
+
total += chunk.length;
|
|
1964
|
+
if (total > maxBytes) {
|
|
1965
|
+
stop();
|
|
1966
|
+
logger.warn({ maxBytes }, "Request body exceeded the mount body limit; connection closed");
|
|
1967
|
+
if (!res.headersSent) {
|
|
1968
|
+
const body = JSON.stringify({
|
|
1969
|
+
jsonrpc: "2.0",
|
|
1970
|
+
id: null,
|
|
1971
|
+
error: { code: -32600, message: `Request body exceeds the ${maxBytes}-byte mount limit` }
|
|
1972
|
+
});
|
|
1973
|
+
try {
|
|
1974
|
+
res.writeHead(413, {
|
|
1975
|
+
"content-type": "application/json",
|
|
1976
|
+
"content-length": Buffer.byteLength(body)
|
|
1977
|
+
});
|
|
1978
|
+
res.end(body, () => socket.destroy());
|
|
1979
|
+
return;
|
|
1980
|
+
} catch {
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
socket.destroy();
|
|
1984
|
+
}
|
|
1985
|
+
};
|
|
1986
|
+
const stop = () => {
|
|
1987
|
+
if (stopped) return;
|
|
1988
|
+
stopped = true;
|
|
1989
|
+
socket.removeListener("data", onData);
|
|
1990
|
+
socket.removeListener("close", stop);
|
|
1991
|
+
};
|
|
1992
|
+
socket.on("data", onData);
|
|
1993
|
+
socket.once("close", stop);
|
|
1994
|
+
return stop;
|
|
1995
|
+
}
|
|
1996
|
+
async function getAdapterHealth(runtime, clock) {
|
|
1997
|
+
if (runtime.startFailure) return runtime.startFailure;
|
|
1998
|
+
try {
|
|
1999
|
+
return await runtime.adapter.health();
|
|
2000
|
+
} catch (error) {
|
|
2001
|
+
return { status: "fail", detail: describeError3(error), checkedAt: clock.nowIso() };
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
async function stopAdapters(runtimes, logger) {
|
|
2005
|
+
for (const runtime of runtimes) {
|
|
2006
|
+
try {
|
|
2007
|
+
await runtime.adapter.stop();
|
|
2008
|
+
} catch (error) {
|
|
2009
|
+
logger.child({ adapter: runtime.adapter.name }).error({ err: describeError3(error) }, "Protocol adapter failed to stop cleanly");
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
function describeError3(error) {
|
|
2014
|
+
return error instanceof Error ? error.message : String(error);
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
// src/gateway/public-resource.ts
|
|
2018
|
+
function toPublicResource(resource) {
|
|
2019
|
+
return {
|
|
2020
|
+
id: resource.id,
|
|
2021
|
+
name: resource.name,
|
|
2022
|
+
...resource.description !== void 0 ? { description: resource.description } : {},
|
|
2023
|
+
...resource.inputSchema !== void 0 ? { inputSchema: resource.inputSchema } : {},
|
|
2024
|
+
...resource.outputSchema !== void 0 ? { outputSchema: resource.outputSchema } : {},
|
|
2025
|
+
method: resource.handler.method,
|
|
2026
|
+
pricing: resource.pricing,
|
|
2027
|
+
exposedVia: resource.exposedVia,
|
|
2028
|
+
paymentMethods: resource.paymentMethods
|
|
2029
|
+
};
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
// src/gateway/readiness.ts
|
|
2033
|
+
var STORE_DETAIL = {
|
|
2034
|
+
pass: void 0,
|
|
2035
|
+
warn: "store-degraded",
|
|
2036
|
+
fail: "store-unwritable"
|
|
2037
|
+
};
|
|
2038
|
+
var ADAPTER_DETAIL = {
|
|
2039
|
+
pass: void 0,
|
|
2040
|
+
warn: "adapter-degraded",
|
|
2041
|
+
fail: "adapter-unreachable"
|
|
2042
|
+
};
|
|
2043
|
+
var PAYMENT_PROVIDER_DETAIL = {
|
|
2044
|
+
pass: void 0,
|
|
2045
|
+
warn: "payment-provider-degraded",
|
|
2046
|
+
fail: "payment-provider-unreachable"
|
|
2047
|
+
};
|
|
2048
|
+
async function checkReadiness(options) {
|
|
2049
|
+
let storeHealth;
|
|
2050
|
+
let storeThrew = false;
|
|
2051
|
+
try {
|
|
2052
|
+
storeHealth = await options.store.health();
|
|
2053
|
+
} catch (error) {
|
|
2054
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2055
|
+
options.logger.error({ err: message }, "store.health() threw");
|
|
2056
|
+
storeHealth = { status: "fail", checkedAt: options.clock.nowIso() };
|
|
2057
|
+
storeThrew = true;
|
|
2058
|
+
}
|
|
2059
|
+
if (storeHealth.detail !== void 0) {
|
|
2060
|
+
options.logger.debug(
|
|
2061
|
+
{ detail: storeHealth.detail },
|
|
2062
|
+
"store health detail (not sent to the client)"
|
|
2063
|
+
);
|
|
2064
|
+
}
|
|
2065
|
+
const adapterChecks = await Promise.all(
|
|
2066
|
+
options.adapterRuntimes.map(async (runtime) => {
|
|
2067
|
+
const health = await getAdapterHealth(runtime, options.clock);
|
|
2068
|
+
if (health.detail !== void 0) {
|
|
2069
|
+
options.logger.debug(
|
|
2070
|
+
{ adapter: runtime.adapter.name, detail: health.detail },
|
|
2071
|
+
"adapter health detail (not sent to the client)"
|
|
2072
|
+
);
|
|
2073
|
+
}
|
|
2074
|
+
const detail = ADAPTER_DETAIL[health.status];
|
|
2075
|
+
return {
|
|
2076
|
+
name: runtime.adapter.name,
|
|
2077
|
+
status: health.status,
|
|
2078
|
+
...detail !== void 0 ? { detail } : {}
|
|
2079
|
+
};
|
|
2080
|
+
})
|
|
2081
|
+
);
|
|
2082
|
+
const paymentProviderChecks = await Promise.all(
|
|
2083
|
+
options.paymentProviders.map(async (provider) => {
|
|
2084
|
+
let health;
|
|
2085
|
+
try {
|
|
2086
|
+
health = await provider.health();
|
|
2087
|
+
} catch (error) {
|
|
2088
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2089
|
+
options.logger.error(
|
|
2090
|
+
{ err: message, provider: provider.name },
|
|
2091
|
+
"payment provider health() threw"
|
|
2092
|
+
);
|
|
2093
|
+
health = { status: "fail", checkedAt: options.clock.nowIso() };
|
|
2094
|
+
}
|
|
2095
|
+
if (health.detail !== void 0) {
|
|
2096
|
+
options.logger.debug(
|
|
2097
|
+
{ provider: provider.name, detail: health.detail },
|
|
2098
|
+
"payment provider health detail (not sent to the client)"
|
|
2099
|
+
);
|
|
2100
|
+
}
|
|
2101
|
+
const detail = PAYMENT_PROVIDER_DETAIL[health.status];
|
|
2102
|
+
return {
|
|
2103
|
+
name: provider.name,
|
|
2104
|
+
status: health.status,
|
|
2105
|
+
...detail !== void 0 ? { detail } : {}
|
|
2106
|
+
};
|
|
2107
|
+
})
|
|
2108
|
+
);
|
|
2109
|
+
const storeReady = storeHealth.status !== "fail";
|
|
2110
|
+
const adaptersReady = adapterChecks.every((check) => check.status !== "fail");
|
|
2111
|
+
const paymentProvidersReady = paymentProviderChecks.every((check) => check.status !== "fail");
|
|
2112
|
+
const storeDetail = storeThrew ? "store-unreachable" : STORE_DETAIL[storeHealth.status];
|
|
2113
|
+
return {
|
|
2114
|
+
ready: storeReady && adaptersReady && paymentProvidersReady,
|
|
2115
|
+
store: {
|
|
2116
|
+
name: "store",
|
|
2117
|
+
status: storeHealth.status,
|
|
2118
|
+
...storeDetail !== void 0 ? { detail: storeDetail } : {}
|
|
2119
|
+
},
|
|
2120
|
+
adapters: adapterChecks,
|
|
2121
|
+
paymentProviders: paymentProviderChecks
|
|
2122
|
+
};
|
|
2123
|
+
}
|
|
2124
|
+
var READINESS_TTL_MS = 2e3;
|
|
2125
|
+
function createReadinessProbe(options, ttlMs = READINESS_TTL_MS) {
|
|
2126
|
+
let cached;
|
|
2127
|
+
let inFlight;
|
|
2128
|
+
return {
|
|
2129
|
+
async check() {
|
|
2130
|
+
const now = options.clock.monotonicMs();
|
|
2131
|
+
if (cached && now - cached.at < ttlMs) return cached.result;
|
|
2132
|
+
if (inFlight) return inFlight;
|
|
2133
|
+
inFlight = checkReadiness(options).then((result) => {
|
|
2134
|
+
cached = { result, at: options.clock.monotonicMs() };
|
|
2135
|
+
return result;
|
|
2136
|
+
}).finally(() => {
|
|
2137
|
+
inFlight = void 0;
|
|
2138
|
+
});
|
|
2139
|
+
return inFlight;
|
|
2140
|
+
}
|
|
2141
|
+
};
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
// src/gateway/well-known.ts
|
|
2145
|
+
var GATEWAY_SUPPORTED_SPEC = "agent-commerce/v0.1.0-alpha";
|
|
2146
|
+
async function buildWellKnownDocument(options) {
|
|
2147
|
+
const adapters = await Promise.all(
|
|
2148
|
+
options.adapterRuntimes.map(async (runtime) => ({
|
|
2149
|
+
...runtime.adapter.descriptor,
|
|
2150
|
+
health: await getAdapterHealth(runtime, options.clock)
|
|
2151
|
+
}))
|
|
2152
|
+
);
|
|
2153
|
+
const x402 = options.config.payments.x402;
|
|
2154
|
+
return {
|
|
2155
|
+
gateway: {
|
|
2156
|
+
implementationVersion: PACKAGE_VERSION,
|
|
2157
|
+
supportedSpec: GATEWAY_SUPPORTED_SPEC
|
|
2158
|
+
},
|
|
2159
|
+
merchant: options.config.merchant,
|
|
2160
|
+
protocols: options.config.protocols,
|
|
2161
|
+
adapters,
|
|
2162
|
+
paymentProviders: options.paymentProviders.map((provider) => provider.descriptor),
|
|
2163
|
+
store: options.store.descriptor,
|
|
2164
|
+
payments: {
|
|
2165
|
+
...x402 !== void 0 ? {
|
|
2166
|
+
x402: {
|
|
2167
|
+
enabled: x402.enabled,
|
|
2168
|
+
network: x402.network,
|
|
2169
|
+
asset: x402.asset,
|
|
2170
|
+
assetName: x402.assetName,
|
|
2171
|
+
assetVersion: x402.assetVersion,
|
|
2172
|
+
assetDecimals: x402.assetDecimals,
|
|
2173
|
+
payTo: x402.payTo,
|
|
2174
|
+
maxTimeoutSeconds: x402.maxTimeoutSeconds,
|
|
2175
|
+
// Never the signerPrivateKey — only whether the facilitator is
|
|
2176
|
+
// local (dev-chain only) or remote, plus its (non-secret) URL.
|
|
2177
|
+
facilitator: x402.facilitator.mode === "local" ? { mode: "local" } : { mode: "remote", url: x402.facilitator.url }
|
|
2178
|
+
}
|
|
2179
|
+
} : {}
|
|
2180
|
+
}
|
|
2181
|
+
};
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
// src/gateway/routes.ts
|
|
2185
|
+
var SSE_HEARTBEAT_MS = 15e3;
|
|
2186
|
+
var MAX_SSE_SUBSCRIBERS = 32;
|
|
2187
|
+
function registerRoutes(options) {
|
|
2188
|
+
const { server } = options;
|
|
2189
|
+
const readinessProbe = createReadinessProbe({
|
|
2190
|
+
store: options.store,
|
|
2191
|
+
adapterRuntimes: options.adapterRuntimes,
|
|
2192
|
+
paymentProviders: options.paymentProviders,
|
|
2193
|
+
clock: options.clock,
|
|
2194
|
+
logger: options.logger
|
|
2195
|
+
});
|
|
2196
|
+
server.get("/health", async () => ({ status: "ok" }));
|
|
2197
|
+
server.get("/ready", async (_request, reply) => {
|
|
2198
|
+
const readiness = await readinessProbe.check();
|
|
2199
|
+
reply.status(readiness.ready ? 200 : 503);
|
|
2200
|
+
return readiness;
|
|
2201
|
+
});
|
|
2202
|
+
server.get(
|
|
2203
|
+
"/.well-known/agent-commerce",
|
|
2204
|
+
async () => buildWellKnownDocument({
|
|
2205
|
+
config: options.config,
|
|
2206
|
+
paymentProviders: options.paymentProviders,
|
|
2207
|
+
store: options.store,
|
|
2208
|
+
adapterRuntimes: options.adapterRuntimes,
|
|
2209
|
+
clock: options.clock
|
|
2210
|
+
})
|
|
2211
|
+
);
|
|
2212
|
+
server.get("/api/resources", async () => ({
|
|
2213
|
+
resources: options.resources.list().map(toPublicResource)
|
|
2214
|
+
}));
|
|
2215
|
+
server.post("/api/resources/:id/invoke", async (request, reply) => {
|
|
2216
|
+
await handleInvoke(request, reply, options);
|
|
2217
|
+
});
|
|
2218
|
+
const receiptsTokenHook = buildOperatorTokenHook(options.config.server.adminToken);
|
|
2219
|
+
const eventsTokenHook = buildOperatorTokenHook(options.config.server.adminToken);
|
|
2220
|
+
const eventsStreamTokenHook = buildOperatorTokenHook(options.config.server.adminToken);
|
|
2221
|
+
server.get("/api/receipts", { onRequest: receiptsTokenHook }, async (request, reply) => {
|
|
2222
|
+
try {
|
|
2223
|
+
const limit = parseLimit(request.query["limit"]);
|
|
2224
|
+
return { receipts: await options.store.listReceipts(limit !== void 0 ? { limit } : {}) };
|
|
2225
|
+
} catch (error) {
|
|
2226
|
+
const commerceError = toCommerceError(error);
|
|
2227
|
+
reply.status(commerceError.httpStatus).send(toErrorEnvelope(commerceError));
|
|
2228
|
+
return void 0;
|
|
2229
|
+
}
|
|
2230
|
+
});
|
|
2231
|
+
server.get("/api/events", { onRequest: eventsTokenHook }, async (request, reply) => {
|
|
2232
|
+
try {
|
|
2233
|
+
const limit = parseLimit(request.query["limit"]);
|
|
2234
|
+
return { events: await options.store.listEvents(limit !== void 0 ? { limit } : {}) };
|
|
2235
|
+
} catch (error) {
|
|
2236
|
+
const commerceError = toCommerceError(error);
|
|
2237
|
+
reply.status(commerceError.httpStatus).send(toErrorEnvelope(commerceError));
|
|
2238
|
+
return void 0;
|
|
2239
|
+
}
|
|
2240
|
+
});
|
|
2241
|
+
let activeSseSubscribers = 0;
|
|
2242
|
+
server.get("/api/events/stream", { onRequest: eventsStreamTokenHook }, (request, reply) => {
|
|
2243
|
+
if (activeSseSubscribers >= MAX_SSE_SUBSCRIBERS) {
|
|
2244
|
+
reply.status(503).send({
|
|
2245
|
+
status: "error",
|
|
2246
|
+
code: "UNAVAILABLE",
|
|
2247
|
+
message: "Too many event-stream subscribers"
|
|
2248
|
+
});
|
|
2249
|
+
return;
|
|
2250
|
+
}
|
|
2251
|
+
reply.raw.writeHead(200, {
|
|
2252
|
+
"Content-Type": "text/event-stream",
|
|
2253
|
+
"Cache-Control": "no-cache",
|
|
2254
|
+
Connection: "keep-alive"
|
|
2255
|
+
});
|
|
2256
|
+
activeSseSubscribers += 1;
|
|
2257
|
+
reply.raw.write(": connected\n\n");
|
|
2258
|
+
let writable = true;
|
|
2259
|
+
reply.raw.on("drain", () => {
|
|
2260
|
+
writable = true;
|
|
2261
|
+
});
|
|
2262
|
+
const unsubscribe = options.eventBus.subscribe((event) => {
|
|
2263
|
+
queueMicrotask(() => {
|
|
2264
|
+
if (!writable) return;
|
|
2265
|
+
writable = reply.raw.write(`data: ${JSON.stringify(event)}
|
|
2266
|
+
|
|
2267
|
+
`);
|
|
2268
|
+
});
|
|
2269
|
+
});
|
|
2270
|
+
const heartbeat = setInterval(() => {
|
|
2271
|
+
if (!writable) return;
|
|
2272
|
+
writable = reply.raw.write(": heartbeat\n\n");
|
|
2273
|
+
}, SSE_HEARTBEAT_MS);
|
|
2274
|
+
heartbeat.unref();
|
|
2275
|
+
let cleanedUp = false;
|
|
2276
|
+
const cleanup = () => {
|
|
2277
|
+
if (cleanedUp) return;
|
|
2278
|
+
cleanedUp = true;
|
|
2279
|
+
clearInterval(heartbeat);
|
|
2280
|
+
unsubscribe();
|
|
2281
|
+
activeSseSubscribers -= 1;
|
|
2282
|
+
};
|
|
2283
|
+
request.raw.on("close", cleanup);
|
|
2284
|
+
reply.raw.on("error", cleanup);
|
|
2285
|
+
reply.hijack();
|
|
2286
|
+
});
|
|
2287
|
+
}
|
|
2288
|
+
async function handleInvoke(request, reply, options) {
|
|
2289
|
+
const resourceId = request.params.id;
|
|
2290
|
+
try {
|
|
2291
|
+
const resource = options.resources.get(resourceId);
|
|
2292
|
+
if (!resource || !resource.exposedVia.includes("http")) {
|
|
2293
|
+
throw new CommerceError("RESOURCE_NOT_FOUND", `Resource "${resourceId}" was not found`, {
|
|
2294
|
+
requestId: request.id,
|
|
2295
|
+
resourceId
|
|
2296
|
+
});
|
|
2297
|
+
}
|
|
2298
|
+
const paymentHeader = request.headers[PAYMENT_HEADER];
|
|
2299
|
+
const paymentValue = Array.isArray(paymentHeader) ? paymentHeader[0] : paymentHeader;
|
|
2300
|
+
const paymentMethod = resource.paymentMethods[0];
|
|
2301
|
+
const payment = paymentValue !== void 0 && paymentMethod !== void 0 ? { method: paymentMethod, payload: paymentValue } : void 0;
|
|
2302
|
+
const canonicalRequest = {
|
|
2303
|
+
requestId: request.id,
|
|
2304
|
+
resourceId,
|
|
2305
|
+
input: request.body ?? {},
|
|
2306
|
+
protocol: "http",
|
|
2307
|
+
receivedAt: options.clock.nowIso(),
|
|
2308
|
+
...payment !== void 0 ? { payment } : {}
|
|
2309
|
+
};
|
|
2310
|
+
const outcome = await options.pipeline.execute(canonicalRequest);
|
|
2311
|
+
if (outcome.kind === "payment-required") {
|
|
2312
|
+
reply.status(402).send(toPaymentRequiredEnvelope(outcome));
|
|
2313
|
+
return;
|
|
2314
|
+
}
|
|
2315
|
+
if (outcome.payment) {
|
|
2316
|
+
reply.header(PAYMENT_RESPONSE_HEADER, encodePaymentSummary(outcome.payment));
|
|
2317
|
+
}
|
|
2318
|
+
reply.status(outcome.backendStatus).send(outcome.body);
|
|
2319
|
+
} catch (error) {
|
|
2320
|
+
const commerceError = toCommerceError(error);
|
|
2321
|
+
const settledPayment = errorPaymentSummary(commerceError);
|
|
2322
|
+
if (settledPayment !== void 0) {
|
|
2323
|
+
reply.header(PAYMENT_RESPONSE_HEADER, encodePaymentSummary(settledPayment));
|
|
2324
|
+
}
|
|
2325
|
+
reply.status(commerceError.httpStatus).send(toErrorEnvelope(commerceError));
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
function errorPaymentSummary(error) {
|
|
2329
|
+
const payment = error.details?.["payment"];
|
|
2330
|
+
if (typeof payment !== "object" || payment === null) return void 0;
|
|
2331
|
+
const rec = payment;
|
|
2332
|
+
const { status, provider, amount, currency, externalReference } = rec;
|
|
2333
|
+
if (typeof status !== "string" || typeof provider !== "string" || typeof amount !== "string" || typeof currency !== "string") {
|
|
2334
|
+
return void 0;
|
|
2335
|
+
}
|
|
2336
|
+
return {
|
|
2337
|
+
status,
|
|
2338
|
+
provider,
|
|
2339
|
+
amount,
|
|
2340
|
+
currency,
|
|
2341
|
+
...typeof externalReference === "string" ? { externalReference } : {}
|
|
2342
|
+
};
|
|
2343
|
+
}
|
|
2344
|
+
function encodePaymentSummary(payment) {
|
|
2345
|
+
const summary = {
|
|
2346
|
+
status: payment.status,
|
|
2347
|
+
provider: payment.provider,
|
|
2348
|
+
amount: payment.amount,
|
|
2349
|
+
currency: payment.currency,
|
|
2350
|
+
...payment.externalReference !== void 0 ? { externalReference: payment.externalReference } : {}
|
|
2351
|
+
};
|
|
2352
|
+
return Buffer.from(JSON.stringify(summary), "utf8").toString("base64");
|
|
2353
|
+
}
|
|
2354
|
+
function parseLimit(value) {
|
|
2355
|
+
if (Array.isArray(value)) return parseLimit(value[0]);
|
|
2356
|
+
let n;
|
|
2357
|
+
if (typeof value === "number" && Number.isFinite(value)) n = Math.trunc(value);
|
|
2358
|
+
else if (typeof value === "string" && value.length > 0) {
|
|
2359
|
+
const parsed = Number(value);
|
|
2360
|
+
if (Number.isFinite(parsed)) n = Math.trunc(parsed);
|
|
2361
|
+
}
|
|
2362
|
+
if (n === void 0) return void 0;
|
|
2363
|
+
if (n <= 0) {
|
|
2364
|
+
throw new CommerceError(
|
|
2365
|
+
"INPUT_INVALID",
|
|
2366
|
+
`Query parameter "limit" must be a positive integer (got ${n})`,
|
|
2367
|
+
{
|
|
2368
|
+
details: { field: "limit" }
|
|
2369
|
+
}
|
|
2370
|
+
);
|
|
2371
|
+
}
|
|
2372
|
+
return n;
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
// src/gateway/server.ts
|
|
2376
|
+
var REQUEST_ID_HEADER = "x-request-id";
|
|
2377
|
+
var CLIENT_REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,64}$/;
|
|
2378
|
+
async function createGateway(options) {
|
|
2379
|
+
const clock = options.clock ?? systemClock;
|
|
2380
|
+
const ids = options.ids ?? createDefaultIdGenerator();
|
|
2381
|
+
const logger = options.logger ?? createGatewayLogger({ name: options.config.merchant.id }).core;
|
|
2382
|
+
const backend = options.backend ?? new HttpBackendExecutor({ logger });
|
|
2383
|
+
const resources = createResourceRegistry(options.config.resources);
|
|
2384
|
+
const eventBus = createEventBus({ store: options.store, logger });
|
|
2385
|
+
const pipeline = createExecutionPipeline({
|
|
2386
|
+
resources,
|
|
2387
|
+
paymentProviders: options.paymentProviders,
|
|
2388
|
+
store: options.store,
|
|
2389
|
+
backend,
|
|
2390
|
+
events: eventBus,
|
|
2391
|
+
logger,
|
|
2392
|
+
clock,
|
|
2393
|
+
ids
|
|
2394
|
+
});
|
|
2395
|
+
const server = Fastify({
|
|
2396
|
+
// One number for both surfaces — see adapters.ts's doc comment
|
|
2397
|
+
// on MOUNT_BODY_LIMIT_BYTES for why they're enforced two different ways.
|
|
2398
|
+
bodyLimit: MOUNT_BODY_LIMIT_BYTES,
|
|
2399
|
+
genReqId: (rawReq) => {
|
|
2400
|
+
const header = rawReq.headers[REQUEST_ID_HEADER];
|
|
2401
|
+
const value = Array.isArray(header) ? header[0] : header;
|
|
2402
|
+
return value !== void 0 && CLIENT_REQUEST_ID_PATTERN.test(value) ? value : ids.next("req");
|
|
2403
|
+
},
|
|
2404
|
+
logger: fastifyLoggerOptions({ name: `${options.config.merchant.id}-http` })
|
|
2405
|
+
});
|
|
2406
|
+
server.setNotFoundHandler(buildNotFoundHandler());
|
|
2407
|
+
server.addHook(
|
|
2408
|
+
"onRequest",
|
|
2409
|
+
buildAccessControlHook({
|
|
2410
|
+
publicBaseUrl: options.config.merchant.publicBaseUrl,
|
|
2411
|
+
allowedOrigins: options.config.server.allowedOrigins
|
|
2412
|
+
})
|
|
2413
|
+
);
|
|
2414
|
+
const adapterRuntimes = [];
|
|
2415
|
+
registerRoutes({
|
|
2416
|
+
server,
|
|
2417
|
+
config: options.config,
|
|
2418
|
+
pipeline,
|
|
2419
|
+
resources,
|
|
2420
|
+
store: options.store,
|
|
2421
|
+
paymentProviders: options.paymentProviders,
|
|
2422
|
+
eventBus,
|
|
2423
|
+
clock,
|
|
2424
|
+
adapterRuntimes,
|
|
2425
|
+
logger
|
|
2426
|
+
});
|
|
2427
|
+
const context = {
|
|
2428
|
+
pipeline,
|
|
2429
|
+
resources,
|
|
2430
|
+
events: eventBus,
|
|
2431
|
+
logger,
|
|
2432
|
+
clock,
|
|
2433
|
+
ids,
|
|
2434
|
+
publicBaseUrl: options.config.merchant.publicBaseUrl
|
|
2435
|
+
};
|
|
2436
|
+
const started = await startAndMountAdapters({
|
|
2437
|
+
server,
|
|
2438
|
+
adapters: options.protocolAdapters,
|
|
2439
|
+
context,
|
|
2440
|
+
logger,
|
|
2441
|
+
clock
|
|
2442
|
+
});
|
|
2443
|
+
adapterRuntimes.push(...started);
|
|
2444
|
+
await server.ready();
|
|
2445
|
+
return {
|
|
2446
|
+
pipeline,
|
|
2447
|
+
resources,
|
|
2448
|
+
async listen() {
|
|
2449
|
+
const url = await server.listen({
|
|
2450
|
+
port: options.config.server.port,
|
|
2451
|
+
host: options.config.server.host
|
|
2452
|
+
});
|
|
2453
|
+
return { url };
|
|
2454
|
+
},
|
|
2455
|
+
async close() {
|
|
2456
|
+
await stopAdapters(adapterRuntimes, logger);
|
|
2457
|
+
await server.close();
|
|
2458
|
+
},
|
|
2459
|
+
server
|
|
2460
|
+
};
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
// src/storage/receipts/redact.ts
|
|
2464
|
+
var SECRET_KEY_PATTERN = /private[-_ ]?key|secret|password|mnemonic|seed[-_ ]?phrase|api[-_ ]?key|authoriz(e|ation)|^auth$|auth[-_]?header|payment[-_ ]?proof|^proof$|x-payment|signature|signed[-_ ]?message|token|bearer|credential|cookie|session|jwt/i;
|
|
2465
|
+
var REDACTED = "[REDACTED]";
|
|
2466
|
+
function isSecretKey(key) {
|
|
2467
|
+
return SECRET_KEY_PATTERN.test(key);
|
|
2468
|
+
}
|
|
2469
|
+
function redact(value) {
|
|
2470
|
+
return redactValue(value);
|
|
2471
|
+
}
|
|
2472
|
+
function redactValue(value) {
|
|
2473
|
+
if (Array.isArray(value)) {
|
|
2474
|
+
return value.map(redactValue);
|
|
2475
|
+
}
|
|
2476
|
+
if (value !== null && typeof value === "object") {
|
|
2477
|
+
const out = {};
|
|
2478
|
+
for (const [key, inner] of Object.entries(value)) {
|
|
2479
|
+
out[key] = isSecretKey(key) ? REDACTED : redactValue(inner);
|
|
2480
|
+
}
|
|
2481
|
+
return out;
|
|
2482
|
+
}
|
|
2483
|
+
return value;
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
// src/storage/receipts/rows.ts
|
|
2487
|
+
function receiptToRow(receipt) {
|
|
2488
|
+
return {
|
|
2489
|
+
id: receipt.id,
|
|
2490
|
+
request_id: receipt.requestId,
|
|
2491
|
+
resource_id: receipt.resourceId,
|
|
2492
|
+
payment_json: receipt.payment !== void 0 ? JSON.stringify(redact(receipt.payment)) : null,
|
|
2493
|
+
delivered_at: receipt.deliveredAt,
|
|
2494
|
+
backend_status: receipt.backendStatus,
|
|
2495
|
+
duration_ms: receipt.durationMs !== void 0 ? receipt.durationMs : null,
|
|
2496
|
+
protocol: receipt.protocol !== void 0 ? receipt.protocol : null,
|
|
2497
|
+
metadata_json: receipt.metadata !== void 0 ? JSON.stringify(redact(receipt.metadata)) : null
|
|
2498
|
+
};
|
|
2499
|
+
}
|
|
2500
|
+
function rowToReceipt(row) {
|
|
2501
|
+
const payment = row.payment_json !== null ? JSON.parse(row.payment_json) : void 0;
|
|
2502
|
+
const metadata = row.metadata_json !== null ? JSON.parse(row.metadata_json) : void 0;
|
|
2503
|
+
return {
|
|
2504
|
+
id: row.id,
|
|
2505
|
+
requestId: row.request_id,
|
|
2506
|
+
resourceId: row.resource_id,
|
|
2507
|
+
deliveredAt: row.delivered_at,
|
|
2508
|
+
backendStatus: row.backend_status,
|
|
2509
|
+
...payment !== void 0 ? { payment } : {},
|
|
2510
|
+
...row.duration_ms !== null ? { durationMs: row.duration_ms } : {},
|
|
2511
|
+
...row.protocol !== null ? { protocol: row.protocol } : {},
|
|
2512
|
+
...metadata !== void 0 ? { metadata } : {}
|
|
2513
|
+
};
|
|
2514
|
+
}
|
|
2515
|
+
function eventToRow(event) {
|
|
2516
|
+
return {
|
|
2517
|
+
id: event.id,
|
|
2518
|
+
type: event.type,
|
|
2519
|
+
request_id: event.requestId,
|
|
2520
|
+
resource_id: event.resourceId !== void 0 ? event.resourceId : null,
|
|
2521
|
+
at: event.at,
|
|
2522
|
+
adapter: event.adapter !== void 0 ? event.adapter : null,
|
|
2523
|
+
payment_provider: event.paymentProvider !== void 0 ? event.paymentProvider : null,
|
|
2524
|
+
duration_ms: event.durationMs !== void 0 ? event.durationMs : null,
|
|
2525
|
+
status: event.status !== void 0 ? event.status : null,
|
|
2526
|
+
data_json: event.data !== void 0 ? JSON.stringify(redact(event.data)) : null
|
|
2527
|
+
};
|
|
2528
|
+
}
|
|
2529
|
+
function rowToEvent(row) {
|
|
2530
|
+
const data = row.data_json !== null ? JSON.parse(row.data_json) : void 0;
|
|
2531
|
+
return {
|
|
2532
|
+
id: row.id,
|
|
2533
|
+
type: row.type,
|
|
2534
|
+
requestId: row.request_id,
|
|
2535
|
+
at: row.at,
|
|
2536
|
+
...row.resource_id !== null ? { resourceId: row.resource_id } : {},
|
|
2537
|
+
...row.adapter !== null ? { adapter: row.adapter } : {},
|
|
2538
|
+
...row.payment_provider !== null ? { paymentProvider: row.payment_provider } : {},
|
|
2539
|
+
...row.duration_ms !== null ? { durationMs: row.duration_ms } : {},
|
|
2540
|
+
...row.status !== null ? { status: row.status } : {},
|
|
2541
|
+
...data !== void 0 ? { data } : {}
|
|
2542
|
+
};
|
|
2543
|
+
}
|
|
2544
|
+
function rowToPaymentAttempt(row) {
|
|
2545
|
+
return {
|
|
2546
|
+
id: row.id,
|
|
2547
|
+
requestId: row.request_id,
|
|
2548
|
+
resourceId: row.resource_id,
|
|
2549
|
+
provider: row.provider,
|
|
2550
|
+
replayKey: row.replay_key,
|
|
2551
|
+
status: row.status,
|
|
2552
|
+
amount: row.amount,
|
|
2553
|
+
currency: row.currency,
|
|
2554
|
+
...row.payer !== null ? { payer: row.payer } : {},
|
|
2555
|
+
...row.payee !== null ? { payee: row.payee } : {},
|
|
2556
|
+
...row.external_reference !== null ? { externalReference: row.external_reference } : {},
|
|
2557
|
+
...row.rejection_reason !== null ? { rejectionReason: row.rejection_reason } : {},
|
|
2558
|
+
createdAt: row.created_at,
|
|
2559
|
+
updatedAt: row.updated_at
|
|
2560
|
+
};
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
// src/storage/receipts/schema.ts
|
|
2564
|
+
var MIGRATIONS = [
|
|
2565
|
+
{
|
|
2566
|
+
version: 1,
|
|
2567
|
+
up(db) {
|
|
2568
|
+
db.exec(`
|
|
2569
|
+
CREATE TABLE IF NOT EXISTS receipts (
|
|
2570
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2571
|
+
id TEXT NOT NULL UNIQUE,
|
|
2572
|
+
request_id TEXT NOT NULL,
|
|
2573
|
+
resource_id TEXT NOT NULL,
|
|
2574
|
+
payment_json TEXT,
|
|
2575
|
+
delivered_at TEXT NOT NULL,
|
|
2576
|
+
backend_status INTEGER NOT NULL,
|
|
2577
|
+
duration_ms INTEGER,
|
|
2578
|
+
protocol TEXT,
|
|
2579
|
+
metadata_json TEXT
|
|
2580
|
+
);
|
|
2581
|
+
CREATE INDEX IF NOT EXISTS idx_receipts_request_id ON receipts(request_id);
|
|
2582
|
+
CREATE INDEX IF NOT EXISTS idx_receipts_delivered_at ON receipts(delivered_at);
|
|
2583
|
+
|
|
2584
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
2585
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2586
|
+
id TEXT NOT NULL UNIQUE,
|
|
2587
|
+
type TEXT NOT NULL,
|
|
2588
|
+
request_id TEXT NOT NULL,
|
|
2589
|
+
resource_id TEXT,
|
|
2590
|
+
at TEXT NOT NULL,
|
|
2591
|
+
adapter TEXT,
|
|
2592
|
+
payment_provider TEXT,
|
|
2593
|
+
duration_ms INTEGER,
|
|
2594
|
+
status TEXT,
|
|
2595
|
+
data_json TEXT
|
|
2596
|
+
);
|
|
2597
|
+
CREATE INDEX IF NOT EXISTS idx_events_request_id ON events(request_id);
|
|
2598
|
+
CREATE INDEX IF NOT EXISTS idx_events_at ON events(at);
|
|
2599
|
+
|
|
2600
|
+
CREATE TABLE IF NOT EXISTS payment_attempts (
|
|
2601
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2602
|
+
id TEXT NOT NULL UNIQUE,
|
|
2603
|
+
request_id TEXT NOT NULL,
|
|
2604
|
+
resource_id TEXT NOT NULL,
|
|
2605
|
+
provider TEXT NOT NULL,
|
|
2606
|
+
replay_key TEXT NOT NULL UNIQUE,
|
|
2607
|
+
status TEXT NOT NULL,
|
|
2608
|
+
amount TEXT NOT NULL,
|
|
2609
|
+
currency TEXT NOT NULL,
|
|
2610
|
+
payer TEXT,
|
|
2611
|
+
payee TEXT,
|
|
2612
|
+
external_reference TEXT,
|
|
2613
|
+
rejection_reason TEXT,
|
|
2614
|
+
created_at TEXT NOT NULL,
|
|
2615
|
+
updated_at TEXT NOT NULL
|
|
2616
|
+
);
|
|
2617
|
+
CREATE INDEX IF NOT EXISTS idx_payment_attempts_request_id ON payment_attempts(request_id);
|
|
2618
|
+
CREATE INDEX IF NOT EXISTS idx_payment_attempts_created_at ON payment_attempts(created_at);
|
|
2619
|
+
`);
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
];
|
|
2623
|
+
var SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
|
|
2624
|
+
function migrate(db) {
|
|
2625
|
+
const current = db.pragma("user_version", { simple: true });
|
|
2626
|
+
for (const migration of MIGRATIONS) {
|
|
2627
|
+
if (migration.version <= current) continue;
|
|
2628
|
+
const apply = db.transaction(() => {
|
|
2629
|
+
migration.up(db);
|
|
2630
|
+
db.pragma(`user_version = ${migration.version}`);
|
|
2631
|
+
});
|
|
2632
|
+
apply();
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
// src/storage/receipts/store.ts
|
|
2637
|
+
var DEFAULT_LIST_LIMIT = 50;
|
|
2638
|
+
var MAX_LIST_LIMIT = 500;
|
|
2639
|
+
function clampListLimit(limit) {
|
|
2640
|
+
const base = limit !== void 0 && Number.isFinite(limit) ? limit : DEFAULT_LIST_LIMIT;
|
|
2641
|
+
return Math.min(Math.max(Math.trunc(base), 1), MAX_LIST_LIMIT);
|
|
2642
|
+
}
|
|
2643
|
+
var defaultIds = {
|
|
2644
|
+
next: (prefix) => prefix !== void 0 ? `${prefix}_${randomUUID()}` : randomUUID()
|
|
2645
|
+
};
|
|
2646
|
+
function isUniqueConstraintOn(err, column) {
|
|
2647
|
+
return err instanceof Error && "code" in err && err.code === "SQLITE_CONSTRAINT_UNIQUE" && err.message.includes(column);
|
|
2648
|
+
}
|
|
2649
|
+
function restrictDatabasePermissions(path2, logger) {
|
|
2650
|
+
for (const file of [path2, `${path2}-wal`, `${path2}-shm`]) {
|
|
2651
|
+
try {
|
|
2652
|
+
chmodSync(file, 384);
|
|
2653
|
+
} catch (error) {
|
|
2654
|
+
const code = error.code;
|
|
2655
|
+
if (code === "ENOENT") continue;
|
|
2656
|
+
logger.warn(
|
|
2657
|
+
{ file, error: code ?? "unknown" },
|
|
2658
|
+
"could not restrict receipt database permissions to owner-only"
|
|
2659
|
+
);
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
}
|
|
2663
|
+
function createSqliteReceiptStore(options) {
|
|
2664
|
+
const logger = options.logger ?? NOOP_LOGGER;
|
|
2665
|
+
const clock = options.clock ?? systemClock;
|
|
2666
|
+
const ids = options.ids ?? defaultIds;
|
|
2667
|
+
const path2 = options.path;
|
|
2668
|
+
const isFileBacked = path2 !== ":memory:";
|
|
2669
|
+
if (isFileBacked) {
|
|
2670
|
+
const dir = dirname(path2);
|
|
2671
|
+
if (dir !== "" && dir !== "." && !existsSync(dir)) {
|
|
2672
|
+
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
if (isFileBacked && existsSync(path2)) {
|
|
2676
|
+
try {
|
|
2677
|
+
accessSync(path2, constants.W_OK);
|
|
2678
|
+
} catch {
|
|
2679
|
+
throw new CommerceError(
|
|
2680
|
+
"STORAGE_ERROR",
|
|
2681
|
+
`Receipt database "${path2}" exists but is not writable by this process (uid ${typeof process.getuid === "function" ? process.getuid() : "unknown"}). If this is a container that recently changed user, an existing volume may still be owned by the previous one \u2014 recreate it (docker compose down -v) or fix its ownership.`,
|
|
2682
|
+
{ details: { path: path2 } }
|
|
2683
|
+
);
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
2686
|
+
const db = new Database(path2);
|
|
2687
|
+
db.pragma("journal_mode = WAL");
|
|
2688
|
+
db.pragma("foreign_keys = ON");
|
|
2689
|
+
migrate(db);
|
|
2690
|
+
if (isFileBacked) restrictDatabasePermissions(path2, logger);
|
|
2691
|
+
const insertReceiptStmt = db.prepare(
|
|
2692
|
+
`INSERT INTO receipts (id, request_id, resource_id, payment_json, delivered_at, backend_status, duration_ms, protocol, metadata_json)
|
|
2693
|
+
VALUES (@id, @request_id, @resource_id, @payment_json, @delivered_at, @backend_status, @duration_ms, @protocol, @metadata_json)`
|
|
2694
|
+
);
|
|
2695
|
+
const getReceiptStmt = db.prepare("SELECT * FROM receipts WHERE id = ?");
|
|
2696
|
+
const listReceiptsStmt = db.prepare(
|
|
2697
|
+
"SELECT * FROM receipts ORDER BY delivered_at DESC, seq DESC LIMIT ?"
|
|
2698
|
+
);
|
|
2699
|
+
const listReceiptsByRequestStmt = db.prepare(
|
|
2700
|
+
"SELECT * FROM receipts WHERE request_id = ? ORDER BY delivered_at DESC, seq DESC LIMIT ?"
|
|
2701
|
+
);
|
|
2702
|
+
const countReceiptsStmt = db.prepare(
|
|
2703
|
+
"SELECT COUNT(*) AS count FROM receipts"
|
|
2704
|
+
);
|
|
2705
|
+
const countUndeliveredReceiptsStmt = db.prepare(
|
|
2706
|
+
"SELECT COUNT(*) AS count FROM receipts WHERE backend_status NOT BETWEEN 200 AND 299"
|
|
2707
|
+
);
|
|
2708
|
+
const insertEventStmt = db.prepare(
|
|
2709
|
+
`INSERT INTO events (id, type, request_id, resource_id, at, adapter, payment_provider, duration_ms, status, data_json)
|
|
2710
|
+
VALUES (@id, @type, @request_id, @resource_id, @at, @adapter, @payment_provider, @duration_ms, @status, @data_json)`
|
|
2711
|
+
);
|
|
2712
|
+
const listEventsStmt = db.prepare(
|
|
2713
|
+
"SELECT * FROM events ORDER BY at DESC, seq DESC LIMIT ?"
|
|
2714
|
+
);
|
|
2715
|
+
const listEventsByRequestStmt = db.prepare(
|
|
2716
|
+
"SELECT * FROM events WHERE request_id = ? ORDER BY at DESC, seq DESC LIMIT ?"
|
|
2717
|
+
);
|
|
2718
|
+
const insertPaymentAttemptStmt = db.prepare(
|
|
2719
|
+
`INSERT INTO payment_attempts (id, request_id, resource_id, provider, replay_key, status, amount, currency, payer, payee, external_reference, rejection_reason, created_at, updated_at)
|
|
2720
|
+
VALUES (@id, @request_id, @resource_id, @provider, @replay_key, @status, @amount, @currency, @payer, @payee, @external_reference, @rejection_reason, @created_at, @updated_at)`
|
|
2721
|
+
);
|
|
2722
|
+
const getPaymentAttemptByIdStmt = db.prepare(
|
|
2723
|
+
"SELECT * FROM payment_attempts WHERE id = ?"
|
|
2724
|
+
);
|
|
2725
|
+
const updatePaymentAttemptStmt = db.prepare(
|
|
2726
|
+
`UPDATE payment_attempts SET status = @status,
|
|
2727
|
+
external_reference = COALESCE(@external_reference, external_reference),
|
|
2728
|
+
rejection_reason = COALESCE(@rejection_reason, rejection_reason),
|
|
2729
|
+
updated_at = @updated_at
|
|
2730
|
+
WHERE replay_key = @replay_key`
|
|
2731
|
+
);
|
|
2732
|
+
const listPaymentAttemptsStmt = db.prepare(
|
|
2733
|
+
"SELECT * FROM payment_attempts ORDER BY created_at DESC, seq DESC LIMIT ?"
|
|
2734
|
+
);
|
|
2735
|
+
const listPaymentAttemptsByRequestStmt = db.prepare(
|
|
2736
|
+
"SELECT * FROM payment_attempts WHERE request_id = ? ORDER BY created_at DESC, seq DESC LIMIT ?"
|
|
2737
|
+
);
|
|
2738
|
+
let closed = false;
|
|
2739
|
+
const descriptor = {
|
|
2740
|
+
name: "sqlite-receipt-store",
|
|
2741
|
+
kind: "storage",
|
|
2742
|
+
implementationVersion: PACKAGE_VERSION,
|
|
2743
|
+
supportedSpec: `sqlite-schema-v${SCHEMA_VERSION}`,
|
|
2744
|
+
capabilities: ["receipts", "events", "payment-attempts"],
|
|
2745
|
+
status: "stable"
|
|
2746
|
+
};
|
|
2747
|
+
const store = {
|
|
2748
|
+
async init() {
|
|
2749
|
+
const version = db.pragma("user_version", { simple: true });
|
|
2750
|
+
if (version !== SCHEMA_VERSION) {
|
|
2751
|
+
throw new CommerceError(
|
|
2752
|
+
"STORAGE_ERROR",
|
|
2753
|
+
`Receipt store schema version mismatch (found ${version}, expected ${SCHEMA_VERSION})`
|
|
2754
|
+
);
|
|
2755
|
+
}
|
|
2756
|
+
},
|
|
2757
|
+
async appendEvent(event) {
|
|
2758
|
+
try {
|
|
2759
|
+
insertEventStmt.run(eventToRow(event));
|
|
2760
|
+
} catch (err) {
|
|
2761
|
+
logger.warn(
|
|
2762
|
+
{
|
|
2763
|
+
err: err instanceof Error ? err.message : String(err),
|
|
2764
|
+
eventId: event.id,
|
|
2765
|
+
requestId: event.requestId
|
|
2766
|
+
},
|
|
2767
|
+
"receipt-store: failed to persist commerce event"
|
|
2768
|
+
);
|
|
2769
|
+
}
|
|
2770
|
+
},
|
|
2771
|
+
async reservePaymentAttempt(reservation) {
|
|
2772
|
+
const now = clock.nowIso();
|
|
2773
|
+
const id = ids.next("attempt");
|
|
2774
|
+
try {
|
|
2775
|
+
insertPaymentAttemptStmt.run({
|
|
2776
|
+
id,
|
|
2777
|
+
request_id: reservation.requestId,
|
|
2778
|
+
resource_id: reservation.resourceId,
|
|
2779
|
+
provider: reservation.provider,
|
|
2780
|
+
replay_key: reservation.replayKey,
|
|
2781
|
+
status: "reserved",
|
|
2782
|
+
amount: reservation.amount,
|
|
2783
|
+
currency: reservation.currency,
|
|
2784
|
+
payer: reservation.payer ?? null,
|
|
2785
|
+
payee: reservation.payee ?? null,
|
|
2786
|
+
external_reference: null,
|
|
2787
|
+
rejection_reason: null,
|
|
2788
|
+
created_at: now,
|
|
2789
|
+
updated_at: now
|
|
2790
|
+
});
|
|
2791
|
+
} catch (err) {
|
|
2792
|
+
if (isUniqueConstraintOn(err, "payment_attempts.replay_key")) {
|
|
2793
|
+
throw new CommerceError(
|
|
2794
|
+
"PAYMENT_REPLAYED",
|
|
2795
|
+
`Payment authorisation has already been used (replayKey=${reservation.replayKey})`,
|
|
2796
|
+
{ requestId: reservation.requestId, resourceId: reservation.resourceId }
|
|
2797
|
+
);
|
|
2798
|
+
}
|
|
2799
|
+
throw toCommerceError(err, "STORAGE_ERROR", "Failed to reserve payment attempt");
|
|
2800
|
+
}
|
|
2801
|
+
const row = getPaymentAttemptByIdStmt.get(id);
|
|
2802
|
+
if (row === void 0) {
|
|
2803
|
+
throw new CommerceError(
|
|
2804
|
+
"STORAGE_ERROR",
|
|
2805
|
+
"Payment attempt vanished immediately after insert"
|
|
2806
|
+
);
|
|
2807
|
+
}
|
|
2808
|
+
return rowToPaymentAttempt(row);
|
|
2809
|
+
},
|
|
2810
|
+
async updatePaymentAttempt(update) {
|
|
2811
|
+
try {
|
|
2812
|
+
const result = updatePaymentAttemptStmt.run({
|
|
2813
|
+
replay_key: update.replayKey,
|
|
2814
|
+
status: update.status,
|
|
2815
|
+
external_reference: update.externalReference ?? null,
|
|
2816
|
+
rejection_reason: update.rejectionReason ?? null,
|
|
2817
|
+
updated_at: clock.nowIso()
|
|
2818
|
+
});
|
|
2819
|
+
if (result.changes === 0) {
|
|
2820
|
+
throw new CommerceError(
|
|
2821
|
+
"STORAGE_ERROR",
|
|
2822
|
+
`No payment attempt found for replayKey=${update.replayKey}`
|
|
2823
|
+
);
|
|
2824
|
+
}
|
|
2825
|
+
} catch (err) {
|
|
2826
|
+
throw toCommerceError(err, "STORAGE_ERROR", "Failed to update payment attempt");
|
|
2827
|
+
}
|
|
2828
|
+
},
|
|
2829
|
+
async saveReceipt(receipt) {
|
|
2830
|
+
try {
|
|
2831
|
+
insertReceiptStmt.run(receiptToRow(receipt));
|
|
2832
|
+
} catch (err) {
|
|
2833
|
+
throw toCommerceError(err, "STORAGE_ERROR", "Failed to save receipt");
|
|
2834
|
+
}
|
|
2835
|
+
},
|
|
2836
|
+
async getReceipt(id) {
|
|
2837
|
+
const row = getReceiptStmt.get(id);
|
|
2838
|
+
return row !== void 0 ? rowToReceipt(row) : void 0;
|
|
2839
|
+
},
|
|
2840
|
+
async listReceipts(listOptions = {}) {
|
|
2841
|
+
const limit = clampListLimit(listOptions.limit);
|
|
2842
|
+
const rows = listOptions.requestId !== void 0 ? listReceiptsByRequestStmt.all(listOptions.requestId, limit) : listReceiptsStmt.all(limit);
|
|
2843
|
+
return rows.map(rowToReceipt);
|
|
2844
|
+
},
|
|
2845
|
+
/** Exact total, ignoring the listReceipts clamp — see the interface doc for why this exists. */
|
|
2846
|
+
async countReceipts() {
|
|
2847
|
+
return countReceiptsStmt.get()?.count ?? 0;
|
|
2848
|
+
},
|
|
2849
|
+
/** Exact count of non-2xx receipts — see the interface doc for why this exists. */
|
|
2850
|
+
async countUndeliveredReceipts() {
|
|
2851
|
+
return countUndeliveredReceiptsStmt.get()?.count ?? 0;
|
|
2852
|
+
},
|
|
2853
|
+
async listEvents(listOptions = {}) {
|
|
2854
|
+
const limit = clampListLimit(listOptions.limit);
|
|
2855
|
+
const rows = listOptions.requestId !== void 0 ? listEventsByRequestStmt.all(listOptions.requestId, limit) : listEventsStmt.all(limit);
|
|
2856
|
+
return rows.map(rowToEvent);
|
|
2857
|
+
},
|
|
2858
|
+
async listPaymentAttempts(listOptions = {}) {
|
|
2859
|
+
const limit = clampListLimit(listOptions.limit);
|
|
2860
|
+
const rows = listOptions.requestId !== void 0 ? listPaymentAttemptsByRequestStmt.all(listOptions.requestId, limit) : listPaymentAttemptsStmt.all(limit);
|
|
2861
|
+
return rows.map(rowToPaymentAttempt);
|
|
2862
|
+
},
|
|
2863
|
+
descriptor,
|
|
2864
|
+
async health() {
|
|
2865
|
+
const startedAt = clock.monotonicMs();
|
|
2866
|
+
if (closed) {
|
|
2867
|
+
return { status: "fail", detail: "store-unavailable", checkedAt: clock.nowIso() };
|
|
2868
|
+
}
|
|
2869
|
+
if (isFileBacked) {
|
|
2870
|
+
try {
|
|
2871
|
+
accessSync(path2, constants.W_OK);
|
|
2872
|
+
} catch (err) {
|
|
2873
|
+
logger.warn(
|
|
2874
|
+
{ err: err instanceof Error ? err.message : String(err) },
|
|
2875
|
+
"receipt-store: health check failed (store not writable)"
|
|
2876
|
+
);
|
|
2877
|
+
return {
|
|
2878
|
+
status: "fail",
|
|
2879
|
+
detail: "store-unwritable",
|
|
2880
|
+
checkedAt: clock.nowIso(),
|
|
2881
|
+
durationMs: clock.monotonicMs() - startedAt
|
|
2882
|
+
};
|
|
2883
|
+
}
|
|
2884
|
+
}
|
|
2885
|
+
try {
|
|
2886
|
+
const version = db.pragma("user_version", { simple: true });
|
|
2887
|
+
if (version !== SCHEMA_VERSION) {
|
|
2888
|
+
logger.warn(
|
|
2889
|
+
{ found: version, expected: SCHEMA_VERSION },
|
|
2890
|
+
"receipt-store: health check failed (schema version mismatch)"
|
|
2891
|
+
);
|
|
2892
|
+
return {
|
|
2893
|
+
status: "fail",
|
|
2894
|
+
detail: "store-schema-mismatch",
|
|
2895
|
+
checkedAt: clock.nowIso(),
|
|
2896
|
+
durationMs: clock.monotonicMs() - startedAt
|
|
2897
|
+
};
|
|
2898
|
+
}
|
|
2899
|
+
return {
|
|
2900
|
+
status: "pass",
|
|
2901
|
+
detail: `sqlite schema v${SCHEMA_VERSION} writable`,
|
|
2902
|
+
checkedAt: clock.nowIso(),
|
|
2903
|
+
durationMs: clock.monotonicMs() - startedAt
|
|
2904
|
+
};
|
|
2905
|
+
} catch (err) {
|
|
2906
|
+
logger.warn(
|
|
2907
|
+
{ err: err instanceof Error ? err.message : String(err) },
|
|
2908
|
+
"receipt-store: health check failed (store unavailable)"
|
|
2909
|
+
);
|
|
2910
|
+
return {
|
|
2911
|
+
status: "fail",
|
|
2912
|
+
detail: "store-unavailable",
|
|
2913
|
+
checkedAt: clock.nowIso(),
|
|
2914
|
+
durationMs: clock.monotonicMs() - startedAt
|
|
2915
|
+
};
|
|
2916
|
+
}
|
|
2917
|
+
},
|
|
2918
|
+
async close() {
|
|
2919
|
+
if (!closed) {
|
|
2920
|
+
closed = true;
|
|
2921
|
+
db.close();
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
};
|
|
2925
|
+
return store;
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
export { createGateway, createSqliteReceiptStore, loadConfig, parseConfig, createSqliteReceiptStore as receipts };
|
|
2929
|
+
//# sourceMappingURL=index.js.map
|
|
2930
|
+
//# sourceMappingURL=index.js.map
|