diff --git a/dfns_sdk/_internal/http_client.py b/dfns_sdk/_internal/http_client.py index 878690d..7f29f33 100644 --- a/dfns_sdk/_internal/http_client.py +++ b/dfns_sdk/_internal/http_client.py @@ -4,20 +4,35 @@ import json from collections.abc import Mapping from typing import Any, cast -from urllib.parse import urlencode +from urllib.parse import urlencode, urlsplit, urlunsplit import httpx from dfns_sdk.types import DfnsClientConfig, DfnsDelegatedClientConfig, DfnsError +def _normalize_base_url(base_url: str) -> str: + """Validate and normalize a complete API transport base URL.""" + parsed = urlsplit(base_url) + if not parsed.scheme or not parsed.netloc: + raise ValueError("base_url must be an absolute URL") + if "?" in base_url: + raise ValueError("base_url must not include a query") + if "#" in base_url: + raise ValueError("base_url must not include a fragment") + + normalized_path = parsed.path.rstrip("/") + return urlunsplit((parsed.scheme, parsed.netloc, normalized_path, "", "")) + + class HttpClient: """HTTP client for Dfns API requests.""" def __init__(self, config: "DfnsClientConfig | DfnsDelegatedClientConfig"): self.config = config + self._base_url = _normalize_base_url(config.base_url) self._client = httpx.Client( - base_url=config.base_url, + base_url=self._base_url, timeout=30.0, ) @@ -41,7 +56,10 @@ def _build_url( query_params: Mapping[str, Any] | None = None, ) -> str: """Build the full URL with path and query parameters.""" - url = path + if not path.startswith("/") or path.startswith("//"): + raise ValueError("request path must be root-relative") + + url = f"{self._base_url}/{path.removeprefix('/')}" if path_params: for key, value in path_params.items(): @@ -114,7 +132,7 @@ def _get_user_action_token( challenge_response = self._client.request( method="POST", - url="/auth/action/init", + url=self._build_url("/auth/action/init"), headers=self._build_headers(), json=challenge_body, ) @@ -131,7 +149,7 @@ def _get_user_action_token( signature_response = self._client.request( method="POST", - url="/auth/action", + url=self._build_url("/auth/action"), headers=self._build_headers(), json=signature_body, ) @@ -249,8 +267,9 @@ class AsyncHttpClient: def __init__(self, config: DfnsClientConfig): self.config = config + self._base_url = _normalize_base_url(config.base_url) self._client = httpx.AsyncClient( - base_url=config.base_url, + base_url=self._base_url, timeout=30.0, ) @@ -274,7 +293,10 @@ def _build_url( query_params: Mapping[str, Any] | None = None, ) -> str: """Build the full URL with path and query parameters.""" - url = path + if not path.startswith("/") or path.startswith("//"): + raise ValueError("request path must be root-relative") + + url = f"{self._base_url}/{path.removeprefix('/')}" if path_params: for key, value in path_params.items(): @@ -347,7 +369,7 @@ async def _get_user_action_token( challenge_response = await self._client.request( method="POST", - url="/auth/action/init", + url=self._build_url("/auth/action/init"), headers=self._build_headers(), json=challenge_body, ) @@ -364,7 +386,7 @@ async def _get_user_action_token( signature_response = await self._client.request( method="POST", - url="/auth/action", + url=self._build_url("/auth/action"), headers=self._build_headers(), json=signature_body, ) diff --git a/dfns_sdk/generated/auth/client.py b/dfns_sdk/generated/auth/client.py index 44adc75..5de2cf9 100644 --- a/dfns_sdk/generated/auth/client.py +++ b/dfns_sdk/generated/auth/client.py @@ -482,6 +482,50 @@ def logout(self, body: T.LogoutRequest) -> T.LogoutResponse: ) return cast(T.LogoutResponse, response) + def complete_oidc_login(self, body: T.CompleteOidcLoginRequest) -> dict[str, Any]: + """ + Complete OIDC Login. + + Completes the OIDC login process by exchanging the authorization code obtained from the identity provider. If the verified user has no active first-factor credential yet, it returns a registration challenge to complete via [Complete User Registration](/api-reference/auth/complete-user-registration); otherwise it returns the user's authentication token. + + Args: + body: Request body. + + Returns: + dict[str, Any]: The API response. + """ # noqa: E501 + response = self._http.request( + method="POST", + path="/auth/login/oidc", + path_params={}, + query_params=None, + body=body, + requires_signature=False, + ) + return cast(dict[str, Any], response) + + def initiate_oidc_login(self, body: T.InitiateOidcLoginRequest) -> T.InitiateOidcLoginResponse: + """ + Initiate OIDC Login. + + Initialize the OIDC login process by returning the identity provider authorization URL to redirect the user to. + + Args: + body: Request body. + + Returns: + T.InitiateOidcLoginResponse: The API response. + """ # noqa: E501 + response = self._http.request( + method="POST", + path="/auth/login/oidc/init", + path_params={}, + query_params=None, + body=body, + requires_signature=False, + ) + return cast(T.InitiateOidcLoginResponse, response) + def send_login_code(self, body: T.SendLoginCodeRequest) -> T.SendLoginCodeResponse: """ Send Login Code. diff --git a/dfns_sdk/generated/auth/delegated_client.py b/dfns_sdk/generated/auth/delegated_client.py index 3fcb50d..ecafa20 100644 --- a/dfns_sdk/generated/auth/delegated_client.py +++ b/dfns_sdk/generated/auth/delegated_client.py @@ -646,6 +646,50 @@ def logout(self, body: T.LogoutRequest) -> T.LogoutResponse: ) return cast(T.LogoutResponse, response) + def complete_oidc_login(self, body: T.CompleteOidcLoginRequest) -> dict[str, Any]: + """ + Complete OIDC Login. + + Completes the OIDC login process by exchanging the authorization code obtained from the identity provider. If the verified user has no active first-factor credential yet, it returns a registration challenge to complete via [Complete User Registration](/api-reference/auth/complete-user-registration); otherwise it returns the user's authentication token. + + Args: + body: Request body. + + Returns: + dict[str, Any]: The API response. + """ # noqa: E501 + response = self._http.request( + method="POST", + path="/auth/login/oidc", + path_params={}, + query_params=None, + body=body, + requires_signature=False, + ) + return cast(dict[str, Any], response) + + def initiate_oidc_login(self, body: T.InitiateOidcLoginRequest) -> T.InitiateOidcLoginResponse: + """ + Initiate OIDC Login. + + Initialize the OIDC login process by returning the identity provider authorization URL to redirect the user to. + + Args: + body: Request body. + + Returns: + T.InitiateOidcLoginResponse: The API response. + """ # noqa: E501 + response = self._http.request( + method="POST", + path="/auth/login/oidc/init", + path_params={}, + query_params=None, + body=body, + requires_signature=False, + ) + return cast(T.InitiateOidcLoginResponse, response) + def send_login_code(self, body: T.SendLoginCodeRequest) -> T.SendLoginCodeResponse: """ Send Login Code. diff --git a/dfns_sdk/generated/auth/types.py b/dfns_sdk/generated/auth/types.py index 8f51e3e..40bfdbe 100644 --- a/dfns_sdk/generated/auth/types.py +++ b/dfns_sdk/generated/auth/types.py @@ -225,6 +225,27 @@ class LogoutResponse(TypedDict, total=False): message: str +class CompleteOidcLoginRequest(TypedDict, total=False): + """completeOidcLogin request body.""" + + code: str + state: str + + +class InitiateOidcLoginRequest(TypedDict, total=False): + """initiateOidcLogin request body.""" + + org_id: NotRequired[str] + tenant_id: NotRequired[str] + redirect_uri: str + + +class InitiateOidcLoginResponse(TypedDict, total=False): + """initiateOidcLogin response.""" + + redirect_url: str + + class SendLoginCodeRequest(TypedDict, total=False): """sendLoginCode request body.""" diff --git a/dfns_sdk/generated/permissions/types.py b/dfns_sdk/generated/permissions/types.py index 8a17e07..a1ec407 100644 --- a/dfns_sdk/generated/permissions/types.py +++ b/dfns_sdk/generated/permissions/types.py @@ -190,6 +190,8 @@ class CreatePermissionRequest(TypedDict, total=False): "Vaults:Tags:Delete", "Vaults:Addresses:Create", "Vaults:Quarantines:Release", + "Vaults:Locks:Create", + "Vaults:Locks:Delete", "Vaults:Transfers:Create", "Webhooks:Create", "Webhooks:Read", @@ -385,6 +387,8 @@ class UpdatePermissionRequest(TypedDict, total=False): "Vaults:Tags:Delete", "Vaults:Addresses:Create", "Vaults:Quarantines:Release", + "Vaults:Locks:Create", + "Vaults:Locks:Delete", "Vaults:Transfers:Create", "Webhooks:Create", "Webhooks:Read", diff --git a/dfns_sdk/generated/vaults/client.py b/dfns_sdk/generated/vaults/client.py index a8d9b3d..41d1748 100644 --- a/dfns_sdk/generated/vaults/client.py +++ b/dfns_sdk/generated/vaults/client.py @@ -79,6 +79,52 @@ def create_vault_address(self, vault_id: str, body: T.CreateVaultAddressRequest) ) return cast(T.CreateVaultAddressResponse, response) + def list_vault_locks(self, vault_id: str, query: T.ListVaultLocksQuery | None = None) -> T.ListVaultLocksResponse: + """ + List Vault Locks. + + Lists a vault's locks, active and released. + + Args: + vault_id: Vault id. + query: Query parameters. + + Returns: + T.ListVaultLocksResponse: The API response. + """ # noqa: E501 + response = self._http.request( + method="GET", + path="/vaults/{vaultId}/locks", + path_params={"vaultId": vault_id}, + query_params=query, + body=None, + requires_signature=False, + ) + return cast(T.ListVaultLocksResponse, response) + + def create_vault_lock(self, vault_id: str, body: T.CreateVaultLockRequest) -> T.CreateVaultLockResponse: + """ + Create Vault Lock. + + Locks funds from the vault's available balance for off-chain settlement or escrow. + + Args: + vault_id: Vault id. + body: Request body. + + Returns: + T.CreateVaultLockResponse: The API response. + """ # noqa: E501 + response = self._http.request( + method="POST", + path="/vaults/{vaultId}/locks", + path_params={"vaultId": vault_id}, + query_params=None, + body=body, + requires_signature=True, + ) + return cast(T.CreateVaultLockResponse, response) + def create_vault_transfer(self, vault_id: str, body: T.CreateVaultTransferRequest) -> T.CreateVaultTransferResponse: """ Create Vault Transfer. @@ -102,6 +148,52 @@ def create_vault_transfer(self, vault_id: str, body: T.CreateVaultTransferReques ) return cast(T.CreateVaultTransferResponse, response) + def get_vault_lock(self, vault_id: str, lock_id: str) -> T.GetVaultLockResponse: + """ + Get Vault Lock. + + Retrieves a vault lock by its ID. + + Args: + vault_id: Vault id. + lock_id: The lock to retrieve. + + Returns: + T.GetVaultLockResponse: The API response. + """ # noqa: E501 + response = self._http.request( + method="GET", + path="/vaults/{vaultId}/locks/{lockId}", + path_params={"vaultId": vault_id, "lockId": lock_id}, + query_params=None, + body=None, + requires_signature=False, + ) + return cast(T.GetVaultLockResponse, response) + + def delete_vault_lock(self, vault_id: str, lock_id: str) -> T.DeleteVaultLockResponse: + """ + Delete Vault Lock. + + Releases a lock, returning the locked funds to the vault's available balance. Owner only. + + Args: + vault_id: Vault id. + lock_id: Vault lock id. + + Returns: + T.DeleteVaultLockResponse: The API response. + """ # noqa: E501 + response = self._http.request( + method="DELETE", + path="/vaults/{vaultId}/locks/{lockId}", + path_params={"vaultId": vault_id, "lockId": lock_id}, + query_params=None, + body=None, + requires_signature=True, + ) + return cast(T.DeleteVaultLockResponse, response) + def get_vault(self, vault_id: str) -> T.GetVaultResponse: """ Get Vault. diff --git a/dfns_sdk/generated/vaults/delegated_client.py b/dfns_sdk/generated/vaults/delegated_client.py index 25dc62f..609db92 100644 --- a/dfns_sdk/generated/vaults/delegated_client.py +++ b/dfns_sdk/generated/vaults/delegated_client.py @@ -146,6 +146,82 @@ def create_vault_address_complete( ) return cast(T.CreateVaultAddressResponse, response) + def list_vault_locks(self, vault_id: str, query: T.ListVaultLocksQuery | None = None) -> T.ListVaultLocksResponse: + """ + List Vault Locks. + + Lists a vault's locks, active and released. + + Args: + vault_id: Vault id. + query: Query parameters. + + Returns: + T.ListVaultLocksResponse: The API response. + """ # noqa: E501 + response = self._http.request( + method="GET", + path="/vaults/{vaultId}/locks", + path_params={"vaultId": vault_id}, + query_params=query, + body=None, + requires_signature=False, + ) + return cast(T.ListVaultLocksResponse, response) + + def create_vault_lock_init(self, vault_id: str, body: T.CreateVaultLockRequest) -> UserActionChallengeResponse: + """ + Initialize Create Vault Lock. + + Creates a user action challenge for external signing. + + Args: + vault_id: Vault id. + body: Request body. + + Returns: + UserActionChallengeResponse: The challenge to sign externally. + """ # noqa: E501 + path = "/vaults/{vaultId}/locks" + path = path.replace("{vaultId}", str(vault_id)) + payload = json.dumps(body, separators=(",", ":")) if body else "" + + return BaseAuthApi.create_user_action_challenge( + self._http, + user_action_http_method="POST", + user_action_http_path=path, + user_action_payload=payload, + ) + + def create_vault_lock_complete( + self, vault_id: str, body: T.CreateVaultLockRequest, signed_challenge: SignUserActionChallengeRequest + ) -> T.CreateVaultLockResponse: + """ + Complete Create Vault Lock. + + Submits the signed challenge and makes the API request. + + Args: + vault_id: Vault id. + body: Request body. + signed_challenge: The signed challenge from external signing. + + Returns: + T.CreateVaultLockResponse: The API response. + """ # noqa: E501 + user_action_result = BaseAuthApi.sign_user_action_challenge(self._http, signed_challenge) + user_action_token = user_action_result["userAction"] + + response = self._http.request_with_user_action( + method="POST", + path="/vaults/{vaultId}/locks", + path_params={"vaultId": vault_id}, + query_params=None, + body=body, + user_action=user_action_token, + ) + return cast(T.CreateVaultLockResponse, response) + def create_vault_transfer_init( self, vault_id: str, body: T.CreateVaultTransferRequest ) -> UserActionChallengeResponse: @@ -201,6 +277,83 @@ def create_vault_transfer_complete( ) return cast(T.CreateVaultTransferResponse, response) + def get_vault_lock(self, vault_id: str, lock_id: str) -> T.GetVaultLockResponse: + """ + Get Vault Lock. + + Retrieves a vault lock by its ID. + + Args: + vault_id: Vault id. + lock_id: The lock to retrieve. + + Returns: + T.GetVaultLockResponse: The API response. + """ # noqa: E501 + response = self._http.request( + method="GET", + path="/vaults/{vaultId}/locks/{lockId}", + path_params={"vaultId": vault_id, "lockId": lock_id}, + query_params=None, + body=None, + requires_signature=False, + ) + return cast(T.GetVaultLockResponse, response) + + def delete_vault_lock_init(self, vault_id: str, lock_id: str) -> UserActionChallengeResponse: + """ + Initialize Delete Vault Lock. + + Creates a user action challenge for external signing. + + Args: + vault_id: Vault id. + lock_id: Vault lock id. + + Returns: + UserActionChallengeResponse: The challenge to sign externally. + """ # noqa: E501 + path = "/vaults/{vaultId}/locks/{lockId}" + path = path.replace("{vaultId}", str(vault_id)) + path = path.replace("{lockId}", str(lock_id)) + payload = "" + + return BaseAuthApi.create_user_action_challenge( + self._http, + user_action_http_method="DELETE", + user_action_http_path=path, + user_action_payload=payload, + ) + + def delete_vault_lock_complete( + self, vault_id: str, lock_id: str, signed_challenge: SignUserActionChallengeRequest + ) -> T.DeleteVaultLockResponse: + """ + Complete Delete Vault Lock. + + Submits the signed challenge and makes the API request. + + Args: + vault_id: Vault id. + lock_id: Vault lock id. + signed_challenge: The signed challenge from external signing. + + Returns: + T.DeleteVaultLockResponse: The API response. + """ # noqa: E501 + user_action_result = BaseAuthApi.sign_user_action_challenge(self._http, signed_challenge) + user_action_token = user_action_result["userAction"] + + response = self._http.request_with_user_action( + method="DELETE", + path="/vaults/{vaultId}/locks/{lockId}", + path_params={"vaultId": vault_id, "lockId": lock_id}, + query_params=None, + body=None, + user_action=user_action_token, + ) + return cast(T.DeleteVaultLockResponse, response) + def get_vault(self, vault_id: str) -> T.GetVaultResponse: """ Get Vault. diff --git a/dfns_sdk/generated/vaults/types.py b/dfns_sdk/generated/vaults/types.py index 4a293b7..a96607f 100644 --- a/dfns_sdk/generated/vaults/types.py +++ b/dfns_sdk/generated/vaults/types.py @@ -112,6 +112,102 @@ class CreateVaultAddressResponse(TypedDict, total=False): address: str +class ListVaultLocksResponse(TypedDict, total=False): + """listVaultLocks response.""" + + items: list[dict[str, Any]] + next_page_token: NotRequired[str] + + +class ListVaultLocksQuery(TypedDict, total=False): + """listVaultLocks query parameters.""" + + limit: NotRequired[int] + pagination_token: NotRequired[str] + network: NotRequired[str] + tid: NotRequired[str] + + +class CreateVaultLockRequest(TypedDict, total=False): + """createVaultLock request body.""" + + network: Literal[ + "ArbitrumOne", + "ArbitrumSepolia", + "ArcTestnet", + "AvalancheC", + "AvalancheCFuji", + "Base", + "BaseSepolia", + "Bob", + "BobSepolia", + "Bsc", + "BscTestnet", + "Berachain", + "BerachainBepolia", + "Celo", + "CeloAlfajores", + "Codex", + "CodexSepolia", + "Ethereum", + "EthereumClassic", + "EthereumClassicMordor", + "EthereumSepolia", + "EthereumHoodi", + "FlareC", + "FlareCCoston2", + "FlowEvm", + "FlowEvmTestnet", + "Ink", + "InkSepolia", + "Optimism", + "OptimismSepolia", + "Plasma", + "PlasmaTestnet", + "Plume", + "PlumeSepolia", + "Polygon", + "PolygonAmoy", + "Race", + "RaceSepolia", + "Rayls", + "RaylsTestnet", + "Robinhood", + "RobinhoodSepolia", + "SeiPacific1", + "SeiAtlantic2", + "Sonic", + "SonicTestnet", + "Tempo", + "TempoModerato", + "Tsc", + "TscTestnet1", + "Xdc", + "XdcApothem", + "XLayer", + "XLayerSepolia", + ] + tid: str + amount: str + external_id: NotRequired[str] + reason: NotRequired[str] + + +class CreateVaultLockResponse(TypedDict, total=False): + """createVaultLock response.""" + + id: str + vault_id: str + network: str + tid: str + amount: str + owner: str + external_id: NotRequired[str] + reason: NotRequired[str] + date_created: str + date_deleted: NotRequired[str] + + class CreateVaultTransferRequest(TypedDict, total=False): """createVaultTransfer request body.""" @@ -204,6 +300,36 @@ class CreateVaultTransferResponse(TypedDict, total=False): details: NotRequired[dict[str, dict[str, Any]]] +class GetVaultLockResponse(TypedDict, total=False): + """getVaultLock response.""" + + id: str + vault_id: str + network: str + tid: str + amount: str + owner: str + external_id: NotRequired[str] + reason: NotRequired[str] + date_created: str + date_deleted: NotRequired[str] + + +class DeleteVaultLockResponse(TypedDict, total=False): + """deleteVaultLock response.""" + + id: str + vault_id: str + network: str + tid: str + amount: str + owner: str + external_id: NotRequired[str] + reason: NotRequired[str] + date_created: str + date_deleted: NotRequired[str] + + class GetVaultResponse(TypedDict, total=False): """getVault response.""" diff --git a/dfns_sdk/types.py b/dfns_sdk/types.py index a545f2d..cd821c5 100644 --- a/dfns_sdk/types.py +++ b/dfns_sdk/types.py @@ -15,7 +15,7 @@ class DfnsClientConfig: """Authentication token (JWT).""" base_url: str = "https://api.dfns.io" - """Base URL for the Dfns API.""" + """Complete transport base URL for the Dfns API, including any path prefix.""" signer: "Signer | None" = None """Signer for user action requests.""" @@ -37,7 +37,7 @@ class DfnsDelegatedClientConfig: """Authentication token (JWT) for the service account.""" base_url: str = "https://api.dfns.io" - """Base URL for the Dfns API.""" + """Complete transport base URL for the Dfns API, including any path prefix.""" headers: dict[str, str] = field(default_factory=dict) """Additional headers to include in requests."""