diff --git a/decart/tokens/client.py b/decart/tokens/client.py index 70cbb4b..52d8175 100644 --- a/decart/tokens/client.py +++ b/decart/tokens/client.py @@ -25,10 +25,11 @@ class TokensClient: # With metadata: token = await client.tokens.create(metadata={"role": "viewer"}) - # With expiry, model restrictions, and constraints: + # With expiry, model restrictions, origin restrictions, and constraints: token = await client.tokens.create( expires_in=120, allowed_models=["lucy-2.1"], + allowed_origins=["https://example.com"], constraints={"realtime": {"maxSessionDuration": 300}}, ) ``` @@ -46,6 +47,7 @@ async def create( metadata: dict[str, Any] | None = None, expires_in: int | None = None, allowed_models: list[Union[Model, str]] | None = None, + allowed_origins: list[str] | None = None, constraints: TokenConstraints | None = None, ) -> CreateTokenResponse: """ @@ -55,6 +57,11 @@ async def create( metadata: Optional custom key-value pairs to attach to the token. expires_in: Seconds until the token expires (1-3600, default 60). allowed_models: Restrict which models this token can access (max 20). + allowed_origins: Restrict which web origins this token can be used + from (max 20). Each entry must be a full origin including + scheme, e.g. ``https://example.com``. Enforced on realtime + sessions by matching the WebSocket ``Origin`` header verbatim. + Defense-in-depth — only effective for browser-based clients. constraints: Operational limits, e.g. ``{"realtime": {"maxSessionDuration": 120}}``. @@ -71,6 +78,7 @@ async def create( metadata={"role": "viewer"}, expires_in=120, allowed_models=["lucy-2.1"], + allowed_origins=["https://example.com"], constraints={"realtime": {"maxSessionDuration": 300}}, ) ``` @@ -93,6 +101,8 @@ async def create( body["expiresIn"] = expires_in if allowed_models is not None: body["allowedModels"] = list(allowed_models) + if allowed_origins is not None: + body["allowedOrigins"] = list(allowed_origins) if constraints is not None: body["constraints"] = constraints diff --git a/decart/tokens/types.py b/decart/tokens/types.py index 109c5f4..4649329 100644 --- a/decart/tokens/types.py +++ b/decart/tokens/types.py @@ -11,8 +11,9 @@ class TokenConstraints(TypedDict, total=False): realtime: RealtimeConstraints -class TokenPermissions(TypedDict): +class TokenPermissions(TypedDict, total=False): models: list[str] + origins: list[str] class CreateTokenResponse(BaseModel): diff --git a/examples/create_token.py b/examples/create_token.py index 69c01ce..6580b86 100644 --- a/examples/create_token.py +++ b/examples/create_token.py @@ -8,11 +8,15 @@ async def main() -> None: async with DecartClient(api_key=os.getenv("DECART_API_KEY")) as server_client: print("Creating client token...") - token = await server_client.tokens.create() + token = await server_client.tokens.create( + allowed_origins=["https://example.com"], + ) print("Token created successfully:") print(f" API Key: {token.api_key[:10]}...") print(f" Expires At: {token.expires_at}") + origins = (token.permissions or {}).get("origins") + print(f" Allowed Origins: {', '.join(origins) if origins else '(any)'}") # Client-side: Use the client token # In a real app, you would send token.api_key to the frontend diff --git a/tests/test_tokens.py b/tests/test_tokens.py index 884661e..368258a 100644 --- a/tests/test_tokens.py +++ b/tests/test_tokens.py @@ -164,6 +164,33 @@ async def test_create_token_with_allowed_models() -> None: assert call_kwargs.kwargs["json"] == {"allowedModels": ["lucy-2.1"]} +@pytest.mark.asyncio +async def test_create_token_with_allowed_origins() -> None: + """Sends allowedOrigins in request body.""" + client = DecartClient(api_key="test-api-key") + + mock_response = AsyncMock() + mock_response.ok = True + mock_response.json = AsyncMock( + return_value={"apiKey": "ek_test123", "expiresAt": "2024-12-15T12:10:00Z"} + ) + + mock_session = MagicMock() + mock_session.post = MagicMock( + return_value=AsyncMock(__aenter__=AsyncMock(return_value=mock_response)) + ) + + with patch.object(client, "_get_session", AsyncMock(return_value=mock_session)): + await client.tokens.create( + allowed_origins=["https://example.com", "https://app.example.com"] + ) + + call_kwargs = mock_session.post.call_args + assert call_kwargs.kwargs["json"] == { + "allowedOrigins": ["https://example.com", "https://app.example.com"] + } + + @pytest.mark.asyncio async def test_create_token_with_constraints() -> None: """Sends constraints in request body.""" @@ -199,7 +226,10 @@ async def test_create_token_with_all_v2_fields() -> None: return_value={ "apiKey": "ek_test123", "expiresAt": "2024-12-15T12:10:00Z", - "permissions": {"models": ["lucy-2.1"]}, + "permissions": { + "models": ["lucy-2.1"], + "origins": ["https://example.com"], + }, "constraints": {"realtime": {"maxSessionDuration": 120}}, } ) @@ -214,12 +244,16 @@ async def test_create_token_with_all_v2_fields() -> None: metadata={"role": "viewer"}, expires_in=120, allowed_models=["lucy-2.1"], + allowed_origins=["https://example.com"], constraints={"realtime": {"maxSessionDuration": 120}}, ) assert result.api_key == "ek_test123" assert result.expires_at == "2024-12-15T12:10:00Z" - assert result.permissions == {"models": ["lucy-2.1"]} + assert result.permissions == { + "models": ["lucy-2.1"], + "origins": ["https://example.com"], + } assert result.constraints == {"realtime": {"maxSessionDuration": 120}} call_kwargs = mock_session.post.call_args @@ -227,5 +261,6 @@ async def test_create_token_with_all_v2_fields() -> None: "metadata": {"role": "viewer"}, "expiresIn": 120, "allowedModels": ["lucy-2.1"], + "allowedOrigins": ["https://example.com"], "constraints": {"realtime": {"maxSessionDuration": 120}}, } diff --git a/uv.lock b/uv.lock index ceff5ff..74fbd3a 100644 --- a/uv.lock +++ b/uv.lock @@ -597,7 +597,7 @@ wheels = [ [[package]] name = "decart" -version = "0.0.33" +version = "0.0.36" source = { editable = "." } dependencies = [ { name = "aiofiles" },