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