Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions decart/tokens/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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},
)
Comment thread
AdirAmsalem marked this conversation as resolved.
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"),
Expand Down
8 changes: 6 additions & 2 deletions decart/tokens/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions examples/create_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down
66 changes: 58 additions & 8 deletions tests/test_tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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()
Loading