xytara 3.1.13 → 3.1.14
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 +8 -1
- package/BSV_TERANODE_PLATFORM_DISCONTINUITY_RUNBOOK.md +126 -126
- package/RELEASE_NOTES.md +6 -4
- package/lib/a2a_rpc.js +280 -280
- package/lib/account_auth.js +585 -585
- package/lib/agent_identity.js +162 -162
- package/lib/bolt_adapter.js +187 -187
- package/lib/bolt_payment_front.js +224 -224
- package/lib/checkout_processor_adapter.js +217 -217
- package/lib/command_flow.js +722 -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 +81 -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/payment_config.js +253 -253
- package/lib/payment_verification.js +357 -357
- package/lib/provider_payout_execution.js +155 -155
- package/lib/provider_registry.js +842 -842
- package/lib/rate_limiter.js +81 -81
- package/lib/runtime_state_store.js +491 -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 +191 -191
- package/package.json +5 -3
- package/scripts/state_backup_offsite.js +196 -0
- package/scripts/verify_a2a_rpc.js +105 -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_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_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 +208 -208
- package/scripts/verify_x402_facilitator.js +286 -286
- package/server.js +11976 -11974
|
@@ -1,217 +1,217 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
// Provider-agnostic hosted-checkout processor adapter (fiat money-in).
|
|
4
|
-
//
|
|
5
|
-
// Turns the synthetic hosted-checkout scaffold into a real fiat rail WITHOUT
|
|
6
|
-
// provider lock-in and WITHOUT changing existing behavior: it engages only when
|
|
7
|
-
// env configures a processor. When unconfigured, every function reports
|
|
8
|
-
// "not configured" and the caller preserves the current synthetic flow.
|
|
9
|
-
//
|
|
10
|
-
// First provider: Stripe (Checkout Sessions + webhooks). Dependency-free
|
|
11
|
-
// (node https + crypto). To add another processor, extend the provider
|
|
12
|
-
// switches below; nothing else changes.
|
|
13
|
-
//
|
|
14
|
-
// Env:
|
|
15
|
-
// XYTARA_CHECKOUT_PROCESSOR = "stripe" (unset/empty => not configured)
|
|
16
|
-
// STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET
|
|
17
|
-
// XYTARA_CHECKOUT_SUCCESS_URL, XYTARA_CHECKOUT_CANCEL_URL
|
|
18
|
-
// XYTARA_CHECKOUT_PRODUCT_NAME (optional), STRIPE_WEBHOOK_TOLERANCE_SECONDS (optional)
|
|
19
|
-
|
|
20
|
-
const https = require("https");
|
|
21
|
-
const crypto = require("crypto");
|
|
22
|
-
|
|
23
|
-
function env(name) {
|
|
24
|
-
const v = process.env[name];
|
|
25
|
-
return typeof v === "string" && v.trim() ? v.trim() : "";
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function loadCheckoutProcessorConfig() {
|
|
29
|
-
const provider = env("XYTARA_CHECKOUT_PROCESSOR").toLowerCase();
|
|
30
|
-
if (provider === "stripe") {
|
|
31
|
-
return {
|
|
32
|
-
provider: "stripe",
|
|
33
|
-
secretKey: env("STRIPE_SECRET_KEY"),
|
|
34
|
-
webhookSecret: env("STRIPE_WEBHOOK_SECRET"),
|
|
35
|
-
successUrl: env("XYTARA_CHECKOUT_SUCCESS_URL") || env("STRIPE_SUCCESS_URL"),
|
|
36
|
-
cancelUrl: env("XYTARA_CHECKOUT_CANCEL_URL") || env("STRIPE_CANCEL_URL"),
|
|
37
|
-
productName: env("XYTARA_CHECKOUT_PRODUCT_NAME") || "naxytra runtime credits",
|
|
38
|
-
webhookToleranceSeconds: Math.max(60, Number(env("STRIPE_WEBHOOK_TOLERANCE_SECONDS") || 300) || 300)
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
return { provider: null };
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function isConfigured(config) {
|
|
45
|
-
return Boolean(config && config.provider);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function normalizeCurrency(currency) {
|
|
49
|
-
const c = String(currency || "usd").toLowerCase();
|
|
50
|
-
// The runtime books some prices as USD_CENTS; the minor-unit amount is already
|
|
51
|
-
// in cents, so the processor currency is plain "usd".
|
|
52
|
-
return c === "usd_cents" ? "usd" : c;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// Minimal form-encoded POST to Stripe. Resolves { status, json, raw }.
|
|
56
|
-
function stripePost(path, formParams, secretKey, timeoutMs) {
|
|
57
|
-
const body = new URLSearchParams(formParams).toString();
|
|
58
|
-
return new Promise((resolve, reject) => {
|
|
59
|
-
const req = https.request(
|
|
60
|
-
{
|
|
61
|
-
method: "POST",
|
|
62
|
-
hostname: "api.stripe.com",
|
|
63
|
-
path,
|
|
64
|
-
headers: {
|
|
65
|
-
Authorization: "Bearer " + secretKey,
|
|
66
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
67
|
-
"Content-Length": Buffer.byteLength(body)
|
|
68
|
-
}
|
|
69
|
-
},
|
|
70
|
-
(res) => {
|
|
71
|
-
let data = "";
|
|
72
|
-
res.on("data", (chunk) => {
|
|
73
|
-
data += chunk;
|
|
74
|
-
});
|
|
75
|
-
res.on("end", () => {
|
|
76
|
-
let json = null;
|
|
77
|
-
try {
|
|
78
|
-
json = data ? JSON.parse(data) : null;
|
|
79
|
-
} catch (_) {
|
|
80
|
-
json = null;
|
|
81
|
-
}
|
|
82
|
-
resolve({ status: res.statusCode, json, raw: data });
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
);
|
|
86
|
-
req.on("error", reject);
|
|
87
|
-
req.setTimeout(timeoutMs || 15000, () => {
|
|
88
|
-
req.destroy(new Error("stripe_request_timeout"));
|
|
89
|
-
});
|
|
90
|
-
req.write(body);
|
|
91
|
-
req.end();
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Create a real processor checkout session for a purchase intent.
|
|
96
|
-
// `intent` = { amountMinor, currency, units, accountId, purchaseIntentId, successUrl?, cancelUrl? }
|
|
97
|
-
// Returns { ok, checkout_url, checkout_reference, checkout_provider } | { ok:false, reason, detail? }.
|
|
98
|
-
async function createProcessorCheckoutSession(config, intent) {
|
|
99
|
-
if (!isConfigured(config)) return { ok: false, reason: "processor_not_configured" };
|
|
100
|
-
if (config.provider === "stripe") {
|
|
101
|
-
if (!config.secretKey) return { ok: false, reason: "stripe_secret_key_missing" };
|
|
102
|
-
const amountMinor = Number(intent && intent.amountMinor);
|
|
103
|
-
if (!Number.isFinite(amountMinor) || amountMinor <= 0) return { ok: false, reason: "invalid_amount" };
|
|
104
|
-
const successUrl = (intent && intent.successUrl) || config.successUrl;
|
|
105
|
-
const cancelUrl = (intent && intent.cancelUrl) || config.cancelUrl;
|
|
106
|
-
if (!successUrl || !cancelUrl) return { ok: false, reason: "success_or_cancel_url_missing" };
|
|
107
|
-
const params = {
|
|
108
|
-
mode: "payment",
|
|
109
|
-
success_url: successUrl,
|
|
110
|
-
cancel_url: cancelUrl,
|
|
111
|
-
"line_items[0][quantity]": "1",
|
|
112
|
-
"line_items[0][price_data][currency]": normalizeCurrency(intent.currency),
|
|
113
|
-
"line_items[0][price_data][unit_amount]": String(Math.round(amountMinor)),
|
|
114
|
-
"line_items[0][price_data][product_data][name]": config.productName,
|
|
115
|
-
"metadata[purchase_intent_id]": String((intent && intent.purchaseIntentId) || ""),
|
|
116
|
-
"metadata[account_id]": String((intent && intent.accountId) || ""),
|
|
117
|
-
"metadata[credits_units]": String((intent && intent.units) || "")
|
|
118
|
-
};
|
|
119
|
-
let res;
|
|
120
|
-
try {
|
|
121
|
-
res = await stripePost("/v1/checkout/sessions", params, config.secretKey, 15000);
|
|
122
|
-
} catch (e) {
|
|
123
|
-
return { ok: false, reason: "stripe_unreachable", detail: e && e.message ? e.message : "request_error" };
|
|
124
|
-
}
|
|
125
|
-
if (!res.json || res.status >= 400) {
|
|
126
|
-
const detail = res.json && res.json.error ? res.json.error.message : `HTTP ${res.status}`;
|
|
127
|
-
return { ok: false, reason: "stripe_session_create_failed", detail };
|
|
128
|
-
}
|
|
129
|
-
return { ok: true, checkout_url: res.json.url, checkout_reference: res.json.id, checkout_provider: "stripe" };
|
|
130
|
-
}
|
|
131
|
-
return { ok: false, reason: "unsupported_processor" };
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Verify a processor webhook request. Returns { ok, event } | { ok:false, reason }.
|
|
135
|
-
// Uses the PROCESSOR's own signature scheme (Stripe: Stripe-Signature "t=,v1=").
|
|
136
|
-
function verifyProcessorWebhook(config, headers, rawBody) {
|
|
137
|
-
if (!isConfigured(config)) return { ok: false, reason: "processor_not_configured" };
|
|
138
|
-
if (config.provider === "stripe") {
|
|
139
|
-
if (!config.webhookSecret) return { ok: false, reason: "stripe_webhook_secret_missing" };
|
|
140
|
-
const lower = {};
|
|
141
|
-
for (const k in headers || {}) lower[k.toLowerCase()] = headers[k];
|
|
142
|
-
const sigHeader = String(lower["stripe-signature"] || "");
|
|
143
|
-
const parts = {};
|
|
144
|
-
sigHeader.split(",").forEach((piece) => {
|
|
145
|
-
const idx = piece.indexOf("=");
|
|
146
|
-
if (idx === -1) return;
|
|
147
|
-
const k = piece.slice(0, idx).trim();
|
|
148
|
-
const v = piece.slice(idx + 1).trim();
|
|
149
|
-
(parts[k] = parts[k] || []).push(v);
|
|
150
|
-
});
|
|
151
|
-
const t = parts.t && parts.t[0];
|
|
152
|
-
const v1s = parts.v1 || [];
|
|
153
|
-
if (!t || !v1s.length) return { ok: false, reason: "stripe_signature_malformed" };
|
|
154
|
-
const ageSeconds = Math.abs(Date.now() / 1000 - Number(t));
|
|
155
|
-
if (!Number.isFinite(ageSeconds) || ageSeconds > config.webhookToleranceSeconds) {
|
|
156
|
-
return { ok: false, reason: "stripe_timestamp_out_of_tolerance" };
|
|
157
|
-
}
|
|
158
|
-
const expected = crypto.createHmac("sha256", config.webhookSecret).update(`${t}.${rawBody}`, "utf8").digest("hex");
|
|
159
|
-
const match = v1s.some((v) => {
|
|
160
|
-
try {
|
|
161
|
-
return v.length === expected.length && crypto.timingSafeEqual(Buffer.from(v, "hex"), Buffer.from(expected, "hex"));
|
|
162
|
-
} catch (_) {
|
|
163
|
-
return false;
|
|
164
|
-
}
|
|
165
|
-
});
|
|
166
|
-
if (!match) return { ok: false, reason: "stripe_signature_invalid" };
|
|
167
|
-
let event = null;
|
|
168
|
-
try {
|
|
169
|
-
event = JSON.parse(rawBody);
|
|
170
|
-
} catch (_) {
|
|
171
|
-
return { ok: false, reason: "stripe_body_not_json" };
|
|
172
|
-
}
|
|
173
|
-
return { ok: true, event };
|
|
174
|
-
}
|
|
175
|
-
return { ok: false, reason: "unsupported_processor" };
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// Build the Stripe-Signature header value for a raw body (used by tests and by
|
|
179
|
-
// anyone generating a signed event). Not used in production verification.
|
|
180
|
-
function buildStripeSignatureHeader(webhookSecret, rawBody, timestampSeconds) {
|
|
181
|
-
const t = String(timestampSeconds || Math.floor(Date.now() / 1000));
|
|
182
|
-
const v1 = crypto.createHmac("sha256", webhookSecret).update(`${t}.${rawBody}`, "utf8").digest("hex");
|
|
183
|
-
return `t=${t},v1=${v1}`;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Translate a verified webhook event into a credit-grant body (matches
|
|
187
|
-
// createExternalCreditGrant), or null when it is not a completed payment.
|
|
188
|
-
function mapProcessorEventToGrant(config, event) {
|
|
189
|
-
if (!isConfigured(config) || !event || typeof event !== "object") return null;
|
|
190
|
-
if (config.provider === "stripe") {
|
|
191
|
-
if (event.type !== "checkout.session.completed") return null;
|
|
192
|
-
const s = (event.data && event.data.object) || {};
|
|
193
|
-
if (s.payment_status !== "paid") return null;
|
|
194
|
-
const md = s.metadata || {};
|
|
195
|
-
return {
|
|
196
|
-
account_id: md.account_id || null,
|
|
197
|
-
units: Number(md.credits_units) || null,
|
|
198
|
-
source: "stripe",
|
|
199
|
-
provider_reference: s.payment_intent || s.id || null,
|
|
200
|
-
idempotency_key: "stripe:" + (event.id || s.id),
|
|
201
|
-
amount_minor: Number(s.amount_total),
|
|
202
|
-
currency: String(s.currency || "usd").toUpperCase(),
|
|
203
|
-
purchase_intent_id: md.purchase_intent_id || null,
|
|
204
|
-
metadata: { processor: "stripe", checkout_session_id: s.id, event_id: event.id }
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
return null;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
module.exports = {
|
|
211
|
-
loadCheckoutProcessorConfig,
|
|
212
|
-
isConfigured,
|
|
213
|
-
createProcessorCheckoutSession,
|
|
214
|
-
verifyProcessorWebhook,
|
|
215
|
-
buildStripeSignatureHeader,
|
|
216
|
-
mapProcessorEventToGrant
|
|
217
|
-
};
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Provider-agnostic hosted-checkout processor adapter (fiat money-in).
|
|
4
|
+
//
|
|
5
|
+
// Turns the synthetic hosted-checkout scaffold into a real fiat rail WITHOUT
|
|
6
|
+
// provider lock-in and WITHOUT changing existing behavior: it engages only when
|
|
7
|
+
// env configures a processor. When unconfigured, every function reports
|
|
8
|
+
// "not configured" and the caller preserves the current synthetic flow.
|
|
9
|
+
//
|
|
10
|
+
// First provider: Stripe (Checkout Sessions + webhooks). Dependency-free
|
|
11
|
+
// (node https + crypto). To add another processor, extend the provider
|
|
12
|
+
// switches below; nothing else changes.
|
|
13
|
+
//
|
|
14
|
+
// Env:
|
|
15
|
+
// XYTARA_CHECKOUT_PROCESSOR = "stripe" (unset/empty => not configured)
|
|
16
|
+
// STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET
|
|
17
|
+
// XYTARA_CHECKOUT_SUCCESS_URL, XYTARA_CHECKOUT_CANCEL_URL
|
|
18
|
+
// XYTARA_CHECKOUT_PRODUCT_NAME (optional), STRIPE_WEBHOOK_TOLERANCE_SECONDS (optional)
|
|
19
|
+
|
|
20
|
+
const https = require("https");
|
|
21
|
+
const crypto = require("crypto");
|
|
22
|
+
|
|
23
|
+
function env(name) {
|
|
24
|
+
const v = process.env[name];
|
|
25
|
+
return typeof v === "string" && v.trim() ? v.trim() : "";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function loadCheckoutProcessorConfig() {
|
|
29
|
+
const provider = env("XYTARA_CHECKOUT_PROCESSOR").toLowerCase();
|
|
30
|
+
if (provider === "stripe") {
|
|
31
|
+
return {
|
|
32
|
+
provider: "stripe",
|
|
33
|
+
secretKey: env("STRIPE_SECRET_KEY"),
|
|
34
|
+
webhookSecret: env("STRIPE_WEBHOOK_SECRET"),
|
|
35
|
+
successUrl: env("XYTARA_CHECKOUT_SUCCESS_URL") || env("STRIPE_SUCCESS_URL"),
|
|
36
|
+
cancelUrl: env("XYTARA_CHECKOUT_CANCEL_URL") || env("STRIPE_CANCEL_URL"),
|
|
37
|
+
productName: env("XYTARA_CHECKOUT_PRODUCT_NAME") || "naxytra runtime credits",
|
|
38
|
+
webhookToleranceSeconds: Math.max(60, Number(env("STRIPE_WEBHOOK_TOLERANCE_SECONDS") || 300) || 300)
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
return { provider: null };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isConfigured(config) {
|
|
45
|
+
return Boolean(config && config.provider);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeCurrency(currency) {
|
|
49
|
+
const c = String(currency || "usd").toLowerCase();
|
|
50
|
+
// The runtime books some prices as USD_CENTS; the minor-unit amount is already
|
|
51
|
+
// in cents, so the processor currency is plain "usd".
|
|
52
|
+
return c === "usd_cents" ? "usd" : c;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Minimal form-encoded POST to Stripe. Resolves { status, json, raw }.
|
|
56
|
+
function stripePost(path, formParams, secretKey, timeoutMs) {
|
|
57
|
+
const body = new URLSearchParams(formParams).toString();
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
const req = https.request(
|
|
60
|
+
{
|
|
61
|
+
method: "POST",
|
|
62
|
+
hostname: "api.stripe.com",
|
|
63
|
+
path,
|
|
64
|
+
headers: {
|
|
65
|
+
Authorization: "Bearer " + secretKey,
|
|
66
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
67
|
+
"Content-Length": Buffer.byteLength(body)
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
(res) => {
|
|
71
|
+
let data = "";
|
|
72
|
+
res.on("data", (chunk) => {
|
|
73
|
+
data += chunk;
|
|
74
|
+
});
|
|
75
|
+
res.on("end", () => {
|
|
76
|
+
let json = null;
|
|
77
|
+
try {
|
|
78
|
+
json = data ? JSON.parse(data) : null;
|
|
79
|
+
} catch (_) {
|
|
80
|
+
json = null;
|
|
81
|
+
}
|
|
82
|
+
resolve({ status: res.statusCode, json, raw: data });
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
);
|
|
86
|
+
req.on("error", reject);
|
|
87
|
+
req.setTimeout(timeoutMs || 15000, () => {
|
|
88
|
+
req.destroy(new Error("stripe_request_timeout"));
|
|
89
|
+
});
|
|
90
|
+
req.write(body);
|
|
91
|
+
req.end();
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Create a real processor checkout session for a purchase intent.
|
|
96
|
+
// `intent` = { amountMinor, currency, units, accountId, purchaseIntentId, successUrl?, cancelUrl? }
|
|
97
|
+
// Returns { ok, checkout_url, checkout_reference, checkout_provider } | { ok:false, reason, detail? }.
|
|
98
|
+
async function createProcessorCheckoutSession(config, intent) {
|
|
99
|
+
if (!isConfigured(config)) return { ok: false, reason: "processor_not_configured" };
|
|
100
|
+
if (config.provider === "stripe") {
|
|
101
|
+
if (!config.secretKey) return { ok: false, reason: "stripe_secret_key_missing" };
|
|
102
|
+
const amountMinor = Number(intent && intent.amountMinor);
|
|
103
|
+
if (!Number.isFinite(amountMinor) || amountMinor <= 0) return { ok: false, reason: "invalid_amount" };
|
|
104
|
+
const successUrl = (intent && intent.successUrl) || config.successUrl;
|
|
105
|
+
const cancelUrl = (intent && intent.cancelUrl) || config.cancelUrl;
|
|
106
|
+
if (!successUrl || !cancelUrl) return { ok: false, reason: "success_or_cancel_url_missing" };
|
|
107
|
+
const params = {
|
|
108
|
+
mode: "payment",
|
|
109
|
+
success_url: successUrl,
|
|
110
|
+
cancel_url: cancelUrl,
|
|
111
|
+
"line_items[0][quantity]": "1",
|
|
112
|
+
"line_items[0][price_data][currency]": normalizeCurrency(intent.currency),
|
|
113
|
+
"line_items[0][price_data][unit_amount]": String(Math.round(amountMinor)),
|
|
114
|
+
"line_items[0][price_data][product_data][name]": config.productName,
|
|
115
|
+
"metadata[purchase_intent_id]": String((intent && intent.purchaseIntentId) || ""),
|
|
116
|
+
"metadata[account_id]": String((intent && intent.accountId) || ""),
|
|
117
|
+
"metadata[credits_units]": String((intent && intent.units) || "")
|
|
118
|
+
};
|
|
119
|
+
let res;
|
|
120
|
+
try {
|
|
121
|
+
res = await stripePost("/v1/checkout/sessions", params, config.secretKey, 15000);
|
|
122
|
+
} catch (e) {
|
|
123
|
+
return { ok: false, reason: "stripe_unreachable", detail: e && e.message ? e.message : "request_error" };
|
|
124
|
+
}
|
|
125
|
+
if (!res.json || res.status >= 400) {
|
|
126
|
+
const detail = res.json && res.json.error ? res.json.error.message : `HTTP ${res.status}`;
|
|
127
|
+
return { ok: false, reason: "stripe_session_create_failed", detail };
|
|
128
|
+
}
|
|
129
|
+
return { ok: true, checkout_url: res.json.url, checkout_reference: res.json.id, checkout_provider: "stripe" };
|
|
130
|
+
}
|
|
131
|
+
return { ok: false, reason: "unsupported_processor" };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Verify a processor webhook request. Returns { ok, event } | { ok:false, reason }.
|
|
135
|
+
// Uses the PROCESSOR's own signature scheme (Stripe: Stripe-Signature "t=,v1=").
|
|
136
|
+
function verifyProcessorWebhook(config, headers, rawBody) {
|
|
137
|
+
if (!isConfigured(config)) return { ok: false, reason: "processor_not_configured" };
|
|
138
|
+
if (config.provider === "stripe") {
|
|
139
|
+
if (!config.webhookSecret) return { ok: false, reason: "stripe_webhook_secret_missing" };
|
|
140
|
+
const lower = {};
|
|
141
|
+
for (const k in headers || {}) lower[k.toLowerCase()] = headers[k];
|
|
142
|
+
const sigHeader = String(lower["stripe-signature"] || "");
|
|
143
|
+
const parts = {};
|
|
144
|
+
sigHeader.split(",").forEach((piece) => {
|
|
145
|
+
const idx = piece.indexOf("=");
|
|
146
|
+
if (idx === -1) return;
|
|
147
|
+
const k = piece.slice(0, idx).trim();
|
|
148
|
+
const v = piece.slice(idx + 1).trim();
|
|
149
|
+
(parts[k] = parts[k] || []).push(v);
|
|
150
|
+
});
|
|
151
|
+
const t = parts.t && parts.t[0];
|
|
152
|
+
const v1s = parts.v1 || [];
|
|
153
|
+
if (!t || !v1s.length) return { ok: false, reason: "stripe_signature_malformed" };
|
|
154
|
+
const ageSeconds = Math.abs(Date.now() / 1000 - Number(t));
|
|
155
|
+
if (!Number.isFinite(ageSeconds) || ageSeconds > config.webhookToleranceSeconds) {
|
|
156
|
+
return { ok: false, reason: "stripe_timestamp_out_of_tolerance" };
|
|
157
|
+
}
|
|
158
|
+
const expected = crypto.createHmac("sha256", config.webhookSecret).update(`${t}.${rawBody}`, "utf8").digest("hex");
|
|
159
|
+
const match = v1s.some((v) => {
|
|
160
|
+
try {
|
|
161
|
+
return v.length === expected.length && crypto.timingSafeEqual(Buffer.from(v, "hex"), Buffer.from(expected, "hex"));
|
|
162
|
+
} catch (_) {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
if (!match) return { ok: false, reason: "stripe_signature_invalid" };
|
|
167
|
+
let event = null;
|
|
168
|
+
try {
|
|
169
|
+
event = JSON.parse(rawBody);
|
|
170
|
+
} catch (_) {
|
|
171
|
+
return { ok: false, reason: "stripe_body_not_json" };
|
|
172
|
+
}
|
|
173
|
+
return { ok: true, event };
|
|
174
|
+
}
|
|
175
|
+
return { ok: false, reason: "unsupported_processor" };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Build the Stripe-Signature header value for a raw body (used by tests and by
|
|
179
|
+
// anyone generating a signed event). Not used in production verification.
|
|
180
|
+
function buildStripeSignatureHeader(webhookSecret, rawBody, timestampSeconds) {
|
|
181
|
+
const t = String(timestampSeconds || Math.floor(Date.now() / 1000));
|
|
182
|
+
const v1 = crypto.createHmac("sha256", webhookSecret).update(`${t}.${rawBody}`, "utf8").digest("hex");
|
|
183
|
+
return `t=${t},v1=${v1}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Translate a verified webhook event into a credit-grant body (matches
|
|
187
|
+
// createExternalCreditGrant), or null when it is not a completed payment.
|
|
188
|
+
function mapProcessorEventToGrant(config, event) {
|
|
189
|
+
if (!isConfigured(config) || !event || typeof event !== "object") return null;
|
|
190
|
+
if (config.provider === "stripe") {
|
|
191
|
+
if (event.type !== "checkout.session.completed") return null;
|
|
192
|
+
const s = (event.data && event.data.object) || {};
|
|
193
|
+
if (s.payment_status !== "paid") return null;
|
|
194
|
+
const md = s.metadata || {};
|
|
195
|
+
return {
|
|
196
|
+
account_id: md.account_id || null,
|
|
197
|
+
units: Number(md.credits_units) || null,
|
|
198
|
+
source: "stripe",
|
|
199
|
+
provider_reference: s.payment_intent || s.id || null,
|
|
200
|
+
idempotency_key: "stripe:" + (event.id || s.id),
|
|
201
|
+
amount_minor: Number(s.amount_total),
|
|
202
|
+
currency: String(s.currency || "usd").toUpperCase(),
|
|
203
|
+
purchase_intent_id: md.purchase_intent_id || null,
|
|
204
|
+
metadata: { processor: "stripe", checkout_session_id: s.id, event_id: event.id }
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
module.exports = {
|
|
211
|
+
loadCheckoutProcessorConfig,
|
|
212
|
+
isConfigured,
|
|
213
|
+
createProcessorCheckoutSession,
|
|
214
|
+
verifyProcessorWebhook,
|
|
215
|
+
buildStripeSignatureHeader,
|
|
216
|
+
mapProcessorEventToGrant
|
|
217
|
+
};
|