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,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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
assert.strictEqual(out.
|
|
47
|
-
assert.strictEqual(out.body.result.status.
|
|
48
|
-
assert.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
assert.strictEqual(out.body.result.status.
|
|
54
|
-
assert.
|
|
55
|
-
assert.
|
|
56
|
-
assert.strictEqual(out.body.result.artifacts[0].parts[0].data.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
// "
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
out
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
);
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
);
|
|
82
|
-
assert.
|
|
83
|
-
|
|
84
|
-
//
|
|
85
|
-
out = await
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
);
|
|
93
|
-
assert.strictEqual(out.body.
|
|
94
|
-
|
|
95
|
-
//
|
|
96
|
-
out = await
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
+
});
|