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
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Money-safety policy for the buyer path. Like the provider endpoint policy, each rule is
|
|
4
|
+
// "strict" by default on a hosted deploy (RENDER is set) and "permissive" locally, so the local
|
|
5
|
+
// verification suites (which exercise catalog tasks without a live execution target) keep working.
|
|
6
|
+
//
|
|
7
|
+
// - Paid execution: only a task that resolves to a REAL execution target (an operator-pinned
|
|
8
|
+
// env target or an active open-network provider) may be quoted for payment. Anything else is
|
|
9
|
+
// refused up front with capability_not_executable, so a buyer is never charged for a
|
|
10
|
+
// synthetic result. (XYTARA_PAID_EXECUTION_POLICY=strict|permissive)
|
|
11
|
+
// - Credit spend: spending an account's credits requires a spend credential (xytara_spend_…
|
|
12
|
+
// bearer) bound to that account. A bare account_id is never authority — payer addresses are
|
|
13
|
+
// public on-chain. (XYTARA_CREDIT_SPEND_POLICY=strict|permissive)
|
|
14
|
+
|
|
15
|
+
function policyMode(envName) {
|
|
16
|
+
const raw = String(process.env[envName] || "").trim().toLowerCase();
|
|
17
|
+
if (raw === "strict" || raw === "permissive") return raw;
|
|
18
|
+
return process.env.RENDER ? "strict" : "permissive";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function paidExecutionPolicyMode() {
|
|
22
|
+
return policyMode("XYTARA_PAID_EXECUTION_POLICY");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function creditSpendPolicyMode() {
|
|
26
|
+
return policyMode("XYTARA_CREDIT_SPEND_POLICY");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Ledger/business reads (payer addresses, every quote, receipts, operations, revenue metrics) are
|
|
30
|
+
// operator-only on a hosted deploy. (XYTARA_LEDGER_READ_POLICY=strict|permissive)
|
|
31
|
+
function ledgerReadPolicyMode() {
|
|
32
|
+
return policyMode("XYTARA_LEDGER_READ_POLICY");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const PRIVATE_READ_PREFIXES = [
|
|
36
|
+
"/v1/payment-ledger",
|
|
37
|
+
"/v1/operator-export-pack",
|
|
38
|
+
"/v1/receipts",
|
|
39
|
+
"/v1/operations/",
|
|
40
|
+
"/v1/economics/accounts/",
|
|
41
|
+
"/v1/pricing-optimization/",
|
|
42
|
+
"/v1/revenue-signal",
|
|
43
|
+
"/v1/activity-ledger"
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
// Whether a GET is a private ledger read. A single quote by id stays public (its holder needs it).
|
|
47
|
+
function isPrivateLedgerRead(method, pathname) {
|
|
48
|
+
if (String(method || "").toUpperCase() !== "GET") return false;
|
|
49
|
+
if (pathname === "/v1/payment-quotes") return true;
|
|
50
|
+
return PRIVATE_READ_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`) || pathname.startsWith(`${prefix}-`));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Test networks: their USDC is free from faucets, so by default it cannot buy credits that are
|
|
54
|
+
// spendable on real (mainnet-paid-out) provider work. Pay-per-call on a testnet is unaffected.
|
|
55
|
+
const TESTNET_NETWORKS = new Set(["eip155:84532", "eip155:11155111"]);
|
|
56
|
+
|
|
57
|
+
function isTestnetNetwork(network) {
|
|
58
|
+
return TESTNET_NETWORKS.has(String(network || "").trim().toLowerCase());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// What one credit unit costs, in the payment asset's base units (USDC: 6 decimals). Default
|
|
62
|
+
// 2500 = $0.0025, so a decision-support call (3 units) costs $0.0075 — never more than the flat
|
|
63
|
+
// pay-per-call price for the executable lanes.
|
|
64
|
+
function creditUnitPriceAtomic() {
|
|
65
|
+
const value = Number(process.env.XYTARA_CREDIT_UNIT_PRICE_ATOMIC || 2500);
|
|
66
|
+
return Number.isFinite(value) && value >= 1 ? Math.floor(value) : 2500;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// A top-up is priced as a multiple of the per-call accepts amount (default 100x: $0.01 -> $1.00).
|
|
70
|
+
function topupAmountMultiplier() {
|
|
71
|
+
const value = Number(process.env.XYTARA_X402_TOPUP_AMOUNT_MULTIPLIER || 100);
|
|
72
|
+
return Number.isFinite(value) && value >= 1 ? Math.floor(value) : 100;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The accepts a credit top-up is paid with: mainnet-only unless testnet top-ups are explicitly
|
|
76
|
+
// enabled, each amount scaled to the top-up price.
|
|
77
|
+
function buildTopupAccepts(accepts) {
|
|
78
|
+
const allowTestnet = String(process.env.XYTARA_X402_TESTNET_TOPUP || "").trim() === "1";
|
|
79
|
+
const multiplier = BigInt(topupAmountMultiplier());
|
|
80
|
+
return (Array.isArray(accepts) ? accepts : [])
|
|
81
|
+
.filter((accept) => accept && (allowTestnet || !isTestnetNetwork(accept.network)))
|
|
82
|
+
.filter((accept) => /^\d{1,30}$/.test(String(accept.amount)))
|
|
83
|
+
.map((accept) => ({ ...accept, amount: (BigInt(String(accept.amount)) * multiplier).toString() }));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Credits granted for a settled top-up: always derived from what was actually paid, so a unit is
|
|
87
|
+
// never worth less than creditUnitPriceAtomic(). An accepts entry's own extra.credits may lower
|
|
88
|
+
// that, never raise it.
|
|
89
|
+
function topupUnitsForAccept(accept) {
|
|
90
|
+
const amount = accept && /^\d{1,30}$/.test(String(accept.amount)) ? Number(accept.amount) : 0;
|
|
91
|
+
const derived = Math.floor(amount / creditUnitPriceAtomic());
|
|
92
|
+
const explicit = accept && accept.extra ? Number(accept.extra.credits) : NaN;
|
|
93
|
+
return Number.isFinite(explicit) && explicit > 0 ? Math.min(Math.floor(explicit), derived) : derived;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// While the durable state has not been restored yet (boot, or a Supabase outage), balances read
|
|
97
|
+
// as empty: a credit spend would wrongly say "insufficient credits" and a top-up could be lost if
|
|
98
|
+
// the process died before the restore merged. Those routes answer 503 + Retry-After instead.
|
|
99
|
+
let stateRestorePendingProbe = () => false;
|
|
100
|
+
|
|
101
|
+
function setStateRestorePendingProbe(probe) {
|
|
102
|
+
stateRestorePendingProbe = typeof probe === "function" ? probe : () => false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isStateRestorePending() {
|
|
106
|
+
try {
|
|
107
|
+
return stateRestorePendingProbe() === true;
|
|
108
|
+
} catch (_) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const STATE_RESTORE_PENDING_RESPONSE = Object.freeze({
|
|
114
|
+
ok: false,
|
|
115
|
+
reason: "state_restore_pending",
|
|
116
|
+
detail: "Account balances are being restored after a restart. Retry in about 30 seconds; nothing was charged. Pay-per-call x402 (without payment_preference) works meanwhile."
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
module.exports = {
|
|
120
|
+
STATE_RESTORE_PENDING_RESPONSE,
|
|
121
|
+
isPrivateLedgerRead,
|
|
122
|
+
ledgerReadPolicyMode,
|
|
123
|
+
isStateRestorePending,
|
|
124
|
+
setStateRestorePendingProbe,
|
|
125
|
+
paidExecutionPolicyMode,
|
|
126
|
+
creditSpendPolicyMode,
|
|
127
|
+
isTestnetNetwork,
|
|
128
|
+
creditUnitPriceAtomic,
|
|
129
|
+
topupAmountMultiplier,
|
|
130
|
+
buildTopupAccepts,
|
|
131
|
+
topupUnitsForAccept
|
|
132
|
+
};
|
|
@@ -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
|
+
};
|