Skip to content

Commit f2dcbcc

Browse files
author
Delega Bot
committed
security: normalize base URLs and redact api keys
1 parent be535d0 commit f2dcbcc

File tree

6 files changed

+115
-53
lines changed

6 files changed

+115
-53
lines changed

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,15 @@ client = Delega(api_key="dlg_...")
4444
client = Delega()
4545
```
4646

47-
For self-hosted instances:
47+
For self-hosted instances, point `base_url` at the API namespace:
4848

4949
```python
50-
client = Delega(api_key="dlg_...", base_url="https://delega.yourcompany.com")
50+
client = Delega(api_key="dlg_...", base_url="http://localhost:18890")
51+
# or: Delega(api_key="dlg_...", base_url="https://delega.yourcompany.com/api")
5152
```
5253

54+
Passing a bare localhost URL defaults to the self-hosted `/api` namespace. For remote self-hosted deployments, include `/api` explicitly.
55+
5356
## Tasks
5457

5558
```python
@@ -117,6 +120,8 @@ me = client.me() # Get authenticated agent info
117120
usage = client.usage() # Get API usage stats
118121
```
119122

123+
`me()` and `usage()` are hosted-account endpoints. Self-hosted OSS deployments expose task/agent/project/webhook APIs under `/api`, but may not implement those hosted account endpoints.
124+
120125
## Async Client
121126

122127
```python
@@ -156,6 +161,8 @@ All resource methods return typed dataclasses:
156161
- `Task` - id, content, description, priority, labels, due_date, completed, project_id, parent_id, created_at, updated_at
157162
- `Comment` - id, task_id, content, created_at
158163
- `Agent` - id, name, display_name, description, api_key, created_at, updated_at
164+
165+
The `api_key` field is returned on agent creation and key rotation responses, but it is hidden from the default dataclass `repr()` to reduce accidental secret leakage in logs.
159166
- `Project` - id, name, emoji, color, created_at, updated_at
160167

161168
## License

src/delega/_http.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,51 @@
1111
from .exceptions import (
1212
DelegaAPIError,
1313
DelegaAuthError,
14+
DelegaError,
1415
DelegaNotFoundError,
1516
DelegaRateLimitError,
1617
)
1718

1819
_DEFAULT_TIMEOUT = 30
20+
_LOCAL_API_HOSTS = {"localhost", "127.0.0.1", "::1"}
21+
22+
23+
def _normalize_host(hostname: str) -> str:
24+
return hostname.strip("[]").lower()
25+
26+
27+
def _is_local_host(hostname: str) -> bool:
28+
return _normalize_host(hostname) in _LOCAL_API_HOSTS
29+
30+
31+
def normalize_base_url(raw_url: str) -> str:
32+
"""Normalize a Delega base URL to include its API namespace."""
33+
parsed = urllib.parse.urlparse(raw_url)
34+
if parsed.scheme not in ("http", "https") or not parsed.netloc:
35+
raise DelegaError(
36+
"Invalid Delega base_url. Use a full URL such as "
37+
"'https://api.delega.dev' or 'http://localhost:18890'."
38+
)
39+
40+
if parsed.scheme != "https" and not _is_local_host(parsed.hostname or ""):
41+
raise DelegaError(
42+
"Delega base_url must use HTTPS unless it points to localhost."
43+
)
44+
45+
path = parsed.path.rstrip("/")
46+
if not path:
47+
path = "/api" if _is_local_host(parsed.hostname or "") else "/v1"
48+
49+
return urllib.parse.urlunparse(
50+
(parsed.scheme, parsed.netloc, path, "", "", "")
51+
)
1952

2053

2154
class HTTPClient:
2255
"""Synchronous HTTP client using urllib."""
2356

2457
def __init__(self, base_url: str, api_key: str, timeout: int = _DEFAULT_TIMEOUT) -> None:
25-
self._base_url = base_url.rstrip("/")
58+
self._base_url = normalize_base_url(base_url)
2659
self._api_key = api_key
2760
self._timeout = timeout
2861

