ascendkit 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ascendkit-0.1.0/.github/workflows/publish.yml +29 -0
- ascendkit-0.1.0/.gitignore +24 -0
- ascendkit-0.1.0/CLAUDE.md +22 -0
- ascendkit-0.1.0/PKG-INFO +13 -0
- ascendkit-0.1.0/ascendkit/__init__.py +23 -0
- ascendkit-0.1.0/ascendkit/_access_token.py +103 -0
- ascendkit-0.1.0/ascendkit/_analytics.py +201 -0
- ascendkit-0.1.0/ascendkit/_client.py +131 -0
- ascendkit-0.1.0/ascendkit/_errors.py +21 -0
- ascendkit-0.1.0/ascendkit/_version.py +3 -0
- ascendkit-0.1.0/ascendkit/_webhooks.py +118 -0
- ascendkit-0.1.0/ascendkit/auth.py +143 -0
- ascendkit-0.1.0/ascendkit/fastapi.py +41 -0
- ascendkit-0.1.0/ascendkit/py.typed +0 -0
- ascendkit-0.1.0/pyproject.toml +38 -0
- ascendkit-0.1.0/tests/__init__.py +0 -0
- ascendkit-0.1.0/tests/test_client.py +14 -0
- ascendkit-0.1.0/tests/test_version_header.py +77 -0
- ascendkit-0.1.0/tests/test_webhooks.py +65 -0
- ascendkit-0.1.0/uv.lock +759 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
id-token: write
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
publish:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
environment: pypi
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
|
|
18
|
+
- uses: actions/setup-python@v5
|
|
19
|
+
with:
|
|
20
|
+
python-version: "3.12"
|
|
21
|
+
|
|
22
|
+
- name: Install build tools
|
|
23
|
+
run: pip install build
|
|
24
|
+
|
|
25
|
+
- name: Build package
|
|
26
|
+
run: python -m build
|
|
27
|
+
|
|
28
|
+
- name: Publish to PyPI (trusted publishing)
|
|
29
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.pyc
|
|
4
|
+
*.pyo
|
|
5
|
+
*.egg-info/
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
|
|
9
|
+
# Environment
|
|
10
|
+
.env
|
|
11
|
+
.env.*
|
|
12
|
+
!.env.example
|
|
13
|
+
.venv/
|
|
14
|
+
|
|
15
|
+
# OS
|
|
16
|
+
.DS_Store
|
|
17
|
+
|
|
18
|
+
# IDE
|
|
19
|
+
.idea/
|
|
20
|
+
.vscode/
|
|
21
|
+
*.swp
|
|
22
|
+
*.swo
|
|
23
|
+
.ruff_cache/
|
|
24
|
+
.pytest_cache/
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# SDK Python — `ascendkit`
|
|
2
|
+
|
|
3
|
+
Python 3.10+ SDK. httpx (async), published to PyPI.
|
|
4
|
+
|
|
5
|
+
## Commands
|
|
6
|
+
```bash
|
|
7
|
+
pip install -e ".[dev]" # Install editable
|
|
8
|
+
pytest # Tests
|
|
9
|
+
ruff check . && ruff format . # Lint + format
|
|
10
|
+
mypy ascendkit/ # Type checking
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Rules
|
|
14
|
+
- MUST use `httpx` — not `requests`. Support both sync and async patterns.
|
|
15
|
+
- Logger: drop-in for `logging` module. Keyword args for context: `logger.info("msg", port=3000)`
|
|
16
|
+
- Batch logs (5s / 100 events) via `POST /api/logs/ingest` — NEVER send one-at-a-time
|
|
17
|
+
- Flush on interpreter shutdown via `atexit`
|
|
18
|
+
- Public API through `ascendkit/__init__.py` — internal modules prefixed `_` (e.g., `_client.py`)
|
|
19
|
+
- Typed exceptions (`AscendKitError`, `AuthenticationError`) — NEVER swallow errors
|
|
20
|
+
- All public functions MUST have type annotations
|
|
21
|
+
- Gracefully handle network failures on flush — buffer and retry, NEVER crash host app
|
|
22
|
+
- In async frameworks, use `httpx.AsyncClient` — sync client blocks the event loop
|
ascendkit-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ascendkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AscendKit Python SDK
|
|
5
|
+
Project-URL: Homepage, https://ascendkit.dev
|
|
6
|
+
Project-URL: Documentation, https://ascendkit.dev/docs
|
|
7
|
+
Author: ascendkit.dev
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: ascendkit,auth,b2b,saas,sdk,webhooks
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Requires-Dist: httpx>=0.27.0
|
|
12
|
+
Requires-Dist: pydantic>=2.0.0
|
|
13
|
+
Requires-Dist: pyjwt[crypto]>=2.10.0
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""AscendKit Python SDK."""
|
|
2
|
+
|
|
3
|
+
from ascendkit._access_token import AccessTokenVerifier
|
|
4
|
+
from ascendkit._client import AscendKit, AsyncAscendKit
|
|
5
|
+
from ascendkit._errors import AscendKitError, AuthError, NotFoundError, ValidationError
|
|
6
|
+
from ascendkit._webhooks import verify_webhook_signature
|
|
7
|
+
from ascendkit.auth import AuthClient, AsyncAuthClient
|
|
8
|
+
from ascendkit.fastapi import get_current_user_dependency, get_current_user
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"AccessTokenVerifier",
|
|
12
|
+
"AscendKit",
|
|
13
|
+
"AsyncAscendKit",
|
|
14
|
+
"AuthClient",
|
|
15
|
+
"AsyncAuthClient",
|
|
16
|
+
"AscendKitError",
|
|
17
|
+
"AuthError",
|
|
18
|
+
"NotFoundError",
|
|
19
|
+
"ValidationError",
|
|
20
|
+
"verify_webhook_signature",
|
|
21
|
+
"get_current_user_dependency",
|
|
22
|
+
"get_current_user",
|
|
23
|
+
]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Access token verification with JWKS caching."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import jwt
|
|
10
|
+
from jwt import PyJWKClient
|
|
11
|
+
|
|
12
|
+
from ascendkit._client import _DEFAULT_API_URL
|
|
13
|
+
from ascendkit._errors import AuthError
|
|
14
|
+
_JWKS_CACHE_TTL = 3600 # 1 hour, matches server Cache-Control
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AccessTokenVerifier:
|
|
18
|
+
"""Verify AscendKit RS256 access tokens using JWKS.
|
|
19
|
+
|
|
20
|
+
Caches the JWKS response in memory for 1 hour. Thread-safe.
|
|
21
|
+
|
|
22
|
+
The ``public_key`` parameter is optional — if omitted, the constructor
|
|
23
|
+
reads ``ASCENDKIT_ENV_KEY`` from the environment. Raises ``ValueError``
|
|
24
|
+
if neither is provided.
|
|
25
|
+
|
|
26
|
+
Usage (sync)::
|
|
27
|
+
|
|
28
|
+
verifier = AccessTokenVerifier()
|
|
29
|
+
claims = verifier.verify(token)
|
|
30
|
+
print(claims["sub"]) # usr_...
|
|
31
|
+
|
|
32
|
+
Usage (async)::
|
|
33
|
+
|
|
34
|
+
verifier = AccessTokenVerifier()
|
|
35
|
+
claims = await verifier.verify_async(token)
|
|
36
|
+
|
|
37
|
+
Usage with FastAPI::
|
|
38
|
+
|
|
39
|
+
from fastapi import Depends, Header
|
|
40
|
+
|
|
41
|
+
verifier = AccessTokenVerifier()
|
|
42
|
+
|
|
43
|
+
async def get_current_user(authorization: str = Header()) -> dict:
|
|
44
|
+
token = authorization.removeprefix("Bearer ")
|
|
45
|
+
return await verifier.verify_async(token)
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
public_key: str | None = None,
|
|
51
|
+
api_url: str = _DEFAULT_API_URL,
|
|
52
|
+
cache_ttl: int = _JWKS_CACHE_TTL,
|
|
53
|
+
) -> None:
|
|
54
|
+
resolved_key = public_key or os.environ.get("ASCENDKIT_ENV_KEY")
|
|
55
|
+
if not resolved_key:
|
|
56
|
+
raise ValueError(
|
|
57
|
+
"AscendKit AccessTokenVerifier: missing public key. "
|
|
58
|
+
"Pass public_key or set ASCENDKIT_ENV_KEY."
|
|
59
|
+
)
|
|
60
|
+
self._public_key = resolved_key
|
|
61
|
+
self._jwks_url = f"{api_url}/api/.well-known/jwks.json?pk={resolved_key}"
|
|
62
|
+
self._jwks_client = PyJWKClient(
|
|
63
|
+
self._jwks_url,
|
|
64
|
+
cache_jwk_set=True,
|
|
65
|
+
lifespan=cache_ttl,
|
|
66
|
+
headers={"User-Agent": "ascendkit-python/1.0"},
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def verify(self, token: str) -> dict[str, Any]:
|
|
70
|
+
"""Verify an access token synchronously. Returns decoded claims.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
AuthError: If the token is invalid, expired, or signature fails.
|
|
74
|
+
"""
|
|
75
|
+
try:
|
|
76
|
+
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
|
|
77
|
+
claims: dict[str, Any] = jwt.decode(
|
|
78
|
+
token,
|
|
79
|
+
signing_key.key,
|
|
80
|
+
algorithms=["RS256"],
|
|
81
|
+
issuer="ascendkit",
|
|
82
|
+
)
|
|
83
|
+
return claims
|
|
84
|
+
except jwt.ExpiredSignatureError:
|
|
85
|
+
raise AuthError("Access token expired", status_code=401)
|
|
86
|
+
except jwt.exceptions.PyJWKClientConnectionError:
|
|
87
|
+
raise AuthError(
|
|
88
|
+
f"Failed to fetch JWKS from {self._jwks_url} — "
|
|
89
|
+
"is the AscendKit backend reachable?",
|
|
90
|
+
status_code=503,
|
|
91
|
+
)
|
|
92
|
+
except jwt.InvalidTokenError as e:
|
|
93
|
+
raise AuthError(f"Invalid access token: {e}", status_code=401)
|
|
94
|
+
|
|
95
|
+
async def verify_async(self, token: str) -> dict[str, Any]:
|
|
96
|
+
"""Verify an access token asynchronously. Returns decoded claims.
|
|
97
|
+
|
|
98
|
+
Uses the same JWKS cache as ``verify()``. The JWKS HTTP fetch is
|
|
99
|
+
synchronous (PyJWKClient limitation) but runs in a thread pool to
|
|
100
|
+
avoid blocking the event loop. Cached results return immediately.
|
|
101
|
+
"""
|
|
102
|
+
loop = asyncio.get_running_loop()
|
|
103
|
+
return await loop.run_in_executor(None, self.verify, token)
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Server-side analytics client for AscendKit.
|
|
2
|
+
|
|
3
|
+
.. deprecated::
|
|
4
|
+
This module is not in use. Analytics has moved to the JS SDK
|
|
5
|
+
(@ascendkit/nextjs). This file is kept for reference only.
|
|
6
|
+
|
|
7
|
+
Tracks events from your backend with trusted identity (secret key auth).
|
|
8
|
+
Events are queued in-memory and flushed in batches via a background thread.
|
|
9
|
+
|
|
10
|
+
Example::
|
|
11
|
+
|
|
12
|
+
from ascendkit import Analytics
|
|
13
|
+
|
|
14
|
+
analytics = Analytics("sk_prod_abc123")
|
|
15
|
+
analytics.track("usr_456", "checkout.completed", {"orderId": "ord_789"})
|
|
16
|
+
|
|
17
|
+
# Events flush automatically every 30s or when batch size (10) is reached.
|
|
18
|
+
# On process exit, remaining events are flushed automatically via atexit.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import atexit
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
import threading
|
|
28
|
+
import time
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
from typing import Any
|
|
31
|
+
from urllib.request import Request, urlopen
|
|
32
|
+
from urllib.error import URLError
|
|
33
|
+
|
|
34
|
+
from ascendkit._client import _DEFAULT_API_URL
|
|
35
|
+
from ascendkit._version import SDK_VERSION
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger("ascendkit.analytics")
|
|
38
|
+
DEFAULT_FLUSH_INTERVAL = 30.0 # seconds
|
|
39
|
+
DEFAULT_BATCH_SIZE = 10
|
|
40
|
+
MAX_RETRY_ATTEMPTS = 3
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Analytics:
|
|
44
|
+
"""Server-side analytics client with background flushing.
|
|
45
|
+
|
|
46
|
+
The ``secret_key`` parameter is optional — if omitted, the constructor
|
|
47
|
+
reads ``ASCENDKIT_SECRET_KEY`` from the environment. Raises ``ValueError``
|
|
48
|
+
if neither is provided.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
secret_key: Your project's secret key (sk_prod_...). Falls back to ASCENDKIT_SECRET_KEY env var.
|
|
52
|
+
api_url: AscendKit API base URL. Defaults to https://api.ascendkit.dev.
|
|
53
|
+
flush_interval: Seconds between automatic flushes. Defaults to 30.
|
|
54
|
+
batch_size: Max events before auto-flush. Defaults to 10.
|
|
55
|
+
|
|
56
|
+
Example::
|
|
57
|
+
|
|
58
|
+
analytics = Analytics()
|
|
59
|
+
analytics.track("usr_456", "feature.used", {"feature": "dashboard"})
|
|
60
|
+
analytics.shutdown()
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
secret_key: str | None = None,
|
|
66
|
+
*,
|
|
67
|
+
api_url: str = _DEFAULT_API_URL,
|
|
68
|
+
flush_interval: float = DEFAULT_FLUSH_INTERVAL,
|
|
69
|
+
batch_size: int = DEFAULT_BATCH_SIZE,
|
|
70
|
+
) -> None:
|
|
71
|
+
resolved_key = secret_key or os.environ.get("ASCENDKIT_SECRET_KEY")
|
|
72
|
+
if not resolved_key:
|
|
73
|
+
raise ValueError(
|
|
74
|
+
"AscendKit Analytics: missing secret key. "
|
|
75
|
+
"Pass secret_key or set ASCENDKIT_SECRET_KEY."
|
|
76
|
+
)
|
|
77
|
+
self._secret_key = resolved_key
|
|
78
|
+
self._api_url = api_url.rstrip("/")
|
|
79
|
+
self._flush_interval = flush_interval
|
|
80
|
+
self._batch_size = batch_size
|
|
81
|
+
self._queue: list[dict[str, Any]] = []
|
|
82
|
+
self._lock = threading.Lock()
|
|
83
|
+
self._running = True
|
|
84
|
+
|
|
85
|
+
# Background flush thread
|
|
86
|
+
self._flush_thread = threading.Thread(target=self._flush_loop, daemon=True)
|
|
87
|
+
self._flush_thread.start()
|
|
88
|
+
|
|
89
|
+
# Register atexit handler for graceful shutdown
|
|
90
|
+
atexit.register(self.shutdown)
|
|
91
|
+
|
|
92
|
+
def track(
|
|
93
|
+
self,
|
|
94
|
+
user_id: str,
|
|
95
|
+
event_name: str,
|
|
96
|
+
properties: dict[str, Any] | None = None,
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Queue a server-side event for a user.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
user_id: The user ID (usr_ prefixed).
|
|
102
|
+
event_name: Event name (e.g. "checkout.completed").
|
|
103
|
+
properties: Optional event properties.
|
|
104
|
+
|
|
105
|
+
Example::
|
|
106
|
+
|
|
107
|
+
analytics.track("usr_456", "checkout.completed", {"total": 99.99})
|
|
108
|
+
"""
|
|
109
|
+
prefixed_name = event_name if event_name.startswith("app.") else f"app.{event_name}"
|
|
110
|
+
event = {
|
|
111
|
+
"eventName": prefixed_name,
|
|
112
|
+
"userId": user_id,
|
|
113
|
+
"properties": properties,
|
|
114
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
115
|
+
"context": {
|
|
116
|
+
"sdk": "python",
|
|
117
|
+
"sdkVersion": SDK_VERSION,
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
batch: list[dict[str, Any]] | None = None
|
|
122
|
+
with self._lock:
|
|
123
|
+
self._queue.append(event)
|
|
124
|
+
if len(self._queue) >= self._batch_size:
|
|
125
|
+
batch = self._queue[:]
|
|
126
|
+
self._queue.clear()
|
|
127
|
+
|
|
128
|
+
# Flush outside the lock if batch size reached
|
|
129
|
+
if batch is not None:
|
|
130
|
+
self._send_batch(batch, attempt=0)
|
|
131
|
+
|
|
132
|
+
def flush(self) -> None:
|
|
133
|
+
"""Manually flush the event queue."""
|
|
134
|
+
with self._lock:
|
|
135
|
+
if not self._queue:
|
|
136
|
+
return
|
|
137
|
+
batch = self._queue[:]
|
|
138
|
+
self._queue.clear()
|
|
139
|
+
|
|
140
|
+
self._send_batch(batch, attempt=0)
|
|
141
|
+
|
|
142
|
+
def shutdown(self) -> None:
|
|
143
|
+
"""Gracefully shut down: stop the flush thread and flush remaining events."""
|
|
144
|
+
self._running = False
|
|
145
|
+
self.flush()
|
|
146
|
+
|
|
147
|
+
# -----------------------------------------------------------------------
|
|
148
|
+
# Internal
|
|
149
|
+
# -----------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
def _flush_loop(self) -> None:
|
|
152
|
+
"""Background thread that periodically flushes the queue."""
|
|
153
|
+
while self._running:
|
|
154
|
+
time.sleep(self._flush_interval)
|
|
155
|
+
try:
|
|
156
|
+
self.flush()
|
|
157
|
+
except Exception:
|
|
158
|
+
logger.debug("Flush failed in background thread", exc_info=True)
|
|
159
|
+
|
|
160
|
+
def _send_batch(self, batch: list[dict[str, Any]], attempt: int) -> None:
|
|
161
|
+
"""Send a batch of events to the API with retry."""
|
|
162
|
+
if not batch:
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
url = f"{self._api_url}/api/v1/events"
|
|
166
|
+
# Convert internal format to backend EventBatchRequest schema
|
|
167
|
+
api_batch = [
|
|
168
|
+
{"event": e["eventName"], "userId": e["userId"], "properties": e.get("properties"), "timestamp": e.get("timestamp")}
|
|
169
|
+
for e in batch
|
|
170
|
+
]
|
|
171
|
+
payload = json.dumps({"batch": api_batch}).encode("utf-8")
|
|
172
|
+
|
|
173
|
+
req = Request(
|
|
174
|
+
url,
|
|
175
|
+
data=payload,
|
|
176
|
+
headers={
|
|
177
|
+
"Content-Type": "application/json",
|
|
178
|
+
"X-AscendKit-Secret-Key": self._secret_key,
|
|
179
|
+
"X-AscendKit-Client-Version": f"python/{SDK_VERSION}",
|
|
180
|
+
},
|
|
181
|
+
method="POST",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
with urlopen(req, timeout=10) as response:
|
|
186
|
+
if response.status >= 400:
|
|
187
|
+
raise URLError(f"HTTP {response.status}")
|
|
188
|
+
except Exception:
|
|
189
|
+
if attempt < MAX_RETRY_ATTEMPTS:
|
|
190
|
+
delay = (2 ** attempt) # 1s, 2s, 4s
|
|
191
|
+
time.sleep(delay)
|
|
192
|
+
self._send_batch(batch, attempt + 1)
|
|
193
|
+
else:
|
|
194
|
+
logger.warning(
|
|
195
|
+
"Failed to send %d events after %d retries",
|
|
196
|
+
len(batch),
|
|
197
|
+
MAX_RETRY_ATTEMPTS,
|
|
198
|
+
)
|
|
199
|
+
# Put events back in the queue
|
|
200
|
+
with self._lock:
|
|
201
|
+
self._queue = batch + self._queue
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""AscendKit API client (sync and async)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from ascendkit._errors import AscendKitError, AuthError, NotFoundError, ValidationError
|
|
12
|
+
from ascendkit._version import SDK_VERSION
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("ascendkit")
|
|
15
|
+
|
|
16
|
+
_DEFAULT_API_URL = "https://api.ascendkit.dev"
|
|
17
|
+
_VERSION_HEADER = {"X-AscendKit-Client-Version": f"python/{SDK_VERSION}"}
|
|
18
|
+
|
|
19
|
+
_upgrade_warned = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _check_upgrade(response: httpx.Response) -> None:
|
|
23
|
+
global _upgrade_warned
|
|
24
|
+
if _upgrade_warned:
|
|
25
|
+
return
|
|
26
|
+
upgrade = response.headers.get("X-AscendKit-Upgrade")
|
|
27
|
+
if upgrade == "recommended":
|
|
28
|
+
latest = response.headers.get("X-AscendKit-Latest-Version", "latest")
|
|
29
|
+
logger.warning(
|
|
30
|
+
"A newer SDK version (v%s) is available. "
|
|
31
|
+
"You are running v%s. Run 'pip install --upgrade ascendkit' to upgrade.",
|
|
32
|
+
latest,
|
|
33
|
+
SDK_VERSION,
|
|
34
|
+
)
|
|
35
|
+
_upgrade_warned = True
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _handle_error(response: httpx.Response) -> None:
|
|
39
|
+
if response.status_code < 400:
|
|
40
|
+
return
|
|
41
|
+
try:
|
|
42
|
+
body = response.json()
|
|
43
|
+
detail = body.get("error") or body.get("detail") or str(body)
|
|
44
|
+
except Exception:
|
|
45
|
+
detail = response.text
|
|
46
|
+
|
|
47
|
+
if response.status_code == 401:
|
|
48
|
+
raise AuthError(detail, status_code=401)
|
|
49
|
+
if response.status_code == 404:
|
|
50
|
+
raise NotFoundError(detail, status_code=404)
|
|
51
|
+
if response.status_code == 422:
|
|
52
|
+
raise ValidationError(detail, status_code=422)
|
|
53
|
+
raise AscendKitError(detail, status_code=response.status_code)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AscendKit:
|
|
57
|
+
"""Synchronous AscendKit client.
|
|
58
|
+
|
|
59
|
+
The ``public_key`` parameter is optional — if omitted, the constructor
|
|
60
|
+
reads ``ASCENDKIT_ENV_KEY`` from the environment.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, public_key: str | None = None, api_url: str = _DEFAULT_API_URL) -> None:
|
|
64
|
+
resolved_key = public_key or os.environ.get("ASCENDKIT_ENV_KEY")
|
|
65
|
+
if not resolved_key:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
"AscendKit: missing public key. Pass public_key or set ASCENDKIT_ENV_KEY."
|
|
68
|
+
)
|
|
69
|
+
self.public_key = resolved_key
|
|
70
|
+
self._client = httpx.Client(
|
|
71
|
+
base_url=api_url,
|
|
72
|
+
headers={
|
|
73
|
+
"X-AscendKit-Public-Key": resolved_key,
|
|
74
|
+
**_VERSION_HEADER,
|
|
75
|
+
},
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
79
|
+
response = self._client.request(method, path, **kwargs)
|
|
80
|
+
_check_upgrade(response)
|
|
81
|
+
_handle_error(response)
|
|
82
|
+
body = response.json()
|
|
83
|
+
return body.get("data", body)
|
|
84
|
+
|
|
85
|
+
def close(self) -> None:
|
|
86
|
+
self._client.close()
|
|
87
|
+
|
|
88
|
+
def __enter__(self) -> AscendKit:
|
|
89
|
+
return self
|
|
90
|
+
|
|
91
|
+
def __exit__(self, *args: Any) -> None:
|
|
92
|
+
self.close()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class AsyncAscendKit:
|
|
96
|
+
"""Asynchronous AscendKit client.
|
|
97
|
+
|
|
98
|
+
The ``public_key`` parameter is optional — if omitted, the constructor
|
|
99
|
+
reads ``ASCENDKIT_ENV_KEY`` from the environment.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(self, public_key: str | None = None, api_url: str = _DEFAULT_API_URL) -> None:
|
|
103
|
+
resolved_key = public_key or os.environ.get("ASCENDKIT_ENV_KEY")
|
|
104
|
+
if not resolved_key:
|
|
105
|
+
raise ValueError(
|
|
106
|
+
"AscendKit: missing public key. Pass public_key or set ASCENDKIT_ENV_KEY."
|
|
107
|
+
)
|
|
108
|
+
self.public_key = resolved_key
|
|
109
|
+
self._client = httpx.AsyncClient(
|
|
110
|
+
base_url=api_url,
|
|
111
|
+
headers={
|
|
112
|
+
"X-AscendKit-Public-Key": resolved_key,
|
|
113
|
+
**_VERSION_HEADER,
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
async def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
118
|
+
response = await self._client.request(method, path, **kwargs)
|
|
119
|
+
_check_upgrade(response)
|
|
120
|
+
_handle_error(response)
|
|
121
|
+
body = response.json()
|
|
122
|
+
return body.get("data", body)
|
|
123
|
+
|
|
124
|
+
async def close(self) -> None:
|
|
125
|
+
await self._client.aclose()
|
|
126
|
+
|
|
127
|
+
async def __aenter__(self) -> AsyncAscendKit:
|
|
128
|
+
return self
|
|
129
|
+
|
|
130
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
131
|
+
await self.close()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""AscendKit SDK exceptions."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AscendKitError(Exception):
|
|
5
|
+
"""Base exception for AscendKit SDK."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, message: str, status_code: int | None = None) -> None:
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.status_code = status_code
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuthError(AscendKitError):
|
|
13
|
+
"""Authentication or authorization error."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NotFoundError(AscendKitError):
|
|
17
|
+
"""Resource not found."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ValidationError(AscendKitError):
|
|
21
|
+
"""Request validation error."""
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Webhook signature verification for AscendKit.
|
|
2
|
+
|
|
3
|
+
AscendKit signs every webhook request with an HMAC-SHA256 signature. The
|
|
4
|
+
signature header contains a timestamp and one or more versioned signatures
|
|
5
|
+
in the format: ``t=<unix_seconds>,v1=<hex_hmac>``.
|
|
6
|
+
|
|
7
|
+
The signed content is ``<timestamp>.<raw_body>``, ensuring both freshness
|
|
8
|
+
and integrity.
|
|
9
|
+
|
|
10
|
+
Usage with FastAPI::
|
|
11
|
+
|
|
12
|
+
from fastapi import Request, Response
|
|
13
|
+
from ascendkit import verify_webhook_signature
|
|
14
|
+
|
|
15
|
+
WEBHOOK_SECRET = "whsec_..."
|
|
16
|
+
|
|
17
|
+
@app.post("/webhooks/ascendkit")
|
|
18
|
+
async def handle_webhook(request: Request) -> Response:
|
|
19
|
+
body = await request.body()
|
|
20
|
+
signature = request.headers.get("x-ascendkit-signature", "")
|
|
21
|
+
|
|
22
|
+
if not verify_webhook_signature(
|
|
23
|
+
secret=WEBHOOK_SECRET,
|
|
24
|
+
signature_header=signature,
|
|
25
|
+
payload=body.decode(),
|
|
26
|
+
):
|
|
27
|
+
return Response(status_code=401, content="Invalid signature")
|
|
28
|
+
|
|
29
|
+
event = await request.json()
|
|
30
|
+
# Handle the event...
|
|
31
|
+
return Response(status_code=200, content="OK")
|
|
32
|
+
|
|
33
|
+
Usage with Flask::
|
|
34
|
+
|
|
35
|
+
from flask import Flask, request
|
|
36
|
+
from ascendkit import verify_webhook_signature
|
|
37
|
+
|
|
38
|
+
WEBHOOK_SECRET = "whsec_..."
|
|
39
|
+
|
|
40
|
+
@app.route("/webhooks/ascendkit", methods=["POST"])
|
|
41
|
+
def handle_webhook():
|
|
42
|
+
body = request.get_data(as_text=True)
|
|
43
|
+
signature = request.headers.get("x-ascendkit-signature", "")
|
|
44
|
+
|
|
45
|
+
if not verify_webhook_signature(
|
|
46
|
+
secret=WEBHOOK_SECRET,
|
|
47
|
+
signature_header=signature,
|
|
48
|
+
payload=body,
|
|
49
|
+
):
|
|
50
|
+
return "Invalid signature", 401
|
|
51
|
+
|
|
52
|
+
event = request.get_json()
|
|
53
|
+
# Handle the event...
|
|
54
|
+
return "OK", 200
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
from __future__ import annotations
|
|
58
|
+
|
|
59
|
+
import hashlib
|
|
60
|
+
import hmac
|
|
61
|
+
import time
|
|
62
|
+
|
|
63
|
+
_DEFAULT_TOLERANCE_SECONDS = 300
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def verify_webhook_signature(
|
|
67
|
+
secret: str,
|
|
68
|
+
signature_header: str,
|
|
69
|
+
payload: str,
|
|
70
|
+
tolerance: int = _DEFAULT_TOLERANCE_SECONDS,
|
|
71
|
+
) -> bool:
|
|
72
|
+
"""Verify an AscendKit webhook signature.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
secret: Your webhook signing secret.
|
|
76
|
+
signature_header: The value of the ``X-AscendKit-Signature`` header.
|
|
77
|
+
payload: The raw request body as a string.
|
|
78
|
+
tolerance: Maximum allowed age of the timestamp in seconds (default 300).
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
``True`` if the signature is valid and the timestamp is within tolerance.
|
|
82
|
+
"""
|
|
83
|
+
# Parse header: t=<timestamp>,v1=<hmac>
|
|
84
|
+
parts = signature_header.split(",")
|
|
85
|
+
|
|
86
|
+
timestamp: str | None = None
|
|
87
|
+
signature_hex: str | None = None
|
|
88
|
+
|
|
89
|
+
for part in parts:
|
|
90
|
+
key, _, value = part.partition("=")
|
|
91
|
+
if key == "t":
|
|
92
|
+
timestamp = value
|
|
93
|
+
elif key == "v1":
|
|
94
|
+
signature_hex = value
|
|
95
|
+
|
|
96
|
+
if not timestamp or not signature_hex:
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
# Validate timestamp freshness
|
|
100
|
+
try:
|
|
101
|
+
timestamp_seconds = int(timestamp)
|
|
102
|
+
except ValueError:
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
now = int(time.time())
|
|
106
|
+
if abs(now - timestamp_seconds) > tolerance:
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
# Compute expected signature: HMAC-SHA256 of "<timestamp>.<payload>"
|
|
110
|
+
signed_content = f"{timestamp}.{payload}"
|
|
111
|
+
expected_hmac = hmac.new(
|
|
112
|
+
secret.encode(),
|
|
113
|
+
signed_content.encode(),
|
|
114
|
+
hashlib.sha256,
|
|
115
|
+
).hexdigest()
|
|
116
|
+
|
|
117
|
+
# Constant-time comparison to prevent timing attacks
|
|
118
|
+
return hmac.compare_digest(expected_hmac, signature_hex)
|