@cloudpayx/x402-pay 0.0.0-stage → 0.2.1
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/README.md +93 -2
- package/dist/src/assets.d.ts +53 -0
- package/dist/src/assets.js +36 -0
- package/dist/src/index.d.ts +31 -0
- package/dist/src/index.js +85 -0
- package/dist/src/one_shot_guard.d.ts +52 -0
- package/dist/src/one_shot_guard.js +143 -0
- package/dist/src/policy.d.ts +74 -0
- package/dist/src/policy.js +103 -0
- package/dist/src/settlement_verifier.d.ts +24 -0
- package/dist/src/settlement_verifier.js +78 -0
- package/dist/src/xrpl_x402_wire.d.ts +1 -0
- package/dist/src/xrpl_x402_wire.js +69 -0
- package/package.json +45 -4
- package/scripts/durable-purchase-lifecycle.mjs +114 -0
- package/scripts/local-attempt-journal.mjs +82 -0
- package/scripts/persistent-spending-budget.mjs +137 -0
- package/scripts/public-node.mjs +10 -0
- package/scripts/safe-agent-fetch.mjs +184 -0
- package/scripts/secure-node-purchase.mjs +93 -0
- package/scripts/trusted-xrpl-primitives.mjs +46 -0
- package/scripts/trusted-xrpl-recovery.mjs +64 -0
- package/types/public-node.d.ts +55 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Private single-host cumulative XRPL spending capacity gate.
|
|
3
|
+
* All reserved/uncertain/successful intents COUNT against caps indefinitely.
|
|
4
|
+
* No automatic release after failure or timeout: refund/release needs a separate
|
|
5
|
+
* carefully authenticated, ledger-grounded operator workflow (not shipped).
|
|
6
|
+
* A wallet owner who can replace journalDir or edit storage can bypass these caps;
|
|
7
|
+
* this is NOT an external enforcement boundary or multi-host coordination.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
10
|
+
import { mkdir, lstat, open, readFile, rename, unlink } from 'node:fs/promises';
|
|
11
|
+
import { join, resolve } from 'node:path';
|
|
12
|
+
|
|
13
|
+
const RLUSD = ['524C555344000000000000000000000000000000', 'rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De'];
|
|
14
|
+
const USDC = ['5553444300000000000000000000000000000000', 'rGm7WCVp9gb4jZHWTEtGUr4dd74z2XuWhE'];
|
|
15
|
+
const CAP_KEYS = ['xrpDrops', 'rlusd', 'usdc'];
|
|
16
|
+
const DROPS = /^(?:0|[1-9][0-9]*)$/;
|
|
17
|
+
const TOKEN = /^(?:0|[1-9][0-9]*)(?:\.[0-9]{1,15})?$/;
|
|
18
|
+
const FACTOR = 10n ** 15n;
|
|
19
|
+
function check(ok, code) { if (!ok) throw new Error(code); }
|
|
20
|
+
function record(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
|
|
21
|
+
function parseAmount(v, key) {
|
|
22
|
+
check(typeof v === 'string' && v.length > 0 && v.length <= 64 &&
|
|
23
|
+
(key === 'xrpDrops' ? DROPS : TOKEN).test(v), 'CPX_BUDGET_AMOUNT_INVALID');
|
|
24
|
+
if (key === 'xrpDrops') return BigInt(v);
|
|
25
|
+
const [whole, fract = ''] = v.split('.');
|
|
26
|
+
return BigInt(whole) * FACTOR + BigInt(fract.padEnd(15, '0') || '0');
|
|
27
|
+
}
|
|
28
|
+
export function validateBudgetCaps(caps) {
|
|
29
|
+
check(record(caps) && Object.keys(caps).length === 3 &&
|
|
30
|
+
CAP_KEYS.every(k => Object.prototype.hasOwnProperty.call(caps,k)), 'CPX_BUDGET_CAPS_REQUIRED');
|
|
31
|
+
for (const k of CAP_KEYS) check(parseAmount(caps[k], k) > 0n, 'CPX_BUDGET_CAP_MUST_BE_POSITIVE');
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
function keyFor(asset, issuer) {
|
|
35
|
+
if (asset === 'XRP' && issuer === undefined) return 'xrpDrops';
|
|
36
|
+
if (asset === RLUSD[0] && issuer === RLUSD[1]) return 'rlusd';
|
|
37
|
+
if (asset === USDC[0] && issuer === USDC[1]) return 'usdc';
|
|
38
|
+
throw new Error('CPX_BUDGET_UNSUPPORTED_ASSET_OR_ISSUER');
|
|
39
|
+
}
|
|
40
|
+
async function syncDirectory(dir) {
|
|
41
|
+
const fd = await open(dir, 'r');
|
|
42
|
+
try { await fd.sync(); } finally { await fd.close(); }
|
|
43
|
+
}
|
|
44
|
+
async function writeAtomic(dir, file, state) {
|
|
45
|
+
const temp = file + '.' + randomUUID() + '.tmp';
|
|
46
|
+
const fd = await open(temp, 'wx', 0o600);
|
|
47
|
+
try { await fd.writeFile(JSON.stringify(state, null, 2)); await fd.sync(); }
|
|
48
|
+
finally { await fd.close(); }
|
|
49
|
+
await rename(temp, file);
|
|
50
|
+
await syncDirectory(dir);
|
|
51
|
+
}
|
|
52
|
+
export function createPersistentSpendingBudget(journalDir) {
|
|
53
|
+
check(typeof journalDir === 'string' && journalDir.trim().length > 0, 'CPX_BUDGET_DIRECTORY_REQUIRED');
|
|
54
|
+
const parent = resolve(journalDir);
|
|
55
|
+
const dir = join(parent, '.cpx-spending-budget');
|
|
56
|
+
const fileFor = payer => join(dir, createHash('sha256').update(payer).digest('hex') + '.json');
|
|
57
|
+
|
|
58
|
+
async function ensurePrivateDir() {
|
|
59
|
+
await mkdir(parent, {recursive: true, mode: 0o700});
|
|
60
|
+
await mkdir(dir, {recursive: true, mode: 0o700});
|
|
61
|
+
for (const folder of [parent, dir]) {
|
|
62
|
+
const st = await lstat(folder);
|
|
63
|
+
check(st.isDirectory() && !st.isSymbolicLink() && (st.mode & 0o077) === 0,
|
|
64
|
+
'CPX_BUDGET_STORAGE_NOT_PRIVATE');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async function reserve({intent, issuer, caps, maxFeeDrops, maxSendMax}) {
|
|
68
|
+
validateBudgetCaps(caps);
|
|
69
|
+
check(record(intent) && typeof intent.intentId === 'string' && intent.intentId.length >= 12 &&
|
|
70
|
+
intent.intentId.length <= 200 && typeof intent.payer === 'string' && intent.payer.length > 0 &&
|
|
71
|
+
typeof intent.asset === 'string', 'CPX_BUDGET_INVALID_INTENT');
|
|
72
|
+
const key = keyFor(intent.asset, issuer);
|
|
73
|
+
const deliveryUnits = parseAmount(intent.amount, key);
|
|
74
|
+
check(deliveryUnits > 0n, 'CPX_BUDGET_AMOUNT_MUST_BE_POSITIVE');
|
|
75
|
+
const feeUnits = parseAmount(maxFeeDrops, 'xrpDrops');
|
|
76
|
+
check(feeUnits > 0n, 'CPX_BUDGET_FEE_REQUIRED');
|
|
77
|
+
// Reserve the WORST-CASE issuer debit, not merely the delivered tokens.
|
|
78
|
+
check((key === 'xrpDrops' && maxSendMax === undefined) ||
|
|
79
|
+
(key !== 'xrpDrops' && typeof maxSendMax === 'string'), 'CPX_BUDGET_SENDMAX_REQUIRED');
|
|
80
|
+
const units = key === 'xrpDrops' ? deliveryUnits : parseAmount(maxSendMax, key);
|
|
81
|
+
check(units >= deliveryUnits, 'CPX_BUDGET_SENDMAX_BELOW_DELIVERY');
|
|
82
|
+
check(units <= parseAmount(caps[key], key), 'CPX_CUMULATIVE_BUDGET_EXCEEDED');
|
|
83
|
+
await ensurePrivateDir();
|
|
84
|
+
const file = fileFor(intent.payer);
|
|
85
|
+
const lock = file + '.lock';
|
|
86
|
+
let lockFd;
|
|
87
|
+
try { lockFd = await open(lock, 'wx', 0o600); }
|
|
88
|
+
catch (err) {
|
|
89
|
+
if (err?.code === 'EEXIST') throw new Error('CPX_BUDGET_LOCKED_FAIL_CLOSED');
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
// The exclusive lock is never automatically broken after a crash: fail closed.
|
|
93
|
+
// Unlink only during normal cleanup after the state has been fsynced.
|
|
94
|
+
try {
|
|
95
|
+
await lockFd.writeFile(String(process.pid)); await lockFd.sync();
|
|
96
|
+
const wantedCaps = Object.fromEntries(CAP_KEYS.map(k=>[k, parseAmount(caps[k], k).toString()]));
|
|
97
|
+
let state;
|
|
98
|
+
try { state = JSON.parse(await readFile(file, 'utf8')); }
|
|
99
|
+
catch (err) {
|
|
100
|
+
if (err?.code !== 'ENOENT') throw err;
|
|
101
|
+
state = {version: 1, payer:intent.payer, caps:wantedCaps,
|
|
102
|
+
reserved:{xrpDrops:'0',rlusd:'0',usdc:'0'}, intentIds:{}};
|
|
103
|
+
}
|
|
104
|
+
check(record(state) && state.version === 1 && state.payer === intent.payer &&
|
|
105
|
+
record(state.caps) && record(state.reserved) && record(state.intentIds),
|
|
106
|
+
'CPX_BUDGET_CORRUPT');
|
|
107
|
+
for (const k of CAP_KEYS) {
|
|
108
|
+
check(state.caps[k] === wantedCaps[k], 'CPX_BUDGET_CAP_CHANGE_FORBIDDEN');
|
|
109
|
+
check(typeof state.reserved[k] === 'string' && DROPS.test(state.reserved[k]), 'CPX_BUDGET_CORRUPT');
|
|
110
|
+
check(BigInt(state.reserved[k]) <= BigInt(state.caps[k]), 'CPX_BUDGET_CORRUPT');
|
|
111
|
+
}
|
|
112
|
+
const idHash = createHash('sha256').update(intent.intentId).digest('hex');
|
|
113
|
+
check(!Object.prototype.hasOwnProperty.call(state.intentIds, idHash), 'CPX_BUDGET_DUPLICATE_INTENT');
|
|
114
|
+
const next = BigInt(state.reserved[key]) + units;
|
|
115
|
+
const newXrp = BigInt(state.reserved.xrpDrops) + feeUnits +
|
|
116
|
+
(key === 'xrpDrops' ? units : 0n);
|
|
117
|
+
check(next <= BigInt(state.caps[key]) && newXrp <= BigInt(state.caps.xrpDrops),
|
|
118
|
+
'CPX_CUMULATIVE_BUDGET_EXCEEDED');
|
|
119
|
+
state.reserved[key] = next.toString();
|
|
120
|
+
state.reserved.xrpDrops = newXrp.toString();
|
|
121
|
+
state.intentIds[idHash] = {asset:key, units:units.toString(), feeDrops:feeUnits.toString()};
|
|
122
|
+
check(Buffer.byteLength(JSON.stringify(state)) <= 4 * 1024 * 1024, 'CPX_BUDGET_JOURNAL_TOO_LARGE');
|
|
123
|
+
await writeAtomic(dir, file, state);
|
|
124
|
+
return {status:'BUDGET_CAPACITY_RESERVED',asset:key,
|
|
125
|
+
reserved:state.reserved[key], limit:state.caps[key], xrpReservedDrops:state.reserved.xrpDrops};
|
|
126
|
+
} finally {
|
|
127
|
+
await lockFd.close();
|
|
128
|
+
await unlink(lock);
|
|
129
|
+
await syncDirectory(dir);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async function inspect(payer) {
|
|
133
|
+
await ensurePrivateDir();
|
|
134
|
+
return JSON.parse(await readFile(fileFor(payer), 'utf8'));
|
|
135
|
+
}
|
|
136
|
+
return {reserve, inspect};
|
|
137
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supported cpX x402 Pay Node.js package surface.
|
|
3
|
+
* Low-level modules are internal plumbing, not public entry points.
|
|
4
|
+
* An agent controlling its own wallet keys may always bypass any SDK in
|
|
5
|
+
* its own process; this boundary prevents accidental imports, not hostile
|
|
6
|
+
* code running with the same privileges.
|
|
7
|
+
*/
|
|
8
|
+
export { executeSecureNodePurchase, validateSecureNodePurchase } from './secure-node-purchase.mjs';
|
|
9
|
+
|
|
10
|
+
export { reconcileSecureNodePurchase } from './trusted-xrpl-recovery.mjs';
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cpX x402 Pay - Node.js HTTPS-only agent request transport.
|
|
3
|
+
* Protects agent-side buyer requests to seller resources with public-only,
|
|
4
|
+
* pinned DNS resolution and no redirect following. No XRPL signing here.
|
|
5
|
+
* IMPORTANT: Node.js adapter; browser runtimes need a separate transport.
|
|
6
|
+
*/
|
|
7
|
+
import { lookup as dnsLookup } from 'node:dns/promises';
|
|
8
|
+
import { request as httpsRequest } from 'node:https';
|
|
9
|
+
import { isIP, BlockList } from 'node:net';
|
|
10
|
+
|
|
11
|
+
const BLOCKED = new BlockList();
|
|
12
|
+
for (const [ip,bits] of [
|
|
13
|
+
['0.0.0.0',8], ['10.0.0.0',8], ['100.64.0.0',10],
|
|
14
|
+
['127.0.0.0',8], ['169.254.0.0',16], ['172.16.0.0',12],
|
|
15
|
+
['192.0.0.0',24], ['192.0.2.0',24], ['192.88.99.0',24],
|
|
16
|
+
['192.168.0.0',16], ['198.18.0.0',15], ['198.51.100.0',24],
|
|
17
|
+
['203.0.113.0',24], ['224.0.0.0',4], ['240.0.0.0',4]
|
|
18
|
+
]) BLOCKED.addSubnet(ip,bits,'ipv4');
|
|
19
|
+
for (const [ip,bits] of [
|
|
20
|
+
['::',128], ['::1',128],
|
|
21
|
+
['64:ff9b::',96], ['64:ff9b:1::',48], ['100::',64],
|
|
22
|
+
['2001::',23], ['2001:db8::',32], ['2002::',16],
|
|
23
|
+
['fc00::',7], ['fe80::',10], ['ff00::',8]
|
|
24
|
+
]) BLOCKED.addSubnet(ip,bits,'ipv6');
|
|
25
|
+
|
|
26
|
+
function deny(code) { throw new Error(code); }
|
|
27
|
+
|
|
28
|
+
/** Conservative public-address check. Rejects atypical IPv6 routing schemes. */
|
|
29
|
+
export function isPublicIp(address) {
|
|
30
|
+
const family = isIP(address);
|
|
31
|
+
if (!family) return false;
|
|
32
|
+
if (family === 6 && address.toLowerCase().startsWith('::ffff:')) return false;
|
|
33
|
+
if (BLOCKED.check(address, family===4?'ipv4':'ipv6')) return false;
|
|
34
|
+
if (family===6) {
|
|
35
|
+
// IPv6 global unicast: 2000::/3 only, as a conservative baseline.
|
|
36
|
+
const first = parseInt(address.split(':')[0] || '0',16);
|
|
37
|
+
if (!Number.isFinite(first) || first < 0x2000 || first > 0x3fff) return false;
|
|
38
|
+
}
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function validateSellerUrl(input) {
|
|
43
|
+
// Disallow backslashes before URL normalization (WHATWG treats them as slashes).
|
|
44
|
+
if (String(input).includes('\\')) deny('CPX_INVALID_SELLER_URL');
|
|
45
|
+
let url;
|
|
46
|
+
try { url = new URL(String(input)); }
|
|
47
|
+
catch { return deny('CPX_INVALID_SELLER_URL'); }
|
|
48
|
+
if (url.protocol !== 'https:') deny('CPX_HTTPS_REQUIRED');
|
|
49
|
+
if (url.port && url.port !== '443') deny('CPX_NONSTANDARD_PORT_BLOCKED');
|
|
50
|
+
if (url.username || url.password) deny('CPX_URL_CREDENTIALS_BLOCKED');
|
|
51
|
+
if (url.hash) deny('CPX_URL_FRAGMENT_BLOCKED');
|
|
52
|
+
const host = url.hostname.toLowerCase().replace(/\.$/,'');
|
|
53
|
+
if (!host || host.length > 253 || host.includes('%') || host.includes('\\')) deny('CPX_INVALID_SELLER_HOST');
|
|
54
|
+
const isIpHost = isIP(host.replace(/^\[|\]$/g,''));
|
|
55
|
+
if (isIpHost) {
|
|
56
|
+
if (!isPublicIp(host.replace(/^\[|\]$/g,''))) deny('CPX_PRIVATE_NETWORK_BLOCKED');
|
|
57
|
+
} else {
|
|
58
|
+
if (!/^[a-z0-9.-]+$/.test(host) || !host.includes('.') ||
|
|
59
|
+
host.split('.').some(p => !p || p.length > 63 || p.startsWith('-') || p.endsWith('-')))
|
|
60
|
+
deny('CPX_INVALID_SELLER_HOST');
|
|
61
|
+
if (/(?:^|\.)(?:localhost|local|internal|test|invalid|example|onion)$/.test(host) ||
|
|
62
|
+
host.endsWith('.home.arpa') || host.endsWith('.arpa')) deny('CPX_PRIVATE_HOST_BLOCKED');
|
|
63
|
+
}
|
|
64
|
+
// Constructed Request/URL normalizes alternate IPv4 encodings to dotted-quad.
|
|
65
|
+
return url;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Resolve once, reject ANY non-public DNS answers, pin the chosen answer at socket connect. */
|
|
69
|
+
export async function resolvePublicSeller(hostname, resolve = dnsLookup) {
|
|
70
|
+
const host = hostname.replace(/^\[|\]$/g,'');
|
|
71
|
+
const family = isIP(host);
|
|
72
|
+
if (family) {
|
|
73
|
+
if (!isPublicIp(host)) deny('CPX_PRIVATE_NETWORK_BLOCKED');
|
|
74
|
+
return {address:host,family};
|
|
75
|
+
}
|
|
76
|
+
let answers;
|
|
77
|
+
try { answers = await resolve(host, {all:true,verbatim:true}); }
|
|
78
|
+
catch { return deny('CPX_DNS_LOOKUP_FAILED'); }
|
|
79
|
+
if (!Array.isArray(answers) || !answers.length) deny('CPX_DNS_LOOKUP_FAILED');
|
|
80
|
+
if (!answers.every(item => item && item.family === isIP(item.address) && isPublicIp(item.address)))
|
|
81
|
+
deny('CPX_DNS_PRIVATE_OR_UNROUTABLE_ADDRESS');
|
|
82
|
+
return {address:answers[0].address, family:answers[0].family};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function getTimeout(value) {
|
|
86
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > 60000) deny('CPX_INVALID_HTTP_TIMEOUT');
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Explicit Node-only opt-in transport for SDK requestFetch. No redirects or
|
|
92
|
+
* browser cookies. Uses the SAME approved DNS address for the actual TLS socket.
|
|
93
|
+
* The caller decides which seller URL to request; no merchant registration.
|
|
94
|
+
*/
|
|
95
|
+
export function createSafeAgentFetch({
|
|
96
|
+
resolve = dnsLookup,
|
|
97
|
+
request = httpsRequest,
|
|
98
|
+
timeoutMs = 15000,
|
|
99
|
+
maxRequestBytes = 256 * 1024,
|
|
100
|
+
maxResponseBytes = 8 * 1024 * 1024
|
|
101
|
+
} = {}) {
|
|
102
|
+
getTimeout(timeoutMs);
|
|
103
|
+
if (![maxRequestBytes,maxResponseBytes].every(n=>Number.isSafeInteger(n)&&n>0&&n<=32*1024*1024))
|
|
104
|
+
deny('CPX_INVALID_HTTP_BODY_LIMIT');
|
|
105
|
+
return async function safeAgentFetch(input, init = {}) {
|
|
106
|
+
// Constructing Request preserves x402's method, headers and body on the retry.
|
|
107
|
+
validateSellerUrl(input instanceof Request ? input.url : input);
|
|
108
|
+
const req = new Request(input, init);
|
|
109
|
+
const url = validateSellerUrl(req.url);
|
|
110
|
+
const pinned = await resolvePublicSeller(url.hostname,resolve);
|
|
111
|
+
if (req.signal.aborted) deny('CPX_HTTP_ABORTED');
|
|
112
|
+
const bytes = req.body ? Buffer.from(await req.arrayBuffer()) : Buffer.alloc(0);
|
|
113
|
+
if (bytes.length > maxRequestBytes) deny('CPX_HTTP_REQUEST_TOO_LARGE');
|
|
114
|
+
const headers = Object.fromEntries(req.headers);
|
|
115
|
+
// Node fetch adds implicit Accept-Encoding but https.request doesn't; avoid
|
|
116
|
+
// transparent decompression ambiguity and keep the response byte limit real.
|
|
117
|
+
headers['accept-encoding'] = 'identity';
|
|
118
|
+
headers['content-length'] = String(bytes.length);
|
|
119
|
+
return await new Promise((resolvePromise,rejectPromise) => {
|
|
120
|
+
let settled = false;
|
|
121
|
+
let signalHandler;
|
|
122
|
+
let deadline;
|
|
123
|
+
const reject = err => { if (!settled) { settled = true; clearTimeout(deadline); rejectPromise(err); } };
|
|
124
|
+
const finish = val => { if (!settled) { settled = true; clearTimeout(deadline); resolvePromise(val); } };
|
|
125
|
+
const requestOptions = {
|
|
126
|
+
method:req.method,headers,
|
|
127
|
+
timeout:timeoutMs,
|
|
128
|
+
autoSelectFamily:false,
|
|
129
|
+
// TLS certificate validation and SNI use the *original hostname*.
|
|
130
|
+
// Only the DNS address is pinned, avoiding DNS-rebinding after checks.
|
|
131
|
+
lookup(host, opts, callback) {
|
|
132
|
+
if (host.toLowerCase().replace(/\.$/,'') !== url.hostname.toLowerCase().replace(/\.$/,''))
|
|
133
|
+
return callback(new Error('CPX_DNS_HOST_MISMATCH'));
|
|
134
|
+
if (opts?.all) return callback(null, [{...pinned}]);
|
|
135
|
+
callback(null, pinned.address, pinned.family);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
let client;
|
|
139
|
+
try {
|
|
140
|
+
client = request(url, requestOptions, incoming => {
|
|
141
|
+
const code = incoming.statusCode ?? 0;
|
|
142
|
+
// NEVER follow 3xx, even on a paid x402 retry.
|
|
143
|
+
if (code >= 300 && code < 400) {
|
|
144
|
+
incoming.resume();
|
|
145
|
+
return reject(new Error('CPX_HTTP_REDIRECT_BLOCKED'));
|
|
146
|
+
}
|
|
147
|
+
if (code < 200 || code > 599) {
|
|
148
|
+
incoming.resume();
|
|
149
|
+
return reject(new Error('CPX_BAD_HTTP_STATUS'));
|
|
150
|
+
}
|
|
151
|
+
let total = 0; const chunks=[];
|
|
152
|
+
incoming.on('data', chunk => {
|
|
153
|
+
total += chunk.length;
|
|
154
|
+
if (total > maxResponseBytes) {
|
|
155
|
+
incoming.destroy(new Error('CPX_HTTP_RESPONSE_TOO_LARGE'));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
chunks.push(chunk);
|
|
159
|
+
});
|
|
160
|
+
incoming.on('error',reject);
|
|
161
|
+
incoming.on('end',()=>{
|
|
162
|
+
try {
|
|
163
|
+
const responseHeaders = new Headers();
|
|
164
|
+
for (const [name,value] of Object.entries(incoming.headers)) {
|
|
165
|
+
if (value !== undefined) responseHeaders.set(name,Array.isArray(value)?value.join(', '):value);
|
|
166
|
+
}
|
|
167
|
+
const body = [204,205,304].includes(code)?null:Buffer.concat(chunks);
|
|
168
|
+
finish(new Response(body,{status:code,headers:responseHeaders}));
|
|
169
|
+
} catch(e) { reject(e); }
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
} catch(e) { return reject(e); }
|
|
173
|
+
deadline = setTimeout(()=>client.destroy(new Error('CPX_HTTP_TIMEOUT')),timeoutMs);
|
|
174
|
+
deadline.unref?.();
|
|
175
|
+
client.on('timeout',()=>client.destroy(new Error('CPX_HTTP_TIMEOUT')));
|
|
176
|
+
client.on('error',reject);
|
|
177
|
+
signalHandler = () => client.destroy(new Error('CPX_HTTP_ABORTED'));
|
|
178
|
+
req.signal.addEventListener('abort',signalHandler,{once:true});
|
|
179
|
+
client.on('close',()=>req.signal.removeEventListener('abort',signalHandler));
|
|
180
|
+
if (bytes.length) client.write(bytes);
|
|
181
|
+
client.end();
|
|
182
|
+
});
|
|
183
|
+
};
|
|
184
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Private pre-release, Node-only cpX x402 Pay entry point.
|
|
3
|
+
* One caller-facing function wires safe HTTPS-only Node transport, one-shot
|
|
4
|
+
* XRPL signing constraints, and the durable same-intent purchase journal.
|
|
5
|
+
* This does NOT make the lower-level SDK impossible to invoke unsafely;
|
|
6
|
+
* before publication the public package exports must be restricted/audited.
|
|
7
|
+
*/
|
|
8
|
+
import { createSafeAgentFetch, validateSellerUrl } from './safe-agent-fetch.mjs';
|
|
9
|
+
import { createLocalAttemptJournal } from './local-attempt-journal.mjs';
|
|
10
|
+
import { createPersistentSpendingBudget, validateBudgetCaps } from './persistent-spending-budget.mjs';
|
|
11
|
+
import { executeDurablePurchase } from './durable-purchase-lifecycle.mjs';
|
|
12
|
+
import { loadOfficialXrplPrimitives } from './trusted-xrpl-primitives.mjs';
|
|
13
|
+
|
|
14
|
+
const KEYS = new Set([
|
|
15
|
+
'intent', 'journalDir', 'signer', 'policy', 'oneShot',
|
|
16
|
+
'maxFeeDrops', 'request', 'timeoutMs', 'budgetCaps'
|
|
17
|
+
]);
|
|
18
|
+
const INT = /^(0|[1-9][0-9]*)$/;
|
|
19
|
+
const OWN = Object.prototype.hasOwnProperty;
|
|
20
|
+
function fail(code) { throw new Error(code); }
|
|
21
|
+
function object(v) { return v !== null && typeof v === 'object' && !Array.isArray(v); }
|
|
22
|
+
function requireValue(ok, code) { if (!ok) fail(code); }
|
|
23
|
+
|
|
24
|
+
export function validateSecureNodePurchase(options) {
|
|
25
|
+
requireValue(object(options), 'CPX_SECURE_OPTIONS_REQUIRED');
|
|
26
|
+
requireValue(Object.keys(options).every(key => KEYS.has(key)), 'CPX_UNSAFE_OPTION_FORBIDDEN');
|
|
27
|
+
const { intent, journalDir, signer, policy, oneShot,
|
|
28
|
+
maxFeeDrops, request, timeoutMs = 20000, budgetCaps } = options;
|
|
29
|
+
validateBudgetCaps(budgetCaps);
|
|
30
|
+
requireValue(object(intent) && typeof intent.intentId === 'string' && intent.intentId.length >= 12,
|
|
31
|
+
'CPX_STABLE_INTENT_ID_REQUIRED');
|
|
32
|
+
requireValue(typeof intent.resource === 'string' && typeof intent.seller === 'string' &&
|
|
33
|
+
typeof intent.payer === 'string' && typeof intent.asset === 'string' && typeof intent.amount === 'string',
|
|
34
|
+
'CPX_PURCHASE_INTENT_REQUIRED');
|
|
35
|
+
validateSellerUrl(intent.resource); // reject unsafe URL before reserving journal or signing
|
|
36
|
+
requireValue(typeof journalDir === 'string' && journalDir.length > 0,
|
|
37
|
+
'CPX_DURABLE_JOURNAL_PATH_REQUIRED');
|
|
38
|
+
requireValue(object(signer) && signer.classicAddress === intent.payer && typeof signer.sign === 'function',
|
|
39
|
+
'CPX_WRONG_OR_MISSING_PAYER_SIGNER');
|
|
40
|
+
requireValue(object(policy), 'CPX_POLICY_REQUIRED');
|
|
41
|
+
requireValue(object(oneShot) && oneShot.recipient === intent.seller &&
|
|
42
|
+
oneShot.asset === intent.asset && oneShot.exactAmount === intent.amount &&
|
|
43
|
+
!OWN.call(oneShot, 'getValidatedLedgerIndex'), 'CPX_ONE_SHOT_INTENT_MISMATCH');
|
|
44
|
+
requireValue(typeof maxFeeDrops === 'string' && INT.test(maxFeeDrops) && BigInt(maxFeeDrops) > 0n &&
|
|
45
|
+
oneShot.maxFeeDrops === maxFeeDrops, 'CPX_FEE_CEILING_MISMATCH');
|
|
46
|
+
requireValue((intent.asset === 'XRP' && oneShot.issuer === undefined && oneShot.maxSendMax === undefined) ||
|
|
47
|
+
(intent.asset !== 'XRP' && typeof oneShot.issuer === 'string' &&
|
|
48
|
+
typeof oneShot.maxSendMax === 'string'), 'CPX_ASSET_GUARD_REQUIRED');
|
|
49
|
+
requireValue(object(request) && request.url === intent.resource && object(request.init),
|
|
50
|
+
'CPX_RESOURCE_MISMATCH');
|
|
51
|
+
requireValue((request.init.method ?? 'GET').toUpperCase() === 'POST', 'CPX_POST_REQUIRED');
|
|
52
|
+
requireValue(typeof request.init.body === 'string' && request.init.body.length <= 262144,
|
|
53
|
+
'CPX_REQUEST_BODY_REQUIRED');
|
|
54
|
+
requireValue(Number.isSafeInteger(timeoutMs) && timeoutMs > 0 && timeoutMs <= 60000,
|
|
55
|
+
'CPX_INVALID_HTTP_TIMEOUT');
|
|
56
|
+
requireValue(!OWN.call(request.init, 'signal') || request.init.signal instanceof AbortSignal,
|
|
57
|
+
'CPX_BAD_ABORT_SIGNAL');
|
|
58
|
+
// Only the first unpaid request is caller supplied. The official x402 client
|
|
59
|
+
// alone can populate the payment-signature header on its paid retry.
|
|
60
|
+
const headers = new Headers(request.init.headers);
|
|
61
|
+
for (const h of ['payment-signature', 'x-payment', 'authorization', 'cookie']) {
|
|
62
|
+
requireValue(!headers.has(h), 'CPX_CREDENTIALS_IN_INITIAL_REQUEST_FORBIDDEN');
|
|
63
|
+
}
|
|
64
|
+
// The normal requestFetch override is deliberately not an option here.
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function executeSecureNodePurchase(options) {
|
|
69
|
+
validateSecureNodePurchase(options);
|
|
70
|
+
// Imported at runtime from the pinned official xrpl package; no caller injection.
|
|
71
|
+
const trusted = await loadOfficialXrplPrimitives();
|
|
72
|
+
const lockedOneShot = { ...options.oneShot, getValidatedLedgerIndex:trusted.getValidatedLedgerIndex };
|
|
73
|
+
const { createCpXX402AgentPay } = await import('../dist/src/index.js');
|
|
74
|
+
const journal = createLocalAttemptJournal(options.journalDir);
|
|
75
|
+
const budget = createPersistentSpendingBudget(options.journalDir);
|
|
76
|
+
const safeNodeFetch = createSafeAgentFetch({timeoutMs: options.timeoutMs ?? 20000});
|
|
77
|
+
return executeDurablePurchase({
|
|
78
|
+
journal,
|
|
79
|
+
intent: options.intent,
|
|
80
|
+
reserveBudget: intent => budget.reserve({intent,issuer:options.oneShot.issuer,caps:options.budgetCaps,
|
|
81
|
+
maxFeeDrops:options.maxFeeDrops,maxSendMax:options.oneShot.maxSendMax}),
|
|
82
|
+
signer: options.signer,
|
|
83
|
+
hashSignedTx: trusted.hashSignedTx,
|
|
84
|
+
maxFeeDrops: options.maxFeeDrops,
|
|
85
|
+
request: options.request,
|
|
86
|
+
createPaymentClient: journaledSigner => createCpXX402AgentPay({
|
|
87
|
+
signer: journaledSigner,
|
|
88
|
+
policy: options.policy,
|
|
89
|
+
oneShot: lockedOneShot,
|
|
90
|
+
requestFetch: safeNodeFetch
|
|
91
|
+
})
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Official XRPL transaction primitives for the private secure Node purchase path.
|
|
3
|
+
* The supported package API MUST NOT accept caller replacements for these.
|
|
4
|
+
* A single fixed Ripple-operated mainnet endpoint is a trust dependency, not
|
|
5
|
+
* a cryptographic consensus quorum.
|
|
6
|
+
*/
|
|
7
|
+
export const MAINNET_LEDGER_URL = 'wss://s1.ripple.com';
|
|
8
|
+
const HASH = /^[0-9A-F]{64}$/;
|
|
9
|
+
function required(condition, code) { if (!condition) throw new Error(code); }
|
|
10
|
+
|
|
11
|
+
/** Private constructor seam: the public API always uses import('xrpl'). */
|
|
12
|
+
export function buildTrustedXrplPrimitives(xrpl) {
|
|
13
|
+
required(xrpl && typeof xrpl === 'object' && typeof xrpl.Client === 'function' &&
|
|
14
|
+
typeof xrpl.hashes?.hashSignedTx === 'function', 'CPX_OFFICIAL_XRPL_LIBRARY_REQUIRED');
|
|
15
|
+
|
|
16
|
+
function hashSignedTx(signedTxBlob) {
|
|
17
|
+
required(typeof signedTxBlob === 'string' && signedTxBlob.length > 0 &&
|
|
18
|
+
signedTxBlob.length % 2 === 0 && /^[a-fA-F0-9]+$/.test(signedTxBlob),
|
|
19
|
+
'CPX_SIGNED_BLOB_INVALID');
|
|
20
|
+
const hash = xrpl.hashes.hashSignedTx(signedTxBlob);
|
|
21
|
+
required(typeof hash === 'string' && HASH.test(hash.toUpperCase()),
|
|
22
|
+
'CPX_OFFICIAL_SIGNED_HASH_INVALID');
|
|
23
|
+
return hash.toUpperCase();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function getValidatedLedgerIndex() {
|
|
27
|
+
const client = new xrpl.Client(MAINNET_LEDGER_URL);
|
|
28
|
+
try {
|
|
29
|
+
await client.connect();
|
|
30
|
+
const index = await client.getLedgerIndex();
|
|
31
|
+
required(Number.isSafeInteger(index) && index > 0 && index <= 0xffffffff,
|
|
32
|
+
'CPX_UNTRUSTED_LEDGER_INDEX');
|
|
33
|
+
return index;
|
|
34
|
+
} finally {
|
|
35
|
+
// No alternate RPC, submitted transaction, or payment keys are involved.
|
|
36
|
+
// A failed disconnect must fail closed rather than authorize signing.
|
|
37
|
+
await client.disconnect();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return Object.freeze({ hashSignedTx, getValidatedLedgerIndex });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function loadOfficialXrplPrimitives() {
|
|
44
|
+
const xrpl = await import('xrpl');
|
|
45
|
+
return buildTrustedXrplPrimitives(xrpl);
|
|
46
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only XRPL mainnet reconciliation for cpX x402 Pay.
|
|
3
|
+
* The public path cannot inject a seller-controlled RPC, verifier or HTTP response.
|
|
4
|
+
* The fixed public XRPL server is a trusted service dependency, not independent
|
|
5
|
+
* cryptographic consensus verification. All uncertain outcomes remain blocked.
|
|
6
|
+
*/
|
|
7
|
+
import { createLocalAttemptJournal } from './local-attempt-journal.mjs';
|
|
8
|
+
import { verifyValidatedXrplSettlement } from '../dist/src/settlement_verifier.js';
|
|
9
|
+
|
|
10
|
+
export const XRPL_MAINNET_READ_ONLY_URL = 'wss://s1.ripple.com';
|
|
11
|
+
const HASH = /^[A-F0-9]{64}$/;
|
|
12
|
+
const valid = (v, code) => { if (!v) throw new Error(code); };
|
|
13
|
+
|
|
14
|
+
/** Internal test seam. Not exported by package root. */
|
|
15
|
+
export async function queryValidatedTransaction({hash, makeClient}) {
|
|
16
|
+
valid(typeof hash === 'string' && HASH.test(hash), 'CPX_RECOVERY_INVALID_HASH');
|
|
17
|
+
valid(typeof makeClient === 'function', 'CPX_RECOVERY_CLIENT_REQUIRED');
|
|
18
|
+
const client = await makeClient(XRPL_MAINNET_READ_ONLY_URL);
|
|
19
|
+
valid(client && typeof client.connect === 'function' && typeof client.request === 'function' &&
|
|
20
|
+
typeof client.disconnect === 'function', 'CPX_RECOVERY_INVALID_CLIENT');
|
|
21
|
+
try {
|
|
22
|
+
await client.connect();
|
|
23
|
+
// No submit, submitAndWait, sign, autofill, account_tx or seller endpoint.
|
|
24
|
+
return await client.request({
|
|
25
|
+
command:'tx', transaction:hash, binary:false, api_version:2
|
|
26
|
+
});
|
|
27
|
+
} finally {
|
|
28
|
+
await client.disconnect();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Private test seam, intentionally not a package export. */
|
|
33
|
+
export async function reconcileWithLedger({journal, intentId, makeClient}) {
|
|
34
|
+
valid(journal && typeof journal.load === 'function' && typeof journal.validated === 'function',
|
|
35
|
+
'CPX_RECOVERY_JOURNAL_REQUIRED');
|
|
36
|
+
valid(typeof intentId === 'string' && intentId.length >= 12 && intentId.length <= 200,
|
|
37
|
+
'CPX_RECOVERY_INTENT_REQUIRED');
|
|
38
|
+
const state = await journal.load(intentId);
|
|
39
|
+
valid(state.status === 'SIGNED_SUBMISSION_UNKNOWN' && state.expected &&
|
|
40
|
+
state.transactionHash === state.expected.transactionHash && HASH.test(state.transactionHash),
|
|
41
|
+
'CPX_RECOVERY_SIGNED_EXPECTATION_REQUIRED');
|
|
42
|
+
// If tx isn't found, network errors, is unvalidated, or has mismatched fields,
|
|
43
|
+
// DO NOT update the journal or permit a new payment.
|
|
44
|
+
const txResult = await queryValidatedTransaction({hash:state.transactionHash, makeClient});
|
|
45
|
+
const receipt = verifyValidatedXrplSettlement(txResult,state.expected);
|
|
46
|
+
await journal.validated(intentId,receipt);
|
|
47
|
+
return receipt;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Public supported entry point. No caller-provided lookup or verification functions. */
|
|
51
|
+
export async function reconcileSecureNodePurchase(options) {
|
|
52
|
+
valid(options && typeof options === 'object' && !Array.isArray(options) &&
|
|
53
|
+
Object.keys(options).length === 2 &&
|
|
54
|
+
Object.keys(options).every(x=>x === 'intentId' || x === 'journalDir'),
|
|
55
|
+
'CPX_RECOVERY_UNSAFE_OPTION');
|
|
56
|
+
const {journalDir, intentId} = options;
|
|
57
|
+
valid(typeof journalDir === 'string' && journalDir.length > 0, 'CPX_RECOVERY_JOURNAL_DIR_REQUIRED');
|
|
58
|
+
// Load the official XRPL library, never a caller-supplied node or callback.
|
|
59
|
+
const { Client } = await import('xrpl');
|
|
60
|
+
return reconcileWithLedger({
|
|
61
|
+
journal:createLocalAttemptJournal(journalDir), intentId,
|
|
62
|
+
makeClient: url => new Client(url)
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** cpX x402 Pay private pre-release Node API; no wallet keys are handled here. */
|
|
2
|
+
import type { ClientXrplSigner } from '@x402/xrpl';
|
|
3
|
+
import type { AgentPaymentConfig } from '../dist/src/policy.js';
|
|
4
|
+
import type { GuardOptions } from '../dist/src/one_shot_guard.js';
|
|
5
|
+
|
|
6
|
+
export type SecureNodePurchaseOptions = {
|
|
7
|
+
intent: {
|
|
8
|
+
intentId: string;
|
|
9
|
+
payer: string;
|
|
10
|
+
seller: string;
|
|
11
|
+
resource: string;
|
|
12
|
+
asset: string;
|
|
13
|
+
amount: string;
|
|
14
|
+
};
|
|
15
|
+
journalDir: string;
|
|
16
|
+
/** Strict cumulative single-host spending caps; XRP in drops, tokens in decimal units.
|
|
17
|
+
* Reservation is not automatically released after a failure or timeout. */
|
|
18
|
+
budgetCaps: { xrpDrops: string; rlusd: string; usdc: string };
|
|
19
|
+
signer: ClientXrplSigner;
|
|
20
|
+
policy: AgentPaymentConfig;
|
|
21
|
+
/** Ledger-index lookup is provided internally from official xrpl.js on mainnet. */
|
|
22
|
+
oneShot: Omit<GuardOptions, 'getValidatedLedgerIndex'>;
|
|
23
|
+
maxFeeDrops: string;
|
|
24
|
+
request: {
|
|
25
|
+
url: string;
|
|
26
|
+
init: { method: 'POST'; body: string; headers?: HeadersInit; signal?: AbortSignal };
|
|
27
|
+
};
|
|
28
|
+
timeoutMs?: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Validate safe configuration without constructing any payment request. */
|
|
32
|
+
export declare function validateSecureNodePurchase(options: SecureNodePurchaseOptions): true;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Initiates at most one guarded x402 HTTP payment attempt with durable journal.
|
|
36
|
+
* A successful HTTP response is NOT equivalent to verified XRPL settlement.
|
|
37
|
+
*/
|
|
38
|
+
export declare function executeSecureNodePurchase(options: SecureNodePurchaseOptions): Promise<{
|
|
39
|
+
status: 'HTTP_RESPONSE_LEDGER_UNVERIFIED';
|
|
40
|
+
httpStatus: number;
|
|
41
|
+
paymentResponseHeaderPresent: boolean;
|
|
42
|
+
journalStatus: string;
|
|
43
|
+
}>;
|
|
44
|
+
|
|
45
|
+
/** Read-only, XRPL-mainnet settlement recovery for a previously signed, journaled purchase.
|
|
46
|
+
* Rejects unknown/unvalidated/failed transactions; NEVER resubmits payments. */
|
|
47
|
+
export declare function reconcileSecureNodePurchase(options: {journalDir: string; intentId: string}): Promise<{
|
|
48
|
+
status: 'VALIDATED_SUCCESS';
|
|
49
|
+
transactionHash: string;
|
|
50
|
+
ledgerIndex: number;
|
|
51
|
+
deliveredAmount: string;
|
|
52
|
+
asset: string;
|
|
53
|
+
feeDrops: string;
|
|
54
|
+
invoiceID: string;
|
|
55
|
+
}>;
|