@@ -45,7 +78,7 @@ def request(
4578
4679
Args:
4780
method: HTTP method (GET, POST, PUT, PATCH, DELETE).
48-
path: API path (e.g. ``/v1/tasks``).
81+
path: API path relative to the configured API namespace (e.g. ``/tasks``).
4982
params: Optional query parameters.
5083
body: Optional JSON request body.
5184

src/delega/async_client.py

Lines changed: 26 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import os
66
from typing import Any, Optional
77

8+
from ._http import normalize_base_url
89
from .exceptions import (
910
DelegaAPIError,
1011
DelegaAuthError,
@@ -34,7 +35,7 @@ class _AsyncHTTPClient:
3435

3536
def __init__(self, base_url: str, api_key: str, timeout: int = 30) -> None:
3637
httpx = _require_httpx()
37-
self._base_url = base_url.rstrip("/")
38+
self._base_url = normalize_base_url(base_url)
3839
self._api_key = api_key
3940
self._client = httpx.AsyncClient(
4041
base_url=self._base_url,
@@ -136,7 +137,7 @@ async def list(
136137
"due_before": due_before,
137138
"completed": completed,
138139
}
139-
data = await self._http.get("/v1/tasks", params=params)
140+
data = await self._http.get("/tasks", params=params)
140141
return [Task.from_dict(t) for t in data]
141142

142143
async def create(
@@ -159,32 +160,32 @@ async def create(
159160
body["due_date"] = due_date
160161
if project_id is not None:
161162
body["project_id"] = project_id
162-
data = await self._http.post("/v1/tasks", body=body)
163+
data = await self._http.post("/tasks", body=body)
163164
return Task.from_dict(data)
164165

165166
async def get(self, task_id: str) -> Task:
166167
"""Get a task by ID."""
167-
data = await self._http.get(f"/v1/tasks/{task_id}")
168+
data = await self._http.get(f"/tasks/{task_id}")
168169
return Task.from_dict(data)
169170

170171
async def update(self, task_id: str, **fields: Any) -> Task:
171172
"""Update a task."""
172-
data = await self._http.patch(f"/v1/tasks/{task_id}", body=fields)
173+
data = await self._http.patch(f"/tasks/{task_id}", body=fields)
173174
return Task.from_dict(data)
174175

175176
async def delete(self, task_id: str) -> bool:
176177
"""Delete a task."""
177-
await self._http.delete(f"/v1/tasks/{task_id}")
178+
await self._http.delete(f"/tasks/{task_id}")
178179
return True
179180

180181
async def complete(self, task_id: str) -> Task:
181182
"""Mark a task as completed."""
182-
data = await self._http.post(f"/v1/tasks/{task_id}/complete")
183+
data = await self._http.post(f"/tasks/{task_id}/complete")
183184
return Task.from_dict(data)
184185

185186
async def uncomplete(self, task_id: str) -> Task:
186187
"""Mark a task as not completed."""
187-
data = await self._http.post(f"/v1/tasks/{task_id}/uncomplete")
188+
data = await self._http.post(f"/tasks/{task_id}/uncomplete")
188189
return Task.from_dict(data)
189190

190191
async def search(self, query: str) -> list[Task]:
@@ -205,17 +206,17 @@ async def delegate(
205206
body["description"] = description
206207
if priority is not None:
207208
body["priority"] = priority
208-
data = await self._http.post(f"/v1/tasks/{parent_task_id}/delegate", body=body)
209+
data = await self._http.post(f"/tasks/{parent_task_id}/delegate", body=body)
209210
return Task.from_dict(data)
210211

211212
async def add_comment(self, task_id: str, content: str) -> Comment:
212213
"""Add a comment to a task."""
213-
data = await self._http.post(f"/v1/tasks/{task_id}/comments", body={"content": content})
214+
data = await self._http.post(f"/tasks/{task_id}/comments", body={"content": content})
214215
return Comment.from_dict(data)
215216

216217
async def list_comments(self, task_id: str) -> list[Comment]:
217218
"""List all comments on a task."""
218-
data = await self._http.get(f"/v1/tasks/{task_id}/comments")
219+
data = await self._http.get(f"/tasks/{task_id}/comments")
219220
return [Comment.from_dict(c) for c in data]
220221

221222

@@ -227,7 +228,7 @@ def __init__(self, http: _AsyncHTTPClient) -> None:
227228

228229
async def list(self) -> list[Agent]:
229230
"""List all agents."""
230-
data = await self._http.get("/v1/agents")
231+
data = await self._http.get("/agents")
231232
return [Agent.from_dict(a) for a in data]
232233

233234
async def create(
@@ -243,22 +244,22 @@ async def create(
243244
body["display_name"] = display_name
244245
if description is not None:
245246
body["description"] = description
246-
data = await self._http.post("/v1/agents", body=body)
247+
data = await self._http.post("/agents", body=body)
247248
return Agent.from_dict(data)
248249

249250
async def update(self, agent_id: str, **fields: Any) -> Agent:
250251
"""Update an agent."""
251-
data = await self._http.patch(f"/v1/agents/{agent_id}", body=fields)
252+
data = await self._http.patch(f"/agents/{agent_id}", body=fields)
252253
return Agent.from_dict(data)
253254

254255
async def delete(self, agent_id: str) -> bool:
255256
"""Delete an agent."""
256-
await self._http.delete(f"/v1/agents/{agent_id}")
257+
await self._http.delete(f"/agents/{agent_id}")
257258
return True
258259

259260
async def rotate_key(self, agent_id: str) -> dict[str, Any]:
260261
"""Rotate an agent's API key."""
261-
data = await self._http.post(f"/v1/agents/{agent_id}/rotate-key")
262+
data = await self._http.post(f"/agents/{agent_id}/rotate-key")
262263
return data # type: ignore[no-any-return]
263264

264265

@@ -270,7 +271,7 @@ def __init__(self, http: _AsyncHTTPClient) -> None:
270271

271272
async def list(self) -> list[Project]:
272273
"""List all projects."""
273-
data = await self._http.get("/v1/projects")
274+
data = await self._http.get("/projects")
274275
return [Project.from_dict(p) for p in data]
275276

276277
async def create(
@@ -286,7 +287,7 @@ async def create(
286287
body["emoji"] = emoji
287288
if color is not None:
288289
body["color"] = color
289-
data = await self._http.post("/v1/projects", body=body)
290+
data = await self._http.post("/projects", body=body)
290291
return Project.from_dict(data)
291292

292293

@@ -298,7 +299,7 @@ def __init__(self, http: _AsyncHTTPClient) -> None:
298299

299300
async def list(self) -> list[Any]:
300301
"""List all webhooks."""
301-
return await self._http.get("/v1/webhooks") # type: ignore[no-any-return]
302+
return await self._http.get("/webhooks") # type: ignore[no-any-return]
302303

303304
async def create(
304305
self,
@@ -313,7 +314,7 @@ async def create(
313314
body["events"] = events
314315
if secret is not None:
315316
body["secret"] = secret
316-
return await self._http.post("/v1/webhooks", body=body) # type: ignore[no-any-return]
317+
return await self._http.post("/webhooks", body=body) # type: ignore[no-any-return]
317318

318319

319320
class AsyncDelega:
@@ -332,7 +333,9 @@ class AsyncDelega:
332333
api_key: API key for authentication. If not provided, reads from
333334
the ``DELEGA_API_KEY`` environment variable.
334335
base_url: Base URL of the Delega API. Defaults to
335-
``https://api.delega.dev``.
336+
``https://api.delega.dev`` (normalized to ``/v1``). For
337+
self-hosted deployments, use ``http://localhost:18890`` or an
338+
explicit ``.../api`` base URL.
336339
timeout: Request timeout in seconds. Defaults to 30.
337340
338341
Raises:
@@ -360,11 +363,11 @@ def __init__(
360363

361364
async def me(self) -> dict[str, Any]:
362365
"""Get information about the authenticated agent."""
363-
return await self._http.get("/v1/agent/me") # type: ignore[no-any-return]
366+
return await self._http.get("/agent/me") # type: ignore[no-any-return]
364367

365368
async def usage(self) -> dict[str, Any]:
366369
"""Get API usage information."""
367-
return await self._http.get("/v1/usage") # type: ignore[no-any-return]
370+
return await self._http.get("/usage") # type: ignore[no-any-return]
368371

369372
async def aclose(self) -> None:
370373
"""Close the underlying HTTP client."""

0 commit comments

Comments
 (0)