dominusnode 2.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.
- dominusnode/__init__.py +45 -0
- dominusnode/_ops.py +122 -0
- dominusnode/client.py +192 -0
- dominusnode/constants.py +24 -0
- dominusnode/errors.py +153 -0
- dominusnode/http_client.py +354 -0
- dominusnode/messages.py +79 -0
- dominusnode/pow.py +33 -0
- dominusnode/proxy_fetch.py +341 -0
- dominusnode/py.typed +0 -0
- dominusnode/resources.py +791 -0
- dominusnode/secret.py +60 -0
- dominusnode/signer.py +56 -0
- dominusnode/ssrf.py +176 -0
- dominusnode/token_manager.py +150 -0
- dominusnode/types.py +99 -0
- dominusnode/validation.py +117 -0
- dominusnode-2.0.0.dist-info/METADATA +177 -0
- dominusnode-2.0.0.dist-info/RECORD +22 -0
- dominusnode-2.0.0.dist-info/WHEEL +5 -0
- dominusnode-2.0.0.dist-info/licenses/LICENSE +21 -0
- dominusnode-2.0.0.dist-info/top_level.txt +1 -0
dominusnode/secret.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Secret wrapper for credentials the server returns exactly once."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
REDACTED = "[REDACTED]"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Secret:
|
|
9
|
+
"""Wraps a credential (new API key, team key, ``dn_node_`` key).
|
|
10
|
+
|
|
11
|
+
``str()``, ``repr()``, ``format()`` and JSON serialisation (via ``__json__``/``to_json``)
|
|
12
|
+
yield ``[REDACTED]``; call :meth:`reveal` for the value. The value lives in a
|
|
13
|
+
name-mangled slot so ``vars()``/``__dict__`` do not expose it.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
__slots__ = ("__value",)
|
|
17
|
+
|
|
18
|
+
def __init__(self, value: str) -> None:
|
|
19
|
+
self.__value = value
|
|
20
|
+
|
|
21
|
+
def reveal(self) -> str:
|
|
22
|
+
return self.__value
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def prefix(self) -> str:
|
|
26
|
+
"""First 8 characters, safe for logs."""
|
|
27
|
+
return self.__value[:8]
|
|
28
|
+
|
|
29
|
+
def __str__(self) -> str:
|
|
30
|
+
return REDACTED
|
|
31
|
+
|
|
32
|
+
def __repr__(self) -> str:
|
|
33
|
+
return f"Secret({REDACTED})"
|
|
34
|
+
|
|
35
|
+
def __format__(self, spec: str) -> str:
|
|
36
|
+
return format(REDACTED, spec)
|
|
37
|
+
|
|
38
|
+
def to_json(self) -> str:
|
|
39
|
+
return REDACTED
|
|
40
|
+
|
|
41
|
+
__json__ = to_json
|
|
42
|
+
|
|
43
|
+
def __eq__(self, other: object) -> bool:
|
|
44
|
+
return isinstance(other, Secret) and other.__value == self.__value
|
|
45
|
+
|
|
46
|
+
def __hash__(self) -> int:
|
|
47
|
+
return hash(("Secret", self.__value))
|
|
48
|
+
|
|
49
|
+
def __reduce__(self) -> object:
|
|
50
|
+
raise TypeError("Secret cannot be pickled")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def json_default(obj: object) -> object:
|
|
54
|
+
"""``json.dumps(..., default=json_default)`` serialises Secret as ``[REDACTED]``."""
|
|
55
|
+
if isinstance(obj, Secret):
|
|
56
|
+
return REDACTED
|
|
57
|
+
to_json = getattr(obj, "to_json", None)
|
|
58
|
+
if callable(to_json):
|
|
59
|
+
return to_json()
|
|
60
|
+
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
dominusnode/signer.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Caller-supplied EIP-191 signer callback.
|
|
2
|
+
|
|
3
|
+
The SDK has no crypto dependency. Wrap eth-account, web3.py, a KMS or an HSM::
|
|
4
|
+
|
|
5
|
+
from eth_account import Account
|
|
6
|
+
from eth_account.messages import encode_defunct
|
|
7
|
+
|
|
8
|
+
acct = Account.from_key(pk)
|
|
9
|
+
|
|
10
|
+
class MySigner:
|
|
11
|
+
address = acct.address
|
|
12
|
+
def sign_message(self, message: str) -> str:
|
|
13
|
+
return "0x" + acct.sign_message(encode_defunct(text=message)).signature.hex().removeprefix("0x")
|
|
14
|
+
|
|
15
|
+
``sign_message`` MUST perform ``personal_sign`` over the exact UTF-8 string and return a
|
|
16
|
+
65-byte 0x-hex signature. With ``AsyncDominusNodeClient`` it may be ``async def``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Any, Awaitable, Generator, Union
|
|
22
|
+
|
|
23
|
+
try: # Python 3.8+
|
|
24
|
+
from typing import Protocol, runtime_checkable
|
|
25
|
+
except ImportError: # pragma: no cover
|
|
26
|
+
from typing_extensions import Protocol, runtime_checkable # type: ignore
|
|
27
|
+
|
|
28
|
+
from ._ops import Sign
|
|
29
|
+
from .errors import ProtocolError, ValidationError, invalid_arg
|
|
30
|
+
from .validation import validate_address, validate_signature
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@runtime_checkable
|
|
34
|
+
class Signer(Protocol):
|
|
35
|
+
address: str
|
|
36
|
+
|
|
37
|
+
def sign_message(self, message: str) -> Union[str, Awaitable[str]]: ...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def assert_signer(signer: Any, name: str = "signer") -> Any:
|
|
41
|
+
if signer is None or not callable(getattr(signer, "sign_message", None)):
|
|
42
|
+
raise invalid_arg(f"{name} must have an address and a sign_message(message) method")
|
|
43
|
+
validate_address(getattr(signer, "address", None), f"{name}.address")
|
|
44
|
+
return signer
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def sign(signer: Any, message: str, name: str = "signer") -> Generator[Any, Any, str]:
|
|
48
|
+
"""Ask the signer for a signature (as an op) and validate its format; failures raise ProtocolError."""
|
|
49
|
+
try:
|
|
50
|
+
sig = yield Sign(signer, message)
|
|
51
|
+
except Exception as err: # noqa: BLE001
|
|
52
|
+
raise ProtocolError(f"{name}.sign_message failed: {err}") from None
|
|
53
|
+
try:
|
|
54
|
+
return validate_signature(sig, f"{name} signature")
|
|
55
|
+
except ValidationError as err:
|
|
56
|
+
raise ProtocolError(err.message) from None
|
dominusnode/ssrf.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Client-side SSRF guard for proxy.fetch().
|
|
2
|
+
|
|
3
|
+
The gateway and node re-check after their own DNS resolution; this layer fails
|
|
4
|
+
fast and blocks DNS rebinding by resolving the name and checking EVERY address.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ipaddress
|
|
10
|
+
import re
|
|
11
|
+
import socket
|
|
12
|
+
from typing import Callable, List, Optional
|
|
13
|
+
from urllib.parse import SplitResult, urlsplit
|
|
14
|
+
|
|
15
|
+
from .errors import ProxyError
|
|
16
|
+
|
|
17
|
+
#: Resolver: hostname -> every address (A + AAAA). Injectable for tests.
|
|
18
|
+
LookupAll = Callable[[str], List[str]]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def default_lookup(hostname: str) -> List[str]:
|
|
22
|
+
infos = socket.getaddrinfo(hostname, None, proto=socket.IPPROTO_TCP)
|
|
23
|
+
return [str(info[4][0]) for info in infos]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
BLOCKED_HOSTNAMES = frozenset({"localhost", "localhost.localdomain", "ip6-localhost", "ip6-loopback"})
|
|
27
|
+
BLOCKED_SUFFIXES = (".localhost", ".local", ".internal", ".arpa")
|
|
28
|
+
|
|
29
|
+
_HEX = re.compile(r"^0x[0-9a-f]+\Z", re.IGNORECASE)
|
|
30
|
+
_OCT = re.compile(r"^0[0-7]+\Z")
|
|
31
|
+
_DEC = re.compile(r"^(0|[1-9][0-9]*)\Z")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def normalize_ipv4(host: str) -> Optional[str]:
|
|
35
|
+
"""Decode decimal/hex/octal/short IPv4 forms (2130706433, 0x7f000001, 0177.0.0.1, 127.1); None if not IPv4-like."""
|
|
36
|
+
parts = host.split(".")
|
|
37
|
+
if len(parts) < 1 or len(parts) > 4:
|
|
38
|
+
return None
|
|
39
|
+
nums: List[int] = []
|
|
40
|
+
for p in parts:
|
|
41
|
+
if _HEX.match(p):
|
|
42
|
+
nums.append(int(p, 16))
|
|
43
|
+
elif _OCT.match(p):
|
|
44
|
+
nums.append(int(p, 8))
|
|
45
|
+
elif _DEC.match(p):
|
|
46
|
+
nums.append(int(p, 10))
|
|
47
|
+
else:
|
|
48
|
+
return None
|
|
49
|
+
last = nums.pop()
|
|
50
|
+
if any(n > 255 for n in nums):
|
|
51
|
+
return None
|
|
52
|
+
rem = 4 - len(nums)
|
|
53
|
+
if last >= 2 ** (8 * rem):
|
|
54
|
+
return None
|
|
55
|
+
out = list(nums)
|
|
56
|
+
for i in range(rem - 1, -1, -1):
|
|
57
|
+
out.append((last >> (8 * i)) & 0xFF)
|
|
58
|
+
return ".".join(str(b) for b in out)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _is_private_v4(ip: str) -> bool:
|
|
62
|
+
a, b = (int(x) for x in ip.split(".")[:2])
|
|
63
|
+
return (
|
|
64
|
+
a in (0, 10, 127)
|
|
65
|
+
or (a == 169 and b == 254)
|
|
66
|
+
or (a == 172 and 16 <= b <= 31)
|
|
67
|
+
or (a == 192 and b == 168)
|
|
68
|
+
or (a == 100 and 64 <= b <= 127)
|
|
69
|
+
or (a == 192 and b == 0) # 192.0.0.0/24 IETF + 192.0.2.0/24 docs
|
|
70
|
+
or (a == 198 and b in (18, 19)) # benchmarking
|
|
71
|
+
or a >= 224 # multicast, reserved, broadcast
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def is_private_ip(host: str) -> bool:
|
|
76
|
+
"""True for private/reserved IPv4 (any spelling) and IPv6 literals; False for public IPs and non-IP names."""
|
|
77
|
+
ip = host
|
|
78
|
+
if ip.startswith("["):
|
|
79
|
+
ip = ip[1:]
|
|
80
|
+
if ip.endswith("]"):
|
|
81
|
+
ip = ip[:-1]
|
|
82
|
+
zone = ip.find("%")
|
|
83
|
+
if zone != -1:
|
|
84
|
+
ip = ip[:zone]
|
|
85
|
+
v4 = normalize_ipv4(ip)
|
|
86
|
+
if v4 is not None:
|
|
87
|
+
return _is_private_v4(v4)
|
|
88
|
+
try:
|
|
89
|
+
b = ipaddress.IPv6Address(ip).packed
|
|
90
|
+
except ValueError:
|
|
91
|
+
return False
|
|
92
|
+
zero10 = all(x == 0 for x in b[:10])
|
|
93
|
+
tail = ".".join(str(x) for x in b[12:])
|
|
94
|
+
if zero10 and b[10] == 0xFF and b[11] == 0xFF:
|
|
95
|
+
return _is_private_v4(tail) # ::ffff:a.b.c.d
|
|
96
|
+
if zero10 and b[10] == 0 and b[11] == 0:
|
|
97
|
+
# ::, ::1 and IPv4-compatible ::a.b.c.d
|
|
98
|
+
return True if all(x == 0 for x in b[12:15]) else _is_private_v4(tail)
|
|
99
|
+
if b[:4] == b"\x00\x64\xff\x9b":
|
|
100
|
+
return _is_private_v4(tail) # NAT64 64:ff9b::/96
|
|
101
|
+
if (b[0] & 0xFE) == 0xFC:
|
|
102
|
+
return True # fc00::/7
|
|
103
|
+
if b[0] == 0xFE and (b[1] & 0xC0) == 0x80:
|
|
104
|
+
return True # fe80::/10
|
|
105
|
+
if b[0] == 0xFE and (b[1] & 0xC0) == 0xC0:
|
|
106
|
+
return True # fec0::/10
|
|
107
|
+
if b[0] == 0xFF:
|
|
108
|
+
return True # multicast
|
|
109
|
+
if b[:4] == b"\x20\x01\x00\x00":
|
|
110
|
+
return True # Teredo 2001::/32
|
|
111
|
+
if b[:2] == b"\x20\x02":
|
|
112
|
+
return True # 6to4
|
|
113
|
+
if b[:4] == b"\x20\x01\x0d\xb8":
|
|
114
|
+
return True # documentation
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
_HOST_RE = re.compile(r"^[a-z0-9._-]+\Z")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _is_ip_literal(host: str) -> bool:
|
|
122
|
+
try:
|
|
123
|
+
ipaddress.ip_address(host.split("%", 1)[0])
|
|
124
|
+
return True
|
|
125
|
+
except ValueError:
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def validate_target_url(url: str) -> SplitResult:
|
|
130
|
+
"""http(s) only, no userinfo, port empty/80/443, no local names or private literals."""
|
|
131
|
+
if not isinstance(url, str):
|
|
132
|
+
raise ProxyError("Invalid URL")
|
|
133
|
+
try:
|
|
134
|
+
u = urlsplit(url.strip())
|
|
135
|
+
port = u.port
|
|
136
|
+
except ValueError:
|
|
137
|
+
raise ProxyError("Invalid URL") from None
|
|
138
|
+
scheme = u.scheme.lower()
|
|
139
|
+
if scheme not in ("http", "https"):
|
|
140
|
+
raise ProxyError("Only http: and https: URLs are supported")
|
|
141
|
+
if "@" in u.netloc:
|
|
142
|
+
raise ProxyError("URLs with embedded credentials are not allowed")
|
|
143
|
+
if port is not None and port not in (80, 443):
|
|
144
|
+
raise ProxyError("Only ports 80 and 443 are reachable through the relay network")
|
|
145
|
+
host = (u.hostname or "").lower()
|
|
146
|
+
if host.endswith("."):
|
|
147
|
+
host = host[:-1]
|
|
148
|
+
if not host:
|
|
149
|
+
raise ProxyError("URL has no host")
|
|
150
|
+
if not _is_ip_literal(host) and not _HOST_RE.match(host):
|
|
151
|
+
raise ProxyError("Invalid URL")
|
|
152
|
+
if host in BLOCKED_HOSTNAMES or any(host.endswith(s) for s in BLOCKED_SUFFIXES):
|
|
153
|
+
raise ProxyError("Requests to local/internal hostnames are blocked")
|
|
154
|
+
if is_private_ip(host):
|
|
155
|
+
raise ProxyError("Requests to private/internal IP addresses are blocked")
|
|
156
|
+
return u
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def assert_public_resolution(hostname: str, lookup: Optional[LookupAll] = None) -> None:
|
|
160
|
+
"""DNS-rebinding guard: resolve and reject if resolution fails, is empty, or ANY address is private."""
|
|
161
|
+
host = hostname.strip("[]")
|
|
162
|
+
if host.endswith("."):
|
|
163
|
+
host = host[:-1]
|
|
164
|
+
if _is_ip_literal(host) or normalize_ipv4(host) is not None:
|
|
165
|
+
if is_private_ip(host):
|
|
166
|
+
raise ProxyError("Requests to private/internal IP addresses are blocked")
|
|
167
|
+
return
|
|
168
|
+
try:
|
|
169
|
+
addrs = (lookup or default_lookup)(host)
|
|
170
|
+
except Exception:
|
|
171
|
+
raise ProxyError(f"DNS resolution failed for {host}") from None
|
|
172
|
+
if not addrs:
|
|
173
|
+
raise ProxyError(f"DNS resolution returned no addresses for {host}")
|
|
174
|
+
for a in addrs:
|
|
175
|
+
if is_private_ip(str(a)):
|
|
176
|
+
raise ProxyError("Hostname resolves to a private/internal IP address (blocked)")
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""JWT access/refresh pair holders (internal). clear() keeps the refresh callback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import json
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from typing import Awaitable, Callable, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
from .constants import TOKEN_REFRESH_BUFFER_SECONDS
|
|
13
|
+
from .errors import AuthenticationError
|
|
14
|
+
|
|
15
|
+
#: refresh_token -> (access_token, new_refresh_token or None)
|
|
16
|
+
RefreshResult = Tuple[str, Optional[str]]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def is_expired(token: str) -> bool:
|
|
20
|
+
try:
|
|
21
|
+
parts = token.split(".")
|
|
22
|
+
if len(parts) != 3:
|
|
23
|
+
return True
|
|
24
|
+
seg = parts[1] + "=" * (-len(parts[1]) % 4)
|
|
25
|
+
payload = json.loads(base64.urlsafe_b64decode(seg))
|
|
26
|
+
exp = payload.get("exp") if isinstance(payload, dict) else None
|
|
27
|
+
if not isinstance(exp, (int, float)) or isinstance(exp, bool):
|
|
28
|
+
return True
|
|
29
|
+
return exp < time.time() + TOKEN_REFRESH_BUFFER_SECONDS
|
|
30
|
+
except Exception:
|
|
31
|
+
return True
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _BaseTokenManager:
|
|
35
|
+
def __init__(self) -> None:
|
|
36
|
+
self._access: Optional[str] = None
|
|
37
|
+
self._refresh: Optional[str] = None
|
|
38
|
+
|
|
39
|
+
def set_tokens(self, access: str, refresh: Optional[str] = None) -> None:
|
|
40
|
+
self._access = access
|
|
41
|
+
if refresh:
|
|
42
|
+
self._refresh = refresh
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def access_token(self) -> Optional[str]:
|
|
46
|
+
return self._access
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def refresh_token(self) -> Optional[str]:
|
|
50
|
+
return self._refresh
|
|
51
|
+
|
|
52
|
+
def has_tokens(self) -> bool:
|
|
53
|
+
return self._access is not None or self._refresh is not None
|
|
54
|
+
|
|
55
|
+
def _clear_tokens(self) -> None:
|
|
56
|
+
self._access = None
|
|
57
|
+
self._refresh = None
|
|
58
|
+
|
|
59
|
+
def __repr__(self) -> str:
|
|
60
|
+
return f"{type(self).__name__}([REDACTED])"
|
|
61
|
+
|
|
62
|
+
__str__ = __repr__
|
|
63
|
+
|
|
64
|
+
def to_json(self) -> str:
|
|
65
|
+
return "[REDACTED]"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TokenManager(_BaseTokenManager):
|
|
69
|
+
def __init__(self) -> None:
|
|
70
|
+
super().__init__()
|
|
71
|
+
self._refresh_fn: Optional[Callable[[str], RefreshResult]] = None
|
|
72
|
+
self._lock = threading.Lock()
|
|
73
|
+
|
|
74
|
+
def set_refresh_function(self, fn: Callable[[str], RefreshResult]) -> None:
|
|
75
|
+
self._refresh_fn = fn
|
|
76
|
+
|
|
77
|
+
def has_refresh_function(self) -> bool:
|
|
78
|
+
return self._refresh_fn is not None
|
|
79
|
+
|
|
80
|
+
def get_valid_token(self) -> str:
|
|
81
|
+
if self._access and not is_expired(self._access):
|
|
82
|
+
return self._access
|
|
83
|
+
return self.force_refresh()
|
|
84
|
+
|
|
85
|
+
def force_refresh(self) -> str:
|
|
86
|
+
"""Refresh regardless of expiry. Concurrent callers share one refresh (lock + re-check)."""
|
|
87
|
+
before = self._access
|
|
88
|
+
with self._lock:
|
|
89
|
+
if self._access is not None and self._access != before and not is_expired(self._access):
|
|
90
|
+
return self._access # another thread refreshed while we waited
|
|
91
|
+
if not self._refresh or not self._refresh_fn:
|
|
92
|
+
raise AuthenticationError("No valid token and cannot refresh")
|
|
93
|
+
try:
|
|
94
|
+
access, refresh = self._refresh_fn(self._refresh)
|
|
95
|
+
except Exception:
|
|
96
|
+
self._clear_tokens()
|
|
97
|
+
raise
|
|
98
|
+
self._access = access
|
|
99
|
+
if refresh:
|
|
100
|
+
self._refresh = refresh
|
|
101
|
+
return access
|
|
102
|
+
|
|
103
|
+
def clear(self) -> None:
|
|
104
|
+
"""Drop tokens. Does NOT drop the refresh function (reconnecting must keep working)."""
|
|
105
|
+
self._clear_tokens()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class AsyncTokenManager(_BaseTokenManager):
|
|
109
|
+
def __init__(self) -> None:
|
|
110
|
+
super().__init__()
|
|
111
|
+
self._refresh_fn: Optional[Callable[[str], Awaitable[RefreshResult]]] = None
|
|
112
|
+
self._inflight: Optional["asyncio.Future[str]"] = None
|
|
113
|
+
|
|
114
|
+
def set_refresh_function(self, fn: Callable[[str], Awaitable[RefreshResult]]) -> None:
|
|
115
|
+
self._refresh_fn = fn
|
|
116
|
+
|
|
117
|
+
def has_refresh_function(self) -> bool:
|
|
118
|
+
return self._refresh_fn is not None
|
|
119
|
+
|
|
120
|
+
async def get_valid_token(self) -> str:
|
|
121
|
+
if self._access and not is_expired(self._access):
|
|
122
|
+
return self._access
|
|
123
|
+
return await self.force_refresh()
|
|
124
|
+
|
|
125
|
+
async def force_refresh(self) -> str:
|
|
126
|
+
if self._inflight is not None:
|
|
127
|
+
return await asyncio.shield(self._inflight)
|
|
128
|
+
if not self._refresh or not self._refresh_fn:
|
|
129
|
+
raise AuthenticationError("No valid token and cannot refresh")
|
|
130
|
+
fut: "asyncio.Future[str]" = asyncio.get_running_loop().create_future()
|
|
131
|
+
self._inflight = fut
|
|
132
|
+
try:
|
|
133
|
+
access, refresh = await self._refresh_fn(self._refresh)
|
|
134
|
+
self._access = access
|
|
135
|
+
if refresh:
|
|
136
|
+
self._refresh = refresh
|
|
137
|
+
fut.set_result(access)
|
|
138
|
+
return access
|
|
139
|
+
except BaseException as err:
|
|
140
|
+
self._clear_tokens()
|
|
141
|
+
fut.set_exception(err)
|
|
142
|
+
fut.exception() # mark retrieved
|
|
143
|
+
raise
|
|
144
|
+
finally:
|
|
145
|
+
if self._inflight is fut:
|
|
146
|
+
self._inflight = None
|
|
147
|
+
|
|
148
|
+
def clear(self) -> None:
|
|
149
|
+
self._clear_tokens()
|
|
150
|
+
self._inflight = None
|
dominusnode/types.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Documentation-only TypedDicts for the main results (wire keys are camelCase, as the server sends them)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
from typing import TypedDict
|
|
9
|
+
except ImportError: # pragma: no cover
|
|
10
|
+
from typing_extensions import TypedDict # type: ignore
|
|
11
|
+
|
|
12
|
+
from .secret import Secret
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Money(TypedDict):
|
|
16
|
+
musd: str # integer µUSD
|
|
17
|
+
usd: str # 6 decimals
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConnectResult(TypedDict, total=False):
|
|
21
|
+
mfaRequired: bool
|
|
22
|
+
userId: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ActiveSession(TypedDict):
|
|
26
|
+
id: str
|
|
27
|
+
startedAt: str
|
|
28
|
+
status: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CreatedApiKey(TypedDict):
|
|
32
|
+
id: str
|
|
33
|
+
prefix: str
|
|
34
|
+
label: str
|
|
35
|
+
created_at: str
|
|
36
|
+
key: Secret
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PowChallenge(TypedDict):
|
|
40
|
+
challengeId: str
|
|
41
|
+
prefix: str
|
|
42
|
+
difficulty: int
|
|
43
|
+
algorithm: str
|
|
44
|
+
expiresAt: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class NodeChallenge(TypedDict):
|
|
48
|
+
nonce: str
|
|
49
|
+
attestationText: str
|
|
50
|
+
attestationVersion: int
|
|
51
|
+
policyVersion: int
|
|
52
|
+
message: str
|
|
53
|
+
expiresAt: str
|
|
54
|
+
powChallenge: PowChallenge
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class RegisteredNode(TypedDict):
|
|
58
|
+
nodeId: str
|
|
59
|
+
status: str
|
|
60
|
+
attestationVersion: int
|
|
61
|
+
nodeKey: Secret
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Payout(TypedDict, total=False):
|
|
65
|
+
id: str
|
|
66
|
+
amount: Money
|
|
67
|
+
trigger: str
|
|
68
|
+
status: str # pending held_tax held_frozen submitted confirmed failed reversed
|
|
69
|
+
txHash: Optional[str]
|
|
70
|
+
createdAt: str
|
|
71
|
+
failureReason: Optional[str]
|
|
72
|
+
address: str
|
|
73
|
+
chain: str
|
|
74
|
+
updatedAt: str
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class AgenticWallet(TypedDict):
|
|
78
|
+
id: str
|
|
79
|
+
label: str
|
|
80
|
+
balanceCents: int
|
|
81
|
+
spendingLimitCents: int
|
|
82
|
+
dailyLimitCents: Optional[int]
|
|
83
|
+
allowedDomains: Optional[List[str]]
|
|
84
|
+
status: str
|
|
85
|
+
createdAt: str
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class PricingEntry(TypedDict):
|
|
89
|
+
ipClass: str
|
|
90
|
+
pricePerGbMusd: str
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class NetworkPricing(TypedDict):
|
|
94
|
+
currency: str
|
|
95
|
+
unit: str
|
|
96
|
+
prices: List[PricingEntry]
|
|
97
|
+
nodeShare: List[Dict[str, object]]
|
|
98
|
+
maturationHours: int
|
|
99
|
+
rails: List[str]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Client-side argument validation. Failures raise ValidationError(code=INVALID_ARGUMENT) before any request."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import uuid
|
|
7
|
+
from typing import Any, Mapping, Optional, Union
|
|
8
|
+
from urllib.parse import quote, urlencode
|
|
9
|
+
|
|
10
|
+
from .constants import MAX_AMOUNT_CENTS
|
|
11
|
+
from .errors import invalid_arg
|
|
12
|
+
|
|
13
|
+
ETH_ADDRESS_RE = re.compile(r"^0x[0-9a-fA-F]{40}\Z")
|
|
14
|
+
EIP191_SIGNATURE_RE = re.compile(r"^0x[0-9a-fA-F]{130}\Z")
|
|
15
|
+
MAX_SIGNATURE_LENGTH = 200
|
|
16
|
+
UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\Z", re.IGNORECASE)
|
|
17
|
+
HEX64_RE = re.compile(r"^[0-9a-f]{64}\Z")
|
|
18
|
+
IDEMPOTENCY_KEY_RE = re.compile(r"^[A-Za-z0-9_-]{8,120}\Z")
|
|
19
|
+
API_KEY_RE = re.compile(r"^dn_(live|test)_[A-Za-z0-9_-]{8,200}\Z")
|
|
20
|
+
_MUSD_RE = re.compile(r"^[1-9][0-9]{0,15}\Z")
|
|
21
|
+
|
|
22
|
+
MusdInput = Union[int, str]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _is_int(v: Any) -> bool:
|
|
26
|
+
# bool is an int subclass; floats (incl. NaN/Infinity) are never accepted.
|
|
27
|
+
return isinstance(v, int) and not isinstance(v, bool)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def validate_api_key(api_key: Any) -> str:
|
|
31
|
+
if not isinstance(api_key, str) or not API_KEY_RE.match(api_key):
|
|
32
|
+
raise invalid_arg("api_key must be a dn_live_ or dn_test_ key")
|
|
33
|
+
return api_key
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def validate_address(address: Any, name: str = "address") -> str:
|
|
37
|
+
if not isinstance(address, str) or not ETH_ADDRESS_RE.match(address):
|
|
38
|
+
raise invalid_arg(f"{name} must be an Ethereum address (0x followed by 40 hex characters)")
|
|
39
|
+
return address
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def validate_signature(signature: Any, name: str = "signature") -> str:
|
|
43
|
+
if not isinstance(signature, str):
|
|
44
|
+
raise invalid_arg(f"{name} must be a string")
|
|
45
|
+
if len(signature) > MAX_SIGNATURE_LENGTH:
|
|
46
|
+
raise invalid_arg(f"{name} must not exceed {MAX_SIGNATURE_LENGTH} characters")
|
|
47
|
+
if not EIP191_SIGNATURE_RE.match(signature):
|
|
48
|
+
raise invalid_arg(f"{name} must be an EIP-191 signature (0x followed by 130 hex characters)")
|
|
49
|
+
return signature
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def validate_cents(amount_cents: Any, name: str = "amount_cents", allow_zero: bool = False) -> int:
|
|
53
|
+
ok = _is_int(amount_cents) and (amount_cents >= 0 if allow_zero else amount_cents > 0) and amount_cents <= MAX_AMOUNT_CENTS
|
|
54
|
+
if not ok:
|
|
55
|
+
raise invalid_arg(f"{name} must be an integer {'>= 0' if allow_zero else '> 0'} and <= {MAX_AMOUNT_CENTS}")
|
|
56
|
+
return int(amount_cents)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def to_musd(v: Any, minimum: int, maximum: int, name: str = "amount_musd") -> str:
|
|
60
|
+
"""Normalise a µUSD amount (int or digit string) to its canonical decimal string; enforce [minimum, maximum]."""
|
|
61
|
+
if _is_int(v):
|
|
62
|
+
n = int(v)
|
|
63
|
+
elif isinstance(v, str) and _MUSD_RE.match(v):
|
|
64
|
+
n = int(v)
|
|
65
|
+
else:
|
|
66
|
+
raise invalid_arg(f"{name} must be a positive integer number of µUSD (int or digit string)")
|
|
67
|
+
if n < minimum or n > maximum:
|
|
68
|
+
raise invalid_arg(f"{name} must be between {minimum} and {maximum} µUSD")
|
|
69
|
+
return str(n)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def new_idempotency_key() -> str:
|
|
73
|
+
"""``"dn-" + UUIDv4`` (fits [A-Za-z0-9_-]{8,120})."""
|
|
74
|
+
return f"dn-{uuid.uuid4()}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def validate_idempotency_key(key: Any) -> str:
|
|
78
|
+
if not isinstance(key, str) or not IDEMPOTENCY_KEY_RE.match(key):
|
|
79
|
+
raise invalid_arg("idempotency_key must be 8-120 characters of [A-Za-z0-9_-]")
|
|
80
|
+
return key
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def validate_uuid(v: Any, name: str) -> str:
|
|
84
|
+
if not isinstance(v, str) or not UUID_RE.match(v):
|
|
85
|
+
raise invalid_arg(f"{name} must be a UUID")
|
|
86
|
+
return v
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def validate_non_empty(v: Any, name: str, maximum: int = 256) -> str:
|
|
90
|
+
if not isinstance(v, str) or len(v) == 0 or len(v) > maximum:
|
|
91
|
+
raise invalid_arg(f"{name} must be a non-empty string (max {maximum})")
|
|
92
|
+
return v
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def validate_int_range(v: Any, name: str, minimum: int, maximum: int) -> int:
|
|
96
|
+
if not _is_int(v) or v < minimum or v > maximum:
|
|
97
|
+
raise invalid_arg(f"{name} must be an integer in [{minimum}, {maximum}]")
|
|
98
|
+
return int(v)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def validate_optional_int(v: Any, name: str) -> Optional[int]:
|
|
102
|
+
"""Pagination values passed through to the server: integers only (no floats/NaN)."""
|
|
103
|
+
if v is None:
|
|
104
|
+
return None
|
|
105
|
+
if not _is_int(v):
|
|
106
|
+
raise invalid_arg(f"{name} must be an integer")
|
|
107
|
+
return int(v)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def seg(v: str) -> str:
|
|
111
|
+
"""Path segment encoder (encodeURIComponent semantics) for every user-supplied path parameter."""
|
|
112
|
+
return quote(str(v), safe="-_.!~*'()")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def qs(params: Mapping[str, Any]) -> str:
|
|
116
|
+
items = [(k, str(v)) for k, v in params.items() if v is not None]
|
|
117
|
+
return f"?{urlencode(items)}" if items else ""
|