@dominusnode/openai-functions 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.
@@ -0,0 +1,1069 @@
1
+ /**
2
+ * DomiNode OpenAI-compatible function calling handler (TypeScript).
3
+ *
4
+ * Provides a factory function that creates a handler for dispatching
5
+ * OpenAI function calls to the DomiNode REST API. Works with any
6
+ * function-calling LLM system: OpenAI GPT, Anthropic Claude tool_use,
7
+ * Google Gemini, or custom implementations.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * import { createDominusNodeFunctionHandler } from "./handler";
12
+ *
13
+ * const handler = createDominusNodeFunctionHandler({
14
+ * apiKey: "dn_live_...",
15
+ * baseUrl: "https://api.dominusnode.com",
16
+ * });
17
+ *
18
+ * // Dispatch a function call from an LLM response
19
+ * const result = await handler("dominusnode_check_balance", {});
20
+ * console.log(result); // JSON string with balance info
21
+ * ```
22
+ *
23
+ * @module
24
+ */
25
+ import dns from "dns/promises";
26
+ import * as http from "node:http";
27
+ import * as tls from "node:tls";
28
+ // ---------------------------------------------------------------------------
29
+ // SSRF Prevention -- URL validation
30
+ // ---------------------------------------------------------------------------
31
+ const BLOCKED_HOSTNAMES = new Set([
32
+ "localhost",
33
+ "localhost.localdomain",
34
+ "ip6-localhost",
35
+ "ip6-loopback",
36
+ "[::1]",
37
+ "[::ffff:127.0.0.1]",
38
+ "0.0.0.0",
39
+ "[::]",
40
+ ]);
41
+ /**
42
+ * Normalize non-standard IPv4 representations (hex, octal, decimal integer)
43
+ * to standard dotted-decimal to prevent SSRF bypasses like 0x7f000001,
44
+ * 2130706433, or 0177.0.0.1.
45
+ */
46
+ function normalizeIpv4(hostname) {
47
+ // Single decimal integer (e.g., 2130706433 = 127.0.0.1)
48
+ if (/^\d+$/.test(hostname)) {
49
+ const n = parseInt(hostname, 10);
50
+ if (n >= 0 && n <= 0xffffffff) {
51
+ return `${(n >>> 24) & 0xff}.${(n >>> 16) & 0xff}.${(n >>> 8) & 0xff}.${n & 0xff}`;
52
+ }
53
+ }
54
+ // Hex notation (e.g., 0x7f000001)
55
+ if (/^0x[0-9a-fA-F]+$/i.test(hostname)) {
56
+ const n = parseInt(hostname, 16);
57
+ if (n >= 0 && n <= 0xffffffff) {
58
+ return `${(n >>> 24) & 0xff}.${(n >>> 16) & 0xff}.${(n >>> 8) & 0xff}.${n & 0xff}`;
59
+ }
60
+ }
61
+ // Octal or mixed-radix octets (e.g., 0177.0.0.1)
62
+ const parts = hostname.split(".");
63
+ if (parts.length === 4) {
64
+ const octets = [];
65
+ for (const part of parts) {
66
+ let val;
67
+ if (/^0x[0-9a-fA-F]+$/i.test(part)) {
68
+ val = parseInt(part, 16);
69
+ }
70
+ else if (/^0\d+$/.test(part)) {
71
+ val = parseInt(part, 8);
72
+ }
73
+ else if (/^\d+$/.test(part)) {
74
+ val = parseInt(part, 10);
75
+ }
76
+ else {
77
+ return null;
78
+ }
79
+ if (isNaN(val) || val < 0 || val > 255)
80
+ return null;
81
+ octets.push(val);
82
+ }
83
+ return octets.join(".");
84
+ }
85
+ return null;
86
+ }
87
+ function isPrivateIp(hostname) {
88
+ let ip = hostname.replace(/^\[|\]$/g, "");
89
+ // Strip IPv6 zone ID (%25eth0, %eth0)
90
+ const zoneIdx = ip.indexOf("%");
91
+ if (zoneIdx !== -1) {
92
+ ip = ip.substring(0, zoneIdx);
93
+ }
94
+ const normalized = normalizeIpv4(ip);
95
+ const checkIp = normalized ?? ip;
96
+ // IPv4 private ranges
97
+ const ipv4Match = checkIp.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
98
+ if (ipv4Match) {
99
+ const a = Number(ipv4Match[1]);
100
+ const b = Number(ipv4Match[2]);
101
+ if (a === 0)
102
+ return true; // 0.0.0.0/8
103
+ if (a === 10)
104
+ return true; // 10.0.0.0/8
105
+ if (a === 127)
106
+ return true; // 127.0.0.0/8
107
+ if (a === 169 && b === 254)
108
+ return true; // 169.254.0.0/16
109
+ if (a === 172 && b >= 16 && b <= 31)
110
+ return true; // 172.16.0.0/12
111
+ if (a === 192 && b === 168)
112
+ return true; // 192.168.0.0/16
113
+ if (a === 100 && b >= 64 && b <= 127)
114
+ return true; // 100.64.0.0/10 CGNAT
115
+ if (a >= 224)
116
+ return true; // multicast + reserved
117
+ return false;
118
+ }
119
+ // IPv6 private ranges
120
+ const ipLower = ip.toLowerCase();
121
+ if (ipLower === "::1")
122
+ return true;
123
+ if (ipLower === "::")
124
+ return true;
125
+ if (ipLower.startsWith("fc") || ipLower.startsWith("fd"))
126
+ return true;
127
+ if (ipLower.startsWith("fe80"))
128
+ return true;
129
+ if (ipLower.startsWith("::ffff:")) {
130
+ const embedded = ipLower.slice(7);
131
+ if (embedded.includes("."))
132
+ return isPrivateIp(embedded);
133
+ const hexParts = embedded.split(":");
134
+ if (hexParts.length === 2) {
135
+ const hi = parseInt(hexParts[0], 16);
136
+ const lo = parseInt(hexParts[1], 16);
137
+ if (!isNaN(hi) && !isNaN(lo)) {
138
+ const reconstructed = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
139
+ return isPrivateIp(reconstructed);
140
+ }
141
+ }
142
+ return isPrivateIp(embedded);
143
+ }
144
+ // IPv4-compatible IPv6 (::x.x.x.x) — deprecated but still parsed
145
+ if (ipLower.startsWith("::") && !ipLower.startsWith("::ffff:")) {
146
+ const rest = ipLower.slice(2);
147
+ if (rest && rest.includes("."))
148
+ return isPrivateIp(rest);
149
+ const hexParts = rest.split(":");
150
+ if (hexParts.length === 2 && hexParts[0] && hexParts[1]) {
151
+ const hi = parseInt(hexParts[0], 16);
152
+ const lo = parseInt(hexParts[1], 16);
153
+ if (!isNaN(hi) && !isNaN(lo)) {
154
+ const reconstructed = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
155
+ return isPrivateIp(reconstructed);
156
+ }
157
+ }
158
+ }
159
+ // Teredo (2001:0000::/32) — block unconditionally
160
+ if (ipLower.startsWith("2001:0000:") || ipLower.startsWith("2001:0:"))
161
+ return true;
162
+ // 6to4 (2002::/16) — block unconditionally
163
+ if (ipLower.startsWith("2002:"))
164
+ return true;
165
+ // IPv6 multicast (ff00::/8)
166
+ if (ipLower.startsWith("ff"))
167
+ return true;
168
+ return false;
169
+ }
170
+ /**
171
+ * Validate a URL for safety before sending through the proxy.
172
+ * Blocks private IPs, localhost, internal hostnames, and non-HTTP(S) protocols.
173
+ *
174
+ * @throws {Error} If the URL is invalid or targets a private/blocked address.
175
+ */
176
+ function validateUrl(url) {
177
+ let parsed;
178
+ try {
179
+ parsed = new URL(url);
180
+ }
181
+ catch {
182
+ throw new Error(`Invalid URL: ${url}`);
183
+ }
184
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
185
+ throw new Error(`Only http: and https: protocols are supported, got ${parsed.protocol}`);
186
+ }
187
+ const hostname = parsed.hostname.toLowerCase();
188
+ if (BLOCKED_HOSTNAMES.has(hostname)) {
189
+ throw new Error("Requests to localhost/loopback addresses are blocked");
190
+ }
191
+ if (isPrivateIp(hostname)) {
192
+ throw new Error("Requests to private/internal IP addresses are blocked");
193
+ }
194
+ if (hostname.endsWith(".localhost")) {
195
+ throw new Error("Requests to localhost/loopback addresses are blocked");
196
+ }
197
+ if (hostname.endsWith(".local") ||
198
+ hostname.endsWith(".internal") ||
199
+ hostname.endsWith(".arpa")) {
200
+ throw new Error("Requests to internal network hostnames are blocked");
201
+ }
202
+ // Block embedded credentials in URL
203
+ if (parsed.username || parsed.password) {
204
+ throw new Error("URLs with embedded credentials are not allowed");
205
+ }
206
+ return parsed;
207
+ }
208
+ // ---------------------------------------------------------------------------
209
+ // Sanctioned countries (OFAC)
210
+ // ---------------------------------------------------------------------------
211
+ const SANCTIONED_COUNTRIES = new Set(["CU", "IR", "KP", "RU", "SY"]);
212
+ // ---------------------------------------------------------------------------
213
+ // Prototype pollution prevention
214
+ // ---------------------------------------------------------------------------
215
+ const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
216
+ function stripDangerousKeys(obj, depth = 0) {
217
+ if (depth > 50 || !obj || typeof obj !== "object")
218
+ return;
219
+ if (Array.isArray(obj)) {
220
+ for (const item of obj)
221
+ stripDangerousKeys(item, depth + 1);
222
+ return;
223
+ }
224
+ const record = obj;
225
+ for (const key of Object.keys(record)) {
226
+ if (DANGEROUS_KEYS.has(key)) {
227
+ delete record[key];
228
+ }
229
+ else if (record[key] && typeof record[key] === "object") {
230
+ stripDangerousKeys(record[key], depth + 1);
231
+ }
232
+ }
233
+ }
234
+ function safeJsonParse(text) {
235
+ const parsed = JSON.parse(text);
236
+ stripDangerousKeys(parsed);
237
+ return parsed;
238
+ }
239
+ // ---------------------------------------------------------------------------
240
+ // DNS rebinding protection
241
+ // ---------------------------------------------------------------------------
242
+ /**
243
+ * Resolve a hostname and verify none of the resolved IPs are private.
244
+ * Prevents DNS rebinding attacks where a hostname initially resolves to a
245
+ * public IP during validation but later resolves to a private IP.
246
+ */
247
+ async function checkDnsRebinding(hostname) {
248
+ // Skip if hostname is already an IP literal
249
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname) || hostname.startsWith("[")) {
250
+ return;
251
+ }
252
+ // Check IPv4 addresses
253
+ try {
254
+ const addresses = await dns.resolve4(hostname);
255
+ for (const addr of addresses) {
256
+ if (isPrivateIp(addr)) {
257
+ throw new Error(`Hostname resolves to private IP ${addr}`);
258
+ }
259
+ }
260
+ }
261
+ catch (err) {
262
+ if (err.code === "ENOTFOUND") {
263
+ throw new Error(`Could not resolve hostname: ${hostname}`);
264
+ }
265
+ if (err instanceof Error && err.message.includes("private IP"))
266
+ throw err;
267
+ }
268
+ // Check IPv6 addresses
269
+ try {
270
+ const addresses = await dns.resolve6(hostname);
271
+ for (const addr of addresses) {
272
+ if (isPrivateIp(addr)) {
273
+ throw new Error(`Hostname resolves to private IPv6 ${addr}`);
274
+ }
275
+ }
276
+ }
277
+ catch {
278
+ // IPv6 resolution failure is acceptable
279
+ }
280
+ }
281
+ // ---------------------------------------------------------------------------
282
+ // Credential sanitization
283
+ // ---------------------------------------------------------------------------
284
+ const CREDENTIAL_RE = /dn_(live|test)_[a-zA-Z0-9]+/g;
285
+ function sanitizeError(message) {
286
+ return message.replace(CREDENTIAL_RE, "***");
287
+ }
288
+ // ---------------------------------------------------------------------------
289
+ // Allowed HTTP methods for proxied fetch
290
+ // ---------------------------------------------------------------------------
291
+ const ALLOWED_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
292
+ // ---------------------------------------------------------------------------
293
+ // Configuration
294
+ // ---------------------------------------------------------------------------
295
+ function getProxyHost() {
296
+ return process.env.DOMINUSNODE_PROXY_HOST || "proxy.dominusnode.com";
297
+ }
298
+ function getProxyPort() {
299
+ const port = parseInt(process.env.DOMINUSNODE_PROXY_PORT || "8080", 10);
300
+ if (isNaN(port) || port < 1 || port > 65535)
301
+ return 8080;
302
+ return port;
303
+ }
304
+ // ---------------------------------------------------------------------------
305
+ // Internal HTTP helper
306
+ // ---------------------------------------------------------------------------
307
+ const MAX_RESPONSE_BYTES = 10 * 1024 * 1024; // 10 MB
308
+ async function apiRequest(config, method, path, body) {
309
+ const url = `${config.baseUrl}${path}`;
310
+ const headers = {
311
+ "User-Agent": "dominusnode-openai-functions/1.0.0",
312
+ "Content-Type": "application/json",
313
+ Authorization: `Bearer ${config.token}`,
314
+ };
315
+ const response = await fetch(url, {
316
+ method,
317
+ headers,
318
+ body: body !== undefined ? JSON.stringify(body) : undefined,
319
+ signal: AbortSignal.timeout(config.timeoutMs),
320
+ redirect: "error",
321
+ });
322
+ const contentLength = parseInt(response.headers.get("content-length") ?? "0", 10);
323
+ if (contentLength > MAX_RESPONSE_BYTES) {
324
+ throw new Error("Response body too large");
325
+ }
326
+ const responseText = await response.text();
327
+ if (responseText.length > MAX_RESPONSE_BYTES) {
328
+ throw new Error("Response body exceeds size limit");
329
+ }
330
+ if (!response.ok) {
331
+ let message;
332
+ try {
333
+ const parsed = JSON.parse(responseText);
334
+ message = parsed.error ?? parsed.message ?? responseText;
335
+ }
336
+ catch {
337
+ message = responseText;
338
+ }
339
+ if (message.length > 500)
340
+ message = message.slice(0, 500) + "... [truncated]";
341
+ throw new Error(`API error ${response.status}: ${sanitizeError(message)}`);
342
+ }
343
+ return responseText ? safeJsonParse(responseText) : {};
344
+ }
345
+ // ---------------------------------------------------------------------------
346
+ // Period to date range helper
347
+ // ---------------------------------------------------------------------------
348
+ function periodToDateRange(period) {
349
+ const now = new Date();
350
+ const until = now.toISOString();
351
+ let since;
352
+ switch (period) {
353
+ case "day":
354
+ since = new Date(now.getTime() - 24 * 60 * 60 * 1000);
355
+ break;
356
+ case "week":
357
+ since = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
358
+ break;
359
+ case "month":
360
+ default:
361
+ since = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
362
+ break;
363
+ }
364
+ return { since: since.toISOString(), until };
365
+ }
366
+ /**
367
+ * Create a DomiNode function handler for OpenAI-compatible function calling.
368
+ *
369
+ * Authenticates using the provided API key, then returns a handler function
370
+ * that dispatches function calls to the appropriate DomiNode REST API endpoint.
371
+ *
372
+ * @param config - API key and optional base URL / timeout.
373
+ * @returns A handler function: (name, args) => Promise<string>
374
+ *
375
+ * @example
376
+ * ```ts
377
+ * import { createDominusNodeFunctionHandler } from "./handler";
378
+ *
379
+ * const handler = createDominusNodeFunctionHandler({
380
+ * apiKey: "dn_live_abc123",
381
+ * baseUrl: "https://api.dominusnode.com",
382
+ * });
383
+ *
384
+ * // Handle a function call from an LLM
385
+ * const result = await handler("dominusnode_check_balance", {});
386
+ * console.log(JSON.parse(result));
387
+ * // { balanceCents: 5000, balanceUsd: 50.00, currency: "USD", lastToppedUp: "..." }
388
+ * ```
389
+ */
390
+ // Test exports — used by handler.test.ts
391
+ export { isPrivateIp, validateUrl, normalizeIpv4, sanitizeError, stripDangerousKeys, safeJsonParse, };
392
+ export function createDominusNodeFunctionHandler(config) {
393
+ const baseUrl = config.baseUrl ?? "https://api.dominusnode.com";
394
+ const timeoutMs = config.timeoutMs ?? 30_000;
395
+ if (!config.apiKey || typeof config.apiKey !== "string") {
396
+ throw new Error("apiKey is required and must be a non-empty string");
397
+ }
398
+ // Authentication state -- lazily initialized on first call
399
+ let authToken = null;
400
+ let authPromise = null;
401
+ async function authenticate() {
402
+ const response = await fetch(`${baseUrl}/api/auth/verify-key`, {
403
+ method: "POST",
404
+ headers: {
405
+ "User-Agent": "dominusnode-openai-functions/1.0.0",
406
+ "Content-Type": "application/json",
407
+ },
408
+ body: JSON.stringify({ apiKey: config.apiKey }),
409
+ signal: AbortSignal.timeout(timeoutMs),
410
+ redirect: "error",
411
+ });
412
+ if (!response.ok) {
413
+ const text = await response.text();
414
+ throw new Error(`Authentication failed (${response.status}): ${sanitizeError(text.slice(0, 500))}`);
415
+ }
416
+ const data = safeJsonParse(await response.text());
417
+ if (!data.token) {
418
+ throw new Error("Authentication response missing token");
419
+ }
420
+ authToken = data.token;
421
+ }
422
+ async function ensureAuth() {
423
+ if (authToken)
424
+ return;
425
+ if (!authPromise) {
426
+ authPromise = authenticate().finally(() => {
427
+ authPromise = null;
428
+ });
429
+ }
430
+ await authPromise;
431
+ }
432
+ function api(method, path, body) {
433
+ if (!authToken)
434
+ throw new Error("Not authenticated");
435
+ return apiRequest({ baseUrl, timeoutMs, token: authToken }, method, path, body);
436
+ }
437
+ // -----------------------------------------------------------------------
438
+ // Function handlers
439
+ // -----------------------------------------------------------------------
440
+ async function handleProxiedFetch(args) {
441
+ const url = args.url;
442
+ if (!url || typeof url !== "string") {
443
+ return JSON.stringify({ error: "url is required and must be a string" });
444
+ }
445
+ // SSRF validation
446
+ let parsedUrl;
447
+ try {
448
+ parsedUrl = validateUrl(url);
449
+ }
450
+ catch (err) {
451
+ return JSON.stringify({
452
+ error: err instanceof Error ? err.message : "URL validation failed",
453
+ });
454
+ }
455
+ // DNS rebinding protection
456
+ try {
457
+ await checkDnsRebinding(parsedUrl.hostname);
458
+ }
459
+ catch (err) {
460
+ return JSON.stringify({
461
+ error: err instanceof Error ? err.message : "DNS validation failed",
462
+ });
463
+ }
464
+ // Country validation
465
+ const country = args.country;
466
+ if (country) {
467
+ const upper = country.toUpperCase();
468
+ if (SANCTIONED_COUNTRIES.has(upper)) {
469
+ return JSON.stringify({
470
+ error: `Country '${upper}' is blocked (OFAC sanctioned country)`,
471
+ });
472
+ }
473
+ }
474
+ const method = (args.method ?? "GET").toUpperCase();
475
+ // Restrict to read-only HTTP methods
476
+ if (!ALLOWED_METHODS.has(method)) {
477
+ return JSON.stringify({
478
+ error: `HTTP method '${method}' is not allowed. Only GET, HEAD, OPTIONS are permitted.`,
479
+ });
480
+ }
481
+ const proxyType = args.proxy_type ?? "dc";
482
+ const headers = args.headers;
483
+ // Build proxy username for geo-targeting
484
+ const userParts = [proxyType];
485
+ if (country) {
486
+ userParts.push(`country-${country.toUpperCase()}`);
487
+ }
488
+ const username = userParts.join("-");
489
+ // Use the proxy gateway for the actual request
490
+ // The handler routes through the DomiNode proxy endpoint
491
+ try {
492
+ // Strip security-sensitive headers from user-provided headers
493
+ const BLOCKED_HEADERS = new Set([
494
+ "host", "connection", "content-length", "transfer-encoding",
495
+ "proxy-authorization", "authorization", "user-agent",
496
+ ]);
497
+ const safeHeaders = {};
498
+ if (headers) {
499
+ for (const [key, value] of Object.entries(headers)) {
500
+ if (!BLOCKED_HEADERS.has(key.toLowerCase())) {
501
+ // CRLF injection prevention
502
+ if (/[\r\n\0]/.test(key) || /[\r\n\0]/.test(value)) {
503
+ continue;
504
+ }
505
+ safeHeaders[key] = value;
506
+ }
507
+ }
508
+ }
509
+ // Route through proxy gateway directly
510
+ const proxyHost = getProxyHost();
511
+ const proxyPort = getProxyPort();
512
+ const apiKey = config.apiKey;
513
+ const parts = [];
514
+ if (proxyType && proxyType !== "auto")
515
+ parts.push(proxyType);
516
+ if (country)
517
+ parts.push(`country-${country.toUpperCase()}`);
518
+ const username = parts.length > 0 ? parts.join("-") : "auto";
519
+ const proxyAuth = "Basic " + Buffer.from(`${username}:${apiKey}`).toString("base64");
520
+ const parsed = new URL(url);
521
+ const MAX_BODY_BYTES = 1_048_576; // 1MB response cap
522
+ const result = await new Promise((resolve, reject) => {
523
+ const timeout = setTimeout(() => reject(new Error("Proxy request timed out after 30000ms")), 30_000);
524
+ if (parsed.protocol === "https:") {
525
+ // HTTPS: CONNECT tunnel + TLS
526
+ const connectHost = parsed.hostname.includes(":") ? `[${parsed.hostname}]` : parsed.hostname;
527
+ const connectReq = http.request({
528
+ hostname: proxyHost,
529
+ port: proxyPort,
530
+ method: "CONNECT",
531
+ path: `${connectHost}:${parsed.port || 443}`,
532
+ headers: { "Proxy-Authorization": proxyAuth, Host: `${connectHost}:${parsed.port || 443}` },
533
+ });
534
+ connectReq.on("connect", (_res, tunnelSocket) => {
535
+ if (_res.statusCode !== 200) {
536
+ clearTimeout(timeout);
537
+ tunnelSocket.destroy();
538
+ reject(new Error(`CONNECT failed: ${_res.statusCode}`));
539
+ return;
540
+ }
541
+ const tlsSocket = tls.connect({
542
+ host: parsed.hostname,
543
+ socket: tunnelSocket,
544
+ servername: parsed.hostname,
545
+ minVersion: "TLSv1.2",
546
+ }, () => {
547
+ const reqPath = parsed.pathname + parsed.search;
548
+ let reqLine = `${method} ${reqPath} HTTP/1.1\r\nHost: ${parsed.host}\r\nUser-Agent: dominusnode-openai-functions/1.0.0\r\nAccept: */*\r\nConnection: close\r\n`;
549
+ for (const [k, v] of Object.entries(safeHeaders)) {
550
+ if (!["host", "user-agent", "connection"].includes(k.toLowerCase())) {
551
+ reqLine += `${k}: ${v}\r\n`;
552
+ }
553
+ }
554
+ reqLine += "\r\n";
555
+ tlsSocket.write(reqLine);
556
+ const chunks = [];
557
+ let byteCount = 0;
558
+ tlsSocket.on("data", (chunk) => {
559
+ byteCount += chunk.length;
560
+ if (byteCount <= MAX_BODY_BYTES + 16384)
561
+ chunks.push(chunk);
562
+ });
563
+ let finalized = false;
564
+ const finalize = () => {
565
+ if (finalized)
566
+ return;
567
+ finalized = true;
568
+ clearTimeout(timeout);
569
+ const raw = Buffer.concat(chunks).toString("utf-8");
570
+ const headerEnd = raw.indexOf("\r\n\r\n");
571
+ if (headerEnd === -1) {
572
+ reject(new Error("Malformed response"));
573
+ return;
574
+ }
575
+ const headerSection = raw.substring(0, headerEnd);
576
+ const body = raw.substring(headerEnd + 4).substring(0, MAX_BODY_BYTES);
577
+ const statusLine = headerSection.split("\r\n")[0];
578
+ const statusMatch = statusLine.match(/^HTTP\/\d\.\d\s+(\d+)/);
579
+ const status = statusMatch ? parseInt(statusMatch[1], 10) : 0;
580
+ const headers = {};
581
+ for (const line of headerSection.split("\r\n").slice(1)) {
582
+ const ci = line.indexOf(":");
583
+ if (ci > 0)
584
+ headers[line.substring(0, ci).trim().toLowerCase()] = line.substring(ci + 1).trim();
585
+ }
586
+ stripDangerousKeys(headers);
587
+ resolve({ status, headers, body });
588
+ };
589
+ tlsSocket.on("end", finalize);
590
+ tlsSocket.on("close", finalize);
591
+ tlsSocket.on("error", (err) => { clearTimeout(timeout); reject(err); });
592
+ });
593
+ tlsSocket.on("error", (err) => { clearTimeout(timeout); reject(err); });
594
+ });
595
+ connectReq.on("error", (err) => { clearTimeout(timeout); reject(err); });
596
+ connectReq.end();
597
+ }
598
+ else {
599
+ // HTTP: direct proxy request
600
+ const req = http.request({
601
+ hostname: proxyHost,
602
+ port: proxyPort,
603
+ method,
604
+ path: url,
605
+ headers: { ...safeHeaders, "Proxy-Authorization": proxyAuth, Host: parsed.host },
606
+ }, (res) => {
607
+ const chunks = [];
608
+ let byteCount = 0;
609
+ res.on("data", (chunk) => { byteCount += chunk.length; if (byteCount <= MAX_BODY_BYTES)
610
+ chunks.push(chunk); });
611
+ let httpFinalized = false;
612
+ const finalize = () => {
613
+ if (httpFinalized)
614
+ return;
615
+ httpFinalized = true;
616
+ clearTimeout(timeout);
617
+ const body = Buffer.concat(chunks).toString("utf-8").substring(0, MAX_BODY_BYTES);
618
+ const headers = {};
619
+ for (const [k, v] of Object.entries(res.headers)) {
620
+ if (v)
621
+ headers[k] = Array.isArray(v) ? v.join(", ") : v;
622
+ }
623
+ stripDangerousKeys(headers);
624
+ resolve({ status: res.statusCode ?? 0, headers, body });
625
+ };
626
+ res.on("end", finalize);
627
+ res.on("close", finalize);
628
+ res.on("error", (err) => { clearTimeout(timeout); reject(err); });
629
+ });
630
+ req.on("error", (err) => { clearTimeout(timeout); reject(err); });
631
+ req.end();
632
+ }
633
+ });
634
+ return JSON.stringify({
635
+ status: result.status,
636
+ headers: result.headers,
637
+ body: result.body.substring(0, 4000), // Truncate for AI consumption
638
+ });
639
+ }
640
+ catch (err) {
641
+ return JSON.stringify({
642
+ error: `Proxy fetch failed: ${sanitizeError(err instanceof Error ? err.message : String(err))}`,
643
+ hint: "Ensure the DomiNode proxy gateway is running and accessible.",
644
+ });
645
+ }
646
+ }
647
+ async function handleCheckBalance() {
648
+ const result = await api("GET", "/api/wallet");
649
+ return JSON.stringify(result);
650
+ }
651
+ async function handleCheckUsage(args) {
652
+ const period = args.period ?? "month";
653
+ const { since, until } = periodToDateRange(period);
654
+ const params = new URLSearchParams({ since, until });
655
+ const result = await api("GET", `/api/usage?${params.toString()}`);
656
+ return JSON.stringify(result);
657
+ }
658
+ async function handleGetProxyConfig() {
659
+ const result = await api("GET", "/api/proxy/config");
660
+ return JSON.stringify(result);
661
+ }
662
+ async function handleListSessions() {
663
+ const result = await api("GET", "/api/sessions/active");
664
+ return JSON.stringify(result);
665
+ }
666
+ async function handleCreateAgenticWallet(args) {
667
+ const label = args.label;
668
+ const spendingLimitCents = args.spending_limit_cents;
669
+ if (!label || typeof label !== "string") {
670
+ return JSON.stringify({ error: "label is required and must be a string" });
671
+ }
672
+ if (label.length > 100) {
673
+ return JSON.stringify({ error: "label must be 100 characters or fewer" });
674
+ }
675
+ if (/[\x00-\x1f\x7f]/.test(label)) {
676
+ return JSON.stringify({ error: "label contains invalid control characters" });
677
+ }
678
+ if (!Number.isInteger(spendingLimitCents) ||
679
+ spendingLimitCents <= 0 ||
680
+ spendingLimitCents > 2_147_483_647) {
681
+ return JSON.stringify({
682
+ error: "spending_limit_cents must be a positive integer <= 2,147,483,647",
683
+ });
684
+ }
685
+ const body = {
686
+ label,
687
+ spendingLimitCents,
688
+ };
689
+ // Optional daily_limit_cents
690
+ if (args.daily_limit_cents !== undefined) {
691
+ const dailyLimit = args.daily_limit_cents;
692
+ if (!Number.isInteger(dailyLimit) || dailyLimit < 1 || dailyLimit > 1_000_000) {
693
+ return JSON.stringify({
694
+ error: "daily_limit_cents must be an integer between 1 and 1,000,000",
695
+ });
696
+ }
697
+ body.dailyLimitCents = dailyLimit;
698
+ }
699
+ // Optional allowed_domains
700
+ if (args.allowed_domains !== undefined) {
701
+ const domains = args.allowed_domains;
702
+ if (!Array.isArray(domains)) {
703
+ return JSON.stringify({ error: "allowed_domains must be an array of strings" });
704
+ }
705
+ if (domains.length > 100) {
706
+ return JSON.stringify({ error: "allowed_domains must have 100 or fewer entries" });
707
+ }
708
+ const domainRe = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
709
+ for (const d of domains) {
710
+ if (typeof d !== "string") {
711
+ return JSON.stringify({ error: "Each allowed_domains entry must be a string" });
712
+ }
713
+ if (d.length > 253) {
714
+ return JSON.stringify({ error: "Each allowed_domains entry must be 253 characters or fewer" });
715
+ }
716
+ if (!domainRe.test(d)) {
717
+ return JSON.stringify({ error: `Invalid domain format: ${d}` });
718
+ }
719
+ }
720
+ body.allowedDomains = domains;
721
+ }
722
+ const result = await api("POST", "/api/agent-wallet", body);
723
+ return JSON.stringify(result);
724
+ }
725
+ async function handleFundAgenticWallet(args) {
726
+ const walletId = args.wallet_id;
727
+ const amountCents = args.amount_cents;
728
+ if (!walletId || typeof walletId !== "string") {
729
+ return JSON.stringify({ error: "wallet_id is required and must be a string" });
730
+ }
731
+ if (!Number.isInteger(amountCents) ||
732
+ amountCents <= 0 ||
733
+ amountCents > 2_147_483_647) {
734
+ return JSON.stringify({
735
+ error: "amount_cents must be a positive integer <= 2,147,483,647",
736
+ });
737
+ }
738
+ const result = await api("POST", `/api/agent-wallet/${encodeURIComponent(walletId)}/fund`, { amountCents });
739
+ return JSON.stringify(result);
740
+ }
741
+ async function handleAgenticWalletBalance(args) {
742
+ const walletId = args.wallet_id;
743
+ if (!walletId || typeof walletId !== "string") {
744
+ return JSON.stringify({ error: "wallet_id is required and must be a string" });
745
+ }
746
+ const result = await api("GET", `/api/agent-wallet/${encodeURIComponent(walletId)}`);
747
+ return JSON.stringify(result);
748
+ }
749
+ async function handleListAgenticWallets() {
750
+ const result = await api("GET", "/api/agent-wallet");
751
+ return JSON.stringify(result);
752
+ }
753
+ async function handleAgenticTransactions(args) {
754
+ const walletId = args.wallet_id;
755
+ if (!walletId || typeof walletId !== "string") {
756
+ return JSON.stringify({ error: "wallet_id is required and must be a string" });
757
+ }
758
+ const limit = args.limit;
759
+ const params = new URLSearchParams();
760
+ if (limit !== undefined) {
761
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
762
+ return JSON.stringify({ error: "limit must be an integer between 1 and 100" });
763
+ }
764
+ params.set("limit", String(limit));
765
+ }
766
+ const qs = params.toString();
767
+ const result = await api("GET", `/api/agent-wallet/${encodeURIComponent(walletId)}/transactions${qs ? `?${qs}` : ""}`);
768
+ return JSON.stringify(result);
769
+ }
770
+ async function handleFreezeAgenticWallet(args) {
771
+ const walletId = args.wallet_id;
772
+ if (!walletId || typeof walletId !== "string") {
773
+ return JSON.stringify({ error: "wallet_id is required and must be a string" });
774
+ }
775
+ const result = await api("POST", `/api/agent-wallet/${encodeURIComponent(walletId)}/freeze`);
776
+ return JSON.stringify(result);
777
+ }
778
+ async function handleUnfreezeAgenticWallet(args) {
779
+ const walletId = args.wallet_id;
780
+ if (!walletId || typeof walletId !== "string") {
781
+ return JSON.stringify({ error: "wallet_id is required and must be a string" });
782
+ }
783
+ const result = await api("POST", `/api/agent-wallet/${encodeURIComponent(walletId)}/unfreeze`);
784
+ return JSON.stringify(result);
785
+ }
786
+ async function handleDeleteAgenticWallet(args) {
787
+ const walletId = args.wallet_id;
788
+ if (!walletId || typeof walletId !== "string") {
789
+ return JSON.stringify({ error: "wallet_id is required and must be a string" });
790
+ }
791
+ const result = await api("DELETE", `/api/agent-wallet/${encodeURIComponent(walletId)}`);
792
+ return JSON.stringify(result);
793
+ }
794
+ async function handleCreateTeam(args) {
795
+ const name = args.name;
796
+ if (!name || typeof name !== "string") {
797
+ return JSON.stringify({ error: "name is required and must be a string" });
798
+ }
799
+ if (name.length > 100) {
800
+ return JSON.stringify({ error: "name must be 100 characters or fewer" });
801
+ }
802
+ if (/[\x00-\x1f\x7f]/.test(name)) {
803
+ return JSON.stringify({ error: "name contains invalid control characters" });
804
+ }
805
+ const body = { name };
806
+ if (args.max_members !== undefined) {
807
+ const maxMembers = Number(args.max_members);
808
+ if (!Number.isInteger(maxMembers) || maxMembers < 1 || maxMembers > 100) {
809
+ return JSON.stringify({ error: "max_members must be an integer between 1 and 100" });
810
+ }
811
+ body.maxMembers = maxMembers;
812
+ }
813
+ const result = await api("POST", "/api/teams", body);
814
+ return JSON.stringify(result);
815
+ }
816
+ async function handleListTeams() {
817
+ const result = await api("GET", "/api/teams");
818
+ return JSON.stringify(result);
819
+ }
820
+ async function handleTeamDetails(args) {
821
+ const teamId = args.team_id;
822
+ if (!teamId || typeof teamId !== "string") {
823
+ return JSON.stringify({ error: "team_id is required and must be a string" });
824
+ }
825
+ const result = await api("GET", `/api/teams/${encodeURIComponent(teamId)}`);
826
+ return JSON.stringify(result);
827
+ }
828
+ async function handleTeamFund(args) {
829
+ const teamId = args.team_id;
830
+ const amountCents = args.amount_cents;
831
+ if (!teamId || typeof teamId !== "string") {
832
+ return JSON.stringify({ error: "team_id is required and must be a string" });
833
+ }
834
+ if (!Number.isInteger(amountCents) ||
835
+ amountCents < 100 ||
836
+ amountCents > 1_000_000) {
837
+ return JSON.stringify({
838
+ error: "amount_cents must be an integer between 100 ($1) and 1,000,000 ($10,000)",
839
+ });
840
+ }
841
+ const result = await api("POST", `/api/teams/${encodeURIComponent(teamId)}/wallet/fund`, { amountCents });
842
+ return JSON.stringify(result);
843
+ }
844
+ async function handleTeamCreateKey(args) {
845
+ const teamId = args.team_id;
846
+ const label = args.label;
847
+ if (!teamId || typeof teamId !== "string") {
848
+ return JSON.stringify({ error: "team_id is required and must be a string" });
849
+ }
850
+ if (!label || typeof label !== "string") {
851
+ return JSON.stringify({ error: "label is required and must be a string" });
852
+ }
853
+ if (label.length > 100) {
854
+ return JSON.stringify({ error: "label must be 100 characters or fewer" });
855
+ }
856
+ if (/[\x00-\x1f\x7f]/.test(label)) {
857
+ return JSON.stringify({ error: "label contains invalid control characters" });
858
+ }
859
+ const result = await api("POST", `/api/teams/${encodeURIComponent(teamId)}/keys`, { label });
860
+ return JSON.stringify(result);
861
+ }
862
+ async function handleTeamUsage(args) {
863
+ const teamId = args.team_id;
864
+ if (!teamId || typeof teamId !== "string") {
865
+ return JSON.stringify({ error: "team_id is required and must be a string" });
866
+ }
867
+ const limit = args.limit;
868
+ const params = new URLSearchParams();
869
+ if (limit !== undefined) {
870
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
871
+ return JSON.stringify({ error: "limit must be an integer between 1 and 100" });
872
+ }
873
+ params.set("limit", String(limit));
874
+ }
875
+ const qs = params.toString();
876
+ const result = await api("GET", `/api/teams/${encodeURIComponent(teamId)}/wallet/transactions${qs ? `?${qs}` : ""}`);
877
+ return JSON.stringify(result);
878
+ }
879
+ async function handleUpdateTeam(args) {
880
+ const teamId = args.team_id;
881
+ if (!teamId || typeof teamId !== "string") {
882
+ return JSON.stringify({ error: "team_id is required and must be a string" });
883
+ }
884
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(teamId)) {
885
+ return JSON.stringify({ error: "team_id must be a valid UUID" });
886
+ }
887
+ const body = {};
888
+ if (args.name !== undefined) {
889
+ const name = args.name;
890
+ if (!name || typeof name !== "string") {
891
+ return JSON.stringify({ error: "name must be a non-empty string" });
892
+ }
893
+ if (name.length > 100) {
894
+ return JSON.stringify({ error: "name must be 100 characters or fewer" });
895
+ }
896
+ if (/[\x00-\x1f\x7f]/.test(name)) {
897
+ return JSON.stringify({ error: "name contains invalid control characters" });
898
+ }
899
+ body.name = name;
900
+ }
901
+ if (args.max_members !== undefined) {
902
+ const maxMembers = Number(args.max_members);
903
+ if (!Number.isInteger(maxMembers) || maxMembers < 1 || maxMembers > 100) {
904
+ return JSON.stringify({ error: "max_members must be an integer between 1 and 100" });
905
+ }
906
+ body.maxMembers = maxMembers;
907
+ }
908
+ if (Object.keys(body).length === 0) {
909
+ return JSON.stringify({ error: "At least one of name or max_members must be provided" });
910
+ }
911
+ const result = await api("PATCH", `/api/teams/${encodeURIComponent(teamId)}`, body);
912
+ return JSON.stringify(result);
913
+ }
914
+ async function handleTopupPaypal(args) {
915
+ const amountCents = args.amount_cents;
916
+ if (!Number.isInteger(amountCents) ||
917
+ amountCents < 500 ||
918
+ amountCents > 100_000) {
919
+ return JSON.stringify({
920
+ error: "amount_cents must be an integer between 500 ($5) and 100,000 ($1,000)",
921
+ });
922
+ }
923
+ const result = await api("POST", "/api/wallet/topup/paypal", { amountCents });
924
+ return JSON.stringify(result);
925
+ }
926
+ async function handleUpdateWalletPolicy(args) {
927
+ const walletId = args.wallet_id;
928
+ if (!walletId || typeof walletId !== "string") {
929
+ return JSON.stringify({ error: "wallet_id is required and must be a string" });
930
+ }
931
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(walletId)) {
932
+ return JSON.stringify({ error: "wallet_id must be a valid UUID" });
933
+ }
934
+ const body = {};
935
+ // daily_limit_cents: integer or null (to remove)
936
+ if (args.daily_limit_cents !== undefined) {
937
+ if (args.daily_limit_cents === null) {
938
+ body.dailyLimitCents = null;
939
+ }
940
+ else {
941
+ const dailyLimit = args.daily_limit_cents;
942
+ if (!Number.isInteger(dailyLimit) || dailyLimit < 1 || dailyLimit > 1_000_000) {
943
+ return JSON.stringify({
944
+ error: "daily_limit_cents must be an integer between 1 and 1,000,000 (or null to remove)",
945
+ });
946
+ }
947
+ body.dailyLimitCents = dailyLimit;
948
+ }
949
+ }
950
+ // allowed_domains: array or null (to remove)
951
+ if (args.allowed_domains !== undefined) {
952
+ if (args.allowed_domains === null) {
953
+ body.allowedDomains = null;
954
+ }
955
+ else {
956
+ const domains = args.allowed_domains;
957
+ if (!Array.isArray(domains)) {
958
+ return JSON.stringify({ error: "allowed_domains must be an array of strings or null" });
959
+ }
960
+ if (domains.length > 100) {
961
+ return JSON.stringify({ error: "allowed_domains must have 100 or fewer entries" });
962
+ }
963
+ const domainRe = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
964
+ for (const d of domains) {
965
+ if (typeof d !== "string") {
966
+ return JSON.stringify({ error: "Each allowed_domains entry must be a string" });
967
+ }
968
+ if (d.length > 253) {
969
+ return JSON.stringify({ error: "Each allowed_domains entry must be 253 characters or fewer" });
970
+ }
971
+ if (!domainRe.test(d)) {
972
+ return JSON.stringify({ error: `Invalid domain format: ${d}` });
973
+ }
974
+ }
975
+ body.allowedDomains = domains;
976
+ }
977
+ }
978
+ if (Object.keys(body).length === 0) {
979
+ return JSON.stringify({ error: "At least one of daily_limit_cents or allowed_domains must be provided" });
980
+ }
981
+ const result = await api("PATCH", `/api/agent-wallet/${encodeURIComponent(walletId)}/policy`, body);
982
+ return JSON.stringify(result);
983
+ }
984
+ async function handleUpdateTeamMemberRole(args) {
985
+ const teamId = args.team_id;
986
+ const userId = args.user_id;
987
+ const role = args.role;
988
+ if (!teamId || typeof teamId !== "string") {
989
+ return JSON.stringify({ error: "team_id is required and must be a string" });
990
+ }
991
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(teamId)) {
992
+ return JSON.stringify({ error: "team_id must be a valid UUID" });
993
+ }
994
+ if (!userId || typeof userId !== "string") {
995
+ return JSON.stringify({ error: "user_id is required and must be a string" });
996
+ }
997
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(userId)) {
998
+ return JSON.stringify({ error: "user_id must be a valid UUID" });
999
+ }
1000
+ if (!role || typeof role !== "string") {
1001
+ return JSON.stringify({ error: "role is required and must be a string" });
1002
+ }
1003
+ if (role !== "member" && role !== "admin") {
1004
+ return JSON.stringify({ error: "role must be 'member' or 'admin'" });
1005
+ }
1006
+ const result = await api("PATCH", `/api/teams/${encodeURIComponent(teamId)}/members/${encodeURIComponent(userId)}`, { role });
1007
+ return JSON.stringify(result);
1008
+ }
1009
+ // -----------------------------------------------------------------------
1010
+ // Dispatch table
1011
+ // -----------------------------------------------------------------------
1012
+ const handlers = {
1013
+ dominusnode_proxied_fetch: handleProxiedFetch,
1014
+ dominusnode_check_balance: handleCheckBalance,
1015
+ dominusnode_check_usage: handleCheckUsage,
1016
+ dominusnode_get_proxy_config: handleGetProxyConfig,
1017
+ dominusnode_list_sessions: handleListSessions,
1018
+ dominusnode_create_agentic_wallet: handleCreateAgenticWallet,
1019
+ dominusnode_fund_agentic_wallet: handleFundAgenticWallet,
1020
+ dominusnode_agentic_wallet_balance: handleAgenticWalletBalance,
1021
+ dominusnode_list_agentic_wallets: handleListAgenticWallets,
1022
+ dominusnode_agentic_transactions: handleAgenticTransactions,
1023
+ dominusnode_freeze_agentic_wallet: handleFreezeAgenticWallet,
1024
+ dominusnode_unfreeze_agentic_wallet: handleUnfreezeAgenticWallet,
1025
+ dominusnode_delete_agentic_wallet: handleDeleteAgenticWallet,
1026
+ dominusnode_update_wallet_policy: handleUpdateWalletPolicy,
1027
+ dominusnode_create_team: handleCreateTeam,
1028
+ dominusnode_list_teams: handleListTeams,
1029
+ dominusnode_team_details: handleTeamDetails,
1030
+ dominusnode_team_fund: handleTeamFund,
1031
+ dominusnode_team_create_key: handleTeamCreateKey,
1032
+ dominusnode_team_usage: handleTeamUsage,
1033
+ dominusnode_update_team: handleUpdateTeam,
1034
+ dominusnode_update_team_member_role: handleUpdateTeamMemberRole,
1035
+ dominusnode_topup_paypal: handleTopupPaypal,
1036
+ dominusnode_x402_info: handleX402Info,
1037
+ };
1038
+ async function handleX402Info() {
1039
+ const result = await api("GET", "/api/x402/info");
1040
+ return JSON.stringify(result);
1041
+ }
1042
+ // -----------------------------------------------------------------------
1043
+ // Main handler
1044
+ // -----------------------------------------------------------------------
1045
+ return async function handler(name, args) {
1046
+ // Authenticate on first call
1047
+ await ensureAuth();
1048
+ const fn = handlers[name];
1049
+ if (!fn) {
1050
+ return JSON.stringify({
1051
+ error: `Unknown function: ${name}`,
1052
+ available: Object.keys(handlers),
1053
+ });
1054
+ }
1055
+ try {
1056
+ return await fn(args);
1057
+ }
1058
+ catch (err) {
1059
+ if (err instanceof Error && err.message.includes("401")) {
1060
+ authToken = null;
1061
+ await ensureAuth();
1062
+ return await fn(args);
1063
+ }
1064
+ return JSON.stringify({
1065
+ error: sanitizeError(err instanceof Error ? err.message : String(err)),
1066
+ });
1067
+ }
1068
+ };
1069
+ }