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,79 @@
1
+ "use strict";
2
+
3
+ // Daily buyer-journey report: reads the operator-only failure summary from the live runtime and
4
+ // prints it as Markdown (to $GITHUB_STEP_SUMMARY when run in Actions). Exits 1 — so GitHub emails
5
+ // the workflow owner — when money moved without a delivered result, or when failures that are OUR
6
+ // fault exceed the threshold. Exits 0 with a notice when no operator token is configured.
7
+ //
8
+ // Usage: XYTARA_OPERATOR_READ_BEARER=... node scripts/buyer_events_daily_report.js [--days 1]
9
+ // Env: XYTARA_URL (default https://xytara.onrender.com), BUYER_EVENTS_OURS_THRESHOLD (default 0).
10
+
11
+ const fs = require("fs");
12
+
13
+ function formatReport(summary, events, baseUrl) {
14
+ const t = summary.totals || {};
15
+ const lines = [
16
+ `## naxytra buyer journey — last ${summary.window_days} day(s)`,
17
+ "",
18
+ `Runtime: ${baseUrl} · generated ${summary.generated_at_iso}`,
19
+ "",
20
+ "| Paid calls served | 402 challenges | Failures | Our fault | Upstream | Buyer-side | Money moved, nothing delivered |",
21
+ "|---:|---:|---:|---:|---:|---:|---:|",
22
+ `| ${t.paid_ok || 0} | ${t.challenges_issued || 0} | ${t.failures || 0} | ${t.ours || 0} | ${t.upstream || 0} | ${t.buyer || 0} | ${t.money_moved || 0} |`,
23
+ ""
24
+ ];
25
+ const reasons = Array.isArray(summary.top_reasons) ? summary.top_reasons : [];
26
+ if (reasons.length) {
27
+ lines.push("### Top failure reasons", "", "| Service | Stage | Reason | Fault | Count |", "|---|---|---|---|---:|");
28
+ for (const r of reasons.slice(0, 15)) lines.push(`| ${r.service} | ${r.stage} | ${r.reason} | ${r.fault} | ${r.count} |`);
29
+ lines.push("");
30
+ }
31
+ const notable = (Array.isArray(events) ? events : []).filter((e) => e.money_moved || e.fault === "ours").slice(0, 20);
32
+ if (notable.length) {
33
+ lines.push("### Needs a look (money moved or our fault)", "", "| When (UTC) | Service | Route / tool | Reason | Request id |", "|---|---|---|---|---|");
34
+ for (const e of notable) lines.push(`| ${e.at_iso} | ${e.service} | ${e.task_or_tool || e.route || ""} | ${e.reason}${e.money_moved ? " (money moved)" : ""} | ${e.request_id || ""} |`);
35
+ lines.push("");
36
+ }
37
+ return lines.join("\n");
38
+ }
39
+
40
+ async function main() {
41
+ const token = String(process.env.XYTARA_OPERATOR_READ_BEARER || "").trim();
42
+ const baseUrl = String(process.env.XYTARA_URL || "https://xytara.onrender.com").replace(/\/+$/, "");
43
+ const daysArg = process.argv.indexOf("--days");
44
+ const days = daysArg > -1 ? Number(process.argv[daysArg + 1]) || 1 : 1;
45
+ const out = (text) => {
46
+ if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${text}\n`);
47
+ console.log(text);
48
+ };
49
+ if (!token) {
50
+ out("Buyer journey report skipped: set the XYTARA_OPERATOR_READ_BEARER secret to enable it.");
51
+ return;
52
+ }
53
+ const headers = { authorization: `Bearer ${token}` };
54
+ // The runtime may be asleep on a free tier: allow for a cold start.
55
+ const get = async (path) => {
56
+ const res = await fetch(`${baseUrl}${path}`, { headers, signal: AbortSignal.timeout(90000) });
57
+ if (!res.ok) throw new Error(`${path} -> HTTP ${res.status}`);
58
+ return res.json();
59
+ };
60
+ const since = new Date(Date.now() - days * 86400000).toISOString();
61
+ const summary = await get(`/v1/operator/buyer-events/summary?days=${days}`);
62
+ const listed = await get(`/v1/operator/buyer-events?since=${encodeURIComponent(since)}&limit=200`);
63
+ out(formatReport(summary, listed.events, baseUrl));
64
+ const threshold = Number(process.env.BUYER_EVENTS_OURS_THRESHOLD || 0);
65
+ const totals = summary.totals || {};
66
+ if ((totals.money_moved || 0) > 0 || (totals.ours || 0) > threshold) {
67
+ console.error(`ATTENTION: money_moved=${totals.money_moved || 0}, our-fault failures=${totals.ours || 0} (threshold ${threshold}).`);
68
+ process.exit(1);
69
+ }
70
+ }
71
+
72
+ if (require.main === module) {
73
+ main().catch((error) => {
74
+ console.error(`Buyer journey report failed: ${error && error.message ? error.message : error}`);
75
+ process.exit(1);
76
+ });
77
+ }
78
+
79
+ module.exports = { formatReport };
@@ -0,0 +1,196 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // Off-site, encrypted backups of the runtime's durable state (the Supabase snapshot row).
5
+ //
6
+ // backup <out_dir> read the live row, sanity-check it, gzip + AES-256-GCM encrypt
7
+ // it, verify the ciphertext decrypts back, write <out_dir>/
8
+ // xytara-state-<persisted>.enc + manifest.json (no secrets)
9
+ // decrypt <file.enc> <out.json> decrypt a backup to the plain row { state_key, persisted_at_iso,
10
+ // snapshot }
11
+ // restore <file.enc> --runtime-stopped [--state-key k]
12
+ // write a decrypted backup back as the live row. ONLY with the
13
+ // runtime stopped (suspended on Render): a running runtime would
14
+ // overwrite it with its in-memory state on the next flush.
15
+ // selftest offline round-trip check (used by verify:all)
16
+ //
17
+ // Env: XYTARA_SUPABASE_URL, XYTARA_SUPABASE_SERVICE_ROLE_KEY, XYTARA_SUPABASE_STATE_TABLE
18
+ // (default xytara_runtime_state), XYTARA_SUPABASE_STATE_KEY (default "default"), STATE_BACKUP_KEY
19
+ // (base64, 32 bytes — keep a copy OFF this system: without it no backup can be decrypted),
20
+ // STATE_BACKUP_MIN_PROVIDERS (default 1), STATE_BACKUP_MIN_BYTES (default 10000).
21
+ // The snapshot contains secrets (provider owner secrets, auth tokens): it is only ever written
22
+ // encrypted, and the manifest carries counts and digests only.
23
+
24
+ const crypto = require("crypto");
25
+ const fs = require("fs");
26
+ const path = require("path");
27
+ const zlib = require("zlib");
28
+
29
+ const ENVELOPE_VERSION = "xytara-state-backup/1";
30
+
31
+ function fail(message) {
32
+ console.error(`state backup: ${message}`);
33
+ process.exit(1);
34
+ }
35
+
36
+ function backupKey() {
37
+ const raw = String(process.env.STATE_BACKUP_KEY || "").trim();
38
+ if (!raw) fail("STATE_BACKUP_KEY is not set");
39
+ const key = Buffer.from(raw, "base64");
40
+ if (key.length !== 32) fail("STATE_BACKUP_KEY must be 32 bytes, base64-encoded");
41
+ return key;
42
+ }
43
+
44
+ function encrypt(rowJson, key) {
45
+ const plaintext = zlib.gzipSync(Buffer.from(rowJson, "utf8"), { level: 9 });
46
+ const iv = crypto.randomBytes(12);
47
+ const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
48
+ cipher.setAAD(Buffer.from(ENVELOPE_VERSION, "utf8"));
49
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
50
+ return {
51
+ v: ENVELOPE_VERSION,
52
+ alg: "AES-256-GCM",
53
+ compression: "gzip",
54
+ iv: iv.toString("base64"),
55
+ tag: cipher.getAuthTag().toString("base64"),
56
+ plaintext_sha256: crypto.createHash("sha256").update(rowJson, "utf8").digest("hex"),
57
+ ciphertext: ciphertext.toString("base64")
58
+ };
59
+ }
60
+
61
+ function decrypt(envelope, key) {
62
+ if (!envelope || envelope.v !== ENVELOPE_VERSION) throw new Error("not a xytara state backup envelope");
63
+ const decipher = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(envelope.iv, "base64"));
64
+ decipher.setAAD(Buffer.from(ENVELOPE_VERSION, "utf8"));
65
+ decipher.setAuthTag(Buffer.from(envelope.tag, "base64"));
66
+ const plaintext = Buffer.concat([decipher.update(Buffer.from(envelope.ciphertext, "base64")), decipher.final()]);
67
+ const rowJson = zlib.gunzipSync(plaintext).toString("utf8");
68
+ if (crypto.createHash("sha256").update(rowJson, "utf8").digest("hex") !== envelope.plaintext_sha256) {
69
+ throw new Error("decrypted content digest mismatch");
70
+ }
71
+ return rowJson;
72
+ }
73
+
74
+ function entryCount(value) {
75
+ if (value && value.__runtime_state_kind && Array.isArray(value.value)) return value.value.length;
76
+ if (Array.isArray(value)) return value.length;
77
+ return value && typeof value === "object" ? Object.keys(value).length : 0;
78
+ }
79
+
80
+ function summarize(row, rowJson) {
81
+ const snapshot = row.snapshot || {};
82
+ const counts = {};
83
+ for (const key of ["providerRegistry", "providerSettlementLedger", "providerPayouts", "externalCreditGrants", "accountSpendCredentials", "transactions", "deliveryReceipts", "quotes"]) {
84
+ if (snapshot[key] !== undefined) counts[key] = entryCount(snapshot[key]);
85
+ }
86
+ return { state_key: row.state_key, persisted_at_iso: row.persisted_at_iso, snapshot_bytes: Buffer.byteLength(JSON.stringify(snapshot), "utf8"), row_bytes: Buffer.byteLength(rowJson, "utf8"), section_count: Object.keys(snapshot).length, counts };
87
+ }
88
+
89
+ function supabaseConfig() {
90
+ const url = String(process.env.XYTARA_SUPABASE_URL || "").trim().replace(/\/+$/, "");
91
+ const serviceKey = String(process.env.XYTARA_SUPABASE_SERVICE_ROLE_KEY || "").trim();
92
+ if (!url || !serviceKey) fail("XYTARA_SUPABASE_URL and XYTARA_SUPABASE_SERVICE_ROLE_KEY are required");
93
+ return {
94
+ url,
95
+ serviceKey,
96
+ table: String(process.env.XYTARA_SUPABASE_STATE_TABLE || "xytara_runtime_state").trim(),
97
+ stateKey: String(process.env.XYTARA_SUPABASE_STATE_KEY || "default").trim()
98
+ };
99
+ }
100
+
101
+ async function fetchRow(cfg) {
102
+ const endpoint = `${cfg.url}/rest/v1/${encodeURIComponent(cfg.table)}?state_key=eq.${encodeURIComponent(cfg.stateKey)}&select=state_key,snapshot,persisted_at_iso&limit=1`;
103
+ let lastError = null;
104
+ for (let attempt = 1; attempt <= 4; attempt += 1) {
105
+ try {
106
+ const response = await fetch(endpoint, {
107
+ headers: { apikey: cfg.serviceKey, authorization: `Bearer ${cfg.serviceKey}` },
108
+ signal: AbortSignal.timeout(45000)
109
+ });
110
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${(await response.text()).slice(0, 200)}`);
111
+ const rows = await response.json();
112
+ if (!Array.isArray(rows) || !rows[0]) throw new Error(`no row for state_key "${cfg.stateKey}"`);
113
+ return rows[0];
114
+ } catch (error) {
115
+ lastError = error;
116
+ if (attempt < 4) await new Promise((resolve) => setTimeout(resolve, attempt * 15000));
117
+ }
118
+ }
119
+ throw lastError;
120
+ }
121
+
122
+ async function backup(outDir) {
123
+ if (!outDir) fail("usage: backup <out_dir>");
124
+ const key = backupKey();
125
+ const row = await fetchRow(supabaseConfig()).catch((error) => fail(`could not read the state row: ${error.message}`));
126
+ const rowJson = JSON.stringify(row);
127
+ const summary = summarize(row, rowJson);
128
+ // Sanity gate: a near-empty snapshot means something already went wrong — fail loudly (the
129
+ // failed scheduled run is the alert) instead of quietly storing an empty backup.
130
+ const minProviders = Number(process.env.STATE_BACKUP_MIN_PROVIDERS || 1);
131
+ const minBytes = Number(process.env.STATE_BACKUP_MIN_BYTES || 10000);
132
+ if ((summary.counts.providerRegistry || 0) < minProviders) fail(`snapshot has ${summary.counts.providerRegistry || 0} providers (< ${minProviders}): refusing to treat it as healthy`);
133
+ if (summary.snapshot_bytes < minBytes) fail(`snapshot is only ${summary.snapshot_bytes} bytes (< ${minBytes}): refusing to treat it as healthy`);
134
+ const envelope = encrypt(rowJson, key);
135
+ if (decrypt(envelope, key) !== rowJson) fail("round-trip check failed");
136
+ fs.mkdirSync(outDir, { recursive: true });
137
+ const stamp = String(row.persisted_at_iso || new Date().toISOString()).replace(/[^0-9A-Za-z]/g, "").slice(0, 15);
138
+ const fileName = `xytara-state-${stamp}.enc`;
139
+ fs.writeFileSync(path.join(outDir, fileName), JSON.stringify(envelope));
140
+ const manifest = { v: ENVELOPE_VERSION, file: fileName, created_at_iso: new Date().toISOString(), plaintext_sha256: envelope.plaintext_sha256, ...summary };
141
+ fs.writeFileSync(path.join(outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
142
+ console.log(JSON.stringify(manifest, null, 2));
143
+ }
144
+
145
+ function decryptCommand(inFile, outFile) {
146
+ if (!inFile || !outFile) fail("usage: decrypt <file.enc> <out.json>");
147
+ const rowJson = decrypt(JSON.parse(fs.readFileSync(inFile, "utf8")), backupKey());
148
+ fs.writeFileSync(outFile, rowJson);
149
+ console.log(JSON.stringify(summarize(JSON.parse(rowJson), rowJson), null, 2));
150
+ }
151
+
152
+ async function restoreCommand(inFile, args) {
153
+ if (!inFile || !args.includes("--runtime-stopped")) {
154
+ fail("usage: restore <file.enc> --runtime-stopped [--state-key k] (suspend the runtime first, or it will overwrite the restored row)");
155
+ }
156
+ const cfg = supabaseConfig();
157
+ const keyIndex = args.indexOf("--state-key");
158
+ const stateKey = keyIndex >= 0 && args[keyIndex + 1] ? args[keyIndex + 1] : cfg.stateKey;
159
+ const row = JSON.parse(decrypt(JSON.parse(fs.readFileSync(inFile, "utf8")), backupKey()));
160
+ const response = await fetch(`${cfg.url}/rest/v1/${encodeURIComponent(cfg.table)}?on_conflict=state_key`, {
161
+ method: "POST",
162
+ headers: { apikey: cfg.serviceKey, authorization: `Bearer ${cfg.serviceKey}`, "content-type": "application/json", prefer: "resolution=merge-duplicates,return=minimal" },
163
+ body: JSON.stringify([{ state_key: stateKey, snapshot: row.snapshot, persisted_at_iso: row.persisted_at_iso }]),
164
+ signal: AbortSignal.timeout(60000)
165
+ });
166
+ if (!response.ok) fail(`restore write failed: HTTP ${response.status}`);
167
+ console.log(`restored ${row.state_key}@${row.persisted_at_iso} into state_key "${stateKey}". Resume the runtime now.`);
168
+ }
169
+
170
+ function selftest() {
171
+ const key = crypto.randomBytes(32);
172
+ const row = { state_key: "default", persisted_at_iso: "2026-10-17T00:00:00.000Z", snapshot: { providerRegistry: { __runtime_state_kind: "Map", value: [["p", { status: "active", owner_secret: "prvown_x" }]] } } };
173
+ const rowJson = JSON.stringify(row);
174
+ const envelope = encrypt(rowJson, key);
175
+ if (decrypt(envelope, key) !== rowJson) fail("selftest: round trip mismatch");
176
+ if (JSON.stringify(envelope).includes("prvown_x")) fail("selftest: plaintext secret visible in the envelope");
177
+ let wrongKeyRejected = false;
178
+ try { decrypt(envelope, crypto.randomBytes(32)); } catch (_) { wrongKeyRejected = true; }
179
+ if (!wrongKeyRejected) fail("selftest: a wrong key decrypted the backup");
180
+ const tampered = { ...envelope, ciphertext: Buffer.from(Buffer.from(envelope.ciphertext, "base64").map((b, i) => (i === 0 ? b ^ 1 : b))).toString("base64") };
181
+ let tamperRejected = false;
182
+ try { decrypt(tampered, key); } catch (_) { tamperRejected = true; }
183
+ if (!tamperRejected) fail("selftest: tampered ciphertext was accepted");
184
+ console.log("xytara state-backup selftest passed (round trip, no plaintext secrets, wrong key + tamper rejected)");
185
+ }
186
+
187
+ async function main() {
188
+ const [command, ...args] = process.argv.slice(2);
189
+ if (command === "backup") return backup(args[0]);
190
+ if (command === "decrypt") return decryptCommand(args[0], args[1]);
191
+ if (command === "restore") return restoreCommand(args[0], args);
192
+ if (command === "selftest") return selftest();
193
+ fail("usage: state_backup_offsite.js backup <out_dir> | decrypt <file.enc> <out.json> | restore <file.enc> --runtime-stopped | selftest");
194
+ }
195
+
196
+ main().catch((error) => fail(error && error.message ? error.message : String(error)));
@@ -1,105 +1,131 @@
1
- "use strict";
2
-
3
- // Verifies the A2A JSON-RPC endpoint with the in-band x402 payment extension
4
- // (lib/a2a_rpc.js). The facilitator verify/settle and the command executor are
5
- // injected as mocks so this runs offline and moves no money.
6
-
7
- const assert = require("assert");
8
- const {
9
- handleA2ARpc,
10
- META_PAYLOAD,
11
- META_STATUS,
12
- META_REQUIRED
13
- } = require("../lib/a2a_rpc");
14
-
15
- const accepts = [
16
- { scheme: "exact", network: "eip155:84532", asset: "0xUSDC", amount: "1000", payTo: "0xPAYTO", extra: { name: "USDC", version: "2" } }
17
- ];
18
- const config = { verification_mode: "x402_facilitator", x402: { accepts, facilitator_url: "https://facilitator", settle: true } };
19
-
20
- function deps(overrides) {
21
- return Object.assign(
22
- {
23
- createFacilitator: () => ({}),
24
- verifyX402Payment: async () => ({ ok: true, authorization: { payer: "0xPAYER", paymentRequirements: accepts[0] } }),
25
- settleX402Payment: async () => ({ ok: true, settlement: { success: true, transaction: "0xTX", network: "eip155:84532" } }),
26
- encodeReceipt: (s) => Buffer.from(JSON.stringify(s), "utf8").toString("base64"),
27
- executeAuthorized: async (state, payload, authz) => ({ status: 200, payload: { ok: true, ran: payload.task_ref, azmode: authz.verification_mode } }),
28
- buildNamedTaskPayload: (ref, body) => ({ command: ref, task_ref: ref, body }),
29
- state: {}
30
- },
31
- overrides || {}
32
- );
33
- }
34
-
35
- function send(params, ctxOverrides) {
36
- return handleA2ARpc(
37
- { jsonrpc: "2.0", id: 1, method: "message/send", params },
38
- Object.assign({ config, resourceUrl: "https://xytara.onrender.com/v1/a2a", deps: deps() }, ctxOverrides || {})
39
- );
40
- }
41
-
42
- async function main() {
43
- // 1. Unpaid -> input-required + x402 requirements in-band.
44
- let out = await send({ message: { metadata: { task_ref: "a2a.negotiate" } } });
45
- assert.strictEqual(out.http, 200);
46
- assert.strictEqual(out.body.result.status.state, "input-required");
47
- assert.strictEqual(out.body.result.status.message.metadata[META_STATUS], "payment-required");
48
- assert.deepStrictEqual(out.body.result.status.message.metadata[META_REQUIRED].accepts, accepts);
49
-
50
- // 2. Paid -> completed + PAYMENT-RESPONSE + executed through the x402 authz context.
51
- out = await send({ message: { metadata: { task_ref: "a2a.negotiate", [META_PAYLOAD]: "BASE64PAYLOAD" } } });
52
- assert.strictEqual(out.body.result.status.state, "completed");
53
- assert.strictEqual(out.body.result.status.message.metadata[META_STATUS], "payment-completed");
54
- assert.ok(out.headers && out.headers["PAYMENT-RESPONSE"], "settled 200 must carry PAYMENT-RESPONSE");
55
- assert.strictEqual(out.body.result.artifacts[0].parts[0].data.ran, "a2a.negotiate");
56
- assert.strictEqual(out.body.result.artifacts[0].parts[0].data.azmode, "x402_facilitator");
57
-
58
- // 2b. Paid but the skill execution fails -> task is reported "failed" (not
59
- // "completed"), still carries the settlement receipt (money moved).
60
- out = await send(
61
- { message: { metadata: { task_ref: "a2a.negotiate", [META_PAYLOAD]: "B64" } } },
62
- { deps: deps({ executeAuthorized: async () => ({ status: 500, payload: { ok: false, reason: "boom" } }) }) }
63
- );
64
- assert.strictEqual(out.body.result.status.state, "failed");
65
- assert.strictEqual(out.body.result.status.message.metadata[META_STATUS], "payment-completed");
66
- assert.ok(out.headers && out.headers["PAYMENT-RESPONSE"], "failed-after-payment still carries receipt");
67
- assert.strictEqual(out.body.result.artifacts.length, 0);
68
-
69
- // 3. Verify fails -> payment-rejected.
70
- out = await send(
71
- { message: { metadata: { [META_PAYLOAD]: "BAD" } } },
72
- { deps: deps({ verifyX402Payment: async () => ({ ok: false, reason: "payment_verification_failed", detail: "insufficient_balance" }) }) }
73
- );
74
- assert.strictEqual(out.body.result.status.state, "failed");
75
- assert.strictEqual(out.body.result.status.message.metadata[META_STATUS], "payment-rejected");
76
-
77
- // 4. Settle fails -> payment-failed.
78
- out = await send(
79
- { message: { metadata: { [META_PAYLOAD]: "OK" } } },
80
- { deps: deps({ settleX402Payment: async () => ({ ok: false, reason: "payment_settlement_failed", detail: "facilitator" }) }) }
81
- );
82
- assert.strictEqual(out.body.result.status.message.metadata[META_STATUS], "payment-failed");
83
-
84
- // 5. Unknown method -> -32601.
85
- out = await handleA2ARpc({ jsonrpc: "2.0", id: 9, method: "tasks/cancel" }, { config, resourceUrl: "x", deps: deps() });
86
- assert.strictEqual(out.body.error.code, -32601);
87
-
88
- // 6. x402 mode off -> -32010.
89
- out = await handleA2ARpc(
90
- { jsonrpc: "2.0", id: 9, method: "message/send", params: { message: {} } },
91
- { config: { verification_mode: "local_signed", x402: { accepts } }, resourceUrl: "x", deps: deps() }
92
- );
93
- assert.strictEqual(out.body.error.code, -32010);
94
-
95
- // 7. Non-2.0 envelope -> -32600.
96
- out = await handleA2ARpc({ id: 1, method: "message/send" }, { config, resourceUrl: "x", deps: deps() });
97
- assert.strictEqual(out.body.error.code, -32600);
98
-
99
- console.log("verify_a2a_rpc: all A2A x402 JSON-RPC checks passed");
100
- }
101
-
102
- main().catch((error) => {
103
- console.error("verify_a2a_rpc failed:", error && error.message ? error.message : error);
104
- process.exitCode = 1;
105
- });
1
+ "use strict";
2
+
3
+ // Verifies the A2A JSON-RPC endpoint with the in-band x402 payment extension
4
+ // (lib/a2a_rpc.js). The facilitator verify/settle and the command executor are
5
+ // injected as mocks so this runs offline and moves no money.
6
+
7
+ const assert = require("assert");
8
+ const {
9
+ handleA2ARpc,
10
+ META_PAYLOAD,
11
+ META_STATUS,
12
+ META_REQUIRED,
13
+ META_ERROR
14
+ } = require("../lib/a2a_rpc");
15
+
16
+ const accepts = [
17
+ { scheme: "exact", network: "eip155:84532", asset: "0xUSDC", amount: "1000", payTo: "0xPAYTO", extra: { name: "USDC", version: "2" } }
18
+ ];
19
+ const config = { verification_mode: "x402_facilitator", x402: { accepts, facilitator_url: "https://facilitator", settle: true } };
20
+
21
+ function deps(overrides) {
22
+ return Object.assign(
23
+ {
24
+ createFacilitator: () => ({}),
25
+ verifyX402Payment: async () => ({ ok: true, authorization: { payer: "0xPAYER", paymentRequirements: accepts[0] } }),
26
+ settleX402Payment: async () => ({ ok: true, settlement: { success: true, transaction: "0xTX", network: "eip155:84532" } }),
27
+ encodeReceipt: (s) => Buffer.from(JSON.stringify(s), "utf8").toString("base64"),
28
+ executeAuthorized: async (state, payload, authz) => ({ status: 200, payload: { ok: true, ran: payload.task_ref, azmode: authz.verification_mode } }),
29
+ buildNamedTaskPayload: (ref, body) => ({ command: ref, task_ref: ref, body }),
30
+ state: {}
31
+ },
32
+ overrides || {}
33
+ );
34
+ }
35
+
36
+ function send(params, ctxOverrides) {
37
+ return handleA2ARpc(
38
+ { jsonrpc: "2.0", id: 1, method: "message/send", params },
39
+ Object.assign({ config, resourceUrl: "https://xytara.onrender.com/v1/a2a", deps: deps() }, ctxOverrides || {})
40
+ );
41
+ }
42
+
43
+ async function main() {
44
+ // 1. Unpaid -> input-required + x402 requirements in-band.
45
+ let out = await send({ message: { metadata: { task_ref: "a2a.negotiate" } } });
46
+ assert.strictEqual(out.http, 200);
47
+ assert.strictEqual(out.body.result.status.state, "input-required");
48
+ assert.strictEqual(out.body.result.status.message.metadata[META_STATUS], "payment-required");
49
+ assert.deepStrictEqual(out.body.result.status.message.metadata[META_REQUIRED].accepts, accepts);
50
+
51
+ // 2. Paid -> completed + PAYMENT-RESPONSE + executed through the x402 authz context.
52
+ out = await send({ message: { metadata: { task_ref: "a2a.negotiate", [META_PAYLOAD]: "BASE64PAYLOAD" } } });
53
+ assert.strictEqual(out.body.result.status.state, "completed");
54
+ assert.strictEqual(out.body.result.status.message.metadata[META_STATUS], "payment-completed");
55
+ assert.ok(out.headers && out.headers["PAYMENT-RESPONSE"], "settled 200 must carry PAYMENT-RESPONSE");
56
+ assert.strictEqual(out.body.result.artifacts[0].parts[0].data.ran, "a2a.negotiate");
57
+ assert.strictEqual(out.body.result.artifacts[0].parts[0].data.azmode, "x402_facilitator");
58
+
59
+ // 2b. Paid but the skill execution fails -> "failed", and the payment is NOT settled
60
+ // (verify -> execute -> settle: a failed skill never charges the paying agent).
61
+ let settleCalls = 0;
62
+ out = await send(
63
+ { message: { metadata: { task_ref: "a2a.negotiate", [META_PAYLOAD]: "B64" } } },
64
+ { deps: deps({
65
+ executeAuthorized: async () => ({ status: 500, payload: { ok: false, reason: "boom" } }),
66
+ settleX402Payment: async () => { settleCalls += 1; return { ok: true, settlement: { success: true } }; }
67
+ }) }
68
+ );
69
+ assert.strictEqual(out.body.result.status.state, "failed");
70
+ assert.strictEqual(out.body.result.status.message.metadata[META_STATUS], "not-charged");
71
+ assert.strictEqual(settleCalls, 0, "a failed skill must not be settled");
72
+ assert.ok(!(out.headers && out.headers["PAYMENT-RESPONSE"]), "nothing settled, so no receipt");
73
+ assert.strictEqual(out.body.result.artifacts.length, 0);
74
+
75
+ // 2c. A skill nothing can execute -> refused up front: no payment requested at all.
76
+ out = await send(
77
+ { message: { metadata: { task_ref: "a2a.negotiate" } } },
78
+ { deps: deps({ assessExecutability: () => ({ ok: false, payload: { reason: "capability_not_executable", detail: "no target", executable_task_refs: ["adapter.mcp.invoke"] } }) }) }
79
+ );
80
+ assert.strictEqual(out.body.result.status.state, "failed");
81
+ assert.strictEqual(out.body.result.status.message.metadata[META_STATUS], "not-required");
82
+ assert.ok(/adapter\.mcp\.invoke/.test(out.body.result.status.message.metadata[META_ERROR]), "the refusal names what IS executable");
83
+
84
+ // 2d. Settle times out but the authorization is used on-chain -> completed, not a lost payment.
85
+ out = await send(
86
+ { message: { metadata: { task_ref: "a2a.negotiate", [META_PAYLOAD]: "B64" } } },
87
+ { deps: deps({
88
+ verifyX402Payment: async () => ({ ok: true, authorization: { payer: "0xPAYER", paymentRequirements: accepts[0], paymentPayload: { payload: { authorization: { from: "0xPAYER", nonce: "0x01" } } } } }),
89
+ settleX402Payment: async () => ({ ok: false, reason: "facilitator_unreachable" }),
90
+ confirmAuthorizationUsed: async () => true
91
+ }) }
92
+ );
93
+ assert.strictEqual(out.body.result.status.state, "completed", "a settled-on-chain payment delivers despite the facilitator timeout");
94
+
95
+ // 3. Verify fails -> payment-rejected.
96
+ out = await send(
97
+ { message: { metadata: { [META_PAYLOAD]: "BAD" } } },
98
+ { deps: deps({ verifyX402Payment: async () => ({ ok: false, reason: "payment_verification_failed", detail: "insufficient_balance" }) }) }
99
+ );
100
+ assert.strictEqual(out.body.result.status.state, "failed");
101
+ assert.strictEqual(out.body.result.status.message.metadata[META_STATUS], "payment-rejected");
102
+
103
+ // 4. Settle fails -> payment-failed.
104
+ out = await send(
105
+ { message: { metadata: { [META_PAYLOAD]: "OK" } } },
106
+ { deps: deps({ settleX402Payment: async () => ({ ok: false, reason: "payment_settlement_failed", detail: "facilitator" }) }) }
107
+ );
108
+ assert.strictEqual(out.body.result.status.message.metadata[META_STATUS], "payment-failed");
109
+
110
+ // 5. Unknown method -> -32601.
111
+ out = await handleA2ARpc({ jsonrpc: "2.0", id: 9, method: "tasks/cancel" }, { config, resourceUrl: "x", deps: deps() });
112
+ assert.strictEqual(out.body.error.code, -32601);
113
+
114
+ // 6. x402 mode off -> -32010.
115
+ out = await handleA2ARpc(
116
+ { jsonrpc: "2.0", id: 9, method: "message/send", params: { message: {} } },
117
+ { config: { verification_mode: "local_signed", x402: { accepts } }, resourceUrl: "x", deps: deps() }
118
+ );
119
+ assert.strictEqual(out.body.error.code, -32010);
120
+
121
+ // 7. Non-2.0 envelope -> -32600.
122
+ out = await handleA2ARpc({ id: 1, method: "message/send" }, { config, resourceUrl: "x", deps: deps() });
123
+ assert.strictEqual(out.body.error.code, -32600);
124
+
125
+ console.log("verify_a2a_rpc: all A2A x402 JSON-RPC checks passed");
126
+ }
127
+
128
+ main().catch((error) => {
129
+ console.error("verify_a2a_rpc failed:", error && error.message ? error.message : error);
130
+ process.exitCode = 1;
131
+ });