diff --git a/CHANGELOG.md b/CHANGELOG.md index f0546f4..51684ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to the ChatBotKit Python SDK are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.0] - 2026-06-27 + +### Added + +- Secret token minting and request proxying. `client.secret.mint(...)` / + `client.contact.secret.mint(...)` mint a usable token from a secret + (`oauth`/`jwt` secrets only; owner-only) and return `{ token, expiresAt }`. + `client.secret.proxy(...)` / `client.contact.secret.proxy(...)` proxy a request + through a secret — the credential is injected server-side (it never leaves the + platform) and the raw upstream `httpx.Response` is returned verbatim. A non-2xx + status (including `409 authorization_required`) is returned, not raised. + ## [0.3.0] - 2026-06-26 ### Added diff --git a/chatbotkit/__init__.py b/chatbotkit/__init__.py index 4e8d9d7..2ef7d40 100644 --- a/chatbotkit/__init__.py +++ b/chatbotkit/__init__.py @@ -9,4 +9,4 @@ "Response", ] -__version__ = "0.3.0" +__version__ = "0.4.0" diff --git a/chatbotkit/_transport.py b/chatbotkit/_transport.py index 93812f4..176bd28 100644 --- a/chatbotkit/_transport.py +++ b/chatbotkit/_transport.py @@ -252,6 +252,7 @@ async def request( record: Any = None, headers: Mapping[str, str] | None = None, endpoint: str | None = None, + raw: bool = False, ) -> httpx.Response: request = self._build_request( path, @@ -263,7 +264,11 @@ async def request( ) response = await self._http.request(**request) - await self.raise_for_status(response) + + # in raw (passthrough) mode the caller wants the response verbatim - do + # not raise on a non-2xx status (e.g. the proxy's 409 authorization_required) + if not raw: + await self.raise_for_status(response) return response diff --git a/chatbotkit/contact.py b/chatbotkit/contact.py index 6f19b37..1b70c4a 100644 --- a/chatbotkit/contact.py +++ b/chatbotkit/contact.py @@ -2,6 +2,8 @@ from typing import Any, Mapping +import httpx + from . import types from ._transport import Client, Response @@ -115,6 +117,36 @@ def list( stream_parse=types.ContactSecretListStreamItem.from_dict, ) + def mint( + self, + contact_id: str, + secret_id: str, + ) -> Response[types.ContactSecretMintResponse, Any]: + """Mint a usable token from a contact's secret (owner-only; oauth/jwt only).""" + return self._client.client_fetch( + f"/api/v1/contact/{contact_id}/secret/{secret_id}/mint", + record={}, + parse=types.ContactSecretMintResponse.from_dict, + ) + + async def proxy( + self, + contact_id: str, + secret_id: str, + request: types.ContactSecretProxyRequest | Request, + ) -> httpx.Response: + """Proxy a request through a contact's secret, injected server-side. + + Returns the raw upstream response verbatim; a non-2xx status is returned, + not raised. + """ + return await self._client.request( + f"/api/v1/contact/{contact_id}/secret/{secret_id}/proxy", + method="POST", + record=request, + raw=True, + ) + class ContactSpaceClient: def __init__(self, client: Client) -> None: diff --git a/chatbotkit/secret.py b/chatbotkit/secret.py index c39e952..fdfd426 100644 --- a/chatbotkit/secret.py +++ b/chatbotkit/secret.py @@ -2,6 +2,8 @@ from typing import Any, Mapping +import httpx + from . import types from ._transport import Client, Response @@ -60,3 +62,28 @@ def delete( record=request or {}, parse=types.SecretDeleteResponse.from_dict, ) + + def mint(self, secret_id: str) -> Response[types.SecretMintResponse, Any]: + """Mint a usable token from the secret (owner-only; oauth/jwt only).""" + return self._client.client_fetch( + f"/api/v1/secret/{secret_id}/mint", + record={}, + parse=types.SecretMintResponse.from_dict, + ) + + async def proxy( + self, + secret_id: str, + request: types.SecretProxyRequest | Request, + ) -> httpx.Response: + """Proxy a request through the secret, injected server-side. + + Returns the raw upstream response verbatim; a non-2xx status (including + 409 authorization_required) is returned, not raised. + """ + return await self._client.request( + f"/api/v1/secret/{secret_id}/proxy", + method="POST", + record=request, + raw=True, + ) diff --git a/chatbotkit/types.py b/chatbotkit/types.py index 529b3d7..222af98 100644 --- a/chatbotkit/types.py +++ b/chatbotkit/types.py @@ -4797,6 +4797,122 @@ def to_dict(self) -> dict: return result +class ContactSecretMintParams: + contact_id: str + """The ID of the contact the secret belongs to""" + + secret_id: str + """The ID of the secret to mint""" + + def __init__(self, contact_id: str, secret_id: str) -> None: + self.contact_id = contact_id + self.secret_id = secret_id + + @staticmethod + def from_dict(obj: Any) -> 'ContactSecretMintParams': + assert isinstance(obj, dict) + contact_id = from_str(obj.get("contactId")) + secret_id = from_str(obj.get("secretId")) + return ContactSecretMintParams(contact_id, secret_id) + + def to_dict(self) -> dict: + result: dict = {} + result["contactId"] = from_str(self.contact_id) + result["secretId"] = from_str(self.secret_id) + return result + + +class ContactSecretMintResponse: + expires_at: Optional[float] + """Token expiry as a unix timestamp in ms, or null""" + + token: str + """The usable token to send to the provider""" + + def __init__(self, expires_at: Optional[float], token: str) -> None: + self.expires_at = expires_at + self.token = token + + @staticmethod + def from_dict(obj: Any) -> 'ContactSecretMintResponse': + assert isinstance(obj, dict) + expires_at = from_union([from_float, from_none], obj.get("expiresAt")) + token = from_str(obj.get("token")) + return ContactSecretMintResponse(expires_at, token) + + def to_dict(self) -> dict: + result: dict = {} + if self.expires_at is not None: + result["expiresAt"] = from_union([to_float, from_none], self.expires_at) + result["token"] = from_str(self.token) + return result + + +class ContactSecretProxyParams: + contact_id: str + """The ID of the contact the secret belongs to""" + + secret_id: str + """The ID of the secret to inject""" + + def __init__(self, contact_id: str, secret_id: str) -> None: + self.contact_id = contact_id + self.secret_id = secret_id + + @staticmethod + def from_dict(obj: Any) -> 'ContactSecretProxyParams': + assert isinstance(obj, dict) + contact_id = from_str(obj.get("contactId")) + secret_id = from_str(obj.get("secretId")) + return ContactSecretProxyParams(contact_id, secret_id) + + def to_dict(self) -> dict: + result: dict = {} + result["contactId"] = from_str(self.contact_id) + result["secretId"] = from_str(self.secret_id) + return result + + +class ContactSecretProxyRequest: + body: Optional[str] + """The request body""" + + headers: Optional[Dict[str, str]] + """The request headers (may reference the secret)""" + + method: Optional[str] + """The HTTP method""" + + url: str + """The destination URL""" + + def __init__(self, body: Optional[str], headers: Optional[Dict[str, str]], method: Optional[str], url: str) -> None: + self.body = body + self.headers = headers + self.method = method + self.url = url + + @staticmethod + def from_dict(obj: Any) -> 'ContactSecretProxyRequest': + assert isinstance(obj, dict) + body = from_union([from_str, from_none], obj.get("body")) + headers = from_union([lambda x: from_dict(from_str, x), from_none], obj.get("headers")) + method = from_union([from_str, from_none], obj.get("method")) + url = from_str(obj.get("url")) + return ContactSecretProxyRequest(body, headers, method, url) + + def to_dict(self) -> dict: + result: dict = {} + if self.body is not None: + result["body"] = from_union([from_str, from_none], self.body) + if self.headers is not None: + result["headers"] = from_union([lambda x: from_dict(from_str, x), from_none], self.headers) + if self.method is not None: + result["method"] = from_union([from_str, from_none], self.method) + result["url"] = from_str(self.url) + return result + + class ContactSecretRevokeParams: contact_id: str """The ID of the contact the secret belongs to""" @@ -43875,6 +43991,110 @@ def to_dict(self) -> dict: return result +class SecretMintParams: + secret_id: str + """The ID of the secret to mint""" + + def __init__(self, secret_id: str) -> None: + self.secret_id = secret_id + + @staticmethod + def from_dict(obj: Any) -> 'SecretMintParams': + assert isinstance(obj, dict) + secret_id = from_str(obj.get("secretId")) + return SecretMintParams(secret_id) + + def to_dict(self) -> dict: + result: dict = {} + result["secretId"] = from_str(self.secret_id) + return result + + +class SecretMintResponse: + expires_at: Optional[float] + """Token expiry as a unix timestamp in ms, or null""" + + token: str + """The usable token to send to the provider""" + + def __init__(self, expires_at: Optional[float], token: str) -> None: + self.expires_at = expires_at + self.token = token + + @staticmethod + def from_dict(obj: Any) -> 'SecretMintResponse': + assert isinstance(obj, dict) + expires_at = from_union([from_float, from_none], obj.get("expiresAt")) + token = from_str(obj.get("token")) + return SecretMintResponse(expires_at, token) + + def to_dict(self) -> dict: + result: dict = {} + if self.expires_at is not None: + result["expiresAt"] = from_union([to_float, from_none], self.expires_at) + result["token"] = from_str(self.token) + return result + + +class SecretProxyParams: + secret_id: str + """The ID of the secret to inject""" + + def __init__(self, secret_id: str) -> None: + self.secret_id = secret_id + + @staticmethod + def from_dict(obj: Any) -> 'SecretProxyParams': + assert isinstance(obj, dict) + secret_id = from_str(obj.get("secretId")) + return SecretProxyParams(secret_id) + + def to_dict(self) -> dict: + result: dict = {} + result["secretId"] = from_str(self.secret_id) + return result + + +class SecretProxyRequest: + body: Optional[str] + """The request body""" + + headers: Optional[Dict[str, str]] + """The request headers (may reference the secret)""" + + method: Optional[str] + """The HTTP method""" + + url: str + """The destination URL""" + + def __init__(self, body: Optional[str], headers: Optional[Dict[str, str]], method: Optional[str], url: str) -> None: + self.body = body + self.headers = headers + self.method = method + self.url = url + + @staticmethod + def from_dict(obj: Any) -> 'SecretProxyRequest': + assert isinstance(obj, dict) + body = from_union([from_str, from_none], obj.get("body")) + headers = from_union([lambda x: from_dict(from_str, x), from_none], obj.get("headers")) + method = from_union([from_str, from_none], obj.get("method")) + url = from_str(obj.get("url")) + return SecretProxyRequest(body, headers, method, url) + + def to_dict(self) -> dict: + result: dict = {} + if self.body is not None: + result["body"] = from_union([from_str, from_none], self.body) + if self.headers is not None: + result["headers"] = from_union([lambda x: from_dict(from_str, x), from_none], self.headers) + if self.method is not None: + result["method"] = from_union([from_str, from_none], self.method) + result["url"] = from_str(self.url) + return result + + class SecretRevokeParams: secret_id: str @@ -53476,6 +53696,38 @@ def contact_secret_authenticate_response_to_dict(x: ContactSecretAuthenticateRes return to_class(ContactSecretAuthenticateResponse, x) +def contact_secret_mint_params_from_dict(s: Any) -> ContactSecretMintParams: + return ContactSecretMintParams.from_dict(s) + + +def contact_secret_mint_params_to_dict(x: ContactSecretMintParams) -> Any: + return to_class(ContactSecretMintParams, x) + + +def contact_secret_mint_response_from_dict(s: Any) -> ContactSecretMintResponse: + return ContactSecretMintResponse.from_dict(s) + + +def contact_secret_mint_response_to_dict(x: ContactSecretMintResponse) -> Any: + return to_class(ContactSecretMintResponse, x) + + +def contact_secret_proxy_params_from_dict(s: Any) -> ContactSecretProxyParams: + return ContactSecretProxyParams.from_dict(s) + + +def contact_secret_proxy_params_to_dict(x: ContactSecretProxyParams) -> Any: + return to_class(ContactSecretProxyParams, x) + + +def contact_secret_proxy_request_from_dict(s: Any) -> ContactSecretProxyRequest: + return ContactSecretProxyRequest.from_dict(s) + + +def contact_secret_proxy_request_to_dict(x: ContactSecretProxyRequest) -> Any: + return to_class(ContactSecretProxyRequest, x) + + def contact_secret_revoke_params_from_dict(s: Any) -> ContactSecretRevokeParams: return ContactSecretRevokeParams.from_dict(s) @@ -58708,6 +58960,38 @@ def secret_fetch_response_to_dict(x: SecretFetchResponse) -> Any: return to_class(SecretFetchResponse, x) +def secret_mint_params_from_dict(s: Any) -> SecretMintParams: + return SecretMintParams.from_dict(s) + + +def secret_mint_params_to_dict(x: SecretMintParams) -> Any: + return to_class(SecretMintParams, x) + + +def secret_mint_response_from_dict(s: Any) -> SecretMintResponse: + return SecretMintResponse.from_dict(s) + + +def secret_mint_response_to_dict(x: SecretMintResponse) -> Any: + return to_class(SecretMintResponse, x) + + +def secret_proxy_params_from_dict(s: Any) -> SecretProxyParams: + return SecretProxyParams.from_dict(s) + + +def secret_proxy_params_to_dict(x: SecretProxyParams) -> Any: + return to_class(SecretProxyParams, x) + + +def secret_proxy_request_from_dict(s: Any) -> SecretProxyRequest: + return SecretProxyRequest.from_dict(s) + + +def secret_proxy_request_to_dict(x: SecretProxyRequest) -> Any: + return to_class(SecretProxyRequest, x) + + def secret_revoke_params_from_dict(s: Any) -> SecretRevokeParams: return SecretRevokeParams.from_dict(s) diff --git a/pyproject.toml b/pyproject.toml index ca183e4..9aa149c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "chatbotkit" -version = "0.3.0" +version = "0.4.0" description = "Async Python SDK for ChatBotKit" readme = "README.md" requires-python = ">=3.10"