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.
@@ -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
@@ -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,3 @@
1
+ """Single source of truth for the SDK version."""
2
+
3
+ SDK_VERSION = "0.1.0"
@@ -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)