From cecc889f7614e7289e2331f39441d6c2d8ecabe8 Mon Sep 17 00:00:00 2001 From: Adir Amsalem Date: Tue, 15 Sep 2026 08:35:17 +0000 Subject: [PATCH] Type the client token's signed `token` as always present The platform now guarantees `token` on POST /v1/client/tokens (a signing failure fails the mint), so drop the Optional and raise TokenCreateError if a response ever lacks it instead of handing callers None to branch on. Docs point frontends at `token`; `api_key` stays the opaque form for calls other than realtime and file uploads. --- decart/tokens/client.py | 12 ++++++-- decart/tokens/types.py | 8 +++-- examples/create_token.py | 4 +-- tests/test_tokens.py | 66 +++++++++++++++++++++++++++++++++++----- 4 files changed, 76 insertions(+), 14 deletions(-) diff --git a/decart/tokens/client.py b/decart/tokens/client.py index 24cbf4f..c955188 100644 --- a/decart/tokens/client.py +++ b/decart/tokens/client.py @@ -66,7 +66,8 @@ async def create( ``{"realtime": {"maxSessionDuration": 120}}``. Returns: - A short-lived API key safe for client-side use. + A short-lived client token: the signed ``token`` your frontend uses for + realtime connections and file uploads, plus its opaque ``api_key`` twin. Example: ```python @@ -118,9 +119,16 @@ async def create( data={"status": response.status}, ) data = await response.json() + if "token" not in data: + # The platform guarantees the signed token; a response without it + # is a contract violation, not a value to hand back as None. + raise TokenCreateError( + "Failed to create token: response is missing the signed token", + data={"status": response.status}, + ) return CreateTokenResponse( api_key=data["apiKey"], - token=data.get("token"), + token=data["token"], expires_at=data["expiresAt"], permissions=data.get("permissions"), constraints=data.get("constraints"), diff --git a/decart/tokens/types.py b/decart/tokens/types.py index f885f1c..82cb834 100644 --- a/decart/tokens/types.py +++ b/decart/tokens/types.py @@ -20,8 +20,12 @@ class CreateTokenResponse(BaseModel): """Response from creating a client token.""" api_key: str - token: str | None = None - """Signed JWT mirroring ``api_key``, verifiable offline via the public JWKS.""" + """Opaque ``ek_...`` form of the credential, verified online. Use it for calls + other than realtime and file uploads.""" + token: str + """Signed JWT carrying the same scope and expiry as ``api_key``. The gateway + verifies it offline against the public JWKS; hand this to your frontend for + realtime connections and file uploads.""" expires_at: str permissions: TokenPermissions | None = None constraints: TokenConstraints | None = None diff --git a/examples/create_token.py b/examples/create_token.py index c3d3361..60613bb 100644 --- a/examples/create_token.py +++ b/examples/create_token.py @@ -14,13 +14,13 @@ async def main() -> None: print("Token created successfully:") print(f" API Key: {token.api_key[:10]}...") - print(f" JWT: {f'{token.token[:16]}...' if token.token else '(not issued)'}") + print(f" Token: {token.token[:16]}...") 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 + # In a real app, you would send token.token to the frontend _client = DecartClient(api_key=token.api_key) print("Client created with client token.") diff --git a/tests/test_tokens.py b/tests/test_tokens.py index b17aec5..e1ed88d 100644 --- a/tests/test_tokens.py +++ b/tests/test_tokens.py @@ -13,7 +13,11 @@ async def test_create_token() -> None: mock_response = AsyncMock() mock_response.ok = True mock_response.json = AsyncMock( - return_value={"apiKey": "ek_test123", "expiresAt": "2024-12-15T12:10:00Z"} + return_value={ + "apiKey": "ek_test123", + "token": "eyJhbGciOiJFZERTQS123", + "expiresAt": "2024-12-15T12:10:00Z", + } ) mock_session = MagicMock() @@ -25,7 +29,7 @@ async def test_create_token() -> None: result = await client.tokens.create() assert result.api_key == "ek_test123" - assert result.token is None + assert result.token == "eyJhbGciOiJFZERTQS123" assert result.expires_at == "2024-12-15T12:10:00Z" assert result.permissions is None assert result.constraints is None @@ -79,7 +83,11 @@ async def test_create_token_with_metadata() -> None: mock_response = AsyncMock() mock_response.ok = True mock_response.json = AsyncMock( - return_value={"apiKey": "ek_test123", "expiresAt": "2024-12-15T12:10:00Z"} + return_value={ + "apiKey": "ek_test123", + "token": "eyJhbGciOiJFZERTQS123", + "expiresAt": "2024-12-15T12:10:00Z", + } ) mock_session = MagicMock() @@ -104,7 +112,11 @@ async def test_create_token_without_metadata_sends_null() -> None: mock_response = AsyncMock() mock_response.ok = True mock_response.json = AsyncMock( - return_value={"apiKey": "ek_test123", "expiresAt": "2024-12-15T12:10:00Z"} + return_value={ + "apiKey": "ek_test123", + "token": "eyJhbGciOiJFZERTQS123", + "expiresAt": "2024-12-15T12:10:00Z", + } ) mock_session = MagicMock() @@ -127,7 +139,11 @@ async def test_create_token_with_expires_in() -> None: mock_response = AsyncMock() mock_response.ok = True mock_response.json = AsyncMock( - return_value={"apiKey": "ek_test123", "expiresAt": "2024-12-15T12:10:00Z"} + return_value={ + "apiKey": "ek_test123", + "token": "eyJhbGciOiJFZERTQS123", + "expiresAt": "2024-12-15T12:10:00Z", + } ) mock_session = MagicMock() @@ -150,7 +166,11 @@ async def test_create_token_with_allowed_models() -> None: mock_response = AsyncMock() mock_response.ok = True mock_response.json = AsyncMock( - return_value={"apiKey": "ek_test123", "expiresAt": "2024-12-15T12:10:00Z"} + return_value={ + "apiKey": "ek_test123", + "token": "eyJhbGciOiJFZERTQS123", + "expiresAt": "2024-12-15T12:10:00Z", + } ) mock_session = MagicMock() @@ -173,7 +193,11 @@ async def test_create_token_with_allowed_origins() -> None: mock_response = AsyncMock() mock_response.ok = True mock_response.json = AsyncMock( - return_value={"apiKey": "ek_test123", "expiresAt": "2024-12-15T12:10:00Z"} + return_value={ + "apiKey": "ek_test123", + "token": "eyJhbGciOiJFZERTQS123", + "expiresAt": "2024-12-15T12:10:00Z", + } ) mock_session = MagicMock() @@ -200,7 +224,11 @@ async def test_create_token_with_constraints() -> None: mock_response = AsyncMock() mock_response.ok = True mock_response.json = AsyncMock( - return_value={"apiKey": "ek_test123", "expiresAt": "2024-12-15T12:10:00Z"} + return_value={ + "apiKey": "ek_test123", + "token": "eyJhbGciOiJFZERTQS123", + "expiresAt": "2024-12-15T12:10:00Z", + } ) mock_session = MagicMock() @@ -267,3 +295,25 @@ async def test_create_token_with_all_v2_fields() -> None: "allowedOrigins": ["https://example.com"], "constraints": {"realtime": {"maxSessionDuration": 120}}, } + + +@pytest.mark.asyncio +async def test_create_token_without_signed_token_raises() -> None: + """A response without the signed token is a contract violation, not a None.""" + client = DecartClient(api_key="test-api-key") + + mock_response = AsyncMock() + mock_response.ok = True + mock_response.status = 200 + 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)): + with pytest.raises(TokenCreateError, match="missing the signed token"): + await client.tokens.create()