dominusnode-haystack 1.0.0__py3-none-any.whl

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.

Potentially problematic release.


This version of dominusnode-haystack might be problematic. Click here for more details.

@@ -0,0 +1,1873 @@
1
+ """DomiNode Haystack toolkit -- 22 proxy, wallet, team, and PayPal tools.
2
+
3
+ Provides a Haystack-compatible toolkit class that exposes 22 Tool objects for
4
+ interacting with the DomiNode rotating proxy-as-a-service platform.
5
+
6
+ Tools cover:
7
+ - Proxied HTTP fetching through rotating proxies
8
+ - Wallet balance and usage monitoring
9
+ - Proxy configuration and session listing
10
+ - Agentic wallet management (create, fund, freeze, unfreeze, delete)
11
+ - Team management (create, list, fund, keys, usage, update, role changes)
12
+ - PayPal top-up
13
+
14
+ Security:
15
+ - Full SSRF prevention (private IP blocking, DNS rebinding, Teredo/6to4,
16
+ IPv4-mapped/compatible IPv6, hex/octal/decimal normalization, zone ID
17
+ stripping, .localhost/.local/.internal/.arpa TLD blocking, embedded
18
+ credential blocking)
19
+ - OFAC sanctioned country validation (CU, IR, KP, RU, SY)
20
+ - Credential scrubbing in all error outputs
21
+ - Prototype pollution prevention on all parsed JSON
22
+ - HTTP method restriction (GET/HEAD/OPTIONS only for proxied fetch)
23
+ - 10 MB response cap, 4000 char truncation, 30 s timeout
24
+ - Redirect following disabled to prevent open redirect abuse
25
+
26
+ Example::
27
+
28
+ from dominusnode_haystack import DominusNodeToolkit
29
+
30
+ toolkit = DominusNodeToolkit(api_key="dn_live_...")
31
+ tools = toolkit.get_tools() # list of Haystack Tool objects
32
+
33
+ # Use directly:
34
+ result = toolkit.check_balance() # returns dict
35
+
36
+ Requires: httpx (``pip install httpx``), haystack-ai (``pip install haystack-ai``)
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import ipaddress
42
+ import json
43
+ import os
44
+ import re
45
+ import socket
46
+ from datetime import datetime, timedelta, timezone
47
+ from typing import Any, Dict, List, Optional, Set
48
+ from urllib.parse import quote, urlparse
49
+
50
+ import httpx
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # SSRF Prevention -- blocked hostnames
54
+ # ---------------------------------------------------------------------------
55
+
56
+ BLOCKED_HOSTNAMES: Set[str] = {
57
+ "localhost",
58
+ "localhost.localdomain",
59
+ "ip6-localhost",
60
+ "ip6-loopback",
61
+ "[::1]",
62
+ "[::ffff:127.0.0.1]",
63
+ "0.0.0.0",
64
+ "[::]",
65
+ }
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # SSRF Prevention -- blocked IP networks
69
+ # ---------------------------------------------------------------------------
70
+
71
+ _BLOCKED_IPV4_NETWORKS = [
72
+ ipaddress.IPv4Network("0.0.0.0/8"),
73
+ ipaddress.IPv4Network("10.0.0.0/8"),
74
+ ipaddress.IPv4Network("100.64.0.0/10"), # CGNAT
75
+ ipaddress.IPv4Network("127.0.0.0/8"),
76
+ ipaddress.IPv4Network("169.254.0.0/16"), # link-local
77
+ ipaddress.IPv4Network("172.16.0.0/12"),
78
+ ipaddress.IPv4Network("192.0.0.0/24"),
79
+ ipaddress.IPv4Network("192.0.2.0/24"), # TEST-NET-1
80
+ ipaddress.IPv4Network("192.168.0.0/16"),
81
+ ipaddress.IPv4Network("198.18.0.0/15"), # benchmarking
82
+ ipaddress.IPv4Network("198.51.100.0/24"), # TEST-NET-2
83
+ ipaddress.IPv4Network("203.0.113.0/24"), # TEST-NET-3
84
+ ipaddress.IPv4Network("224.0.0.0/4"), # multicast
85
+ ipaddress.IPv4Network("240.0.0.0/4"), # reserved
86
+ ipaddress.IPv4Network("255.255.255.255/32"),
87
+ ]
88
+
89
+ _BLOCKED_IPV6_NETWORKS = [
90
+ ipaddress.IPv6Network("::1/128"), # loopback
91
+ ipaddress.IPv6Network("::/128"), # unspecified
92
+ ipaddress.IPv6Network("::ffff:0:0/96"), # IPv4-mapped
93
+ ipaddress.IPv6Network("64:ff9b::/96"), # NAT64
94
+ ipaddress.IPv6Network("100::/64"), # discard
95
+ ipaddress.IPv6Network("fe80::/10"), # link-local
96
+ ipaddress.IPv6Network("fc00::/7"), # ULA (includes fd00::/8)
97
+ ipaddress.IPv6Network("ff00::/8"), # multicast
98
+ ]
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # SSRF Prevention -- IP normalization and validation
102
+ # ---------------------------------------------------------------------------
103
+
104
+
105
+ def _normalize_ipv4(hostname: str) -> Optional[str]:
106
+ """Normalize non-standard IPv4 representations to dotted-decimal.
107
+
108
+ Handles hex (0x7f000001), octal (0177.0.0.1), and decimal integer
109
+ (2130706433) forms to prevent SSRF bypasses.
110
+ """
111
+ # Single decimal integer (e.g., 2130706433 = 127.0.0.1)
112
+ if re.match(r"^\d+$", hostname):
113
+ n = int(hostname)
114
+ if 0 <= n <= 0xFFFFFFFF:
115
+ return (
116
+ f"{(n >> 24) & 0xFF}.{(n >> 16) & 0xFF}"
117
+ f".{(n >> 8) & 0xFF}.{n & 0xFF}"
118
+ )
119
+
120
+ # Hex notation (e.g., 0x7f000001)
121
+ if re.match(r"^0x[0-9a-fA-F]+$", hostname, re.IGNORECASE):
122
+ n = int(hostname, 16)
123
+ if 0 <= n <= 0xFFFFFFFF:
124
+ return (
125
+ f"{(n >> 24) & 0xFF}.{(n >> 16) & 0xFF}"
126
+ f".{(n >> 8) & 0xFF}.{n & 0xFF}"
127
+ )
128
+
129
+ # Octal or mixed-radix octets (e.g., 0177.0.0.1)
130
+ parts = hostname.split(".")
131
+ if len(parts) == 4:
132
+ octets = []
133
+ for part in parts:
134
+ try:
135
+ if re.match(r"^0x[0-9a-fA-F]+$", part, re.IGNORECASE):
136
+ val = int(part, 16)
137
+ elif re.match(r"^0\d+$", part):
138
+ val = int(part, 8)
139
+ elif re.match(r"^\d+$", part):
140
+ val = int(part, 10)
141
+ else:
142
+ return None
143
+ if val < 0 or val > 255:
144
+ return None
145
+ octets.append(val)
146
+ except ValueError:
147
+ return None
148
+ return ".".join(str(o) for o in octets)
149
+
150
+ return None
151
+
152
+
153
+ def _extract_teredo_ipv4(addr: ipaddress.IPv6Address) -> Optional[ipaddress.IPv4Address]:
154
+ """Extract the embedded IPv4 server/client from a Teredo address (2001:0000::/32).
155
+
156
+ Teredo format: 2001:0000:SSSS:SSSS:flags:port:CCCC:CCCC
157
+ where SSSS:SSSS is the Teredo server IPv4 and CCCC:CCCC (XORed with 0xFFFF)
158
+ is the client IPv4. Both must be checked.
159
+ """
160
+ packed = addr.packed # 16 bytes
161
+ # Server IPv4 at bytes 4-7
162
+ server_ip = ipaddress.IPv4Address(packed[4:8])
163
+ # Client IPv4 at bytes 12-15 (XOR with 0xFFFFFFFF)
164
+ client_bytes = bytes(b ^ 0xFF for b in packed[12:16])
165
+ client_ip = ipaddress.IPv4Address(client_bytes)
166
+ # Return whichever is private (or server if both are)
167
+ for ip in (server_ip, client_ip):
168
+ if any(ip in net for net in _BLOCKED_IPV4_NETWORKS):
169
+ return ip
170
+ return None
171
+
172
+
173
+ def _extract_6to4_ipv4(addr: ipaddress.IPv6Address) -> Optional[ipaddress.IPv4Address]:
174
+ """Extract the embedded IPv4 from a 6to4 address (2002::/16).
175
+
176
+ 6to4 format: 2002:AABB:CCDD::... where AA.BB.CC.DD is the IPv4.
177
+ """
178
+ packed = addr.packed
179
+ return ipaddress.IPv4Address(packed[2:6])
180
+
181
+
182
+ def _is_private_ip(hostname: str) -> bool:
183
+ """Check if a hostname/IP resolves to a private/reserved IP range.
184
+
185
+ Handles:
186
+ - Standard dotted-decimal IPv4
187
+ - Hex, octal, and decimal-encoded IPv4
188
+ - IPv6 with bracket stripping and zone ID removal
189
+ - IPv4-mapped IPv6 (::ffff:x.x.x.x)
190
+ - IPv4-compatible IPv6 (::x.x.x.x)
191
+ - Teredo tunneling (2001:0000::/32)
192
+ - 6to4 tunneling (2002::/16)
193
+ """
194
+ ip = hostname.strip("[]")
195
+
196
+ # Strip IPv6 zone ID
197
+ zone_idx = ip.find("%")
198
+ if zone_idx != -1:
199
+ ip = ip[:zone_idx]
200
+
201
+ # Try normalizing non-standard IPv4 first
202
+ normalized = _normalize_ipv4(ip)
203
+ check_ip = normalized if normalized else ip
204
+
205
+ # Try parsing as a standard IP address via ipaddress module
206
+ try:
207
+ addr = ipaddress.ip_address(check_ip)
208
+ except ValueError:
209
+ # Not a raw IP literal -- check well-known hostnames
210
+ lower = hostname.lower()
211
+ if lower in ("localhost", "localhost.localdomain"):
212
+ return True
213
+ if lower.endswith(".localhost"):
214
+ return True
215
+ # Hex-encoded IPv4 (e.g. 0x7f000001)
216
+ if lower.startswith("0x"):
217
+ try:
218
+ num = int(lower, 16)
219
+ if 0 <= num <= 0xFFFFFFFF:
220
+ a = ipaddress.IPv4Address(num)
221
+ return any(a in net for net in _BLOCKED_IPV4_NETWORKS)
222
+ except (ValueError, ipaddress.AddressValueError):
223
+ pass
224
+ # Decimal-encoded IPv4 (e.g. 2130706433)
225
+ if lower.isdigit():
226
+ try:
227
+ num = int(lower)
228
+ if 0 <= num <= 0xFFFFFFFF:
229
+ a = ipaddress.IPv4Address(num)
230
+ return any(a in net for net in _BLOCKED_IPV4_NETWORKS)
231
+ except (ValueError, ipaddress.AddressValueError):
232
+ pass
233
+ return False
234
+
235
+ if isinstance(addr, ipaddress.IPv4Address):
236
+ return any(addr in net for net in _BLOCKED_IPV4_NETWORKS)
237
+
238
+ if isinstance(addr, ipaddress.IPv6Address):
239
+ # Check against IPv6 blocked networks
240
+ if any(addr in net for net in _BLOCKED_IPV6_NETWORKS):
241
+ return True
242
+
243
+ # IPv4-mapped IPv6 (::ffff:x.x.x.x)
244
+ mapped = addr.ipv4_mapped
245
+ if mapped is not None:
246
+ return any(mapped in net for net in _BLOCKED_IPV4_NETWORKS)
247
+
248
+ # IPv4-compatible IPv6 (::x.x.x.x) -- deprecated but still a bypass vector
249
+ # These are addresses like ::127.0.0.1 or ::10.0.0.1
250
+ packed = addr.packed
251
+ if all(b == 0 for b in packed[:12]):
252
+ embedded = ipaddress.IPv4Address(packed[12:16])
253
+ return any(embedded in net for net in _BLOCKED_IPV4_NETWORKS)
254
+
255
+ # Teredo tunneling (2001:0000::/32)
256
+ if addr in ipaddress.IPv6Network("2001:0000::/32"):
257
+ private_v4 = _extract_teredo_ipv4(addr)
258
+ if private_v4 is not None:
259
+ return True
260
+ # Even if both IPs are "public", Teredo is suspicious -- block it
261
+ return True
262
+
263
+ # 6to4 tunneling (2002::/16) -- block unconditionally
264
+ if addr in ipaddress.IPv6Network("2002::/16"):
265
+ return True
266
+
267
+ return False
268
+
269
+
270
+ # ---------------------------------------------------------------------------
271
+ # SSRF Prevention -- full URL validation
272
+ # ---------------------------------------------------------------------------
273
+
274
+
275
+ def validate_url(url: str) -> str:
276
+ """Validate a URL for SSRF safety.
277
+
278
+ Args:
279
+ url: The URL to validate.
280
+
281
+ Returns:
282
+ The validated URL string.
283
+
284
+ Raises:
285
+ ValueError: If the URL is invalid or targets a private/blocked address.
286
+ """
287
+ if not url or not isinstance(url, str):
288
+ raise ValueError("URL must be a non-empty string")
289
+
290
+ if len(url) > 2048:
291
+ raise ValueError("URL exceeds maximum length of 2048 characters")
292
+
293
+ try:
294
+ parsed = urlparse(url)
295
+ except Exception:
296
+ raise ValueError(f"Invalid URL: {url}")
297
+
298
+ if parsed.scheme not in ("http", "https"):
299
+ raise ValueError(
300
+ f"Only http: and https: protocols are supported, got {parsed.scheme}:"
301
+ )
302
+
303
+ hostname = (parsed.hostname or "").lower()
304
+ if not hostname:
305
+ raise ValueError("URL must contain a hostname")
306
+
307
+ # Block well-known loopback hostnames
308
+ if hostname in BLOCKED_HOSTNAMES:
309
+ raise ValueError("Requests to localhost/loopback addresses are blocked")
310
+
311
+ # Block private/reserved IPs
312
+ if _is_private_ip(hostname):
313
+ raise ValueError("Requests to private/internal IP addresses are blocked")
314
+
315
+ # Block dangerous TLD suffixes
316
+ if hostname.endswith(".localhost"):
317
+ raise ValueError("Requests to .localhost addresses are blocked")
318
+
319
+ if (
320
+ hostname.endswith(".local")
321
+ or hostname.endswith(".internal")
322
+ or hostname.endswith(".arpa")
323
+ ):
324
+ raise ValueError("Requests to internal network hostnames are blocked")
325
+
326
+ # Block embedded credentials in URL
327
+ if parsed.username or parsed.password:
328
+ raise ValueError("URLs with embedded credentials are not allowed")
329
+
330
+ # DNS rebinding protection: resolve hostname and check all resolved IPs
331
+ try:
332
+ ipaddress.ip_address(hostname.strip("[]"))
333
+ except ValueError:
334
+ # It is a hostname, not a raw IP -- resolve and check
335
+ try:
336
+ infos = socket.getaddrinfo(
337
+ hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM
338
+ )
339
+ for _family, _type, _proto, _canonname, sockaddr in infos:
340
+ addr_str = sockaddr[0]
341
+ # Strip zone ID from resolved addresses
342
+ if "%" in addr_str:
343
+ addr_str = addr_str.split("%")[0]
344
+ if _is_private_ip(addr_str):
345
+ raise ValueError(
346
+ f"Hostname {hostname!r} resolves to private IP {addr_str}"
347
+ )
348
+ except socket.gaierror:
349
+ raise ValueError(f"Could not resolve hostname: {hostname!r}")
350
+
351
+ return url
352
+
353
+
354
+ # ---------------------------------------------------------------------------
355
+ # Sanctioned countries (OFAC)
356
+ # ---------------------------------------------------------------------------
357
+
358
+ SANCTIONED_COUNTRIES: Set[str] = {"CU", "IR", "KP", "RU", "SY"}
359
+
360
+ # ---------------------------------------------------------------------------
361
+ # Max response size
362
+ # ---------------------------------------------------------------------------
363
+
364
+ MAX_RESPONSE_BYTES = 10 * 1024 * 1024 # 10 MB
365
+ MAX_RESPONSE_CHARS = 4000
366
+
367
+ # ---------------------------------------------------------------------------
368
+ # Credential sanitization
369
+ # ---------------------------------------------------------------------------
370
+
371
+ _CREDENTIAL_RE = re.compile(r"dn_(live|test)_[a-zA-Z0-9]+")
372
+
373
+
374
+ def _sanitize_error(message: str) -> str:
375
+ """Remove DomiNode API key patterns from error messages."""
376
+ return _CREDENTIAL_RE.sub("***", message)
377
+
378
+
379
+ # ---------------------------------------------------------------------------
380
+ # Prototype pollution prevention
381
+ # ---------------------------------------------------------------------------
382
+
383
+ _DANGEROUS_KEYS = frozenset({"__proto__", "constructor", "prototype"})
384
+
385
+
386
+ def _strip_dangerous_keys(obj: Any, depth: int = 0) -> None:
387
+ """Recursively remove prototype pollution keys from parsed JSON."""
388
+ if depth > 50 or obj is None or not isinstance(obj, (dict, list)):
389
+ return
390
+ if isinstance(obj, list):
391
+ for item in obj:
392
+ _strip_dangerous_keys(item, depth + 1)
393
+ return
394
+ keys_to_remove = [k for k in obj if k in _DANGEROUS_KEYS]
395
+ for k in keys_to_remove:
396
+ del obj[k]
397
+ for v in obj.values():
398
+ if isinstance(v, (dict, list)):
399
+ _strip_dangerous_keys(v, depth + 1)
400
+
401
+
402
+ # ---------------------------------------------------------------------------
403
+ # Allowed HTTP methods for proxied fetch
404
+ # ---------------------------------------------------------------------------
405
+
406
+ _ALLOWED_FETCH_METHODS: Set[str] = {"GET", "HEAD", "OPTIONS"}
407
+
408
+ # ---------------------------------------------------------------------------
409
+ # UUID regex for ID validation
410
+ # ---------------------------------------------------------------------------
411
+
412
+ _UUID_RE = re.compile(
413
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
414
+ re.IGNORECASE,
415
+ )
416
+
417
+
418
+ # ---------------------------------------------------------------------------
419
+ # Period to date range helper
420
+ # ---------------------------------------------------------------------------
421
+
422
+
423
+ def _period_to_date_range(period: str) -> Dict[str, str]:
424
+ """Convert a human-readable period to ISO date range parameters."""
425
+ now = datetime.now(timezone.utc)
426
+ until = now.isoformat()
427
+
428
+ if period == "day":
429
+ since = (now - timedelta(days=1)).isoformat()
430
+ elif period == "week":
431
+ since = (now - timedelta(weeks=1)).isoformat()
432
+ else: # month (default)
433
+ since = (now - timedelta(days=30)).isoformat()
434
+
435
+ return {"since": since, "until": until}
436
+
437
+
438
+ # ---------------------------------------------------------------------------
439
+ # Input validation helpers
440
+ # ---------------------------------------------------------------------------
441
+
442
+
443
+ def _validate_label(label: Any, field_name: str = "label") -> Optional[str]:
444
+ """Validate a label string, returning an error message or None."""
445
+ if not label or not isinstance(label, str):
446
+ return f"{field_name} is required and must be a string"
447
+ if len(label) > 100:
448
+ return f"{field_name} must be 100 characters or fewer"
449
+ if any(0 <= ord(c) <= 0x1F or ord(c) == 0x7F for c in label):
450
+ return f"{field_name} contains invalid control characters"
451
+ return None
452
+
453
+
454
+ def _validate_positive_int(
455
+ value: Any,
456
+ field_name: str,
457
+ max_value: int = 2_147_483_647,
458
+ min_value: int = 1,
459
+ ) -> Optional[str]:
460
+ """Validate that value is an integer within range, returning error or None."""
461
+ if not isinstance(value, int) or isinstance(value, bool):
462
+ return f"{field_name} must be an integer"
463
+ if value < min_value or value > max_value:
464
+ return f"{field_name} must be between {min_value} and {max_value}"
465
+ return None
466
+
467
+
468
+ def _validate_uuid(value: Any, field_name: str) -> Optional[str]:
469
+ """Validate that value is a valid UUID string, returning error or None."""
470
+ if not value or not isinstance(value, str):
471
+ return f"{field_name} is required and must be a string"
472
+ if not _UUID_RE.match(value):
473
+ return f"{field_name} must be a valid UUID"
474
+ return None
475
+
476
+
477
+ _DOMAIN_RE = re.compile(
478
+ r"^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$"
479
+ )
480
+
481
+
482
+ def _validate_domains(domains: Any) -> Optional[str]:
483
+ """Validate allowed_domains list, returning error message or None."""
484
+ if not isinstance(domains, list):
485
+ return "allowed_domains must be a list of strings"
486
+ if len(domains) > 100:
487
+ return "allowed_domains must have at most 100 entries"
488
+ for i, d in enumerate(domains):
489
+ if not isinstance(d, str) or not d:
490
+ return f"allowed_domains[{i}] must be a non-empty string"
491
+ if len(d) > 253:
492
+ return f"allowed_domains[{i}] exceeds 253 characters"
493
+ if not _DOMAIN_RE.match(d):
494
+ return f"allowed_domains[{i}] is not a valid domain"
495
+ return None
496
+
497
+
498
+ # ===========================================================================
499
+ # DominusNodeToolkit -- main toolkit class
500
+ # ===========================================================================
501
+
502
+
503
+ class DominusNodeToolkit:
504
+ """Haystack toolkit providing DomiNode rotating proxy tools.
505
+
506
+ Authenticates using a DomiNode API key and exposes 22 tools for proxy,
507
+ wallet, team, and payment management. All tool methods return dicts
508
+ suitable for Haystack pipeline consumption.
509
+
510
+ Args:
511
+ api_key: DomiNode API key (``dn_live_...`` or ``dn_test_...``).
512
+ Falls back to ``DOMINUSNODE_API_KEY`` environment variable.
513
+ base_url: Base URL of the DomiNode REST API.
514
+ proxy_host: Hostname of the DomiNode proxy gateway.
515
+ proxy_port: Port of the DomiNode proxy gateway.
516
+ timeout: HTTP request timeout in seconds.
517
+
518
+ Example::
519
+
520
+ toolkit = DominusNodeToolkit(api_key="dn_live_abc123")
521
+ tools = toolkit.get_tools() # list of Haystack Tool objects
522
+
523
+ # Or call directly:
524
+ result = toolkit.check_balance() # dict
525
+ """
526
+
527
+ def __init__(
528
+ self,
529
+ api_key: str | None = None,
530
+ base_url: str = "https://api.dominusnode.com",
531
+ proxy_host: str = "proxy.dominusnode.com",
532
+ proxy_port: int = 8080,
533
+ timeout: float = 30.0,
534
+ ):
535
+ self.api_key = api_key or os.environ.get("DOMINUSNODE_API_KEY", "")
536
+ self.base_url = base_url.rstrip("/")
537
+ self.proxy_host = os.environ.get("DOMINUSNODE_PROXY_HOST", proxy_host)
538
+ self.proxy_port = int(os.environ.get("DOMINUSNODE_PROXY_PORT", str(proxy_port)))
539
+ self.timeout = timeout
540
+ self._token: str | None = None
541
+
542
+ # ------------------------------------------------------------------
543
+ # Authentication
544
+ # ------------------------------------------------------------------
545
+
546
+ def _authenticate(self) -> str:
547
+ """Authenticate with the DomiNode API using the API key.
548
+
549
+ Returns:
550
+ The JWT bearer token.
551
+
552
+ Raises:
553
+ RuntimeError: If authentication fails.
554
+ """
555
+ if not self.api_key:
556
+ raise RuntimeError(
557
+ "DomiNode API key is required. Pass api_key or set "
558
+ "DOMINUSNODE_API_KEY environment variable."
559
+ )
560
+
561
+ with httpx.Client(
562
+ timeout=self.timeout,
563
+ follow_redirects=False,
564
+ ) as client:
565
+ resp = client.post(
566
+ f"{self.base_url}/api/auth/verify-key",
567
+ json={"apiKey": self.api_key},
568
+ headers={
569
+ "User-Agent": "dominusnode-haystack/1.0.0",
570
+ "Content-Type": "application/json",
571
+ },
572
+ )
573
+
574
+ if resp.status_code != 200:
575
+ body = _sanitize_error(resp.text[:500])
576
+ raise RuntimeError(
577
+ f"Authentication failed ({resp.status_code}): {body}"
578
+ )
579
+
580
+ data = resp.json()
581
+ token = data.get("token")
582
+ if not token:
583
+ raise RuntimeError("Authentication response missing token")
584
+ self._token = token
585
+ return token
586
+
587
+ def _ensure_auth(self) -> None:
588
+ """Ensure the toolkit is authenticated, authenticating if needed."""
589
+ if self._token is None:
590
+ self._authenticate()
591
+
592
+ # ------------------------------------------------------------------
593
+ # API request helper
594
+ # ------------------------------------------------------------------
595
+
596
+ def _api_request(
597
+ self,
598
+ method: str,
599
+ path: str,
600
+ body: Optional[Dict[str, Any]] = None,
601
+ ) -> Any:
602
+ """Make an authenticated API request to the DomiNode REST API.
603
+
604
+ Args:
605
+ method: HTTP method (GET, POST, PATCH, DELETE).
606
+ path: API path (e.g., ``/api/wallet``).
607
+ body: Optional JSON body.
608
+
609
+ Returns:
610
+ Parsed JSON response (with dangerous keys stripped).
611
+
612
+ Raises:
613
+ RuntimeError: On auth failure, API errors, or oversized responses.
614
+ """
615
+ if self._token is None:
616
+ raise RuntimeError("Not authenticated")
617
+
618
+ with httpx.Client(
619
+ timeout=self.timeout,
620
+ follow_redirects=False,
621
+ ) as client:
622
+ kwargs: Dict[str, Any] = {
623
+ "headers": {
624
+ "User-Agent": "dominusnode-haystack/1.0.0",
625
+ "Content-Type": "application/json",
626
+ "Authorization": f"Bearer {self._token}",
627
+ },
628
+ }
629
+
630
+ if body is not None and method.upper() not in ("GET", "HEAD", "OPTIONS"):
631
+ kwargs["json"] = body
632
+
633
+ resp = client.request(method, f"{self.base_url}{path}", **kwargs)
634
+
635
+ if len(resp.content) > MAX_RESPONSE_BYTES:
636
+ raise RuntimeError("Response body too large")
637
+
638
+ if resp.status_code >= 400:
639
+ try:
640
+ err_data = resp.json()
641
+ msg = err_data.get("error", resp.text)
642
+ except Exception:
643
+ msg = resp.text
644
+ msg = str(msg)[:500]
645
+ raise RuntimeError(
646
+ f"API error {resp.status_code}: {_sanitize_error(msg)}"
647
+ )
648
+
649
+ if resp.text:
650
+ data = resp.json()
651
+ _strip_dangerous_keys(data)
652
+ return data
653
+ return {}
654
+
655
+ def _request_with_retry(
656
+ self,
657
+ method: str,
658
+ path: str,
659
+ body: Optional[Dict[str, Any]] = None,
660
+ ) -> Any:
661
+ """Make an API request, retrying once on 401 (token expired)."""
662
+ self._ensure_auth()
663
+ try:
664
+ return self._api_request(method, path, body)
665
+ except RuntimeError as e:
666
+ if "401" in str(e):
667
+ self._token = None
668
+ self._ensure_auth()
669
+ return self._api_request(method, path, body)
670
+ raise
671
+
672
+ # ==================================================================
673
+ # Tool 1: proxied_fetch
674
+ # ==================================================================
675
+
676
+ def proxied_fetch(
677
+ self,
678
+ url: str,
679
+ method: str = "GET",
680
+ country: str | None = None,
681
+ proxy_type: str = "dc",
682
+ headers: Dict[str, str] | None = None,
683
+ ) -> Dict[str, Any]:
684
+ """Fetch a URL through the DomiNode rotating proxy network.
685
+
686
+ Args:
687
+ url: Target URL to fetch.
688
+ method: HTTP method (GET, HEAD, or OPTIONS only).
689
+ country: Optional ISO 3166-1 alpha-2 country code for geo-targeting.
690
+ proxy_type: Proxy type: ``dc`` (datacenter, $3/GB) or
691
+ ``residential`` ($5/GB). Default: ``dc``.
692
+ headers: Optional additional headers to send with the request.
693
+
694
+ Returns:
695
+ Dict with status, headers, and body (truncated to 4000 chars).
696
+ """
697
+ # Validate URL for SSRF
698
+ try:
699
+ validate_url(url)
700
+ except ValueError as e:
701
+ return {"error": str(e)}
702
+
703
+ # OFAC country check
704
+ if country:
705
+ upper = country.upper()
706
+ if upper in SANCTIONED_COUNTRIES:
707
+ return {"error": f"Country '{upper}' is blocked (OFAC sanctioned country)"}
708
+
709
+ # Restrict HTTP methods
710
+ method_upper = (method or "GET").upper()
711
+ if method_upper not in _ALLOWED_FETCH_METHODS:
712
+ return {
713
+ "error": (
714
+ f"HTTP method '{method_upper}' is not allowed. "
715
+ "Only GET, HEAD, OPTIONS are permitted."
716
+ )
717
+ }
718
+
719
+ # Validate proxy_type
720
+ if proxy_type not in ("dc", "residential", "auto"):
721
+ return {"error": "proxy_type must be 'dc', 'residential', or 'auto'"}
722
+
723
+ # Sanitize headers
724
+ blocked_header_names = {
725
+ "host", "connection", "content-length", "transfer-encoding",
726
+ "proxy-authorization", "authorization", "user-agent",
727
+ }
728
+ safe_headers: Dict[str, str] = {}
729
+ for key, value in (headers or {}).items():
730
+ if key.lower() not in blocked_header_names:
731
+ # CRLF injection prevention
732
+ if "\r" in key or "\n" in key or "\0" in key:
733
+ continue
734
+ if "\r" in str(value) or "\n" in str(value) or "\0" in str(value):
735
+ continue
736
+ safe_headers[key] = str(value)
737
+
738
+ try:
739
+ # Build proxy username for routing
740
+ parts: list[str] = []
741
+ if proxy_type and proxy_type != "auto":
742
+ parts.append(proxy_type)
743
+ if country:
744
+ parts.append(f"country-{country.upper()}")
745
+ username = "-".join(parts) if parts else "auto"
746
+
747
+ proxy_url = (
748
+ f"http://{username}:{self.api_key}"
749
+ f"@{self.proxy_host}:{self.proxy_port}"
750
+ )
751
+
752
+ with httpx.Client(
753
+ proxy=proxy_url,
754
+ timeout=self.timeout,
755
+ follow_redirects=False,
756
+ max_redirects=0,
757
+ ) as proxy_client:
758
+ resp = proxy_client.request(
759
+ method=method_upper,
760
+ url=url,
761
+ headers=safe_headers,
762
+ )
763
+
764
+ # Enforce response size limit
765
+ if len(resp.content) > MAX_RESPONSE_BYTES:
766
+ return {"error": "Response body too large (>10 MB)"}
767
+
768
+ body = resp.text[:MAX_RESPONSE_CHARS]
769
+ resp_headers = dict(resp.headers)
770
+ # Scrub sensitive response headers
771
+ for h in ("set-cookie", "www-authenticate", "proxy-authenticate"):
772
+ resp_headers.pop(h, None)
773
+
774
+ return {
775
+ "status": resp.status_code,
776
+ "headers": resp_headers,
777
+ "body": body,
778
+ }
779
+ except Exception as e:
780
+ return {
781
+ "error": f"Proxy fetch failed: {_sanitize_error(str(e))}",
782
+ "hint": "Ensure the DomiNode proxy gateway is running and accessible.",
783
+ }
784
+
785
+ # ==================================================================
786
+ # Tool 2: check_balance
787
+ # ==================================================================
788
+
789
+ def check_balance(self) -> Dict[str, Any]:
790
+ """Check the current wallet balance.
791
+
792
+ Returns:
793
+ Dict with wallet balance information.
794
+ """
795
+ try:
796
+ result = self._request_with_retry("GET", "/api/wallet")
797
+ return result
798
+ except Exception as e:
799
+ return {"error": _sanitize_error(str(e))}
800
+
801
+ # ==================================================================
802
+ # Tool 3: check_usage
803
+ # ==================================================================
804
+
805
+ def check_usage(self, period: str = "month") -> Dict[str, Any]:
806
+ """Check proxy usage statistics.
807
+
808
+ Args:
809
+ period: Time period -- ``day``, ``week``, or ``month`` (default).
810
+
811
+ Returns:
812
+ Dict with usage statistics.
813
+ """
814
+ if period not in ("day", "week", "month"):
815
+ return {"error": "period must be 'day', 'week', or 'month'"}
816
+ try:
817
+ date_range = _period_to_date_range(period)
818
+ result = self._request_with_retry(
819
+ "GET",
820
+ f"/api/usage?since={quote(date_range['since'], safe='')}"
821
+ f"&until={quote(date_range['until'], safe='')}",
822
+ )
823
+ return result
824
+ except Exception as e:
825
+ return {"error": _sanitize_error(str(e))}
826
+
827
+ # ==================================================================
828
+ # Tool 4: get_proxy_config
829
+ # ==================================================================
830
+
831
+ def get_proxy_config(self) -> Dict[str, Any]:
832
+ """Get proxy configuration information.
833
+
834
+ Returns:
835
+ Dict with proxy configuration (endpoints, geo-targeting options).
836
+ """
837
+ try:
838
+ result = self._request_with_retry("GET", "/api/proxy/config")
839
+ return result
840
+ except Exception as e:
841
+ return {"error": _sanitize_error(str(e))}
842
+
843
+ # ==================================================================
844
+ # Tool 5: list_sessions
845
+ # ==================================================================
846
+
847
+ def list_sessions(self) -> Dict[str, Any]:
848
+ """List active proxy sessions.
849
+
850
+ Returns:
851
+ Dict with active session information.
852
+ """
853
+ try:
854
+ result = self._request_with_retry("GET", "/api/sessions/active")
855
+ return result
856
+ except Exception as e:
857
+ return {"error": _sanitize_error(str(e))}
858
+
859
+ # ==================================================================
860
+ # Tool 6: create_agentic_wallet
861
+ # ==================================================================
862
+
863
+ def create_agentic_wallet(
864
+ self,
865
+ label: str,
866
+ spending_limit_cents: int,
867
+ daily_limit_cents: Optional[int] = None,
868
+ allowed_domains: Optional[list] = None,
869
+ ) -> Dict[str, Any]:
870
+ """Create a new agentic sub-wallet with a spending limit.
871
+
872
+ Args:
873
+ label: Human-readable label for the wallet (max 100 chars).
874
+ spending_limit_cents: Per-transaction spending limit in cents.
875
+ daily_limit_cents: Optional daily budget cap in cents (1 to 1,000,000).
876
+ allowed_domains: Optional list of allowed domains (max 100, each <= 253 chars).
877
+
878
+ Returns:
879
+ Dict with the created wallet details.
880
+ """
881
+ err = _validate_label(label, "label")
882
+ if err:
883
+ return {"error": err}
884
+
885
+ err = _validate_positive_int(spending_limit_cents, "spending_limit_cents")
886
+ if err:
887
+ return {"error": err}
888
+
889
+ if daily_limit_cents is not None:
890
+ err = _validate_positive_int(
891
+ daily_limit_cents, "daily_limit_cents", max_value=1_000_000
892
+ )
893
+ if err:
894
+ return {"error": err}
895
+
896
+ if allowed_domains is not None:
897
+ err = _validate_domains(allowed_domains)
898
+ if err:
899
+ return {"error": err}
900
+
901
+ try:
902
+ body: Dict[str, Any] = {
903
+ "label": label,
904
+ "spendingLimitCents": spending_limit_cents,
905
+ }
906
+ if daily_limit_cents is not None:
907
+ body["dailyLimitCents"] = daily_limit_cents
908
+ if allowed_domains is not None:
909
+ body["allowedDomains"] = allowed_domains
910
+ result = self._request_with_retry("POST", "/api/agent-wallet", body)
911
+ return result
912
+ except Exception as e:
913
+ return {"error": _sanitize_error(str(e))}
914
+
915
+ # ==================================================================
916
+ # Tool 7: fund_agentic_wallet
917
+ # ==================================================================
918
+
919
+ def fund_agentic_wallet(self, wallet_id: str, amount_cents: int) -> Dict[str, Any]:
920
+ """Fund an agentic wallet from the main wallet.
921
+
922
+ Args:
923
+ wallet_id: ID of the agentic wallet to fund.
924
+ amount_cents: Amount to transfer in cents.
925
+
926
+ Returns:
927
+ Dict with the updated wallet details.
928
+ """
929
+ if not wallet_id or not isinstance(wallet_id, str):
930
+ return {"error": "wallet_id is required and must be a string"}
931
+
932
+ err = _validate_positive_int(amount_cents, "amount_cents")
933
+ if err:
934
+ return {"error": err}
935
+
936
+ try:
937
+ result = self._request_with_retry(
938
+ "POST",
939
+ f"/api/agent-wallet/{quote(wallet_id, safe='')}/fund",
940
+ {"amountCents": amount_cents},
941
+ )
942
+ return result
943
+ except Exception as e:
944
+ return {"error": _sanitize_error(str(e))}
945
+
946
+ # ==================================================================
947
+ # Tool 8: agentic_wallet_balance
948
+ # ==================================================================
949
+
950
+ def agentic_wallet_balance(self, wallet_id: str) -> Dict[str, Any]:
951
+ """Check the balance of an agentic wallet.
952
+
953
+ Args:
954
+ wallet_id: ID of the agentic wallet.
955
+
956
+ Returns:
957
+ Dict with wallet balance details.
958
+ """
959
+ if not wallet_id or not isinstance(wallet_id, str):
960
+ return {"error": "wallet_id is required and must be a string"}
961
+
962
+ try:
963
+ result = self._request_with_retry(
964
+ "GET",
965
+ f"/api/agent-wallet/{quote(wallet_id, safe='')}",
966
+ )
967
+ return result
968
+ except Exception as e:
969
+ return {"error": _sanitize_error(str(e))}
970
+
971
+ # ==================================================================
972
+ # Tool 9: list_agentic_wallets
973
+ # ==================================================================
974
+
975
+ def list_agentic_wallets(self) -> Dict[str, Any]:
976
+ """List all agentic wallets.
977
+
978
+ Returns:
979
+ Dict with a list of all agentic wallets.
980
+ """
981
+ try:
982
+ result = self._request_with_retry("GET", "/api/agent-wallet")
983
+ return result
984
+ except Exception as e:
985
+ return {"error": _sanitize_error(str(e))}
986
+
987
+ # ==================================================================
988
+ # Tool 10: agentic_transactions
989
+ # ==================================================================
990
+
991
+ def agentic_transactions(self, wallet_id: str, limit: int = 20) -> Dict[str, Any]:
992
+ """Get transaction history for an agentic wallet.
993
+
994
+ Args:
995
+ wallet_id: ID of the agentic wallet.
996
+ limit: Maximum number of transactions to return (1-100, default 20).
997
+
998
+ Returns:
999
+ Dict with transaction history.
1000
+ """
1001
+ if not wallet_id or not isinstance(wallet_id, str):
1002
+ return {"error": "wallet_id is required and must be a string"}
1003
+
1004
+ if not isinstance(limit, int) or isinstance(limit, bool) or limit < 1 or limit > 100:
1005
+ return {"error": "limit must be an integer between 1 and 100"}
1006
+
1007
+ qs = f"?limit={limit}" if limit != 20 else ""
1008
+
1009
+ try:
1010
+ result = self._request_with_retry(
1011
+ "GET",
1012
+ f"/api/agent-wallet/{quote(wallet_id, safe='')}/transactions{qs}",
1013
+ )
1014
+ return result
1015
+ except Exception as e:
1016
+ return {"error": _sanitize_error(str(e))}
1017
+
1018
+ # ==================================================================
1019
+ # Tool 11: freeze_agentic_wallet
1020
+ # ==================================================================
1021
+
1022
+ def freeze_agentic_wallet(self, wallet_id: str) -> Dict[str, Any]:
1023
+ """Freeze an agentic wallet (prevent spending).
1024
+
1025
+ Args:
1026
+ wallet_id: ID of the agentic wallet to freeze.
1027
+
1028
+ Returns:
1029
+ Dict confirming the wallet is frozen.
1030
+ """
1031
+ if not wallet_id or not isinstance(wallet_id, str):
1032
+ return {"error": "wallet_id is required and must be a string"}
1033
+
1034
+ try:
1035
+ result = self._request_with_retry(
1036
+ "POST",
1037
+ f"/api/agent-wallet/{quote(wallet_id, safe='')}/freeze",
1038
+ )
1039
+ return result
1040
+ except Exception as e:
1041
+ return {"error": _sanitize_error(str(e))}
1042
+
1043
+ # ==================================================================
1044
+ # Tool 12: unfreeze_agentic_wallet
1045
+ # ==================================================================
1046
+
1047
+ def unfreeze_agentic_wallet(self, wallet_id: str) -> Dict[str, Any]:
1048
+ """Unfreeze an agentic wallet (re-enable spending).
1049
+
1050
+ Args:
1051
+ wallet_id: ID of the agentic wallet to unfreeze.
1052
+
1053
+ Returns:
1054
+ Dict confirming the wallet is unfrozen.
1055
+ """
1056
+ if not wallet_id or not isinstance(wallet_id, str):
1057
+ return {"error": "wallet_id is required and must be a string"}
1058
+
1059
+ try:
1060
+ result = self._request_with_retry(
1061
+ "POST",
1062
+ f"/api/agent-wallet/{quote(wallet_id, safe='')}/unfreeze",
1063
+ )
1064
+ return result
1065
+ except Exception as e:
1066
+ return {"error": _sanitize_error(str(e))}
1067
+
1068
+ # ==================================================================
1069
+ # Tool 13: delete_agentic_wallet
1070
+ # ==================================================================
1071
+
1072
+ def delete_agentic_wallet(self, wallet_id: str) -> Dict[str, Any]:
1073
+ """Delete an agentic wallet (must be unfrozen first; refunds balance).
1074
+
1075
+ Args:
1076
+ wallet_id: ID of the agentic wallet to delete.
1077
+
1078
+ Returns:
1079
+ Dict confirming deletion.
1080
+ """
1081
+ if not wallet_id or not isinstance(wallet_id, str):
1082
+ return {"error": "wallet_id is required and must be a string"}
1083
+
1084
+ try:
1085
+ result = self._request_with_retry(
1086
+ "DELETE",
1087
+ f"/api/agent-wallet/{quote(wallet_id, safe='')}",
1088
+ )
1089
+ return result
1090
+ except Exception as e:
1091
+ return {"error": _sanitize_error(str(e))}
1092
+
1093
+ # ==================================================================
1094
+ # Tool 14: create_team
1095
+ # ==================================================================
1096
+
1097
+ def create_team(self, name: str, max_members: int | None = None) -> Dict[str, Any]:
1098
+ """Create a new team with a shared wallet.
1099
+
1100
+ Args:
1101
+ name: Team name (max 100 chars).
1102
+ max_members: Optional maximum number of team members (1-100).
1103
+
1104
+ Returns:
1105
+ Dict with the created team details.
1106
+ """
1107
+ err = _validate_label(name, "name")
1108
+ if err:
1109
+ return {"error": err}
1110
+
1111
+ body: Dict[str, Any] = {"name": name}
1112
+
1113
+ if max_members is not None:
1114
+ err = _validate_positive_int(max_members, "max_members", max_value=100)
1115
+ if err:
1116
+ return {"error": err}
1117
+ body["maxMembers"] = max_members
1118
+
1119
+ try:
1120
+ result = self._request_with_retry("POST", "/api/teams", body)
1121
+ return result
1122
+ except Exception as e:
1123
+ return {"error": _sanitize_error(str(e))}
1124
+
1125
+ # ==================================================================
1126
+ # Tool 15: list_teams
1127
+ # ==================================================================
1128
+
1129
+ def list_teams(self) -> Dict[str, Any]:
1130
+ """List all teams the user belongs to.
1131
+
1132
+ Returns:
1133
+ Dict with a list of teams.
1134
+ """
1135
+ try:
1136
+ result = self._request_with_retry("GET", "/api/teams")
1137
+ return result
1138
+ except Exception as e:
1139
+ return {"error": _sanitize_error(str(e))}
1140
+
1141
+ # ==================================================================
1142
+ # Tool 16: team_details
1143
+ # ==================================================================
1144
+
1145
+ def team_details(self, team_id: str) -> Dict[str, Any]:
1146
+ """Get details for a specific team.
1147
+
1148
+ Args:
1149
+ team_id: UUID of the team.
1150
+
1151
+ Returns:
1152
+ Dict with team details.
1153
+ """
1154
+ err = _validate_uuid(team_id, "team_id")
1155
+ if err:
1156
+ return {"error": err}
1157
+
1158
+ try:
1159
+ result = self._request_with_retry(
1160
+ "GET", f"/api/teams/{quote(team_id, safe='')}"
1161
+ )
1162
+ return result
1163
+ except Exception as e:
1164
+ return {"error": _sanitize_error(str(e))}
1165
+
1166
+ # ==================================================================
1167
+ # Tool 17: team_fund
1168
+ # ==================================================================
1169
+
1170
+ def team_fund(self, team_id: str, amount_cents: int) -> Dict[str, Any]:
1171
+ """Fund a team's shared wallet from the user's personal wallet.
1172
+
1173
+ Args:
1174
+ team_id: UUID of the team.
1175
+ amount_cents: Amount to transfer in cents (100 to 1,000,000).
1176
+
1177
+ Returns:
1178
+ Dict with the updated team wallet details.
1179
+ """
1180
+ err = _validate_uuid(team_id, "team_id")
1181
+ if err:
1182
+ return {"error": err}
1183
+
1184
+ err = _validate_positive_int(
1185
+ amount_cents, "amount_cents", min_value=100, max_value=1_000_000
1186
+ )
1187
+ if err:
1188
+ return {"error": err}
1189
+
1190
+ try:
1191
+ result = self._request_with_retry(
1192
+ "POST",
1193
+ f"/api/teams/{quote(team_id, safe='')}/wallet/fund",
1194
+ {"amountCents": amount_cents},
1195
+ )
1196
+ return result
1197
+ except Exception as e:
1198
+ return {"error": _sanitize_error(str(e))}
1199
+
1200
+ # ==================================================================
1201
+ # Tool 18: team_create_key
1202
+ # ==================================================================
1203
+
1204
+ def team_create_key(self, team_id: str, label: str) -> Dict[str, Any]:
1205
+ """Create a new API key for a team.
1206
+
1207
+ Args:
1208
+ team_id: UUID of the team.
1209
+ label: Human-readable label for the key (max 100 chars).
1210
+
1211
+ Returns:
1212
+ Dict with the created API key (shown once).
1213
+ """
1214
+ err = _validate_uuid(team_id, "team_id")
1215
+ if err:
1216
+ return {"error": err}
1217
+
1218
+ err = _validate_label(label, "label")
1219
+ if err:
1220
+ return {"error": err}
1221
+
1222
+ try:
1223
+ result = self._request_with_retry(
1224
+ "POST",
1225
+ f"/api/teams/{quote(team_id, safe='')}/keys",
1226
+ {"label": label},
1227
+ )
1228
+ return result
1229
+ except Exception as e:
1230
+ return {"error": _sanitize_error(str(e))}
1231
+
1232
+ # ==================================================================
1233
+ # Tool 19: team_usage
1234
+ # ==================================================================
1235
+
1236
+ def team_usage(self, team_id: str, limit: int = 20) -> Dict[str, Any]:
1237
+ """Get usage/transaction history for a team.
1238
+
1239
+ Args:
1240
+ team_id: UUID of the team.
1241
+ limit: Maximum number of records to return (1-100, default 20).
1242
+
1243
+ Returns:
1244
+ Dict with team usage/transaction history.
1245
+ """
1246
+ err = _validate_uuid(team_id, "team_id")
1247
+ if err:
1248
+ return {"error": err}
1249
+
1250
+ if not isinstance(limit, int) or isinstance(limit, bool) or limit < 1 or limit > 100:
1251
+ return {"error": "limit must be an integer between 1 and 100"}
1252
+
1253
+ qs = f"?limit={limit}" if limit != 20 else ""
1254
+
1255
+ try:
1256
+ result = self._request_with_retry(
1257
+ "GET",
1258
+ f"/api/teams/{quote(team_id, safe='')}/wallet/transactions{qs}",
1259
+ )
1260
+ return result
1261
+ except Exception as e:
1262
+ return {"error": _sanitize_error(str(e))}
1263
+
1264
+ # ==================================================================
1265
+ # Tool 20: update_team
1266
+ # ==================================================================
1267
+
1268
+ def update_team(
1269
+ self,
1270
+ team_id: str,
1271
+ name: str | None = None,
1272
+ max_members: int | None = None,
1273
+ ) -> Dict[str, Any]:
1274
+ """Update team settings (name and/or max_members).
1275
+
1276
+ Args:
1277
+ team_id: UUID of the team.
1278
+ name: New team name (max 100 chars, optional).
1279
+ max_members: New max member count (1-100, optional).
1280
+
1281
+ Returns:
1282
+ Dict with the updated team details.
1283
+ """
1284
+ err = _validate_uuid(team_id, "team_id")
1285
+ if err:
1286
+ return {"error": err}
1287
+
1288
+ body: Dict[str, Any] = {}
1289
+
1290
+ if name is not None:
1291
+ err = _validate_label(name, "name")
1292
+ if err:
1293
+ return {"error": err}
1294
+ body["name"] = name
1295
+
1296
+ if max_members is not None:
1297
+ err = _validate_positive_int(max_members, "max_members", max_value=100)
1298
+ if err:
1299
+ return {"error": err}
1300
+ body["maxMembers"] = max_members
1301
+
1302
+ if not body:
1303
+ return {"error": "At least one of name or max_members must be provided"}
1304
+
1305
+ try:
1306
+ result = self._request_with_retry(
1307
+ "PATCH",
1308
+ f"/api/teams/{quote(team_id, safe='')}",
1309
+ body,
1310
+ )
1311
+ return result
1312
+ except Exception as e:
1313
+ return {"error": _sanitize_error(str(e))}
1314
+
1315
+ # ==================================================================
1316
+ # Tool 21: update_team_member_role
1317
+ # ==================================================================
1318
+
1319
+ def update_team_member_role(
1320
+ self, team_id: str, user_id: str, role: str
1321
+ ) -> Dict[str, Any]:
1322
+ """Update the role of a team member.
1323
+
1324
+ Args:
1325
+ team_id: UUID of the team.
1326
+ user_id: UUID of the user whose role to change.
1327
+ role: New role (``member`` or ``admin``).
1328
+
1329
+ Returns:
1330
+ Dict confirming the role update.
1331
+ """
1332
+ err = _validate_uuid(team_id, "team_id")
1333
+ if err:
1334
+ return {"error": err}
1335
+
1336
+ err = _validate_uuid(user_id, "user_id")
1337
+ if err:
1338
+ return {"error": err}
1339
+
1340
+ if not role or not isinstance(role, str):
1341
+ return {"error": "role is required and must be a string"}
1342
+ if role not in ("member", "admin"):
1343
+ return {"error": "role must be 'member' or 'admin'"}
1344
+
1345
+ try:
1346
+ result = self._request_with_retry(
1347
+ "PATCH",
1348
+ f"/api/teams/{quote(team_id, safe='')}/members/{quote(user_id, safe='')}",
1349
+ {"role": role},
1350
+ )
1351
+ return result
1352
+ except Exception as e:
1353
+ return {"error": _sanitize_error(str(e))}
1354
+
1355
+ # ==================================================================
1356
+ # Tool 22: x402_info
1357
+ # ==================================================================
1358
+
1359
+ def x402_info(self) -> Dict[str, Any]:
1360
+ """Get x402 micropayment protocol information.
1361
+
1362
+ Returns details about x402 HTTP 402 Payment Required protocol support
1363
+ including facilitators, pricing, supported currencies, and payment
1364
+ options for AI agent micropayments.
1365
+
1366
+ Returns:
1367
+ Dict with x402 protocol information.
1368
+ """
1369
+ try:
1370
+ result = self._request_with_retry("GET", "/api/x402/info")
1371
+ return result
1372
+ except Exception as e:
1373
+ return {"error": _sanitize_error(str(e))}
1374
+
1375
+ # ==================================================================
1376
+ # Tool 23: topup_paypal
1377
+ # ==================================================================
1378
+
1379
+ def topup_paypal(self, amount_cents: int) -> Dict[str, Any]:
1380
+ """Create a PayPal top-up order for the user's wallet.
1381
+
1382
+ Args:
1383
+ amount_cents: Amount to top up in cents (500 to 1,000,000).
1384
+ Minimum $5.00 (500 cents).
1385
+
1386
+ Returns:
1387
+ Dict with ``orderId`` and ``approvalUrl`` for completing
1388
+ the PayPal payment flow.
1389
+ """
1390
+ err = _validate_positive_int(
1391
+ amount_cents, "amount_cents", min_value=500, max_value=1_000_000
1392
+ )
1393
+ if err:
1394
+ return {"error": err}
1395
+
1396
+ try:
1397
+ result = self._request_with_retry(
1398
+ "POST",
1399
+ "/api/wallet/topup/paypal",
1400
+ {"amountCents": amount_cents},
1401
+ )
1402
+ return result
1403
+ except Exception as e:
1404
+ return {"error": _sanitize_error(str(e))}
1405
+
1406
+ # ==================================================================
1407
+ # Tool 24: update_wallet_policy
1408
+ # ==================================================================
1409
+
1410
+ def update_wallet_policy(
1411
+ self,
1412
+ wallet_id: str,
1413
+ daily_limit_cents: Optional[int] = None,
1414
+ allowed_domains: Optional[list] = None,
1415
+ ) -> Dict[str, Any]:
1416
+ """Update the policy of an agentic wallet.
1417
+
1418
+ Args:
1419
+ wallet_id: ID of the agentic wallet.
1420
+ daily_limit_cents: Optional daily budget cap in cents (1 to 1,000,000).
1421
+ allowed_domains: Optional list of allowed domains (max 100, each <= 253 chars).
1422
+
1423
+ Returns:
1424
+ Dict with the updated wallet policy.
1425
+ """
1426
+ if not wallet_id or not isinstance(wallet_id, str):
1427
+ return {"error": "wallet_id is required and must be a string"}
1428
+
1429
+ if daily_limit_cents is None and allowed_domains is None:
1430
+ return {"error": "At least one of daily_limit_cents or allowed_domains is required"}
1431
+
1432
+ if daily_limit_cents is not None:
1433
+ err = _validate_positive_int(
1434
+ daily_limit_cents, "daily_limit_cents", max_value=1_000_000
1435
+ )
1436
+ if err:
1437
+ return {"error": err}
1438
+
1439
+ if allowed_domains is not None:
1440
+ err = _validate_domains(allowed_domains)
1441
+ if err:
1442
+ return {"error": err}
1443
+
1444
+ try:
1445
+ body: Dict[str, Any] = {}
1446
+ if daily_limit_cents is not None:
1447
+ body["dailyLimitCents"] = daily_limit_cents
1448
+ if allowed_domains is not None:
1449
+ body["allowedDomains"] = allowed_domains
1450
+ result = self._request_with_retry(
1451
+ "PATCH",
1452
+ f"/api/agent-wallet/{quote(wallet_id, safe='')}/policy",
1453
+ body,
1454
+ )
1455
+ return result
1456
+ except Exception as e:
1457
+ return {"error": _sanitize_error(str(e))}
1458
+
1459
+ # ==================================================================
1460
+ # Haystack Tool list
1461
+ # ==================================================================
1462
+
1463
+ def get_tools(self) -> List[Any]:
1464
+ """Return list of Haystack Tool objects for all 22 DomiNode functions.
1465
+
1466
+ Each Tool wraps one of the toolkit methods with a name, description,
1467
+ and JSON Schema parameter definition compatible with Haystack's
1468
+ tool-calling pipeline.
1469
+
1470
+ Returns:
1471
+ A list of ``haystack.tools.Tool`` objects.
1472
+ """
1473
+ from haystack.tools import Tool
1474
+
1475
+ return [
1476
+ Tool(
1477
+ name="dominusnode_proxied_fetch",
1478
+ description=(
1479
+ "Fetch a URL through DomiNode's rotating proxy network. "
1480
+ "Supports geo-targeting and datacenter/residential pool selection. "
1481
+ "Only GET, HEAD, OPTIONS methods are allowed."
1482
+ ),
1483
+ function=self.proxied_fetch,
1484
+ parameters={
1485
+ "type": "object",
1486
+ "properties": {
1487
+ "url": {"type": "string", "description": "Target URL to fetch"},
1488
+ "method": {
1489
+ "type": "string",
1490
+ "description": "HTTP method (GET/HEAD/OPTIONS)",
1491
+ "default": "GET",
1492
+ },
1493
+ "country": {
1494
+ "type": "string",
1495
+ "description": "ISO 3166-1 alpha-2 country code for geo-targeting",
1496
+ },
1497
+ "proxy_type": {
1498
+ "type": "string",
1499
+ "description": "Proxy type: dc (datacenter, $3/GB) or residential ($5/GB)",
1500
+ "default": "dc",
1501
+ },
1502
+ },
1503
+ "required": ["url"],
1504
+ },
1505
+ ),
1506
+ Tool(
1507
+ name="dominusnode_check_balance",
1508
+ description="Check the current DomiNode wallet balance in cents.",
1509
+ function=self.check_balance,
1510
+ parameters={
1511
+ "type": "object",
1512
+ "properties": {},
1513
+ },
1514
+ ),
1515
+ Tool(
1516
+ name="dominusnode_check_usage",
1517
+ description="Check DomiNode proxy usage statistics for a given time period.",
1518
+ function=self.check_usage,
1519
+ parameters={
1520
+ "type": "object",
1521
+ "properties": {
1522
+ "period": {
1523
+ "type": "string",
1524
+ "description": "Time period: day, week, or month",
1525
+ "default": "month",
1526
+ },
1527
+ },
1528
+ },
1529
+ ),
1530
+ Tool(
1531
+ name="dominusnode_get_proxy_config",
1532
+ description="Get DomiNode proxy configuration including endpoints and geo-targeting options.",
1533
+ function=self.get_proxy_config,
1534
+ parameters={
1535
+ "type": "object",
1536
+ "properties": {},
1537
+ },
1538
+ ),
1539
+ Tool(
1540
+ name="dominusnode_list_sessions",
1541
+ description="List active DomiNode proxy sessions.",
1542
+ function=self.list_sessions,
1543
+ parameters={
1544
+ "type": "object",
1545
+ "properties": {},
1546
+ },
1547
+ ),
1548
+ Tool(
1549
+ name="dominusnode_create_agentic_wallet",
1550
+ description="Create a new agentic sub-wallet with a per-transaction spending limit.",
1551
+ function=self.create_agentic_wallet,
1552
+ parameters={
1553
+ "type": "object",
1554
+ "properties": {
1555
+ "label": {
1556
+ "type": "string",
1557
+ "description": "Human-readable wallet label (max 100 chars)",
1558
+ },
1559
+ "spending_limit_cents": {
1560
+ "type": "integer",
1561
+ "description": "Per-transaction spending limit in cents",
1562
+ },
1563
+ },
1564
+ "required": ["label", "spending_limit_cents"],
1565
+ },
1566
+ ),
1567
+ Tool(
1568
+ name="dominusnode_fund_agentic_wallet",
1569
+ description="Fund an agentic wallet from the main wallet.",
1570
+ function=self.fund_agentic_wallet,
1571
+ parameters={
1572
+ "type": "object",
1573
+ "properties": {
1574
+ "wallet_id": {
1575
+ "type": "string",
1576
+ "description": "Agentic wallet ID",
1577
+ },
1578
+ "amount_cents": {
1579
+ "type": "integer",
1580
+ "description": "Amount to transfer in cents",
1581
+ },
1582
+ },
1583
+ "required": ["wallet_id", "amount_cents"],
1584
+ },
1585
+ ),
1586
+ Tool(
1587
+ name="dominusnode_agentic_wallet_balance",
1588
+ description="Check the balance of an agentic wallet.",
1589
+ function=self.agentic_wallet_balance,
1590
+ parameters={
1591
+ "type": "object",
1592
+ "properties": {
1593
+ "wallet_id": {
1594
+ "type": "string",
1595
+ "description": "Agentic wallet ID",
1596
+ },
1597
+ },
1598
+ "required": ["wallet_id"],
1599
+ },
1600
+ ),
1601
+ Tool(
1602
+ name="dominusnode_list_agentic_wallets",
1603
+ description="List all agentic wallets.",
1604
+ function=self.list_agentic_wallets,
1605
+ parameters={
1606
+ "type": "object",
1607
+ "properties": {},
1608
+ },
1609
+ ),
1610
+ Tool(
1611
+ name="dominusnode_agentic_transactions",
1612
+ description="Get transaction history for an agentic wallet.",
1613
+ function=self.agentic_transactions,
1614
+ parameters={
1615
+ "type": "object",
1616
+ "properties": {
1617
+ "wallet_id": {
1618
+ "type": "string",
1619
+ "description": "Agentic wallet ID",
1620
+ },
1621
+ "limit": {
1622
+ "type": "integer",
1623
+ "description": "Max number of transactions to return (1-100)",
1624
+ "default": 20,
1625
+ },
1626
+ },
1627
+ "required": ["wallet_id"],
1628
+ },
1629
+ ),
1630
+ Tool(
1631
+ name="dominusnode_freeze_agentic_wallet",
1632
+ description="Freeze an agentic wallet to prevent spending.",
1633
+ function=self.freeze_agentic_wallet,
1634
+ parameters={
1635
+ "type": "object",
1636
+ "properties": {
1637
+ "wallet_id": {
1638
+ "type": "string",
1639
+ "description": "Agentic wallet ID",
1640
+ },
1641
+ },
1642
+ "required": ["wallet_id"],
1643
+ },
1644
+ ),
1645
+ Tool(
1646
+ name="dominusnode_unfreeze_agentic_wallet",
1647
+ description="Unfreeze an agentic wallet to re-enable spending.",
1648
+ function=self.unfreeze_agentic_wallet,
1649
+ parameters={
1650
+ "type": "object",
1651
+ "properties": {
1652
+ "wallet_id": {
1653
+ "type": "string",
1654
+ "description": "Agentic wallet ID",
1655
+ },
1656
+ },
1657
+ "required": ["wallet_id"],
1658
+ },
1659
+ ),
1660
+ Tool(
1661
+ name="dominusnode_delete_agentic_wallet",
1662
+ description="Delete an agentic wallet (must be unfrozen first; remaining balance is refunded).",
1663
+ function=self.delete_agentic_wallet,
1664
+ parameters={
1665
+ "type": "object",
1666
+ "properties": {
1667
+ "wallet_id": {
1668
+ "type": "string",
1669
+ "description": "Agentic wallet ID",
1670
+ },
1671
+ },
1672
+ "required": ["wallet_id"],
1673
+ },
1674
+ ),
1675
+ Tool(
1676
+ name="dominusnode_create_team",
1677
+ description="Create a new team with a shared wallet.",
1678
+ function=self.create_team,
1679
+ parameters={
1680
+ "type": "object",
1681
+ "properties": {
1682
+ "name": {
1683
+ "type": "string",
1684
+ "description": "Team name (max 100 chars)",
1685
+ },
1686
+ "max_members": {
1687
+ "type": "integer",
1688
+ "description": "Maximum number of team members (1-100)",
1689
+ },
1690
+ },
1691
+ "required": ["name"],
1692
+ },
1693
+ ),
1694
+ Tool(
1695
+ name="dominusnode_list_teams",
1696
+ description="List all teams the user belongs to.",
1697
+ function=self.list_teams,
1698
+ parameters={
1699
+ "type": "object",
1700
+ "properties": {},
1701
+ },
1702
+ ),
1703
+ Tool(
1704
+ name="dominusnode_team_details",
1705
+ description="Get details for a specific team.",
1706
+ function=self.team_details,
1707
+ parameters={
1708
+ "type": "object",
1709
+ "properties": {
1710
+ "team_id": {
1711
+ "type": "string",
1712
+ "description": "Team UUID",
1713
+ },
1714
+ },
1715
+ "required": ["team_id"],
1716
+ },
1717
+ ),
1718
+ Tool(
1719
+ name="dominusnode_team_fund",
1720
+ description="Fund a team's shared wallet from the user's personal wallet.",
1721
+ function=self.team_fund,
1722
+ parameters={
1723
+ "type": "object",
1724
+ "properties": {
1725
+ "team_id": {
1726
+ "type": "string",
1727
+ "description": "Team UUID",
1728
+ },
1729
+ "amount_cents": {
1730
+ "type": "integer",
1731
+ "description": "Amount to transfer in cents (100-1,000,000)",
1732
+ },
1733
+ },
1734
+ "required": ["team_id", "amount_cents"],
1735
+ },
1736
+ ),
1737
+ Tool(
1738
+ name="dominusnode_team_create_key",
1739
+ description="Create a new API key for a team.",
1740
+ function=self.team_create_key,
1741
+ parameters={
1742
+ "type": "object",
1743
+ "properties": {
1744
+ "team_id": {
1745
+ "type": "string",
1746
+ "description": "Team UUID",
1747
+ },
1748
+ "label": {
1749
+ "type": "string",
1750
+ "description": "Key label (max 100 chars)",
1751
+ },
1752
+ },
1753
+ "required": ["team_id", "label"],
1754
+ },
1755
+ ),
1756
+ Tool(
1757
+ name="dominusnode_team_usage",
1758
+ description="Get usage/transaction history for a team.",
1759
+ function=self.team_usage,
1760
+ parameters={
1761
+ "type": "object",
1762
+ "properties": {
1763
+ "team_id": {
1764
+ "type": "string",
1765
+ "description": "Team UUID",
1766
+ },
1767
+ "limit": {
1768
+ "type": "integer",
1769
+ "description": "Max number of records to return (1-100)",
1770
+ "default": 20,
1771
+ },
1772
+ },
1773
+ "required": ["team_id"],
1774
+ },
1775
+ ),
1776
+ Tool(
1777
+ name="dominusnode_update_team",
1778
+ description="Update team settings (name and/or max_members).",
1779
+ function=self.update_team,
1780
+ parameters={
1781
+ "type": "object",
1782
+ "properties": {
1783
+ "team_id": {
1784
+ "type": "string",
1785
+ "description": "Team UUID",
1786
+ },
1787
+ "name": {
1788
+ "type": "string",
1789
+ "description": "New team name (max 100 chars)",
1790
+ },
1791
+ "max_members": {
1792
+ "type": "integer",
1793
+ "description": "New max members (1-100)",
1794
+ },
1795
+ },
1796
+ "required": ["team_id"],
1797
+ },
1798
+ ),
1799
+ Tool(
1800
+ name="dominusnode_update_team_member_role",
1801
+ description="Update the role of a team member (member or admin).",
1802
+ function=self.update_team_member_role,
1803
+ parameters={
1804
+ "type": "object",
1805
+ "properties": {
1806
+ "team_id": {
1807
+ "type": "string",
1808
+ "description": "Team UUID",
1809
+ },
1810
+ "user_id": {
1811
+ "type": "string",
1812
+ "description": "User UUID",
1813
+ },
1814
+ "role": {
1815
+ "type": "string",
1816
+ "description": "New role: member or admin",
1817
+ },
1818
+ },
1819
+ "required": ["team_id", "user_id", "role"],
1820
+ },
1821
+ ),
1822
+ Tool(
1823
+ name="dominusnode_x402_info",
1824
+ description=(
1825
+ "Get x402 micropayment protocol information including "
1826
+ "supported facilitators, pricing, and payment options."
1827
+ ),
1828
+ function=self.x402_info,
1829
+ parameters={
1830
+ "type": "object",
1831
+ "properties": {},
1832
+ },
1833
+ ),
1834
+ Tool(
1835
+ name="dominusnode_topup_paypal",
1836
+ description="Create a PayPal wallet top-up order. Minimum $5.00 (500 cents).",
1837
+ function=self.topup_paypal,
1838
+ parameters={
1839
+ "type": "object",
1840
+ "properties": {
1841
+ "amount_cents": {
1842
+ "type": "integer",
1843
+ "description": "Amount to top up in cents (500-1,000,000)",
1844
+ },
1845
+ },
1846
+ "required": ["amount_cents"],
1847
+ },
1848
+ ),
1849
+ Tool(
1850
+ name="dominusnode_update_wallet_policy",
1851
+ description="Update an agentic wallet's policy (daily limit and/or allowed domains).",
1852
+ function=self.update_wallet_policy,
1853
+ parameters={
1854
+ "type": "object",
1855
+ "properties": {
1856
+ "wallet_id": {
1857
+ "type": "string",
1858
+ "description": "Agentic wallet ID",
1859
+ },
1860
+ "daily_limit_cents": {
1861
+ "type": "integer",
1862
+ "description": "Daily budget cap in cents (1-1,000,000)",
1863
+ },
1864
+ "allowed_domains": {
1865
+ "type": "array",
1866
+ "items": {"type": "string"},
1867
+ "description": "List of allowed domains (max 100)",
1868
+ },
1869
+ },
1870
+ "required": ["wallet_id"],
1871
+ },
1872
+ ),
1873
+ ]