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/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 ""