xytara 3.1.13 → 3.1.15
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/.env.example +38 -4
- package/BSV_TERANODE_PLATFORM_DISCONTINUITY_RUNBOOK.md +126 -126
- package/REAL_PAYMENT_SETUP.md +14 -7
- package/RELEASE_NOTES.md +9 -4
- package/fixtures/catalog.js +22 -0
- package/lib/a2a_rpc.js +287 -280
- package/lib/account_auth.js +585 -585
- package/lib/agent_identity.js +162 -162
- package/lib/asyncapi_contract.js +3 -3
- package/lib/bolt_adapter.js +187 -187
- package/lib/bolt_payment_front.js +224 -224
- package/lib/buyer_event_log.js +215 -0
- package/lib/buyer_safety_policy.js +132 -0
- package/lib/checkout_processor_adapter.js +217 -217
- package/lib/command_flow.js +911 -722
- package/lib/commerce_artifacts.js +591 -591
- package/lib/commerce_bridge.js +274 -274
- package/lib/commerce_cases.js +399 -399
- package/lib/commerce_client.js +7694 -7694
- package/lib/commerce_quotes.js +29 -0
- package/lib/commerce_runtime.js +322 -322
- package/lib/execution_dedupe.js +86 -86
- package/lib/external_credit_bridge.js +142 -142
- package/lib/http_transport.js +99 -81
- package/lib/l402_adapter.js +241 -241
- package/lib/l402_payment_front.js +265 -265
- package/lib/mpp_adapter.js +237 -237
- package/lib/openapi_contract.js +4 -0
- package/lib/payment_config.js +257 -253
- package/lib/payment_verification.js +357 -357
- package/lib/provider_payout_execution.js +155 -155
- package/lib/provider_registry.js +852 -842
- package/lib/rate_limiter.js +81 -81
- package/lib/runtime_state_store.js +495 -369
- package/lib/settlement_bsv_live.js +600 -600
- package/lib/settlement_bsv_teranode.js +117 -117
- package/lib/stripe_mpp_payment_front.js +258 -258
- package/lib/x402_facilitator.js +198 -191
- package/lib/x402_lane_contract.js +5 -3
- package/lib/x402_settlement_recovery.js +175 -0
- package/package.json +9 -3
- package/scripts/buyer_events_daily_report.js +79 -0
- package/scripts/state_backup_offsite.js +196 -0
- package/scripts/verify_a2a_rpc.js +131 -105
- package/scripts/verify_agent_identity.js +153 -153
- package/scripts/verify_all.js +1 -1
- package/scripts/verify_bolt_adapter.js +128 -128
- package/scripts/verify_buyer_event_log.js +123 -0
- package/scripts/verify_buyer_safety.js +141 -0
- package/scripts/verify_cdp_facilitator.js +110 -110
- package/scripts/verify_checkout_processor.js +109 -109
- package/scripts/verify_commerce_bridge.js +177 -177
- package/scripts/verify_execution_dedupe.js +189 -189
- package/scripts/verify_execution_surfacing.js +76 -76
- package/scripts/verify_final_activation.js +191 -191
- package/scripts/verify_l402_adapter.js +86 -86
- package/scripts/verify_lifecycle_spend_binding.js +88 -0
- package/scripts/verify_mpp_adapter.js +128 -128
- package/scripts/verify_production_readiness.js +268 -268
- package/scripts/verify_rate_limiter.js +52 -52
- package/scripts/verify_refund_credit.js +106 -106
- package/scripts/verify_release_candidate.js +81 -81
- package/scripts/verify_runtime_state_guard.js +117 -0
- package/scripts/verify_service.js +15421 -15421
- package/scripts/verify_verify_execute_settle.js +209 -208
- package/scripts/verify_x402_facilitator.js +307 -286
- package/scripts/verify_x402_settlement_recovery.js +153 -0
- package/server.js +12242 -11974
|
@@ -1,224 +1,224 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
const crypto = require("crypto");
|
|
4
|
-
const boltAdapter = require("./bolt_adapter");
|
|
5
|
-
|
|
6
|
-
function normalizeString(value) {
|
|
7
|
-
return typeof value === "string" ? value.trim() : "";
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
function safeEqualHex(left, right) {
|
|
11
|
-
const a = Buffer.from(String(left || ""), "hex");
|
|
12
|
-
const b = Buffer.from(String(right || ""), "hex");
|
|
13
|
-
if (a.length === 0 || b.length === 0 || a.length !== b.length) return false;
|
|
14
|
-
return crypto.timingSafeEqual(a, b);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function decodeBase64UrlJson(value) {
|
|
18
|
-
try {
|
|
19
|
-
return JSON.parse(Buffer.from(String(value || ""), "base64url").toString("utf8"));
|
|
20
|
-
} catch (_) {
|
|
21
|
-
return null;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function loadBoltFrontConfig() {
|
|
26
|
-
const sharedSecret = normalizeString(process.env.XYTARA_BOLT_SHARED_SECRET);
|
|
27
|
-
return {
|
|
28
|
-
enabled: String(process.env.XYTARA_BOLT_ENABLED || (sharedSecret ? "true" : "false")).trim().toLowerCase() !== "false",
|
|
29
|
-
shared_secret: sharedSecret || null,
|
|
30
|
-
network: normalizeString(process.env.XYTARA_BOLT_NETWORK) || normalizeString(process.env.XYTARA_BSV_TERANODE_NETWORK) || "mainnet",
|
|
31
|
-
proof_mode: normalizeString(process.env.XYTARA_BOLT_PROOF_MODE) || "spv_or_block_header_verifiable",
|
|
32
|
-
max_age_seconds: Math.max(30, Number(process.env.XYTARA_BOLT_MAX_AGE_SECONDS || 300) || 300),
|
|
33
|
-
required_headers: ["authorization"],
|
|
34
|
-
auth_scheme: "BOLT",
|
|
35
|
-
token_shape: "base64url_json_with_utxo_spv_proof_and_hmac_signed_payload"
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function parseBoltAuthorization(headers) {
|
|
40
|
-
const authorization = headers && typeof headers.authorization === "string"
|
|
41
|
-
? headers.authorization.trim()
|
|
42
|
-
: "";
|
|
43
|
-
const match = authorization.match(/^BOLT\s+(.+)$/i);
|
|
44
|
-
if (!match) return null;
|
|
45
|
-
const decoded = decodeBase64UrlJson(match[1]);
|
|
46
|
-
if (!decoded || typeof decoded !== "object") return null;
|
|
47
|
-
return {
|
|
48
|
-
wallet_id: decoded.wallet_id || decoded.gateway_id || "bolt_gateway",
|
|
49
|
-
encoded: decoded.payload || null,
|
|
50
|
-
signature: decoded.signature || null,
|
|
51
|
-
txid: decoded.txid || null,
|
|
52
|
-
spv_proof_ref: decoded.spv_proof_ref || null,
|
|
53
|
-
merkle_path_ref: decoded.merkle_path_ref || null,
|
|
54
|
-
block_header_ref: decoded.block_header_ref || null,
|
|
55
|
-
network: decoded.network || null,
|
|
56
|
-
proof_bundle_ref: decoded.proof_bundle_ref || null,
|
|
57
|
-
scheme: "bolt"
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function verifyBoltPaymentPayload(paymentPayload, quote, walletId, maxAgeSeconds, config) {
|
|
62
|
-
if (!paymentPayload || typeof paymentPayload !== "object") return { ok: false, reason: "bolt_payload_invalid" };
|
|
63
|
-
if (!paymentPayload.quote_id || paymentPayload.quote_id !== quote.quote_id) return { ok: false, reason: "bolt_quote_mismatch" };
|
|
64
|
-
if (paymentPayload.amount_minor !== quote.amount_minor) return { ok: false, reason: "bolt_amount_mismatch" };
|
|
65
|
-
if (paymentPayload.currency !== quote.currency) return { ok: false, reason: "bolt_currency_mismatch" };
|
|
66
|
-
if (paymentPayload.account_id && paymentPayload.account_id !== quote.account_id) return { ok: false, reason: "bolt_account_mismatch" };
|
|
67
|
-
if (walletId && paymentPayload.wallet_id && paymentPayload.wallet_id !== walletId) return { ok: false, reason: "bolt_wallet_mismatch" };
|
|
68
|
-
if (!paymentPayload.nonce || typeof paymentPayload.nonce !== "string") return { ok: false, reason: "bolt_nonce_missing" };
|
|
69
|
-
if (!paymentPayload.issued_at_iso || Number.isNaN(Date.parse(paymentPayload.issued_at_iso))) return { ok: false, reason: "bolt_issued_at_invalid" };
|
|
70
|
-
const ageSeconds = Math.max(0, Math.floor((Date.now() - Date.parse(paymentPayload.issued_at_iso)) / 1000));
|
|
71
|
-
if (ageSeconds > maxAgeSeconds) return { ok: false, reason: "bolt_token_expired" };
|
|
72
|
-
if (!normalizeString(paymentPayload.txid || paymentPayload.transaction_id)) return { ok: false, reason: "bolt_txid_missing" };
|
|
73
|
-
if (
|
|
74
|
-
!normalizeString(paymentPayload.spv_proof_ref || paymentPayload.proof_bundle_ref)
|
|
75
|
-
&& !normalizeString(paymentPayload.merkle_path_ref)
|
|
76
|
-
&& !normalizeString(paymentPayload.block_header_ref)
|
|
77
|
-
) {
|
|
78
|
-
return { ok: false, reason: "bolt_proof_missing" };
|
|
79
|
-
}
|
|
80
|
-
if (
|
|
81
|
-
normalizeString(paymentPayload.network)
|
|
82
|
-
&& normalizeString(config && config.network)
|
|
83
|
-
&& normalizeString(paymentPayload.network) !== normalizeString(config.network)
|
|
84
|
-
) {
|
|
85
|
-
return { ok: false, reason: "bolt_network_mismatch" };
|
|
86
|
-
}
|
|
87
|
-
return { ok: true, age_seconds: ageSeconds };
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
async function verifyBoltAccessToken(headers, quote, state) {
|
|
91
|
-
// Real BSV path: when a chain provider is configured, verify an on-chain BSV
|
|
92
|
-
// payment ("BOLT <txid>") that pays the operator address >= the required amount,
|
|
93
|
-
// with single-use (per-txid) replay protection. Otherwise fall through to the
|
|
94
|
-
// existing gateway-signed token scheme below (unchanged).
|
|
95
|
-
const bsvConfig = boltAdapter.loadBoltConfig();
|
|
96
|
-
if (boltAdapter.isConfigured(bsvConfig)) {
|
|
97
|
-
const authorization = headers && typeof headers.authorization === "string" ? headers.authorization : "";
|
|
98
|
-
const parsed = boltAdapter.parseBoltHeader(authorization);
|
|
99
|
-
if (!parsed) return { ok: false, status: 402, reason: "bolt_authorization_malformed" };
|
|
100
|
-
if (state && state.acceptedPaymentNonces && state.acceptedPaymentNonces.has(parsed.txid)) {
|
|
101
|
-
return { ok: false, status: 403, reason: "bolt_txid_replayed" };
|
|
102
|
-
}
|
|
103
|
-
const requiredSats = boltAdapter.computeRequiredSats(bsvConfig, quote);
|
|
104
|
-
const result = await boltAdapter.verifyBsvPayment(bsvConfig, parsed.txid, undefined, { requiredSats });
|
|
105
|
-
if (!result.ok) return { ok: false, status: 403, reason: result.reason, detail: result.detail || null };
|
|
106
|
-
return {
|
|
107
|
-
ok: true,
|
|
108
|
-
verification_mode: "bolt_bsv_onchain",
|
|
109
|
-
wallet_id: "bolt",
|
|
110
|
-
payment_payload: {
|
|
111
|
-
nonce: result.txid,
|
|
112
|
-
txid: result.txid,
|
|
113
|
-
paid_sats: result.paid_sats,
|
|
114
|
-
confirmations: result.confirmations,
|
|
115
|
-
quote_id: quote && quote.quote_id,
|
|
116
|
-
issued_at_iso: new Date().toISOString()
|
|
117
|
-
},
|
|
118
|
-
bolt: { txid: result.txid, paid_sats: result.paid_sats, confirmations: result.confirmations, network: result.network }
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const config = loadBoltFrontConfig();
|
|
123
|
-
if (!config.enabled) return { ok: false, status: 503, reason: "bolt_not_enabled" };
|
|
124
|
-
if (!config.shared_secret) return { ok: false, status: 503, reason: "bolt_shared_secret_missing" };
|
|
125
|
-
const payment = parseBoltAuthorization(headers || {});
|
|
126
|
-
if (!payment || !payment.encoded || !payment.signature) {
|
|
127
|
-
return { ok: false, status: 402, reason: "bolt_headers_incomplete" };
|
|
128
|
-
}
|
|
129
|
-
const expectedSignature = crypto.createHmac("sha256", config.shared_secret).update(payment.encoded).digest("hex");
|
|
130
|
-
if (!safeEqualHex(expectedSignature, payment.signature)) {
|
|
131
|
-
return { ok: false, status: 403, reason: "bolt_signature_invalid" };
|
|
132
|
-
}
|
|
133
|
-
const payload = decodeBase64UrlJson(payment.encoded);
|
|
134
|
-
const validation = verifyBoltPaymentPayload(payload, quote, payment.wallet_id, config.max_age_seconds, config);
|
|
135
|
-
if (!validation.ok) return { ok: false, status: 403, reason: validation.reason };
|
|
136
|
-
return {
|
|
137
|
-
ok: true,
|
|
138
|
-
verification_mode: "bolt_gateway_signed",
|
|
139
|
-
wallet_id: payment.wallet_id,
|
|
140
|
-
payment_payload: payload,
|
|
141
|
-
age_seconds: validation.age_seconds,
|
|
142
|
-
bolt: {
|
|
143
|
-
txid: payment.txid || payload.txid || payload.transaction_id || null,
|
|
144
|
-
spv_proof_ref: payment.spv_proof_ref || payload.spv_proof_ref || payload.proof_bundle_ref || null,
|
|
145
|
-
merkle_path_ref: payment.merkle_path_ref || payload.merkle_path_ref || null,
|
|
146
|
-
block_header_ref: payment.block_header_ref || payload.block_header_ref || null,
|
|
147
|
-
proof_bundle_ref: payment.proof_bundle_ref || payload.proof_bundle_ref || null,
|
|
148
|
-
network: payment.network || payload.network || config.network,
|
|
149
|
-
proof_mode: config.proof_mode
|
|
150
|
-
}
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function buildBoltSecuritySummary() {
|
|
155
|
-
const config = loadBoltFrontConfig();
|
|
156
|
-
const blockers = [];
|
|
157
|
-
if (config.enabled && !config.shared_secret) blockers.push("bolt_shared_secret_missing");
|
|
158
|
-
return {
|
|
159
|
-
ok: true,
|
|
160
|
-
category: "machine-commerce-bolt-security-summary",
|
|
161
|
-
summary_version: "xytara-bolt-security-summary-v1",
|
|
162
|
-
enabled: config.enabled,
|
|
163
|
-
shared_secret_configured: Boolean(config.shared_secret),
|
|
164
|
-
network: config.network,
|
|
165
|
-
proof_mode: config.proof_mode,
|
|
166
|
-
max_age_seconds: config.max_age_seconds,
|
|
167
|
-
auth_scheme: config.auth_scheme,
|
|
168
|
-
token_shape: config.token_shape,
|
|
169
|
-
ready: config.enabled && blockers.length === 0,
|
|
170
|
-
blockers,
|
|
171
|
-
linked_surfaces: {
|
|
172
|
-
bolt_lane_ref: "/v1/bolt",
|
|
173
|
-
commands_execute_ref: "/bolt/commands/execute",
|
|
174
|
-
mcp_invoke_ref: "/bolt/mcp/tools/invoke"
|
|
175
|
-
}
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function buildBoltChallengePayload(quote) {
|
|
180
|
-
// Real BSV path: advertise where to pay and how much so the caller can send a
|
|
181
|
-
// BSV transaction and retry with "BOLT <txid>".
|
|
182
|
-
const bsvConfig = boltAdapter.loadBoltConfig();
|
|
183
|
-
if (boltAdapter.isConfigured(bsvConfig) && bsvConfig.payToAddress && bsvConfig.priceSats) {
|
|
184
|
-
const requiredSats = boltAdapter.computeRequiredSats(bsvConfig, quote);
|
|
185
|
-
return {
|
|
186
|
-
ok: false,
|
|
187
|
-
payment_required: {
|
|
188
|
-
scheme: "bolt",
|
|
189
|
-
quote,
|
|
190
|
-
bsv: boltAdapter.buildBoltChallenge(bsvConfig, { requiredSats }),
|
|
191
|
-
operator_requirements: {
|
|
192
|
-
auth_scheme: "BOLT",
|
|
193
|
-
token_shape: "authorization_header_BOLT_txid",
|
|
194
|
-
security_ref: "/v1/bolt/security",
|
|
195
|
-
proof_requirements: ["pay_the_exact_amount_sats_to_address_then_present_txid"]
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
};
|
|
199
|
-
}
|
|
200
|
-
return {
|
|
201
|
-
ok: false,
|
|
202
|
-
payment_required: {
|
|
203
|
-
scheme: "bolt",
|
|
204
|
-
quote,
|
|
205
|
-
operator_requirements: {
|
|
206
|
-
auth_scheme: "BOLT",
|
|
207
|
-
token_shape: "base64url_json_with_utxo_spv_proof_and_hmac_signed_payload",
|
|
208
|
-
security_ref: "/v1/bolt/security",
|
|
209
|
-
proof_requirements: [
|
|
210
|
-
"txid",
|
|
211
|
-
"one_of:spv_proof_ref|merkle_path_ref|block_header_ref|proof_bundle_ref"
|
|
212
|
-
]
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
};
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
module.exports = {
|
|
219
|
-
buildBoltChallengePayload,
|
|
220
|
-
buildBoltSecuritySummary,
|
|
221
|
-
loadBoltFrontConfig,
|
|
222
|
-
parseBoltAuthorization,
|
|
223
|
-
verifyBoltAccessToken
|
|
224
|
-
};
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
const boltAdapter = require("./bolt_adapter");
|
|
5
|
+
|
|
6
|
+
function normalizeString(value) {
|
|
7
|
+
return typeof value === "string" ? value.trim() : "";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function safeEqualHex(left, right) {
|
|
11
|
+
const a = Buffer.from(String(left || ""), "hex");
|
|
12
|
+
const b = Buffer.from(String(right || ""), "hex");
|
|
13
|
+
if (a.length === 0 || b.length === 0 || a.length !== b.length) return false;
|
|
14
|
+
return crypto.timingSafeEqual(a, b);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function decodeBase64UrlJson(value) {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(Buffer.from(String(value || ""), "base64url").toString("utf8"));
|
|
20
|
+
} catch (_) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function loadBoltFrontConfig() {
|
|
26
|
+
const sharedSecret = normalizeString(process.env.XYTARA_BOLT_SHARED_SECRET);
|
|
27
|
+
return {
|
|
28
|
+
enabled: String(process.env.XYTARA_BOLT_ENABLED || (sharedSecret ? "true" : "false")).trim().toLowerCase() !== "false",
|
|
29
|
+
shared_secret: sharedSecret || null,
|
|
30
|
+
network: normalizeString(process.env.XYTARA_BOLT_NETWORK) || normalizeString(process.env.XYTARA_BSV_TERANODE_NETWORK) || "mainnet",
|
|
31
|
+
proof_mode: normalizeString(process.env.XYTARA_BOLT_PROOF_MODE) || "spv_or_block_header_verifiable",
|
|
32
|
+
max_age_seconds: Math.max(30, Number(process.env.XYTARA_BOLT_MAX_AGE_SECONDS || 300) || 300),
|
|
33
|
+
required_headers: ["authorization"],
|
|
34
|
+
auth_scheme: "BOLT",
|
|
35
|
+
token_shape: "base64url_json_with_utxo_spv_proof_and_hmac_signed_payload"
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseBoltAuthorization(headers) {
|
|
40
|
+
const authorization = headers && typeof headers.authorization === "string"
|
|
41
|
+
? headers.authorization.trim()
|
|
42
|
+
: "";
|
|
43
|
+
const match = authorization.match(/^BOLT\s+(.+)$/i);
|
|
44
|
+
if (!match) return null;
|
|
45
|
+
const decoded = decodeBase64UrlJson(match[1]);
|
|
46
|
+
if (!decoded || typeof decoded !== "object") return null;
|
|
47
|
+
return {
|
|
48
|
+
wallet_id: decoded.wallet_id || decoded.gateway_id || "bolt_gateway",
|
|
49
|
+
encoded: decoded.payload || null,
|
|
50
|
+
signature: decoded.signature || null,
|
|
51
|
+
txid: decoded.txid || null,
|
|
52
|
+
spv_proof_ref: decoded.spv_proof_ref || null,
|
|
53
|
+
merkle_path_ref: decoded.merkle_path_ref || null,
|
|
54
|
+
block_header_ref: decoded.block_header_ref || null,
|
|
55
|
+
network: decoded.network || null,
|
|
56
|
+
proof_bundle_ref: decoded.proof_bundle_ref || null,
|
|
57
|
+
scheme: "bolt"
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function verifyBoltPaymentPayload(paymentPayload, quote, walletId, maxAgeSeconds, config) {
|
|
62
|
+
if (!paymentPayload || typeof paymentPayload !== "object") return { ok: false, reason: "bolt_payload_invalid" };
|
|
63
|
+
if (!paymentPayload.quote_id || paymentPayload.quote_id !== quote.quote_id) return { ok: false, reason: "bolt_quote_mismatch" };
|
|
64
|
+
if (paymentPayload.amount_minor !== quote.amount_minor) return { ok: false, reason: "bolt_amount_mismatch" };
|
|
65
|
+
if (paymentPayload.currency !== quote.currency) return { ok: false, reason: "bolt_currency_mismatch" };
|
|
66
|
+
if (paymentPayload.account_id && paymentPayload.account_id !== quote.account_id) return { ok: false, reason: "bolt_account_mismatch" };
|
|
67
|
+
if (walletId && paymentPayload.wallet_id && paymentPayload.wallet_id !== walletId) return { ok: false, reason: "bolt_wallet_mismatch" };
|
|
68
|
+
if (!paymentPayload.nonce || typeof paymentPayload.nonce !== "string") return { ok: false, reason: "bolt_nonce_missing" };
|
|
69
|
+
if (!paymentPayload.issued_at_iso || Number.isNaN(Date.parse(paymentPayload.issued_at_iso))) return { ok: false, reason: "bolt_issued_at_invalid" };
|
|
70
|
+
const ageSeconds = Math.max(0, Math.floor((Date.now() - Date.parse(paymentPayload.issued_at_iso)) / 1000));
|
|
71
|
+
if (ageSeconds > maxAgeSeconds) return { ok: false, reason: "bolt_token_expired" };
|
|
72
|
+
if (!normalizeString(paymentPayload.txid || paymentPayload.transaction_id)) return { ok: false, reason: "bolt_txid_missing" };
|
|
73
|
+
if (
|
|
74
|
+
!normalizeString(paymentPayload.spv_proof_ref || paymentPayload.proof_bundle_ref)
|
|
75
|
+
&& !normalizeString(paymentPayload.merkle_path_ref)
|
|
76
|
+
&& !normalizeString(paymentPayload.block_header_ref)
|
|
77
|
+
) {
|
|
78
|
+
return { ok: false, reason: "bolt_proof_missing" };
|
|
79
|
+
}
|
|
80
|
+
if (
|
|
81
|
+
normalizeString(paymentPayload.network)
|
|
82
|
+
&& normalizeString(config && config.network)
|
|
83
|
+
&& normalizeString(paymentPayload.network) !== normalizeString(config.network)
|
|
84
|
+
) {
|
|
85
|
+
return { ok: false, reason: "bolt_network_mismatch" };
|
|
86
|
+
}
|
|
87
|
+
return { ok: true, age_seconds: ageSeconds };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function verifyBoltAccessToken(headers, quote, state) {
|
|
91
|
+
// Real BSV path: when a chain provider is configured, verify an on-chain BSV
|
|
92
|
+
// payment ("BOLT <txid>") that pays the operator address >= the required amount,
|
|
93
|
+
// with single-use (per-txid) replay protection. Otherwise fall through to the
|
|
94
|
+
// existing gateway-signed token scheme below (unchanged).
|
|
95
|
+
const bsvConfig = boltAdapter.loadBoltConfig();
|
|
96
|
+
if (boltAdapter.isConfigured(bsvConfig)) {
|
|
97
|
+
const authorization = headers && typeof headers.authorization === "string" ? headers.authorization : "";
|
|
98
|
+
const parsed = boltAdapter.parseBoltHeader(authorization);
|
|
99
|
+
if (!parsed) return { ok: false, status: 402, reason: "bolt_authorization_malformed" };
|
|
100
|
+
if (state && state.acceptedPaymentNonces && state.acceptedPaymentNonces.has(parsed.txid)) {
|
|
101
|
+
return { ok: false, status: 403, reason: "bolt_txid_replayed" };
|
|
102
|
+
}
|
|
103
|
+
const requiredSats = boltAdapter.computeRequiredSats(bsvConfig, quote);
|
|
104
|
+
const result = await boltAdapter.verifyBsvPayment(bsvConfig, parsed.txid, undefined, { requiredSats });
|
|
105
|
+
if (!result.ok) return { ok: false, status: 403, reason: result.reason, detail: result.detail || null };
|
|
106
|
+
return {
|
|
107
|
+
ok: true,
|
|
108
|
+
verification_mode: "bolt_bsv_onchain",
|
|
109
|
+
wallet_id: "bolt",
|
|
110
|
+
payment_payload: {
|
|
111
|
+
nonce: result.txid,
|
|
112
|
+
txid: result.txid,
|
|
113
|
+
paid_sats: result.paid_sats,
|
|
114
|
+
confirmations: result.confirmations,
|
|
115
|
+
quote_id: quote && quote.quote_id,
|
|
116
|
+
issued_at_iso: new Date().toISOString()
|
|
117
|
+
},
|
|
118
|
+
bolt: { txid: result.txid, paid_sats: result.paid_sats, confirmations: result.confirmations, network: result.network }
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const config = loadBoltFrontConfig();
|
|
123
|
+
if (!config.enabled) return { ok: false, status: 503, reason: "bolt_not_enabled" };
|
|
124
|
+
if (!config.shared_secret) return { ok: false, status: 503, reason: "bolt_shared_secret_missing" };
|
|
125
|
+
const payment = parseBoltAuthorization(headers || {});
|
|
126
|
+
if (!payment || !payment.encoded || !payment.signature) {
|
|
127
|
+
return { ok: false, status: 402, reason: "bolt_headers_incomplete" };
|
|
128
|
+
}
|
|
129
|
+
const expectedSignature = crypto.createHmac("sha256", config.shared_secret).update(payment.encoded).digest("hex");
|
|
130
|
+
if (!safeEqualHex(expectedSignature, payment.signature)) {
|
|
131
|
+
return { ok: false, status: 403, reason: "bolt_signature_invalid" };
|
|
132
|
+
}
|
|
133
|
+
const payload = decodeBase64UrlJson(payment.encoded);
|
|
134
|
+
const validation = verifyBoltPaymentPayload(payload, quote, payment.wallet_id, config.max_age_seconds, config);
|
|
135
|
+
if (!validation.ok) return { ok: false, status: 403, reason: validation.reason };
|
|
136
|
+
return {
|
|
137
|
+
ok: true,
|
|
138
|
+
verification_mode: "bolt_gateway_signed",
|
|
139
|
+
wallet_id: payment.wallet_id,
|
|
140
|
+
payment_payload: payload,
|
|
141
|
+
age_seconds: validation.age_seconds,
|
|
142
|
+
bolt: {
|
|
143
|
+
txid: payment.txid || payload.txid || payload.transaction_id || null,
|
|
144
|
+
spv_proof_ref: payment.spv_proof_ref || payload.spv_proof_ref || payload.proof_bundle_ref || null,
|
|
145
|
+
merkle_path_ref: payment.merkle_path_ref || payload.merkle_path_ref || null,
|
|
146
|
+
block_header_ref: payment.block_header_ref || payload.block_header_ref || null,
|
|
147
|
+
proof_bundle_ref: payment.proof_bundle_ref || payload.proof_bundle_ref || null,
|
|
148
|
+
network: payment.network || payload.network || config.network,
|
|
149
|
+
proof_mode: config.proof_mode
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function buildBoltSecuritySummary() {
|
|
155
|
+
const config = loadBoltFrontConfig();
|
|
156
|
+
const blockers = [];
|
|
157
|
+
if (config.enabled && !config.shared_secret) blockers.push("bolt_shared_secret_missing");
|
|
158
|
+
return {
|
|
159
|
+
ok: true,
|
|
160
|
+
category: "machine-commerce-bolt-security-summary",
|
|
161
|
+
summary_version: "xytara-bolt-security-summary-v1",
|
|
162
|
+
enabled: config.enabled,
|
|
163
|
+
shared_secret_configured: Boolean(config.shared_secret),
|
|
164
|
+
network: config.network,
|
|
165
|
+
proof_mode: config.proof_mode,
|
|
166
|
+
max_age_seconds: config.max_age_seconds,
|
|
167
|
+
auth_scheme: config.auth_scheme,
|
|
168
|
+
token_shape: config.token_shape,
|
|
169
|
+
ready: config.enabled && blockers.length === 0,
|
|
170
|
+
blockers,
|
|
171
|
+
linked_surfaces: {
|
|
172
|
+
bolt_lane_ref: "/v1/bolt",
|
|
173
|
+
commands_execute_ref: "/bolt/commands/execute",
|
|
174
|
+
mcp_invoke_ref: "/bolt/mcp/tools/invoke"
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function buildBoltChallengePayload(quote) {
|
|
180
|
+
// Real BSV path: advertise where to pay and how much so the caller can send a
|
|
181
|
+
// BSV transaction and retry with "BOLT <txid>".
|
|
182
|
+
const bsvConfig = boltAdapter.loadBoltConfig();
|
|
183
|
+
if (boltAdapter.isConfigured(bsvConfig) && bsvConfig.payToAddress && bsvConfig.priceSats) {
|
|
184
|
+
const requiredSats = boltAdapter.computeRequiredSats(bsvConfig, quote);
|
|
185
|
+
return {
|
|
186
|
+
ok: false,
|
|
187
|
+
payment_required: {
|
|
188
|
+
scheme: "bolt",
|
|
189
|
+
quote,
|
|
190
|
+
bsv: boltAdapter.buildBoltChallenge(bsvConfig, { requiredSats }),
|
|
191
|
+
operator_requirements: {
|
|
192
|
+
auth_scheme: "BOLT",
|
|
193
|
+
token_shape: "authorization_header_BOLT_txid",
|
|
194
|
+
security_ref: "/v1/bolt/security",
|
|
195
|
+
proof_requirements: ["pay_the_exact_amount_sats_to_address_then_present_txid"]
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
ok: false,
|
|
202
|
+
payment_required: {
|
|
203
|
+
scheme: "bolt",
|
|
204
|
+
quote,
|
|
205
|
+
operator_requirements: {
|
|
206
|
+
auth_scheme: "BOLT",
|
|
207
|
+
token_shape: "base64url_json_with_utxo_spv_proof_and_hmac_signed_payload",
|
|
208
|
+
security_ref: "/v1/bolt/security",
|
|
209
|
+
proof_requirements: [
|
|
210
|
+
"txid",
|
|
211
|
+
"one_of:spv_proof_ref|merkle_path_ref|block_header_ref|proof_bundle_ref"
|
|
212
|
+
]
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
module.exports = {
|
|
219
|
+
buildBoltChallengePayload,
|
|
220
|
+
buildBoltSecuritySummary,
|
|
221
|
+
loadBoltFrontConfig,
|
|
222
|
+
parseBoltAuthorization,
|
|
223
|
+
verifyBoltAccessToken
|
|
224
|
+
};
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
|
|
5
|
+
// Buyer-journey event log: every failed buyer attempt (ours, upstream, or the buyer's) is recorded
|
|
6
|
+
// so the operator can see who tried to use the system and why it did not work — plus counters for
|
|
7
|
+
// the normal steps (402 challenges issued, paid calls served) as denominators.
|
|
8
|
+
//
|
|
9
|
+
// Privacy: events never carry payment signatures/headers, request bodies, tokens, IP addresses or
|
|
10
|
+
// full wallet addresses. Payer addresses are reduced to a salted hash prefix; detail text is
|
|
11
|
+
// truncated and stripped of long hex/base64 runs.
|
|
12
|
+
//
|
|
13
|
+
// Storage: state.buyerEventLog (bounded ring, newest last) and state.buyerEventDaily (per-day
|
|
14
|
+
// counters, 30 days) are ordinary runtime state, so they persist with the Supabase snapshot and the
|
|
15
|
+
// daily off-site backup. The MCP executor forwards its own events to POST /v1/operator/buyer-events/ingest.
|
|
16
|
+
|
|
17
|
+
const DAILY_RETENTION_DAYS = 30;
|
|
18
|
+
|
|
19
|
+
function maxEvents() {
|
|
20
|
+
const value = Number(process.env.XYTARA_BUYER_EVENT_LOG_MAX || 500);
|
|
21
|
+
return Number.isFinite(value) && value >= 10 ? Math.min(5000, Math.floor(value)) : 500;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function ensureBuyerEventCollections(state) {
|
|
25
|
+
if (!Array.isArray(state.buyerEventLog)) state.buyerEventLog = [];
|
|
26
|
+
if (!(state.buyerEventDaily instanceof Map)) state.buyerEventDaily = new Map();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function cleanText(value, max) {
|
|
30
|
+
if (value === null || value === undefined) return null;
|
|
31
|
+
const text = String(value)
|
|
32
|
+
.replace(/0x[0-9a-fA-F]{40,}/g, "0x…")
|
|
33
|
+
.replace(/[A-Za-z0-9+/=_-]{40,}/g, "…")
|
|
34
|
+
.replace(/\s+/g, " ")
|
|
35
|
+
.trim();
|
|
36
|
+
return text ? text.slice(0, max) : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function payerHash(address) {
|
|
40
|
+
const value = String(address || "").trim().toLowerCase();
|
|
41
|
+
if (!value) return null;
|
|
42
|
+
const salt = String(process.env.XYTARA_BUYER_EVENT_SALT || "naxytra-buyer-events");
|
|
43
|
+
return crypto.createHash("sha256").update(`${salt}:${value}`).digest("hex").slice(0, 12);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Path template without ids: segments that look like identifiers become ":id".
|
|
47
|
+
function routeTemplate(pathname) {
|
|
48
|
+
return String(pathname || "/")
|
|
49
|
+
.split("/")
|
|
50
|
+
.map((segment) => (/(^0x[0-9a-f]{6,})|([0-9a-f]{12,})|(_[0-9a-z]{6,}$)|(^\d{3,}$)/i.test(segment) ? ":id" : segment))
|
|
51
|
+
.join("/")
|
|
52
|
+
.slice(0, 120);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// reason -> [stage, fault]. fault: "ours" (our bug/infra), "upstream" (facilitator, RPC, provider),
|
|
56
|
+
// "buyer" (bad input / bad payment), "none" (expected protocol step).
|
|
57
|
+
const REASON_RULES = [
|
|
58
|
+
[/^(capability_not_executable|task_ref_required|task_not_found|unknown_tool)/, "route", "buyer"],
|
|
59
|
+
[/^(body_must_be_valid_json|invalid_json|request_body_too_large)/, "parse", "buyer"],
|
|
60
|
+
[/^payment_(signature_invalid|requirements_mismatch|header)/, "payment_match", "buyer"],
|
|
61
|
+
[/^payment_authorization_in_progress/, "payment_verify", "buyer"],
|
|
62
|
+
[/^payment_verification_failed|^invalid_exact/, "payment_verify", "buyer"],
|
|
63
|
+
[/^facilitator_|_unreachable$/, "payment_verify", "upstream"],
|
|
64
|
+
[/^(payment_settlement_failed|settlement_failed|settle_)/, "payment_settle", "upstream"],
|
|
65
|
+
[/^(external_execution|no_healthy_provider|provider_)/, "provider", "upstream"],
|
|
66
|
+
[/^(insufficient_credits|credit_spend_|delegated_)/, "credit", "buyer"],
|
|
67
|
+
[/^agent_signature|^spend_credential|_bearer|^unauthorized|^forbidden/, "auth", "buyer"],
|
|
68
|
+
[/^rate_limit/, "rate_limit", "buyer"],
|
|
69
|
+
[/^(state_restore_pending|runtime_state)/, "state", "ours"],
|
|
70
|
+
[/^quote_(expired|already_used)/, "quote", "buyer"]
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
function classify(reason, httpStatus) {
|
|
74
|
+
const text = String(reason || "");
|
|
75
|
+
for (const [pattern, stage, fault] of REASON_RULES) {
|
|
76
|
+
if (pattern.test(text)) return { stage, fault };
|
|
77
|
+
}
|
|
78
|
+
if (httpStatus >= 500) return { stage: "internal", fault: "ours" };
|
|
79
|
+
if (httpStatus === 404) return { stage: "route", fault: "buyer" };
|
|
80
|
+
return { stage: "other", fault: httpStatus >= 400 && httpStatus < 500 ? "buyer" : "ours" };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function dayKey(iso) {
|
|
84
|
+
return String(iso).slice(0, 10);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function bumpDaily(state, iso, service, key) {
|
|
88
|
+
const day = dayKey(iso);
|
|
89
|
+
let bucket = state.buyerEventDaily.get(day);
|
|
90
|
+
if (!bucket) {
|
|
91
|
+
bucket = {};
|
|
92
|
+
state.buyerEventDaily.set(day, bucket);
|
|
93
|
+
// Drop days beyond retention (keys are ISO dates, so lexical order is chronological).
|
|
94
|
+
const days = Array.from(state.buyerEventDaily.keys()).sort();
|
|
95
|
+
while (days.length > DAILY_RETENTION_DAYS) state.buyerEventDaily.delete(days.shift());
|
|
96
|
+
}
|
|
97
|
+
const counterKey = `${service}|${key}`;
|
|
98
|
+
bucket[counterKey] = (Number(bucket[counterKey]) || 0) + 1;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Count a normal step without logging an event (challenge issued, paid call served, ...).
|
|
102
|
+
function countBuyerStep(state, step, options = {}) {
|
|
103
|
+
try {
|
|
104
|
+
ensureBuyerEventCollections(state);
|
|
105
|
+
bumpDaily(state, new Date().toISOString(), options.service || "runtime", `step:${step}|${routeTemplate(options.route)}`);
|
|
106
|
+
} catch (_) { /* observability must never break a buyer response */ }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function recordBuyerEvent(state, input) {
|
|
110
|
+
try {
|
|
111
|
+
ensureBuyerEventCollections(state);
|
|
112
|
+
const at = typeof input.at_iso === "string" && !Number.isNaN(Date.parse(input.at_iso)) ? new Date(input.at_iso).toISOString() : new Date().toISOString();
|
|
113
|
+
const httpStatus = Number.isFinite(Number(input.http_status)) ? Number(input.http_status) : null;
|
|
114
|
+
const reason = cleanText(input.reason, 80) || (httpStatus ? `http_${httpStatus}` : "unknown");
|
|
115
|
+
const classified = classify(reason, httpStatus || 0);
|
|
116
|
+
const event = {
|
|
117
|
+
id: `bev_${crypto.randomBytes(6).toString("hex")}`,
|
|
118
|
+
at_iso: at,
|
|
119
|
+
service: cleanText(input.service, 40) || "runtime",
|
|
120
|
+
request_id: cleanText(input.request_id, 40),
|
|
121
|
+
stage: cleanText(input.stage, 30) || classified.stage,
|
|
122
|
+
reason,
|
|
123
|
+
fault: cleanText(input.fault, 10) || classified.fault,
|
|
124
|
+
http_status: httpStatus,
|
|
125
|
+
route: input.route ? routeTemplate(input.route) : null,
|
|
126
|
+
method: cleanText(input.method, 8),
|
|
127
|
+
task_or_tool: cleanText(input.task_or_tool, 80),
|
|
128
|
+
rail: cleanText(input.rail, 20),
|
|
129
|
+
network: cleanText(input.network, 30),
|
|
130
|
+
amount_atomic: /^\d{1,30}$/.test(String(input.amount_atomic || "")) ? String(input.amount_atomic) : null,
|
|
131
|
+
payer_hash: input.payer_hash ? cleanText(input.payer_hash, 16) : payerHash(input.payer),
|
|
132
|
+
money_moved: input.money_moved === true,
|
|
133
|
+
latency_ms: Number.isFinite(Number(input.latency_ms)) ? Math.round(Number(input.latency_ms)) : null,
|
|
134
|
+
detail: cleanText(input.detail, 160),
|
|
135
|
+
ua: cleanText(String(input.ua || "").split(/[\s/]/)[0], 32)
|
|
136
|
+
};
|
|
137
|
+
state.buyerEventLog.push(event);
|
|
138
|
+
const cap = maxEvents();
|
|
139
|
+
if (state.buyerEventLog.length > cap) state.buyerEventLog.splice(0, state.buyerEventLog.length - cap);
|
|
140
|
+
bumpDaily(state, at, event.service, `fail:${event.stage}:${event.reason}:${event.fault}`);
|
|
141
|
+
if (event.money_moved) bumpDaily(state, at, event.service, "money_moved");
|
|
142
|
+
return event;
|
|
143
|
+
} catch (_) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function listBuyerEvents(state, query = {}) {
|
|
149
|
+
ensureBuyerEventCollections(state);
|
|
150
|
+
const since = query.since && !Number.isNaN(Date.parse(query.since)) ? Date.parse(query.since) : null;
|
|
151
|
+
const limit = Math.max(1, Math.min(500, Number(query.limit) || 100));
|
|
152
|
+
const matches = state.buyerEventLog.filter((event) => {
|
|
153
|
+
if (since !== null && Date.parse(event.at_iso) < since) return false;
|
|
154
|
+
if (query.service && event.service !== query.service) return false;
|
|
155
|
+
if (query.stage && event.stage !== query.stage) return false;
|
|
156
|
+
if (query.fault && event.fault !== query.fault) return false;
|
|
157
|
+
if (query.money_moved === "true" && !event.money_moved) return false;
|
|
158
|
+
return true;
|
|
159
|
+
});
|
|
160
|
+
return { count: matches.length, events: matches.slice(-limit).reverse() };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function summarizeBuyerEvents(state, days = 7) {
|
|
164
|
+
ensureBuyerEventCollections(state);
|
|
165
|
+
const window = Math.max(1, Math.min(DAILY_RETENTION_DAYS, Number(days) || 7));
|
|
166
|
+
const keys = Array.from(state.buyerEventDaily.keys()).sort().slice(-window);
|
|
167
|
+
const totals = { failures: 0, ours: 0, upstream: 0, buyer: 0, money_moved: 0, challenges_issued: 0, paid_ok: 0 };
|
|
168
|
+
const byReason = {};
|
|
169
|
+
const perDay = keys.map((day) => {
|
|
170
|
+
const bucket = state.buyerEventDaily.get(day) || {};
|
|
171
|
+
const dayTotals = { day, failures: 0, ours: 0, upstream: 0, money_moved: 0, challenges_issued: 0, paid_ok: 0 };
|
|
172
|
+
for (const [key, count] of Object.entries(bucket)) {
|
|
173
|
+
const [service, rest] = [key.slice(0, key.indexOf("|")), key.slice(key.indexOf("|") + 1)];
|
|
174
|
+
if (rest === "money_moved") { dayTotals.money_moved += count; totals.money_moved += count; continue; }
|
|
175
|
+
if (rest.startsWith("step:challenge_issued")) { dayTotals.challenges_issued += count; totals.challenges_issued += count; continue; }
|
|
176
|
+
if (rest.startsWith("step:paid_ok")) { dayTotals.paid_ok += count; totals.paid_ok += count; continue; }
|
|
177
|
+
if (!rest.startsWith("fail:")) continue;
|
|
178
|
+
const [, stage, reason, fault] = rest.split(":");
|
|
179
|
+
dayTotals.failures += count;
|
|
180
|
+
totals.failures += count;
|
|
181
|
+
if (fault === "ours") { dayTotals.ours += count; totals.ours += count; }
|
|
182
|
+
if (fault === "upstream") { dayTotals.upstream += count; totals.upstream += count; }
|
|
183
|
+
if (fault === "buyer") totals.buyer += count;
|
|
184
|
+
const reasonKey = `${service}:${stage}:${reason}:${fault}`;
|
|
185
|
+
byReason[reasonKey] = (byReason[reasonKey] || 0) + count;
|
|
186
|
+
}
|
|
187
|
+
return dayTotals;
|
|
188
|
+
});
|
|
189
|
+
const topReasons = Object.entries(byReason)
|
|
190
|
+
.sort((a, b) => b[1] - a[1])
|
|
191
|
+
.slice(0, 25)
|
|
192
|
+
.map(([key, count]) => {
|
|
193
|
+
const [service, stage, reason, fault] = key.split(":");
|
|
194
|
+
return { service, stage, reason, fault, count };
|
|
195
|
+
});
|
|
196
|
+
return {
|
|
197
|
+
window_days: window,
|
|
198
|
+
totals,
|
|
199
|
+
// Needs attention: money moved without a delivered result, or failures that are our fault.
|
|
200
|
+
attention: totals.money_moved > 0 || totals.ours > 0,
|
|
201
|
+
per_day: perDay,
|
|
202
|
+
top_reasons: topReasons,
|
|
203
|
+
generated_at_iso: new Date().toISOString()
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
module.exports = {
|
|
208
|
+
countBuyerStep,
|
|
209
|
+
ensureBuyerEventCollections,
|
|
210
|
+
listBuyerEvents,
|
|
211
|
+
payerHash,
|
|
212
|
+
recordBuyerEvent,
|
|
213
|
+
routeTemplate,
|
|
214
|
+
summarizeBuyerEvents
|
|
215
|
+
};
|