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.
- dominusnode_haystack/__init__.py +5 -0
- dominusnode_haystack/tools.py +1873 -0
- dominusnode_haystack-1.0.0.dist-info/LICENSE +21 -0
- dominusnode_haystack-1.0.0.dist-info/METADATA +25 -0
- dominusnode_haystack-1.0.0.dist-info/RECORD +7 -0
- dominusnode_haystack-1.0.0.dist-info/WHEEL +5 -0
- dominusnode_haystack-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
]
|