@dominusnode/pi-extension 1.0.0
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/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +118 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +373 -0
- package/dist/toolkit.d.ts +94 -0
- package/dist/toolkit.js +1109 -0
- package/package.json +39 -0
- package/skills/use-dominus-proxy.md +115 -0
package/dist/toolkit.js
ADDED
|
@@ -0,0 +1,1109 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Dominus Node Pi Extension Toolkit
|
|
4
|
+
*
|
|
5
|
+
* Provides 26 tools for the pi-mono agent framework (github.com/badlogic/pi-mono).
|
|
6
|
+
* Covers proxied fetching, wallet management, agentic wallets, teams,
|
|
7
|
+
* Stripe/PayPal/crypto top-up, and x402 micropayment info.
|
|
8
|
+
*
|
|
9
|
+
* Security:
|
|
10
|
+
* - Full SSRF protection (private IP blocking, DNS rebinding, Teredo/6to4,
|
|
11
|
+
* IPv4-mapped/compatible IPv6, hex/octal/decimal normalization, zone ID
|
|
12
|
+
* stripping, .localhost/.local/.internal/.arpa TLD blocking, embedded
|
|
13
|
+
* credential blocking)
|
|
14
|
+
* - OFAC sanctioned country validation (CU, IR, KP, RU, SY)
|
|
15
|
+
* - Credential scrubbing in all error outputs
|
|
16
|
+
* - Prototype pollution prevention on all parsed JSON (recursive)
|
|
17
|
+
* - HTTP method restriction (GET/HEAD/OPTIONS only for proxied fetch)
|
|
18
|
+
* - 10 MB response cap, 4000 char truncation, 30 s timeout
|
|
19
|
+
* - Redirect following disabled to prevent open redirect abuse
|
|
20
|
+
* - DNS rebinding protection on all proxied requests
|
|
21
|
+
*/
|
|
22
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
23
|
+
if (k2 === undefined) k2 = k;
|
|
24
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
25
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
26
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
27
|
+
}
|
|
28
|
+
Object.defineProperty(o, k2, desc);
|
|
29
|
+
}) : (function(o, m, k, k2) {
|
|
30
|
+
if (k2 === undefined) k2 = k;
|
|
31
|
+
o[k2] = m[k];
|
|
32
|
+
}));
|
|
33
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
34
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
35
|
+
}) : function(o, v) {
|
|
36
|
+
o["default"] = v;
|
|
37
|
+
});
|
|
38
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
39
|
+
var ownKeys = function(o) {
|
|
40
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
41
|
+
var ar = [];
|
|
42
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
43
|
+
return ar;
|
|
44
|
+
};
|
|
45
|
+
return ownKeys(o);
|
|
46
|
+
};
|
|
47
|
+
return function (mod) {
|
|
48
|
+
if (mod && mod.__esModule) return mod;
|
|
49
|
+
var result = {};
|
|
50
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
51
|
+
__setModuleDefault(result, mod);
|
|
52
|
+
return result;
|
|
53
|
+
};
|
|
54
|
+
})();
|
|
55
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
56
|
+
exports.DominusNodeToolkit = exports.BLOCKED_HOSTNAMES = exports.SANCTIONED_COUNTRIES = void 0;
|
|
57
|
+
exports.scrubCredentials = scrubCredentials;
|
|
58
|
+
exports.truncate = truncate;
|
|
59
|
+
exports.normalizeIpv4 = normalizeIpv4;
|
|
60
|
+
exports.isPrivateIp = isPrivateIp;
|
|
61
|
+
exports.validateTargetUrl = validateTargetUrl;
|
|
62
|
+
exports.validateCountry = validateCountry;
|
|
63
|
+
exports.validateUuid = validateUuid;
|
|
64
|
+
exports.checkDnsRebinding = checkDnsRebinding;
|
|
65
|
+
exports.stripDangerousKeys = stripDangerousKeys;
|
|
66
|
+
exports.formatBytes = formatBytes;
|
|
67
|
+
exports.formatCents = formatCents;
|
|
68
|
+
const http = __importStar(require("node:http"));
|
|
69
|
+
const tls = __importStar(require("node:tls"));
|
|
70
|
+
const dns = __importStar(require("dns/promises"));
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Constants
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
const MAX_RESPONSE_CHARS = 4000;
|
|
75
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
76
|
+
const MAX_RESPONSE_BYTES = 10 * 1024 * 1024; // 10 MB hard cap
|
|
77
|
+
const MAX_PROXY_RESPONSE_BYTES = 1_048_576; // 1 MB for proxied fetch
|
|
78
|
+
const ALLOWED_PROXY_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
79
|
+
/** OFAC sanctioned countries — must never be used as geo-targeting destinations. */
|
|
80
|
+
exports.SANCTIONED_COUNTRIES = new Set(["CU", "IR", "KP", "RU", "SY"]);
|
|
81
|
+
/** ISO 3166-1 alpha-2 country code pattern. */
|
|
82
|
+
const COUNTRY_CODE_RE = /^[A-Z]{2}$/;
|
|
83
|
+
/** UUID v4 pattern for wallet/team IDs. */
|
|
84
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
85
|
+
exports.BLOCKED_HOSTNAMES = new Set([
|
|
86
|
+
"localhost",
|
|
87
|
+
"localhost.localdomain",
|
|
88
|
+
"ip6-localhost",
|
|
89
|
+
"ip6-loopback",
|
|
90
|
+
"[::1]",
|
|
91
|
+
"[::ffff:127.0.0.1]",
|
|
92
|
+
"0.0.0.0",
|
|
93
|
+
"[::]",
|
|
94
|
+
]);
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Credential scrubbing
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
/** Remove any dn_live_* or dn_test_* tokens from error messages. */
|
|
99
|
+
function scrubCredentials(msg) {
|
|
100
|
+
return msg.replace(/dn_(live|test)_[A-Za-z0-9_-]+/g, "dn_$1_***REDACTED***");
|
|
101
|
+
}
|
|
102
|
+
function safeError(err) {
|
|
103
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
104
|
+
return scrubCredentials(raw);
|
|
105
|
+
}
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Response truncation
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
function truncate(text, max = MAX_RESPONSE_CHARS) {
|
|
110
|
+
if (text.length <= max)
|
|
111
|
+
return text;
|
|
112
|
+
return text.slice(0, max) + `\n\n... [truncated, ${text.length - max} chars omitted]`;
|
|
113
|
+
}
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// SSRF Protection
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
/**
|
|
118
|
+
* Normalize non-standard IPv4 representations to dotted-decimal.
|
|
119
|
+
* Handles: decimal integers (2130706433), hex (0x7f000001), octal octets (0177.0.0.1).
|
|
120
|
+
*/
|
|
121
|
+
function normalizeIpv4(hostname) {
|
|
122
|
+
// Helper: parse one numeric part (supports hex 0x.., octal 0.., decimal)
|
|
123
|
+
function parsePart(part) {
|
|
124
|
+
if (/^0x[0-9a-fA-F]+$/i.test(part))
|
|
125
|
+
return parseInt(part, 16);
|
|
126
|
+
if (/^0\d+$/.test(part))
|
|
127
|
+
return parseInt(part, 8);
|
|
128
|
+
if (/^\d+$/.test(part))
|
|
129
|
+
return parseInt(part, 10);
|
|
130
|
+
return NaN;
|
|
131
|
+
}
|
|
132
|
+
function u32ToDotted(n) {
|
|
133
|
+
return `${(n >>> 24) & 0xff}.${(n >>> 16) & 0xff}.${(n >>> 8) & 0xff}.${n & 0xff}`;
|
|
134
|
+
}
|
|
135
|
+
// Single integer form (decimal or hex) — e.g. 2130706433 or 0x7f000001
|
|
136
|
+
if (/^\d+$/.test(hostname)) {
|
|
137
|
+
const n = parseInt(hostname, 10);
|
|
138
|
+
if (n >= 0 && n <= 0xffffffff)
|
|
139
|
+
return u32ToDotted(n);
|
|
140
|
+
}
|
|
141
|
+
if (/^0x[0-9a-fA-F]+$/i.test(hostname)) {
|
|
142
|
+
const n = parseInt(hostname, 16);
|
|
143
|
+
if (n >= 0 && n <= 0xffffffff)
|
|
144
|
+
return u32ToDotted(n);
|
|
145
|
+
}
|
|
146
|
+
const parts = hostname.split(".");
|
|
147
|
+
if (parts.length === 4) {
|
|
148
|
+
// a.b.c.d — each octet 0-255
|
|
149
|
+
const octets = parts.map(parsePart);
|
|
150
|
+
if (octets.some((v) => isNaN(v) || v < 0 || v > 255))
|
|
151
|
+
return null;
|
|
152
|
+
return octets.join(".");
|
|
153
|
+
}
|
|
154
|
+
if (parts.length === 3) {
|
|
155
|
+
// a.b.cd — a,b: 0-255, cd: 0-65535 → a.b.(cd>>8).(cd&0xff)
|
|
156
|
+
const [a, b, cd] = parts.map(parsePart);
|
|
157
|
+
if ([a, b].some((v) => isNaN(v) || v < 0 || v > 255))
|
|
158
|
+
return null;
|
|
159
|
+
if (isNaN(cd) || cd < 0 || cd > 0xffff)
|
|
160
|
+
return null;
|
|
161
|
+
return `${a}.${b}.${(cd >>> 8) & 0xff}.${cd & 0xff}`;
|
|
162
|
+
}
|
|
163
|
+
if (parts.length === 2) {
|
|
164
|
+
// a.bcd — a: 0-255, bcd: 0-16777215 → a.(bcd>>16).(bcd>>8&0xff).(bcd&0xff)
|
|
165
|
+
const [a, bcd] = parts.map(parsePart);
|
|
166
|
+
if (isNaN(a) || a < 0 || a > 255)
|
|
167
|
+
return null;
|
|
168
|
+
if (isNaN(bcd) || bcd < 0 || bcd > 0xffffff)
|
|
169
|
+
return null;
|
|
170
|
+
return `${a}.${(bcd >>> 16) & 0xff}.${(bcd >>> 8) & 0xff}.${bcd & 0xff}`;
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
function isPrivateIp(hostname) {
|
|
175
|
+
let ip = hostname.replace(/^\[|\]$/g, "");
|
|
176
|
+
const zoneIdx = ip.indexOf("%");
|
|
177
|
+
if (zoneIdx !== -1)
|
|
178
|
+
ip = ip.substring(0, zoneIdx);
|
|
179
|
+
const normalized = normalizeIpv4(ip);
|
|
180
|
+
const checkIp = normalized ?? ip;
|
|
181
|
+
const ipv4Match = checkIp.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
182
|
+
if (ipv4Match) {
|
|
183
|
+
const a = Number(ipv4Match[1]);
|
|
184
|
+
const b = Number(ipv4Match[2]);
|
|
185
|
+
if (a === 0)
|
|
186
|
+
return true;
|
|
187
|
+
if (a === 10)
|
|
188
|
+
return true;
|
|
189
|
+
if (a === 127)
|
|
190
|
+
return true;
|
|
191
|
+
if (a === 169 && b === 254)
|
|
192
|
+
return true;
|
|
193
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
194
|
+
return true;
|
|
195
|
+
if (a === 192 && b === 168)
|
|
196
|
+
return true;
|
|
197
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
198
|
+
return true; // CGNAT
|
|
199
|
+
if (a >= 224)
|
|
200
|
+
return true; // Multicast / reserved
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
const ipLower = ip.toLowerCase();
|
|
204
|
+
if (ipLower === "::1")
|
|
205
|
+
return true;
|
|
206
|
+
if (ipLower === "::")
|
|
207
|
+
return true;
|
|
208
|
+
if (ipLower.startsWith("fc") || ipLower.startsWith("fd"))
|
|
209
|
+
return true; // ULA
|
|
210
|
+
if (ipLower.startsWith("fe80"))
|
|
211
|
+
return true; // Link-local
|
|
212
|
+
if (ipLower.startsWith("ff"))
|
|
213
|
+
return true; // Multicast
|
|
214
|
+
if (ipLower.startsWith("2001:0000:") || ipLower.startsWith("2001:0:"))
|
|
215
|
+
return true; // Teredo
|
|
216
|
+
if (ipLower.startsWith("2002:"))
|
|
217
|
+
return true; // 6to4
|
|
218
|
+
// IPv4-mapped ::ffff:x.x.x.x
|
|
219
|
+
if (ipLower.startsWith("::ffff:")) {
|
|
220
|
+
const embedded = ipLower.slice(7);
|
|
221
|
+
if (embedded.includes("."))
|
|
222
|
+
return isPrivateIp(embedded);
|
|
223
|
+
const hexParts = embedded.split(":");
|
|
224
|
+
if (hexParts.length === 2) {
|
|
225
|
+
const hi = parseInt(hexParts[0], 16);
|
|
226
|
+
const lo = parseInt(hexParts[1], 16);
|
|
227
|
+
if (!isNaN(hi) && !isNaN(lo)) {
|
|
228
|
+
return isPrivateIp(`${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return isPrivateIp(embedded);
|
|
232
|
+
}
|
|
233
|
+
// IPv4-compatible ::x.x.x.x (deprecated but parsed)
|
|
234
|
+
if (ipLower.startsWith("::") && !ipLower.startsWith("::ffff:")) {
|
|
235
|
+
const rest = ipLower.slice(2);
|
|
236
|
+
if (rest && rest.includes("."))
|
|
237
|
+
return isPrivateIp(rest);
|
|
238
|
+
const hexParts = rest.split(":");
|
|
239
|
+
if (hexParts.length === 2 && hexParts[0] && hexParts[1]) {
|
|
240
|
+
const hi = parseInt(hexParts[0], 16);
|
|
241
|
+
const lo = parseInt(hexParts[1], 16);
|
|
242
|
+
if (!isNaN(hi) && !isNaN(lo)) {
|
|
243
|
+
return isPrivateIp(`${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
function validateTargetUrl(url) {
|
|
250
|
+
let parsed;
|
|
251
|
+
try {
|
|
252
|
+
parsed = new URL(url);
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
throw new Error(`Invalid URL: ${scrubCredentials(url)}`);
|
|
256
|
+
}
|
|
257
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
258
|
+
throw new Error(`Only http: and https: protocols are supported, got ${parsed.protocol}`);
|
|
259
|
+
}
|
|
260
|
+
if (parsed.username || parsed.password) {
|
|
261
|
+
throw new Error("URLs with embedded credentials are not allowed");
|
|
262
|
+
}
|
|
263
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
264
|
+
if (exports.BLOCKED_HOSTNAMES.has(hostname)) {
|
|
265
|
+
throw new Error("Requests to localhost/loopback addresses are blocked");
|
|
266
|
+
}
|
|
267
|
+
if (isPrivateIp(hostname)) {
|
|
268
|
+
throw new Error("Requests to private/internal IP addresses are blocked");
|
|
269
|
+
}
|
|
270
|
+
if (hostname.endsWith(".localhost")) {
|
|
271
|
+
throw new Error("Requests to localhost/loopback addresses are blocked");
|
|
272
|
+
}
|
|
273
|
+
if (hostname.endsWith(".local") || hostname.endsWith(".internal") || hostname.endsWith(".arpa")) {
|
|
274
|
+
throw new Error("Requests to internal network hostnames are blocked");
|
|
275
|
+
}
|
|
276
|
+
return parsed;
|
|
277
|
+
}
|
|
278
|
+
function validateCountry(country) {
|
|
279
|
+
if (!country)
|
|
280
|
+
return undefined;
|
|
281
|
+
const upper = country.toUpperCase().trim();
|
|
282
|
+
if (!COUNTRY_CODE_RE.test(upper)) {
|
|
283
|
+
throw new Error(`Invalid country code: "${country}". Must be a 2-letter ISO 3166-1 code.`);
|
|
284
|
+
}
|
|
285
|
+
if (exports.SANCTIONED_COUNTRIES.has(upper)) {
|
|
286
|
+
throw new Error(`Country "${upper}" is OFAC sanctioned and cannot be used as a proxy target.`);
|
|
287
|
+
}
|
|
288
|
+
return upper;
|
|
289
|
+
}
|
|
290
|
+
function validateUuid(id, label) {
|
|
291
|
+
const trimmed = id.trim();
|
|
292
|
+
if (!UUID_RE.test(trimmed)) {
|
|
293
|
+
throw new Error(`Invalid ${label}: must be a valid UUID.`);
|
|
294
|
+
}
|
|
295
|
+
return trimmed;
|
|
296
|
+
}
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
// DNS rebinding protection
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
async function checkDnsRebinding(hostname) {
|
|
301
|
+
const stripped = hostname.replace(/^\[|\]$/g, "");
|
|
302
|
+
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(stripped) || stripped.includes(":"))
|
|
303
|
+
return;
|
|
304
|
+
// M-4: Use stripped (bracket-removed) hostname for DNS resolution
|
|
305
|
+
const resolvedIps = [];
|
|
306
|
+
try {
|
|
307
|
+
resolvedIps.push(...(await dns.resolve4(stripped)));
|
|
308
|
+
}
|
|
309
|
+
catch { /* NODATA/NXDOMAIN for A is fine */ }
|
|
310
|
+
try {
|
|
311
|
+
resolvedIps.push(...(await dns.resolve6(stripped)));
|
|
312
|
+
}
|
|
313
|
+
catch { /* NODATA/NXDOMAIN for AAAA is fine */ }
|
|
314
|
+
for (const ip of resolvedIps) {
|
|
315
|
+
if (isPrivateIp(ip)) {
|
|
316
|
+
throw new Error(`DNS rebinding detected: hostname resolves to private IP ${ip}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
// Prototype pollution prevention (recursive)
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
324
|
+
function stripDangerousKeys(obj, depth = 0) {
|
|
325
|
+
if (depth > 50 || !obj || typeof obj !== "object")
|
|
326
|
+
return;
|
|
327
|
+
if (Array.isArray(obj)) {
|
|
328
|
+
for (const item of obj)
|
|
329
|
+
stripDangerousKeys(item, depth + 1);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const record = obj;
|
|
333
|
+
for (const key of Object.keys(record)) {
|
|
334
|
+
if (DANGEROUS_KEYS.has(key)) {
|
|
335
|
+
delete record[key];
|
|
336
|
+
}
|
|
337
|
+
else if (record[key] && typeof record[key] === "object") {
|
|
338
|
+
stripDangerousKeys(record[key], depth + 1);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
// Formatting helpers
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
function formatBytes(bytes) {
|
|
346
|
+
if (bytes < 1024)
|
|
347
|
+
return `${bytes} B`;
|
|
348
|
+
if (bytes < 1024 * 1024)
|
|
349
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
350
|
+
if (bytes < 1024 * 1024 * 1024)
|
|
351
|
+
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
352
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(3)} GB`;
|
|
353
|
+
}
|
|
354
|
+
function formatCents(cents) {
|
|
355
|
+
return `$${(cents / 100).toFixed(2)}`;
|
|
356
|
+
}
|
|
357
|
+
function ok(text) {
|
|
358
|
+
return { content: [{ type: "text", text: truncate(text) }], details: {} };
|
|
359
|
+
}
|
|
360
|
+
/** C-1: Validate a label string — reject empty, control chars, and over-length values.
|
|
361
|
+
* M-1: Includes ASCII C0/C1, DEL, BOM, Unicode line/paragraph separators, bidi overrides. */
|
|
362
|
+
function validateLabel(label, fieldName = "label") {
|
|
363
|
+
const trimmed = label.trim();
|
|
364
|
+
if (!trimmed)
|
|
365
|
+
return `${fieldName} is required`;
|
|
366
|
+
if (trimmed.length > 100)
|
|
367
|
+
return `${fieldName} must be 1-100 characters`;
|
|
368
|
+
if (/[\x00-\x1f\x7f\x80-\x9f\u2028\u2029\ufeff\u200e\u200f\u202a-\u202e\u2066-\u2069]/.test(trimmed))
|
|
369
|
+
return `${fieldName} contains invalid control characters`;
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
function err(e) {
|
|
373
|
+
return ok(`Error: ${safeError(e)}`);
|
|
374
|
+
}
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
// DominusNodeToolkit
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
class DominusNodeToolkit {
|
|
379
|
+
apiKey;
|
|
380
|
+
baseUrl;
|
|
381
|
+
proxyHost;
|
|
382
|
+
proxyPort;
|
|
383
|
+
timeout;
|
|
384
|
+
token = null;
|
|
385
|
+
tokenExpiresAt = 0;
|
|
386
|
+
_authPromise = null; // H-1: mutex for concurrent _ensureAuth
|
|
387
|
+
constructor(options = {}) {
|
|
388
|
+
this.apiKey = options.apiKey || process.env["DOMINUSNODE_API_KEY"] || "";
|
|
389
|
+
// H-6: Validate baseUrl
|
|
390
|
+
const rawBase = (options.baseUrl || process.env["DOMINUSNODE_BASE_URL"] || "https://api.dominusnode.com").replace(/\/+$/, "");
|
|
391
|
+
try {
|
|
392
|
+
const parsedBase = new URL(rawBase);
|
|
393
|
+
if (parsedBase.protocol !== "https:" && parsedBase.protocol !== "http:")
|
|
394
|
+
throw new Error("only http/https allowed");
|
|
395
|
+
if (parsedBase.username || parsedBase.password)
|
|
396
|
+
throw new Error("embedded credentials not allowed in base URL");
|
|
397
|
+
// H-1: Warn on HTTP base URL in production
|
|
398
|
+
if (parsedBase.protocol === "http:" && parsedBase.hostname !== "localhost" && parsedBase.hostname !== "127.0.0.1") {
|
|
399
|
+
console.warn("[DominusNode] WARNING: baseUrl uses HTTP — API key will be sent unencrypted");
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
catch (e) {
|
|
403
|
+
throw new Error(`Invalid DOMINUSNODE_BASE_URL: ${e instanceof Error ? e.message : String(e)}`);
|
|
404
|
+
}
|
|
405
|
+
this.baseUrl = rawBase;
|
|
406
|
+
// H-2: Validate proxyHost against private/internal IPs
|
|
407
|
+
const rawProxyHost = options.proxyHost || process.env["DOMINUSNODE_PROXY_HOST"] || "proxy.dominusnode.com";
|
|
408
|
+
if (exports.BLOCKED_HOSTNAMES.has(rawProxyHost.toLowerCase()) || isPrivateIp(rawProxyHost)) {
|
|
409
|
+
throw new Error("DOMINUSNODE_PROXY_HOST must not be a private or loopback address");
|
|
410
|
+
}
|
|
411
|
+
if (rawProxyHost.endsWith(".localhost") || rawProxyHost.endsWith(".local") ||
|
|
412
|
+
rawProxyHost.endsWith(".internal") || rawProxyHost.endsWith(".arpa")) {
|
|
413
|
+
throw new Error("DOMINUSNODE_PROXY_HOST must not use internal network TLDs");
|
|
414
|
+
}
|
|
415
|
+
this.proxyHost = rawProxyHost;
|
|
416
|
+
const portVal = Number(options.proxyPort ?? process.env["DOMINUSNODE_PROXY_PORT"] ?? 8080);
|
|
417
|
+
this.proxyPort = isNaN(portVal) || portVal < 1 || portVal > 65535 ? 8080 : portVal;
|
|
418
|
+
// H-2: Validate timeout option; store but use REQUEST_TIMEOUT_MS constant for requests
|
|
419
|
+
const tVal = Number(options.timeout ?? 30000);
|
|
420
|
+
this.timeout = Number.isFinite(tVal) && tVal >= 1000 && tVal <= 120000 ? tVal : 30000;
|
|
421
|
+
}
|
|
422
|
+
// -----------------------------------------------------------------------
|
|
423
|
+
// Authentication
|
|
424
|
+
// -----------------------------------------------------------------------
|
|
425
|
+
async _authenticate() {
|
|
426
|
+
if (!this.apiKey)
|
|
427
|
+
throw new Error("Dominus Node API key is required. Set DOMINUSNODE_API_KEY env var.");
|
|
428
|
+
if (!this.apiKey.startsWith("dn_live_") && !this.apiKey.startsWith("dn_test_")) {
|
|
429
|
+
throw new Error('DOMINUSNODE_API_KEY must start with "dn_live_" or "dn_test_".');
|
|
430
|
+
}
|
|
431
|
+
const res = await fetch(`${this.baseUrl}/api/auth/verify-key`, {
|
|
432
|
+
method: "POST",
|
|
433
|
+
headers: { "Content-Type": "application/json", "User-Agent": "dominusnode-pi/1.0.0" },
|
|
434
|
+
body: JSON.stringify({ apiKey: this.apiKey }),
|
|
435
|
+
redirect: "error",
|
|
436
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
437
|
+
});
|
|
438
|
+
if (!res.ok) {
|
|
439
|
+
// L-1: Bounded error body read — avoid buffering large error responses
|
|
440
|
+
let errorText = "";
|
|
441
|
+
if (res.body) {
|
|
442
|
+
const reader = res.body.getReader();
|
|
443
|
+
let eb = 0;
|
|
444
|
+
const parts = [];
|
|
445
|
+
try {
|
|
446
|
+
while (eb < 4096) {
|
|
447
|
+
const { done, value } = await reader.read();
|
|
448
|
+
if (done)
|
|
449
|
+
break;
|
|
450
|
+
eb += value.length;
|
|
451
|
+
parts.push(value);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
catch { /* ignore */ }
|
|
455
|
+
reader.cancel().catch(() => { });
|
|
456
|
+
errorText = Buffer.concat(parts).toString("utf-8").slice(0, 500);
|
|
457
|
+
}
|
|
458
|
+
throw new Error(`Auth failed (${res.status}): ${scrubCredentials(errorText)}`);
|
|
459
|
+
}
|
|
460
|
+
// H-5: Cap auth response size and validate token type
|
|
461
|
+
const rawText = await res.text().catch(() => "");
|
|
462
|
+
if (new TextEncoder().encode(rawText).length > 65536)
|
|
463
|
+
throw new Error("Auth response too large");
|
|
464
|
+
const data = JSON.parse(rawText);
|
|
465
|
+
stripDangerousKeys(data);
|
|
466
|
+
if (!data.token || typeof data.token !== "string" || data.token.length > 4096) {
|
|
467
|
+
throw new Error("Invalid token in auth response");
|
|
468
|
+
}
|
|
469
|
+
this.token = data.token;
|
|
470
|
+
this.tokenExpiresAt = Date.now() + 14 * 60 * 1000;
|
|
471
|
+
return this.token;
|
|
472
|
+
}
|
|
473
|
+
async _ensureAuth() {
|
|
474
|
+
if (this.token && Date.now() < this.tokenExpiresAt)
|
|
475
|
+
return;
|
|
476
|
+
// H-1: Serialize concurrent auth calls — all concurrent callers await the same promise
|
|
477
|
+
if (this._authPromise) {
|
|
478
|
+
await this._authPromise;
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
this._authPromise = this._authenticate()
|
|
482
|
+
.then(() => { this._authPromise = null; })
|
|
483
|
+
.catch((e) => { this._authPromise = null; throw e; });
|
|
484
|
+
await this._authPromise;
|
|
485
|
+
}
|
|
486
|
+
async _apiRequest(method, path, body) {
|
|
487
|
+
await this._ensureAuth();
|
|
488
|
+
const url = `${this.baseUrl}${path}`;
|
|
489
|
+
const headers = {
|
|
490
|
+
Authorization: `Bearer ${this.token}`,
|
|
491
|
+
Accept: "application/json",
|
|
492
|
+
"User-Agent": "dominusnode-pi/1.0.0",
|
|
493
|
+
};
|
|
494
|
+
const init = { method, headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), redirect: "error" };
|
|
495
|
+
// M-6: Drop body on safe HTTP methods
|
|
496
|
+
if (body && !["GET", "HEAD"].includes(method) && ["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
|
|
497
|
+
headers["Content-Type"] = "application/json";
|
|
498
|
+
init.body = JSON.stringify(body);
|
|
499
|
+
}
|
|
500
|
+
let res;
|
|
501
|
+
try {
|
|
502
|
+
res = await fetch(url, init);
|
|
503
|
+
}
|
|
504
|
+
catch (e) {
|
|
505
|
+
throw new Error(`API request failed: ${safeError(e)}`);
|
|
506
|
+
}
|
|
507
|
+
// C-2: Stream response body with size cap to avoid memory exhaustion
|
|
508
|
+
let rawText = "";
|
|
509
|
+
if (res.body) {
|
|
510
|
+
const reader = res.body.getReader();
|
|
511
|
+
const chunks = [];
|
|
512
|
+
let totalBytes = 0;
|
|
513
|
+
try {
|
|
514
|
+
while (true) {
|
|
515
|
+
const { done, value } = await reader.read();
|
|
516
|
+
if (done)
|
|
517
|
+
break;
|
|
518
|
+
totalBytes += value.length;
|
|
519
|
+
if (totalBytes > MAX_RESPONSE_BYTES) {
|
|
520
|
+
reader.cancel().catch(() => { });
|
|
521
|
+
throw new Error("API response too large");
|
|
522
|
+
}
|
|
523
|
+
chunks.push(value);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
catch (e) {
|
|
527
|
+
reader.cancel().catch(() => { });
|
|
528
|
+
throw e;
|
|
529
|
+
}
|
|
530
|
+
rawText = Buffer.concat(chunks).toString("utf-8"); // M-3: Uint8Array[] accepted directly
|
|
531
|
+
}
|
|
532
|
+
if (!res.ok) {
|
|
533
|
+
let msg = `API error ${res.status}`;
|
|
534
|
+
try {
|
|
535
|
+
const parsed = JSON.parse(rawText);
|
|
536
|
+
stripDangerousKeys(parsed);
|
|
537
|
+
const detail = parsed.error ?? parsed.message;
|
|
538
|
+
if (detail)
|
|
539
|
+
msg = `API error ${res.status}: ${scrubCredentials(String(detail))}`;
|
|
540
|
+
}
|
|
541
|
+
catch {
|
|
542
|
+
if (rawText)
|
|
543
|
+
msg = `API error ${res.status}: ${scrubCredentials(rawText.slice(0, 200))}`;
|
|
544
|
+
}
|
|
545
|
+
throw new Error(msg);
|
|
546
|
+
}
|
|
547
|
+
if (!rawText || !rawText.trim())
|
|
548
|
+
return {};
|
|
549
|
+
try {
|
|
550
|
+
const parsed = JSON.parse(rawText);
|
|
551
|
+
stripDangerousKeys(parsed);
|
|
552
|
+
return parsed;
|
|
553
|
+
}
|
|
554
|
+
catch {
|
|
555
|
+
throw new Error("Failed to parse API response as JSON");
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
async _req(method, path, body) {
|
|
559
|
+
try {
|
|
560
|
+
return await this._apiRequest(method, path, body);
|
|
561
|
+
}
|
|
562
|
+
catch (e) {
|
|
563
|
+
// H-7: Match exact "API error 401" pattern, not any string containing "401"
|
|
564
|
+
if (e instanceof Error && /^API error 401\b/.test(e.message)) {
|
|
565
|
+
this.token = null;
|
|
566
|
+
this.tokenExpiresAt = 0;
|
|
567
|
+
return await this._apiRequest(method, path, body);
|
|
568
|
+
}
|
|
569
|
+
throw e;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
// -----------------------------------------------------------------------
|
|
573
|
+
// Tool 1: proxiedFetch
|
|
574
|
+
// -----------------------------------------------------------------------
|
|
575
|
+
async proxiedFetch(url, method = "GET", country, proxyType = "dc") {
|
|
576
|
+
try {
|
|
577
|
+
if (!url)
|
|
578
|
+
return err("url parameter is required");
|
|
579
|
+
const parsedUrl = validateTargetUrl(url);
|
|
580
|
+
await checkDnsRebinding(parsedUrl.hostname);
|
|
581
|
+
const validCountry = validateCountry(country);
|
|
582
|
+
const methodUpper = method.toUpperCase();
|
|
583
|
+
if (!ALLOWED_PROXY_METHODS.has(methodUpper))
|
|
584
|
+
return err(`HTTP method "${methodUpper}" is not allowed. Only GET, HEAD, OPTIONS permitted.`);
|
|
585
|
+
if (!["dc", "residential", "auto"].includes(proxyType))
|
|
586
|
+
return err("proxyType must be 'dc', 'residential', or 'auto'");
|
|
587
|
+
if (!this.apiKey)
|
|
588
|
+
return err("API key is required for proxied fetch");
|
|
589
|
+
const parts = [];
|
|
590
|
+
if (proxyType !== "auto")
|
|
591
|
+
parts.push(proxyType);
|
|
592
|
+
if (validCountry)
|
|
593
|
+
parts.push(`country-${validCountry}`);
|
|
594
|
+
const username = parts.length ? parts.join("-") : "auto";
|
|
595
|
+
const proxyAuth = "Basic " + Buffer.from(`${username}:${this.apiKey}`).toString("base64");
|
|
596
|
+
// C-3: Use the already-validated parsedUrl — do not re-parse the raw url string
|
|
597
|
+
const parsed = parsedUrl;
|
|
598
|
+
const result = await new Promise((resolve, reject) => {
|
|
599
|
+
// H-3: Shared cleanup reference — each branch sets this to destroy its own request socket
|
|
600
|
+
let cleanupFn = null;
|
|
601
|
+
const timer = setTimeout(() => {
|
|
602
|
+
cleanupFn?.();
|
|
603
|
+
reject(new Error("Proxy request timed out"));
|
|
604
|
+
}, REQUEST_TIMEOUT_MS);
|
|
605
|
+
if (parsed.protocol === "https:") {
|
|
606
|
+
const connectHost = parsed.hostname.includes(":") ? `[${parsed.hostname}]` : parsed.hostname;
|
|
607
|
+
const connectReq = http.request({
|
|
608
|
+
hostname: this.proxyHost, port: this.proxyPort, method: "CONNECT",
|
|
609
|
+
path: `${connectHost}:${parsed.port || 443}`,
|
|
610
|
+
headers: { "Proxy-Authorization": proxyAuth, Host: `${connectHost}:${parsed.port || 443}` },
|
|
611
|
+
});
|
|
612
|
+
cleanupFn = () => connectReq.destroy();
|
|
613
|
+
connectReq.on("connect", (_res, sock) => {
|
|
614
|
+
if (_res.statusCode !== 200) {
|
|
615
|
+
clearTimeout(timer);
|
|
616
|
+
sock.destroy();
|
|
617
|
+
reject(new Error(`CONNECT failed: ${_res.statusCode}`));
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
const tlsSock = tls.connect({ host: parsed.hostname, socket: sock, servername: parsed.hostname, minVersion: "TLSv1.2" }, () => {
|
|
621
|
+
const reqLine = `${methodUpper} ${parsed.pathname + parsed.search} HTTP/1.1\r\nHost: ${parsed.host}\r\nUser-Agent: dominusnode-pi/1.0.0\r\nConnection: close\r\n\r\n`;
|
|
622
|
+
tlsSock.write(reqLine);
|
|
623
|
+
const chunks = [];
|
|
624
|
+
let bytes = 0;
|
|
625
|
+
tlsSock.on("data", (c) => {
|
|
626
|
+
bytes += c.length;
|
|
627
|
+
if (bytes > MAX_PROXY_RESPONSE_BYTES + 16384) {
|
|
628
|
+
tlsSock.destroy();
|
|
629
|
+
return;
|
|
630
|
+
} // H-4: destroy on oversize
|
|
631
|
+
chunks.push(c);
|
|
632
|
+
});
|
|
633
|
+
let done = false;
|
|
634
|
+
const fin = () => {
|
|
635
|
+
if (done)
|
|
636
|
+
return;
|
|
637
|
+
done = true;
|
|
638
|
+
clearTimeout(timer);
|
|
639
|
+
const raw = Buffer.concat(chunks).toString("utf-8");
|
|
640
|
+
const hEnd = raw.indexOf("\r\n\r\n");
|
|
641
|
+
if (hEnd === -1) {
|
|
642
|
+
reject(new Error("Malformed response"));
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
const hdr = raw.substring(0, hEnd);
|
|
646
|
+
const body = raw.substring(hEnd + 4).substring(0, MAX_PROXY_RESPONSE_BYTES);
|
|
647
|
+
const sm = hdr.split("\r\n")[0].match(/^HTTP\/\d\.\d\s+(\d+)/);
|
|
648
|
+
resolve({ status: sm ? parseInt(sm[1], 10) : 0, body });
|
|
649
|
+
};
|
|
650
|
+
tlsSock.on("end", fin);
|
|
651
|
+
tlsSock.on("close", fin);
|
|
652
|
+
tlsSock.on("error", (e) => { clearTimeout(timer); reject(e); });
|
|
653
|
+
});
|
|
654
|
+
// M-1: Update cleanupFn to destroy tlsSock — covers the TLS handshake window
|
|
655
|
+
cleanupFn = () => tlsSock.destroy();
|
|
656
|
+
tlsSock.on("error", (e) => { clearTimeout(timer); reject(e); });
|
|
657
|
+
});
|
|
658
|
+
connectReq.on("error", (e) => { clearTimeout(timer); reject(e); });
|
|
659
|
+
connectReq.end();
|
|
660
|
+
}
|
|
661
|
+
else {
|
|
662
|
+
const reqPath = parsed.pathname + parsed.search;
|
|
663
|
+
const proxyReq = http.request({
|
|
664
|
+
hostname: this.proxyHost, port: this.proxyPort, method: methodUpper,
|
|
665
|
+
path: `http://${parsed.host}${reqPath}`,
|
|
666
|
+
headers: { "Proxy-Authorization": proxyAuth, Host: parsed.host, "User-Agent": "dominusnode-pi/1.0.0", Connection: "close" },
|
|
667
|
+
});
|
|
668
|
+
cleanupFn = () => proxyReq.destroy(); // H-3: destroy on timeout
|
|
669
|
+
const chunks = [];
|
|
670
|
+
let bytes = 0;
|
|
671
|
+
proxyReq.on("response", (r) => {
|
|
672
|
+
r.on("data", (c) => {
|
|
673
|
+
bytes += c.length;
|
|
674
|
+
if (bytes > MAX_PROXY_RESPONSE_BYTES + 16384) {
|
|
675
|
+
r.destroy();
|
|
676
|
+
return;
|
|
677
|
+
} // H-4: destroy on oversize
|
|
678
|
+
chunks.push(c);
|
|
679
|
+
});
|
|
680
|
+
r.on("end", () => { clearTimeout(timer); const body = Buffer.concat(chunks).toString("utf-8").substring(0, MAX_PROXY_RESPONSE_BYTES); resolve({ status: r.statusCode ?? 0, body }); });
|
|
681
|
+
r.on("error", (e) => { clearTimeout(timer); reject(e); });
|
|
682
|
+
});
|
|
683
|
+
proxyReq.on("error", (e) => { clearTimeout(timer); reject(e); });
|
|
684
|
+
proxyReq.end();
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
return ok(`HTTP ${result.status}\n\n${result.body}`);
|
|
688
|
+
}
|
|
689
|
+
catch (e) {
|
|
690
|
+
return err(e);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
// -----------------------------------------------------------------------
|
|
694
|
+
// Tool 2: checkBalance
|
|
695
|
+
// -----------------------------------------------------------------------
|
|
696
|
+
async checkBalance() {
|
|
697
|
+
try {
|
|
698
|
+
const data = await this._req("GET", "/api/wallet");
|
|
699
|
+
return ok(JSON.stringify(data, null, 2));
|
|
700
|
+
}
|
|
701
|
+
catch (e) {
|
|
702
|
+
return err(e);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
// -----------------------------------------------------------------------
|
|
706
|
+
// Tool 3: checkUsage
|
|
707
|
+
// -----------------------------------------------------------------------
|
|
708
|
+
async checkUsage(days = 30) {
|
|
709
|
+
try {
|
|
710
|
+
const rawDays = Number(days);
|
|
711
|
+
if (!Number.isFinite(rawDays))
|
|
712
|
+
return err("days must be a finite number between 1 and 365");
|
|
713
|
+
const d = Math.max(1, Math.min(365, Math.floor(rawDays)));
|
|
714
|
+
const until = new Date().toISOString();
|
|
715
|
+
const since = new Date(Date.now() - d * 86400000).toISOString();
|
|
716
|
+
const data = await this._req("GET", `/api/usage?since=${encodeURIComponent(since)}&until=${encodeURIComponent(until)}`);
|
|
717
|
+
return ok(JSON.stringify(data, null, 2));
|
|
718
|
+
}
|
|
719
|
+
catch (e) {
|
|
720
|
+
return err(e);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
// -----------------------------------------------------------------------
|
|
724
|
+
// Tool 4: getProxyConfig
|
|
725
|
+
// -----------------------------------------------------------------------
|
|
726
|
+
async getProxyConfig() {
|
|
727
|
+
try {
|
|
728
|
+
const data = await this._req("GET", "/api/proxy/config");
|
|
729
|
+
return ok(JSON.stringify(data, null, 2));
|
|
730
|
+
}
|
|
731
|
+
catch (e) {
|
|
732
|
+
return err(e);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
// -----------------------------------------------------------------------
|
|
736
|
+
// Tool 5: listSessions
|
|
737
|
+
// -----------------------------------------------------------------------
|
|
738
|
+
async listSessions() {
|
|
739
|
+
try {
|
|
740
|
+
const data = await this._req("GET", "/api/proxy/sessions");
|
|
741
|
+
return ok(JSON.stringify(data, null, 2));
|
|
742
|
+
}
|
|
743
|
+
catch (e) {
|
|
744
|
+
return err(e);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
// -----------------------------------------------------------------------
|
|
748
|
+
// Tool 6: createAgenticWallet
|
|
749
|
+
// -----------------------------------------------------------------------
|
|
750
|
+
async createAgenticWallet(label, spendingLimitCents) {
|
|
751
|
+
try {
|
|
752
|
+
const labelErr = validateLabel(label);
|
|
753
|
+
if (labelErr)
|
|
754
|
+
return err(labelErr);
|
|
755
|
+
const body = { label: label.trim() };
|
|
756
|
+
if (spendingLimitCents !== undefined) {
|
|
757
|
+
const limit = Math.floor(Number(spendingLimitCents));
|
|
758
|
+
if (isNaN(limit) || limit < 1 || limit > 1000000)
|
|
759
|
+
return err("spendingLimitCents must be between 1 and 1,000,000");
|
|
760
|
+
body.spendingLimitCents = limit;
|
|
761
|
+
}
|
|
762
|
+
const data = await this._req("POST", "/api/agent-wallet", body);
|
|
763
|
+
return ok(JSON.stringify(data, null, 2));
|
|
764
|
+
}
|
|
765
|
+
catch (e) {
|
|
766
|
+
return err(e);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
// -----------------------------------------------------------------------
|
|
770
|
+
// Tool 7: fundAgenticWallet
|
|
771
|
+
// -----------------------------------------------------------------------
|
|
772
|
+
async fundAgenticWallet(walletId, amountCents) {
|
|
773
|
+
try {
|
|
774
|
+
const id = validateUuid(walletId, "walletId");
|
|
775
|
+
const amount = Math.floor(Number(amountCents));
|
|
776
|
+
if (isNaN(amount) || amount < 1 || amount > 1000000)
|
|
777
|
+
return err("amountCents must be between 1 and 1,000,000");
|
|
778
|
+
const data = await this._req("POST", `/api/agent-wallet/${id}/fund`, { amountCents: amount });
|
|
779
|
+
return ok(JSON.stringify(data, null, 2));
|
|
780
|
+
}
|
|
781
|
+
catch (e) {
|
|
782
|
+
return err(e);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
// -----------------------------------------------------------------------
|
|
786
|
+
// Tool 8: checkAgenticBalance
|
|
787
|
+
// -----------------------------------------------------------------------
|
|
788
|
+
async checkAgenticBalance(walletId) {
|
|
789
|
+
try {
|
|
790
|
+
const id = validateUuid(walletId, "walletId");
|
|
791
|
+
const data = await this._req("GET", `/api/agent-wallet/${id}`);
|
|
792
|
+
return ok(JSON.stringify(data, null, 2));
|
|
793
|
+
}
|
|
794
|
+
catch (e) {
|
|
795
|
+
return err(e);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
// -----------------------------------------------------------------------
|
|
799
|
+
// Tool 9: listAgenticWallets
|
|
800
|
+
// -----------------------------------------------------------------------
|
|
801
|
+
async listAgenticWallets() {
|
|
802
|
+
try {
|
|
803
|
+
const data = await this._req("GET", "/api/agent-wallet");
|
|
804
|
+
return ok(JSON.stringify(data, null, 2));
|
|
805
|
+
}
|
|
806
|
+
catch (e) {
|
|
807
|
+
return err(e);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
// -----------------------------------------------------------------------
|
|
811
|
+
// Tool 10: agenticTransactions
|
|
812
|
+
// -----------------------------------------------------------------------
|
|
813
|
+
async agenticTransactions(walletId, limit = 20) {
|
|
814
|
+
try {
|
|
815
|
+
const id = validateUuid(walletId, "walletId");
|
|
816
|
+
const rawLim = Number(limit);
|
|
817
|
+
if (!Number.isFinite(rawLim))
|
|
818
|
+
return err("limit must be a finite number");
|
|
819
|
+
const lim = Math.max(1, Math.min(100, Math.floor(rawLim)));
|
|
820
|
+
const data = await this._req("GET", `/api/agent-wallet/${id}/transactions?limit=${lim}`);
|
|
821
|
+
return ok(JSON.stringify(data, null, 2));
|
|
822
|
+
}
|
|
823
|
+
catch (e) {
|
|
824
|
+
return err(e);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
// -----------------------------------------------------------------------
|
|
828
|
+
// Tool 11: freezeAgenticWallet
|
|
829
|
+
// -----------------------------------------------------------------------
|
|
830
|
+
async freezeAgenticWallet(walletId) {
|
|
831
|
+
try {
|
|
832
|
+
const id = validateUuid(walletId, "walletId");
|
|
833
|
+
const data = await this._req("POST", `/api/agent-wallet/${id}/freeze`);
|
|
834
|
+
return ok(JSON.stringify(data, null, 2));
|
|
835
|
+
}
|
|
836
|
+
catch (e) {
|
|
837
|
+
return err(e);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
// -----------------------------------------------------------------------
|
|
841
|
+
// Tool 12: unfreezeAgenticWallet
|
|
842
|
+
// -----------------------------------------------------------------------
|
|
843
|
+
async unfreezeAgenticWallet(walletId) {
|
|
844
|
+
try {
|
|
845
|
+
const id = validateUuid(walletId, "walletId");
|
|
846
|
+
const data = await this._req("POST", `/api/agent-wallet/${id}/unfreeze`);
|
|
847
|
+
return ok(JSON.stringify(data, null, 2));
|
|
848
|
+
}
|
|
849
|
+
catch (e) {
|
|
850
|
+
return err(e);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
// -----------------------------------------------------------------------
|
|
854
|
+
// Tool 13: deleteAgenticWallet
|
|
855
|
+
// -----------------------------------------------------------------------
|
|
856
|
+
async deleteAgenticWallet(walletId) {
|
|
857
|
+
try {
|
|
858
|
+
const id = validateUuid(walletId, "walletId");
|
|
859
|
+
const data = await this._req("DELETE", `/api/agent-wallet/${id}`);
|
|
860
|
+
return ok(JSON.stringify(data, null, 2));
|
|
861
|
+
}
|
|
862
|
+
catch (e) {
|
|
863
|
+
return err(e);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
// -----------------------------------------------------------------------
|
|
867
|
+
// Tool 14: createTeam
|
|
868
|
+
// -----------------------------------------------------------------------
|
|
869
|
+
async createTeam(name, maxMembers = 10) {
|
|
870
|
+
try {
|
|
871
|
+
const nameErr = validateLabel(name, "name");
|
|
872
|
+
if (nameErr)
|
|
873
|
+
return err(nameErr);
|
|
874
|
+
const max = Math.max(1, Math.min(100, Math.floor(Number(maxMembers))));
|
|
875
|
+
if (isNaN(max))
|
|
876
|
+
return err("maxMembers must be a number between 1 and 100");
|
|
877
|
+
const data = await this._req("POST", "/api/teams", { name: name.trim(), maxMembers: max });
|
|
878
|
+
return ok(JSON.stringify(data, null, 2));
|
|
879
|
+
}
|
|
880
|
+
catch (e) {
|
|
881
|
+
return err(e);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
// -----------------------------------------------------------------------
|
|
885
|
+
// Tool 15: listTeams
|
|
886
|
+
// -----------------------------------------------------------------------
|
|
887
|
+
async listTeams(limit = 20) {
|
|
888
|
+
try {
|
|
889
|
+
const rawLim = Number(limit);
|
|
890
|
+
if (!Number.isFinite(rawLim))
|
|
891
|
+
return err("limit must be a finite number");
|
|
892
|
+
const lim = Math.max(1, Math.min(100, Math.floor(rawLim)));
|
|
893
|
+
const data = await this._req("GET", `/api/teams?limit=${lim}`);
|
|
894
|
+
return ok(JSON.stringify(data, null, 2));
|
|
895
|
+
}
|
|
896
|
+
catch (e) {
|
|
897
|
+
return err(e);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
// -----------------------------------------------------------------------
|
|
901
|
+
// Tool 16: teamDetails
|
|
902
|
+
// -----------------------------------------------------------------------
|
|
903
|
+
async teamDetails(teamId) {
|
|
904
|
+
try {
|
|
905
|
+
const id = validateUuid(teamId, "teamId");
|
|
906
|
+
const data = await this._req("GET", `/api/teams/${id}`);
|
|
907
|
+
return ok(JSON.stringify(data, null, 2));
|
|
908
|
+
}
|
|
909
|
+
catch (e) {
|
|
910
|
+
return err(e);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
// -----------------------------------------------------------------------
|
|
914
|
+
// Tool 17: teamFund
|
|
915
|
+
// -----------------------------------------------------------------------
|
|
916
|
+
async teamFund(teamId, amountCents) {
|
|
917
|
+
try {
|
|
918
|
+
const id = validateUuid(teamId, "teamId");
|
|
919
|
+
const amount = Math.floor(Number(amountCents));
|
|
920
|
+
if (isNaN(amount) || amount < 1 || amount > 1000000)
|
|
921
|
+
return err("amountCents must be between 1 and 1,000,000");
|
|
922
|
+
const data = await this._req("POST", `/api/teams/${id}/wallet/fund`, { amountCents: amount });
|
|
923
|
+
return ok(JSON.stringify(data, null, 2));
|
|
924
|
+
}
|
|
925
|
+
catch (e) {
|
|
926
|
+
return err(e);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
// -----------------------------------------------------------------------
|
|
930
|
+
// Tool 18: teamCreateApiKey
|
|
931
|
+
// -----------------------------------------------------------------------
|
|
932
|
+
async teamCreateApiKey(teamId, label) {
|
|
933
|
+
try {
|
|
934
|
+
const id = validateUuid(teamId, "teamId");
|
|
935
|
+
const labelErr = validateLabel(label);
|
|
936
|
+
if (labelErr)
|
|
937
|
+
return err(labelErr);
|
|
938
|
+
const data = await this._req("POST", `/api/teams/${id}/keys`, { label: label.trim() });
|
|
939
|
+
return ok(JSON.stringify(data, null, 2) + "\n\nIMPORTANT: Store this API key securely — it will not be shown again.");
|
|
940
|
+
}
|
|
941
|
+
catch (e) {
|
|
942
|
+
return err(e);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
// -----------------------------------------------------------------------
|
|
946
|
+
// Tool 19: teamUsage
|
|
947
|
+
// -----------------------------------------------------------------------
|
|
948
|
+
async teamUsage(teamId, days = 30) {
|
|
949
|
+
try {
|
|
950
|
+
const id = validateUuid(teamId, "teamId");
|
|
951
|
+
const rawDays = Number(days);
|
|
952
|
+
if (!Number.isFinite(rawDays))
|
|
953
|
+
return err("days must be a finite number between 1 and 365");
|
|
954
|
+
const d = Math.max(1, Math.min(365, Math.floor(rawDays)));
|
|
955
|
+
const until = new Date().toISOString();
|
|
956
|
+
const since = new Date(Date.now() - d * 86400000).toISOString();
|
|
957
|
+
const data = await this._req("GET", `/api/teams/${id}/usage?since=${encodeURIComponent(since)}&until=${encodeURIComponent(until)}`);
|
|
958
|
+
return ok(JSON.stringify(data, null, 2));
|
|
959
|
+
}
|
|
960
|
+
catch (e) {
|
|
961
|
+
return err(e);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
// -----------------------------------------------------------------------
|
|
965
|
+
// Tool 20: updateTeam
|
|
966
|
+
// -----------------------------------------------------------------------
|
|
967
|
+
async updateTeam(teamId, name, maxMembers) {
|
|
968
|
+
try {
|
|
969
|
+
const id = validateUuid(teamId, "teamId");
|
|
970
|
+
const body = {};
|
|
971
|
+
if (name !== undefined) {
|
|
972
|
+
const nameErr = validateLabel(name, "name");
|
|
973
|
+
if (nameErr)
|
|
974
|
+
return err(nameErr);
|
|
975
|
+
body.name = name.trim();
|
|
976
|
+
}
|
|
977
|
+
if (maxMembers !== undefined) {
|
|
978
|
+
const max = Math.max(1, Math.min(100, Math.floor(Number(maxMembers))));
|
|
979
|
+
if (isNaN(max))
|
|
980
|
+
return err("maxMembers must be a number");
|
|
981
|
+
body.maxMembers = max;
|
|
982
|
+
}
|
|
983
|
+
if (!Object.keys(body).length)
|
|
984
|
+
return err("At least one of name or maxMembers must be provided");
|
|
985
|
+
const data = await this._req("PATCH", `/api/teams/${id}`, body);
|
|
986
|
+
return ok(JSON.stringify(data, null, 2));
|
|
987
|
+
}
|
|
988
|
+
catch (e) {
|
|
989
|
+
return err(e);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
// -----------------------------------------------------------------------
|
|
993
|
+
// Tool 21: updateTeamMemberRole
|
|
994
|
+
// -----------------------------------------------------------------------
|
|
995
|
+
async updateTeamMemberRole(teamId, userId, role) {
|
|
996
|
+
try {
|
|
997
|
+
const tid = validateUuid(teamId, "teamId");
|
|
998
|
+
const uid = validateUuid(userId, "userId");
|
|
999
|
+
if (!["admin", "member"].includes(role))
|
|
1000
|
+
return err("role must be 'admin' or 'member'");
|
|
1001
|
+
const data = await this._req("PATCH", `/api/teams/${tid}/members/${uid}`, { role });
|
|
1002
|
+
return ok(JSON.stringify(data, null, 2));
|
|
1003
|
+
}
|
|
1004
|
+
catch (e) {
|
|
1005
|
+
return err(e);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
// -----------------------------------------------------------------------
|
|
1009
|
+
// Tool 22: topupPaypal
|
|
1010
|
+
// -----------------------------------------------------------------------
|
|
1011
|
+
async topupPaypal(amountCents) {
|
|
1012
|
+
try {
|
|
1013
|
+
const amount = Math.floor(Number(amountCents));
|
|
1014
|
+
if (isNaN(amount) || amount < 500 || amount > 1000000)
|
|
1015
|
+
return err("amountCents must be between 500 ($5.00) and 1,000,000 ($10,000.00)");
|
|
1016
|
+
const data = await this._req("POST", "/api/wallet/topup/paypal", { amountCents: amount });
|
|
1017
|
+
return ok(JSON.stringify(data, null, 2));
|
|
1018
|
+
}
|
|
1019
|
+
catch (e) {
|
|
1020
|
+
return err(e);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
// -----------------------------------------------------------------------
|
|
1024
|
+
// Tool 23: topupStripe
|
|
1025
|
+
// -----------------------------------------------------------------------
|
|
1026
|
+
async topupStripe(amountCents) {
|
|
1027
|
+
try {
|
|
1028
|
+
const amount = Math.floor(Number(amountCents));
|
|
1029
|
+
if (isNaN(amount) || amount < 500 || amount > 1000000)
|
|
1030
|
+
return err("amountCents must be between 500 ($5.00) and 1,000,000 ($10,000.00)");
|
|
1031
|
+
const data = await this._req("POST", "/api/wallet/topup/stripe", { amountCents: amount });
|
|
1032
|
+
return ok(JSON.stringify(data, null, 2));
|
|
1033
|
+
}
|
|
1034
|
+
catch (e) {
|
|
1035
|
+
return err(e);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
// -----------------------------------------------------------------------
|
|
1039
|
+
// Tool 24: topupCrypto
|
|
1040
|
+
// -----------------------------------------------------------------------
|
|
1041
|
+
async topupCrypto(amountUsd, currency) {
|
|
1042
|
+
try {
|
|
1043
|
+
// M-2: Require whole-number dollars to avoid silent truncation (5.9 → 5)
|
|
1044
|
+
const rawAmount = Number(amountUsd);
|
|
1045
|
+
if (!Number.isInteger(rawAmount) || rawAmount < 5 || rawAmount > 10000)
|
|
1046
|
+
return err("amountUsd must be a whole number between 5 and 10,000");
|
|
1047
|
+
const amount = rawAmount;
|
|
1048
|
+
const VALID_CURRENCIES = ["BTC", "ETH", "LTC", "XMR", "ZEC", "USDC", "SOL", "USDT", "DAI", "BNB", "LINK"];
|
|
1049
|
+
const cur = (currency || "").toUpperCase().trim();
|
|
1050
|
+
if (!VALID_CURRENCIES.includes(cur))
|
|
1051
|
+
return err(`currency must be one of: ${VALID_CURRENCIES.join(", ")}`);
|
|
1052
|
+
const data = await this._req("POST", "/api/wallet/topup/crypto", { amountUsd: amount, currency: cur });
|
|
1053
|
+
return ok(JSON.stringify(data, null, 2));
|
|
1054
|
+
}
|
|
1055
|
+
catch (e) {
|
|
1056
|
+
return err(e);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
// -----------------------------------------------------------------------
|
|
1060
|
+
// Tool 25: x402Info
|
|
1061
|
+
// -----------------------------------------------------------------------
|
|
1062
|
+
async x402Info() {
|
|
1063
|
+
try {
|
|
1064
|
+
const data = await this._req("GET", "/api/wallet/x402/info");
|
|
1065
|
+
return ok(JSON.stringify(data, null, 2));
|
|
1066
|
+
}
|
|
1067
|
+
catch (e) {
|
|
1068
|
+
return err(e);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
// -----------------------------------------------------------------------
|
|
1072
|
+
// Tool 26: updateWalletPolicy
|
|
1073
|
+
// -----------------------------------------------------------------------
|
|
1074
|
+
async updateWalletPolicy(walletId, dailyLimitCents, allowedDomains) {
|
|
1075
|
+
try {
|
|
1076
|
+
const id = validateUuid(walletId, "walletId");
|
|
1077
|
+
const body = {};
|
|
1078
|
+
if (dailyLimitCents !== undefined) {
|
|
1079
|
+
const limit = Math.floor(Number(dailyLimitCents));
|
|
1080
|
+
if (isNaN(limit) || limit < 0 || limit > 1000000)
|
|
1081
|
+
return err("dailyLimitCents must be between 0 and 1,000,000");
|
|
1082
|
+
body.dailyLimitCents = limit;
|
|
1083
|
+
}
|
|
1084
|
+
if (allowedDomains !== undefined) {
|
|
1085
|
+
if (!Array.isArray(allowedDomains))
|
|
1086
|
+
return err("allowedDomains must be an array of strings");
|
|
1087
|
+
if (allowedDomains.length > 100)
|
|
1088
|
+
return err("allowedDomains must have at most 100 entries");
|
|
1089
|
+
for (let i = 0; i < allowedDomains.length; i++) {
|
|
1090
|
+
if (typeof allowedDomains[i] !== "string")
|
|
1091
|
+
return err(`allowedDomains[${i}] must be a string`);
|
|
1092
|
+
if (allowedDomains[i].length > 253)
|
|
1093
|
+
return err(`allowedDomains[${i}] exceeds 253 characters`);
|
|
1094
|
+
if (/[\x00-\x1f\x7f]/.test(allowedDomains[i]))
|
|
1095
|
+
return err(`allowedDomains[${i}] contains invalid characters`);
|
|
1096
|
+
}
|
|
1097
|
+
body.allowedDomains = allowedDomains.map((d) => String(d).trim().toLowerCase());
|
|
1098
|
+
}
|
|
1099
|
+
if (!Object.keys(body).length)
|
|
1100
|
+
return err("At least one of dailyLimitCents or allowedDomains must be provided");
|
|
1101
|
+
const data = await this._req("PATCH", `/api/agent-wallet/${id}/policy`, body);
|
|
1102
|
+
return ok(JSON.stringify(data, null, 2));
|
|
1103
|
+
}
|
|
1104
|
+
catch (e) {
|
|
1105
|
+
return err(e);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
exports.DominusNodeToolkit = DominusNodeToolkit;
|