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.
Files changed (68) hide show
  1. package/.env.example +38 -4
  2. package/BSV_TERANODE_PLATFORM_DISCONTINUITY_RUNBOOK.md +126 -126
  3. package/REAL_PAYMENT_SETUP.md +14 -7
  4. package/RELEASE_NOTES.md +9 -4
  5. package/fixtures/catalog.js +22 -0
  6. package/lib/a2a_rpc.js +287 -280
  7. package/lib/account_auth.js +585 -585
  8. package/lib/agent_identity.js +162 -162
  9. package/lib/asyncapi_contract.js +3 -3
  10. package/lib/bolt_adapter.js +187 -187
  11. package/lib/bolt_payment_front.js +224 -224
  12. package/lib/buyer_event_log.js +215 -0
  13. package/lib/buyer_safety_policy.js +132 -0
  14. package/lib/checkout_processor_adapter.js +217 -217
  15. package/lib/command_flow.js +911 -722
  16. package/lib/commerce_artifacts.js +591 -591
  17. package/lib/commerce_bridge.js +274 -274
  18. package/lib/commerce_cases.js +399 -399
  19. package/lib/commerce_client.js +7694 -7694
  20. package/lib/commerce_quotes.js +29 -0
  21. package/lib/commerce_runtime.js +322 -322
  22. package/lib/execution_dedupe.js +86 -86
  23. package/lib/external_credit_bridge.js +142 -142
  24. package/lib/http_transport.js +99 -81
  25. package/lib/l402_adapter.js +241 -241
  26. package/lib/l402_payment_front.js +265 -265
  27. package/lib/mpp_adapter.js +237 -237
  28. package/lib/openapi_contract.js +4 -0
  29. package/lib/payment_config.js +257 -253
  30. package/lib/payment_verification.js +357 -357
  31. package/lib/provider_payout_execution.js +155 -155
  32. package/lib/provider_registry.js +852 -842
  33. package/lib/rate_limiter.js +81 -81
  34. package/lib/runtime_state_store.js +495 -369
  35. package/lib/settlement_bsv_live.js +600 -600
  36. package/lib/settlement_bsv_teranode.js +117 -117
  37. package/lib/stripe_mpp_payment_front.js +258 -258
  38. package/lib/x402_facilitator.js +198 -191
  39. package/lib/x402_lane_contract.js +5 -3
  40. package/lib/x402_settlement_recovery.js +175 -0
  41. package/package.json +9 -3
  42. package/scripts/buyer_events_daily_report.js +79 -0
  43. package/scripts/state_backup_offsite.js +196 -0
  44. package/scripts/verify_a2a_rpc.js +131 -105
  45. package/scripts/verify_agent_identity.js +153 -153
  46. package/scripts/verify_all.js +1 -1
  47. package/scripts/verify_bolt_adapter.js +128 -128
  48. package/scripts/verify_buyer_event_log.js +123 -0
  49. package/scripts/verify_buyer_safety.js +141 -0
  50. package/scripts/verify_cdp_facilitator.js +110 -110
  51. package/scripts/verify_checkout_processor.js +109 -109
  52. package/scripts/verify_commerce_bridge.js +177 -177
  53. package/scripts/verify_execution_dedupe.js +189 -189
  54. package/scripts/verify_execution_surfacing.js +76 -76
  55. package/scripts/verify_final_activation.js +191 -191
  56. package/scripts/verify_l402_adapter.js +86 -86
  57. package/scripts/verify_lifecycle_spend_binding.js +88 -0
  58. package/scripts/verify_mpp_adapter.js +128 -128
  59. package/scripts/verify_production_readiness.js +268 -268
  60. package/scripts/verify_rate_limiter.js +52 -52
  61. package/scripts/verify_refund_credit.js +106 -106
  62. package/scripts/verify_release_candidate.js +81 -81
  63. package/scripts/verify_runtime_state_guard.js +117 -0
  64. package/scripts/verify_service.js +15421 -15421
  65. package/scripts/verify_verify_execute_settle.js +209 -208
  66. package/scripts/verify_x402_facilitator.js +307 -286
  67. package/scripts/verify_x402_settlement_recovery.js +153 -0
  68. 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
+ };