devora-python 0.1.0__py3-none-any.whl

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.
devora_sdk/handler.py ADDED
@@ -0,0 +1,298 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import logging
5
+ from dataclasses import dataclass
6
+ from typing import Any, Callable, Mapping, Optional, Union
7
+
8
+ from .constants import DEFAULT_MAX_BODY_SIZE_BYTES, DEVORA_ENDPOINTS
9
+ from .models import DevoraImpersonationContext, DevoraRequest, SDKRoute
10
+ from .security import validate_timestamp_tolerance
11
+ from .signing import get_single_header, parse_verified_json_body, parse_verified_query
12
+ from .utils import (
13
+ create_error_response,
14
+ create_success_response,
15
+ match_path,
16
+ )
17
+
18
+ logger = logging.getLogger("devora_sdk")
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class AdapterRequest:
23
+ """A request exactly as received. The signature covers these bytes.
24
+
25
+ ``path`` is relative to the SDK mount and still percent-encoded; ``query`` is
26
+ everything after the first ``?``; ``body`` is the raw bytes; ``headers`` is a
27
+ mapping or a list of ``(name, value)`` pairs (so duplicates are visible).
28
+ """
29
+
30
+ method: str
31
+ path: str
32
+ query: str
33
+ body: bytes
34
+ headers: Any
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class ProcessRequestOptions:
39
+ timestamp_tolerance: Optional[int] = None
40
+ max_body_size: int = DEFAULT_MAX_BODY_SIZE_BYTES
41
+
42
+ def __post_init__(self) -> None:
43
+ validate_timestamp_tolerance(self.timestamp_tolerance)
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class _PreparedRequest:
48
+ route: SDKRoute
49
+ devora_request: DevoraRequest
50
+ record: Callable[[str, Optional[str]], None]
51
+
52
+
53
+ def process_request(
54
+ sdk: Any,
55
+ routes: list[SDKRoute],
56
+ request: AdapterRequest,
57
+ options: Optional[ProcessRequestOptions] = None,
58
+ ) -> dict[str, Any]:
59
+ prepared = _prepare_request(sdk, routes, request, options)
60
+ if isinstance(prepared, dict):
61
+ return prepared
62
+
63
+ try:
64
+ result = prepared.route.handler(prepared.devora_request)
65
+ if inspect.isawaitable(result):
66
+ close = getattr(result, "close", None)
67
+ if callable(close):
68
+ close()
69
+ raise TypeError("Async Devora handlers require async_process_request")
70
+ prepared.record("success", prepared.route.path)
71
+ return create_success_response(result)
72
+ except Exception:
73
+ logger.exception("Devora SDK: customer handler raised")
74
+ prepared.record("failure", prepared.route.path)
75
+ return create_error_response("Customer handler failed", "HANDLER_ERROR")
76
+
77
+
78
+ async def async_process_request(
79
+ sdk: Any,
80
+ routes: list[SDKRoute],
81
+ request: AdapterRequest,
82
+ options: Optional[ProcessRequestOptions] = None,
83
+ ) -> dict[str, Any]:
84
+ import asyncio
85
+
86
+ loop = asyncio.get_running_loop()
87
+ # _prepare_request verifies the HMAC signature and consumes the replay
88
+ # nonce; in production the replay store is disk- or network-backed, so
89
+ # this can block. Run it off the event loop rather than stalling every
90
+ # other request this async server is handling. Using the stdlib executor
91
+ # (rather than anyio/Starlette's threadpool) keeps this module usable from
92
+ # any asyncio-based framework, not just FastAPI.
93
+ prepared = await loop.run_in_executor(None, _prepare_request, sdk, routes, request, options)
94
+ if isinstance(prepared, dict):
95
+ return prepared
96
+
97
+ try:
98
+ if inspect.iscoroutinefunction(prepared.route.handler):
99
+ result = await prepared.route.handler(prepared.devora_request)
100
+ else:
101
+ # A synchronous customer handler may itself block (a DB call, an
102
+ # HTTP request, ...) — never call it directly on the event loop.
103
+ # This also lets a sync Django ORM call run safely from Django's
104
+ # async view path, which otherwise raises SynchronousOnlyOperation.
105
+ result = await loop.run_in_executor(None, prepared.route.handler, prepared.devora_request)
106
+ if inspect.isawaitable(result):
107
+ result = await result
108
+ prepared.record("success", prepared.route.path)
109
+ return create_success_response(result)
110
+ except Exception:
111
+ logger.exception("Devora SDK: customer handler raised")
112
+ prepared.record("failure", prepared.route.path)
113
+ return create_error_response("Customer handler failed", "HANDLER_ERROR")
114
+
115
+
116
+ def create_generic_handler(
117
+ sdk: Any,
118
+ routes: list[SDKRoute],
119
+ options: Optional[ProcessRequestOptions] = None,
120
+ ) -> Callable[[AdapterRequest], dict[str, Any]]:
121
+ def handler(request: AdapterRequest) -> dict[str, Any]:
122
+ return process_request(sdk, routes, request, options)
123
+
124
+ return handler
125
+
126
+
127
+ def _prepare_request(
128
+ sdk: Any,
129
+ routes: list[SDKRoute],
130
+ request: AdapterRequest,
131
+ options: Optional[ProcessRequestOptions] = None,
132
+ ) -> Union[dict[str, Any], _PreparedRequest]:
133
+ options = options or ProcessRequestOptions()
134
+ method = request.method
135
+ path = request.path
136
+
137
+ def record(outcome: str, endpoint: Optional[str] = None) -> None:
138
+ if hasattr(sdk, "record_request"):
139
+ sdk.record_request(endpoint or "UNMATCHED", outcome)
140
+
141
+ if not isinstance(request.body, (bytes, bytearray)):
142
+ record("failure")
143
+ return create_error_response("Adapter must supply the raw request body bytes", "INVALID_BODY")
144
+ body = bytes(request.body)
145
+ if len(body) > options.max_body_size:
146
+ record("failure")
147
+ return create_error_response("Request body too large", "BODY_TOO_LARGE")
148
+ if method in ("GET", "HEAD") and body:
149
+ record("failure")
150
+ return create_error_response("GET and HEAD requests must not have a body", "INVALID_BODY")
151
+
152
+ # Signature v3 over the exact wire bytes, before any route lookup, so
153
+ # unauthenticated callers learn nothing about registered routes.
154
+ validation = sdk.verify_request(method, path, request.query, body, request.headers, options.timestamp_tolerance)
155
+ if not validation.valid:
156
+ record("security_error")
157
+ return create_error_response(
158
+ validation.error or "Security validation failed",
159
+ validation.error_code or "INVALID_SIGNATURE",
160
+ )
161
+
162
+ route = _find_matching_route(routes, method, path)
163
+ if not route:
164
+ record("failure")
165
+ return create_error_response("No handler found for this request", "NOT_FOUND")
166
+ _, params = match_path(route.path, path)
167
+
168
+ body_ok, parsed_body = parse_verified_json_body(body, get_single_header(request.headers, "content-type") or None)
169
+ if not body_ok:
170
+ record("failure", route.path)
171
+ return create_error_response(parsed_body, "INVALID_BODY")
172
+ try:
173
+ parsed_query = parse_verified_query(request.query)
174
+ except (UnicodeDecodeError, ValueError):
175
+ record("failure", route.path)
176
+ return create_error_response("Invalid query string", "INVALID_REQUEST_TARGET")
177
+
178
+ context_result = _build_devora_context(route, path, parsed_body, params)
179
+ if context_result.get("error"):
180
+ record("security_error", route.path)
181
+ return create_error_response(context_result["error"], "INVALID_IMPERSONATION_CONTEXT")
182
+
183
+ context = context_result.get("context")
184
+ session_id = context.session_id if context else _session_id_from_route(route, params, parsed_body)
185
+
186
+ devora_request = DevoraRequest(
187
+ method=method,
188
+ path=path,
189
+ params=params,
190
+ query=parsed_query,
191
+ body=parsed_body,
192
+ headers=request.headers,
193
+ org_id=validation.org_id or "",
194
+ key_id=validation.key_id or "",
195
+ session_id=session_id,
196
+ devora_context=context,
197
+ )
198
+
199
+ return _PreparedRequest(route=route, devora_request=devora_request, record=record)
200
+
201
+
202
+ def _find_matching_route(routes: list[SDKRoute], method: str, path: str) -> Optional[SDKRoute]:
203
+ for route in routes:
204
+ matched, _ = match_path(route.path, path)
205
+ if route.method.upper() == method and matched:
206
+ return route
207
+ return None
208
+
209
+
210
+ def _build_devora_context(
211
+ route: SDKRoute, path: str, body: Any, params: Mapping[str, str]
212
+ ) -> dict[str, Any]:
213
+ if route.path != DEVORA_ENDPOINTS.IMPERSONATE:
214
+ return {}
215
+ if not isinstance(body, dict):
216
+ return {"error": "Impersonation request body must be an object"}
217
+ session_id = _read_string(body.get("sessionId"))
218
+ scope = body.get("scope")
219
+ expires_at = _read_number(body.get("expiresAt"))
220
+ if not session_id or len(session_id) > 128:
221
+ return {"error": "Impersonation request is missing a valid session ID"}
222
+ if scope not in ("read", "write"):
223
+ return {"error": "Impersonation request is missing a valid scope"}
224
+ from .utils import now_ms
225
+
226
+ if not expires_at or expires_at <= now_ms():
227
+ return {"error": "Impersonation request is expired or missing expiration"}
228
+ target_user = _read_user(body.get("targetUser"), params.get("id"))
229
+ if not target_user:
230
+ _, matched_params = match_path(route.path, path)
231
+ target_user = _read_user(body.get("targetUser"), matched_params.get("id"))
232
+ if not target_user:
233
+ return {"error": "Impersonation request is missing target user context"}
234
+ impersonator = _read_user(body.get("impersonator"))
235
+ if not impersonator:
236
+ return {"error": "Impersonation request is missing impersonator context"}
237
+ auth_method = body.get("authMethod")
238
+ if auth_method != "devora_impersonation":
239
+ return {"error": "Impersonation request has an invalid authentication method"}
240
+ authorization_source = body.get("authorizationSource")
241
+ if authorization_source not in (
242
+ "standard",
243
+ "self_approved",
244
+ "self_approved_read",
245
+ "break_glass",
246
+ ):
247
+ return {"error": "Impersonation request has an invalid authorization source"}
248
+ recording_allowed = body.get("recordingAllowed")
249
+ if not isinstance(recording_allowed, bool):
250
+ return {"error": "Impersonation request is missing recording authorization"}
251
+ return {
252
+ "context": DevoraImpersonationContext(
253
+ session_id=session_id,
254
+ scope=str(scope),
255
+ expires_at=int(expires_at),
256
+ impersonator=impersonator,
257
+ target_user=target_user,
258
+ auth_method=auth_method,
259
+ authorization_source=authorization_source,
260
+ recording_allowed=recording_allowed,
261
+ )
262
+ }
263
+
264
+
265
+ def _session_id_from_route(route: SDKRoute, params: Mapping[str, str], body: Any) -> Optional[str]:
266
+ if route.path == DEVORA_ENDPOINTS.TERMINATE and params.get("id"):
267
+ return params["id"]
268
+ if isinstance(body, dict):
269
+ return _read_string(body.get("sessionId"))
270
+ return None
271
+
272
+
273
+ def _read_string(value: Any) -> Optional[str]:
274
+ return value if isinstance(value, str) and value.strip() else None
275
+
276
+
277
+ def _read_number(value: Any) -> Optional[int]:
278
+ if isinstance(value, (int, float)):
279
+ return int(value)
280
+ if isinstance(value, str) and value.strip():
281
+ try:
282
+ return int(float(value))
283
+ except ValueError:
284
+ return None
285
+ return None
286
+
287
+
288
+ def _read_user(value: Any, fallback_id: Optional[str] = None) -> Optional[dict[str, Any]]:
289
+ source = value if isinstance(value, dict) else {}
290
+ user_id = _read_string(source.get("id")) or fallback_id
291
+ if not user_id:
292
+ return None
293
+ result = {"id": user_id}
294
+ if _read_string(source.get("email")):
295
+ result["email"] = source["email"]
296
+ if _read_string(source.get("name")):
297
+ result["name"] = source["name"]
298
+ return result
devora_sdk/hmac.py ADDED
@@ -0,0 +1,67 @@
1
+ """Request signing v3 primitives (hashlib/hmac)."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import hmac as _hmac
6
+ import time
7
+ import uuid
8
+ from typing import Optional
9
+
10
+ from .constants import SECURITY_HEADERS
11
+ from .signing import VERSION, build_canonical_string, matches
12
+
13
+
14
+ def sha256_hex(body: bytes) -> str:
15
+ return hashlib.sha256(body).hexdigest()
16
+
17
+
18
+ def hmac_hex(secret_key: str, canonical: str) -> str:
19
+ """Lowercase hex HMAC-SHA256 keyed by the secret's ASCII bytes."""
20
+ return _hmac.new(secret_key.encode("ascii"), canonical.encode("ascii"), hashlib.sha256).hexdigest()
21
+
22
+
23
+ def signature_matches(secret_key: str, canonical: str, provided: object) -> bool:
24
+ """Constant-time byte comparison. Never raises; lenient hex is rejected."""
25
+ if not matches("signature", provided):
26
+ return False
27
+ return _hmac.compare_digest(bytes.fromhex(hmac_hex(secret_key, canonical)), bytes.fromhex(provided)) # type: ignore[arg-type]
28
+
29
+
30
+ def sign_request(
31
+ *,
32
+ secret_key: str,
33
+ direction: str,
34
+ key_id: str,
35
+ org_id: str,
36
+ method: str,
37
+ path: str,
38
+ query: str = "",
39
+ body: bytes = b"",
40
+ sent_at: Optional[int] = None,
41
+ request_id: Optional[str] = None,
42
+ ) -> dict[str, str]:
43
+ """Headers for an outgoing signed request (the body is sent as given)."""
44
+ sent = str(int(time.time()) if sent_at is None else sent_at)
45
+ rid = request_id or str(uuid.uuid4())
46
+ canonical = build_canonical_string(
47
+ direction=direction,
48
+ key_id=key_id,
49
+ org_id=org_id,
50
+ sent_at=sent,
51
+ request_id=rid,
52
+ method=method,
53
+ path=path,
54
+ query=query,
55
+ body_sha256=sha256_hex(body),
56
+ )
57
+ headers = {
58
+ SECURITY_HEADERS.SIGNATURE_VERSION: VERSION,
59
+ SECURITY_HEADERS.KEY_ID: key_id,
60
+ SECURITY_HEADERS.ORG_ID: org_id,
61
+ SECURITY_HEADERS.SENT_AT: sent,
62
+ SECURITY_HEADERS.REQUEST_ID: rid,
63
+ SECURITY_HEADERS.SIGNATURE: hmac_hex(secret_key, canonical),
64
+ }
65
+ if body:
66
+ headers["Content-Type"] = "application/json"
67
+ return headers
devora_sdk/models.py ADDED
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Callable, Mapping, MutableMapping, Optional
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class DevoraImpersonationContext:
9
+ session_id: str
10
+ scope: str
11
+ expires_at: int
12
+ impersonator: dict[str, Any]
13
+ target_user: dict[str, Any]
14
+ auth_method: str
15
+ authorization_source: str
16
+ recording_allowed: bool
17
+
18
+ @property
19
+ def actor(self) -> dict[str, Any]:
20
+ return self.impersonator
21
+
22
+ @property
23
+ def subject(self) -> dict[str, Any]:
24
+ return self.target_user
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class DevoraRequest:
29
+ method: str
30
+ path: str
31
+ params: Mapping[str, str]
32
+ query: Mapping[str, Any]
33
+ body: Any
34
+ headers: Mapping[str, Any]
35
+ org_id: str
36
+ key_id: str
37
+ session_id: Optional[str] = None
38
+ devora_context: Optional[DevoraImpersonationContext] = None
39
+
40
+
41
+ DevoraResponse = dict[str, Any]
42
+ RouteHandler = Callable[[DevoraRequest], Any]
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class SDKRoute:
47
+ path: str
48
+ method: str
49
+ handler: RouteHandler
50
+ is_built_in: bool = False
51
+
52
+
53
+ @dataclass
54
+ class SDKStats:
55
+ total_requests: int = 0
56
+ successful_requests: int = 0
57
+ failed_requests: int = 0
58
+ security_errors: int = 0
59
+ requests_by_endpoint: MutableMapping[str, int] = field(default_factory=dict)
60
+
61
+ def to_dict(self) -> dict[str, Any]:
62
+ return {
63
+ "totalRequests": self.total_requests,
64
+ "successfulRequests": self.successful_requests,
65
+ "failedRequests": self.failed_requests,
66
+ "securityErrors": self.security_errors,
67
+ "requestsByEndpoint": dict(self.requests_by_endpoint),
68
+ }
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class ValidationResult:
73
+ valid: bool
74
+ error: Optional[str] = None
75
+ error_code: Optional[str] = None
76
+ org_id: Optional[str] = None
77
+ key_id: Optional[str] = None
78
+ timestamp: Optional[int] = None
79
+ request_id: Optional[str] = None
devora_sdk/policy.py ADDED
@@ -0,0 +1,208 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import math
5
+ import threading
6
+ import time
7
+ from dataclasses import dataclass
8
+ from typing import Any, Callable, Optional
9
+
10
+ from .constants import DEFAULT_API_URL
11
+ from .transport import ControlPlaneError, control_plane_request
12
+
13
+ # How long a cached policy may be served past its window during an outage.
14
+ STALE_GRACE_MS = 5 * 60 * 1000
15
+ # Largest accepted policy, and the longest server-sent cache horizon trusted.
16
+ MAX_POLICY_ENTRIES = 1000
17
+ MAX_PATTERN_LENGTH = 500
18
+ MAX_POLICY_TTL_MS = 60 * 60 * 1000
19
+ _METHOD = re.compile(r"\*|[A-Z]{3,7}")
20
+
21
+
22
+ def _read_endpoints(value: Any) -> Optional[list[dict[str, str]]]:
23
+ if not isinstance(value, list) or len(value) > MAX_POLICY_ENTRIES:
24
+ return None
25
+ endpoints = []
26
+ for entry in value:
27
+ method = entry.get("method") if isinstance(entry, dict) else None
28
+ pattern = entry.get("pattern") if isinstance(entry, dict) else None
29
+ if (
30
+ not isinstance(method, str)
31
+ or not _METHOD.fullmatch(method)
32
+ or not isinstance(pattern, str)
33
+ or not pattern
34
+ or len(pattern) > MAX_PATTERN_LENGTH
35
+ ):
36
+ return None
37
+ endpoints.append({"method": method, "pattern": pattern})
38
+ return endpoints
39
+
40
+
41
+ def _read_policy(data: Any, now: int) -> Optional["ScopeConfig"]:
42
+ """Validate a policy response; ``None`` when any part is malformed (fail closed)."""
43
+ if not isinstance(data, dict):
44
+ return None
45
+ safe = _read_endpoints(data.get("safeReadEndpoints"))
46
+ blocked = _read_endpoints(data.get("blockedEndpoints"))
47
+ version = _safe_integer(data.get("version"), 0)
48
+ requested = _safe_integer(data.get("cachedUntil"), 1)
49
+ # Mirror JavaScript safe integers, including rejection of booleans. A
50
+ # missing deny list/version/cache horizon cannot mean an empty valid policy.
51
+ if safe is None or blocked is None or version is None or requested is None:
52
+ return None
53
+ return ScopeConfig(
54
+ safe_read_endpoints=safe,
55
+ blocked_endpoints=blocked,
56
+ version=version,
57
+ cached_until=int(min(max(requested, now), now + MAX_POLICY_TTL_MS)),
58
+ )
59
+
60
+
61
+ def _safe_integer(value: Any, minimum: int) -> Optional[int]:
62
+ if isinstance(value, bool):
63
+ return None
64
+ if isinstance(value, float):
65
+ if not math.isfinite(value) or not value.is_integer():
66
+ return None
67
+ value = int(value)
68
+ return value if isinstance(value, int) and minimum <= value <= 2**53 - 1 else None
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class ScopeConfig:
73
+ safe_read_endpoints: list[dict[str, str]]
74
+ blocked_endpoints: list[dict[str, str]]
75
+ version: int
76
+ cached_until: int
77
+
78
+ def to_dict(self) -> dict[str, Any]:
79
+ return {
80
+ "safeReadEndpoints": self.safe_read_endpoints,
81
+ "blockedEndpoints": self.blocked_endpoints,
82
+ "version": self.version,
83
+ "cachedUntil": self.cached_until,
84
+ }
85
+
86
+
87
+ class ScopeConfigFetcher:
88
+ def __init__(
89
+ self,
90
+ api_key: str,
91
+ cache_ttl_ms: int = 5 * 60 * 1000,
92
+ api_url: Optional[str] = None,
93
+ prefetch: bool = False,
94
+ sign_request: Optional[Callable[[], dict[str, str]]] = None,
95
+ ) -> None:
96
+ """``sign_request`` returns fresh v3 signature headers for
97
+ ``GET /api/sdk/scope-config`` (the policy is not public);
98
+ ``DevoraBackendSDK`` supplies it from the server secret."""
99
+ if isinstance(cache_ttl_ms, bool) or not isinstance(cache_ttl_ms, int) or not 0 < cache_ttl_ms <= MAX_POLICY_TTL_MS:
100
+ raise ValueError("cache_ttl_ms must be an integer between 1 and 3600000")
101
+ self.api_key = api_key
102
+ self._sign_request = sign_request
103
+ self.api_url = api_url or DEFAULT_API_URL
104
+ self.cache_ttl_ms = cache_ttl_ms
105
+ self.cached_config: Optional[ScopeConfig] = None
106
+ self.etag: Optional[str] = None
107
+ self.stale_until = 0
108
+ self._timer: Optional[threading.Timer] = None
109
+ self._lock = threading.Lock()
110
+ if prefetch:
111
+ # Warm the cache in the background at construction time instead of
112
+ # leaving the first request(s) to pay for it. Without this, every
113
+ # worker process that just started serves its first request (and any
114
+ # request racing it) a synchronous fetch or a 503
115
+ # IMPERSONATION_POLICY_UNAVAILABLE, since get_config() only fetches
116
+ # lazily and a concurrent caller during that first fetch gets None
117
+ # rather than waiting (see refresh()'s single-flight comment).
118
+ # Fire-and-forget: refresh() already handles its own errors.
119
+ threading.Thread(target=self.refresh, daemon=True).start()
120
+
121
+ def get_config(self) -> Optional[ScopeConfig]:
122
+ if self.cached_config and _now_ms() < self.cached_config.cached_until:
123
+ return self.cached_config
124
+ config = self.refresh()
125
+ if config and not self._timer:
126
+ self._schedule_refresh()
127
+ return config
128
+
129
+ def get_cached_config(self) -> Optional[ScopeConfig]:
130
+ return self.cached_config
131
+
132
+ def refresh(self) -> Optional[ScopeConfig]:
133
+ # Single-flight without blocking: a second caller during an in-progress
134
+ # refresh gets the cached/stale policy immediately instead of queueing a
135
+ # worker thread behind a 5-second network call.
136
+ if not self._lock.acquire(blocking=False):
137
+ return self._stale_or_none()
138
+ try:
139
+ return self._refresh_locked()
140
+ finally:
141
+ self._lock.release()
142
+
143
+ def _refresh_locked(self) -> Optional[ScopeConfig]:
144
+ if self._sign_request is None:
145
+ return self._stale_or_none()
146
+ headers = dict(self._sign_request())
147
+ if self.etag:
148
+ headers["If-None-Match"] = self.etag
149
+ try:
150
+ status, payload, response_headers = control_plane_request(
151
+ f"{self.api_url}/api/sdk/scope-config", "GET", headers
152
+ )
153
+ except ControlPlaneError:
154
+ return self._stale_or_none()
155
+ if status == 304 and self.cached_config:
156
+ return self._extend_cached_config()
157
+ config = (
158
+ _read_policy(payload.get("data"), _now_ms())
159
+ if 200 <= status < 300 and payload and payload.get("success") is True
160
+ else None
161
+ )
162
+ if config is None:
163
+ return self._stale_or_none()
164
+ self.etag = response_headers.get("etag")
165
+ self.cached_config = config
166
+ self.stale_until = config.cached_until + STALE_GRACE_MS
167
+ self._schedule_refresh()
168
+ return config
169
+
170
+ def stop(self) -> None:
171
+ if self._timer:
172
+ self._timer.cancel()
173
+ self._timer = None
174
+
175
+ def _stale_or_none(self) -> Optional[ScopeConfig]:
176
+ if self.cached_config and _now_ms() < self.stale_until:
177
+ return self.cached_config
178
+ return None
179
+
180
+ def _extend_cached_config(self) -> ScopeConfig:
181
+ self.cached_config = ScopeConfig(
182
+ safe_read_endpoints=self.cached_config.safe_read_endpoints,
183
+ blocked_endpoints=self.cached_config.blocked_endpoints,
184
+ version=self.cached_config.version,
185
+ cached_until=_now_ms() + self.cache_ttl_ms,
186
+ )
187
+ self.stale_until = self.cached_config.cached_until + STALE_GRACE_MS
188
+ self._schedule_refresh()
189
+ return self.cached_config
190
+
191
+ def _schedule_refresh(self) -> None:
192
+ self.stop()
193
+ refresh_in_ms = self.cache_ttl_ms
194
+ if self.cached_config:
195
+ refresh_in_ms = max(
196
+ self.cached_config.cached_until - _now_ms() - 30_000,
197
+ min(self.cache_ttl_ms // 2, 60_000),
198
+ )
199
+ self._timer = threading.Timer(refresh_in_ms / 1000, self._refresh_from_timer)
200
+ self._timer.daemon = True
201
+ self._timer.start()
202
+
203
+ def _refresh_from_timer(self) -> None:
204
+ self.refresh()
205
+
206
+
207
+ def _now_ms() -> int:
208
+ return int(time.time() * 1000)
devora_sdk/py.typed ADDED
@@ -0,0 +1 @@
1
+
devora_sdk/replay.py ADDED
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import time
5
+ from threading import Lock
6
+ from typing import Protocol
7
+
8
+
9
+ class ReplayStore(Protocol):
10
+ """Atomic replay protection shared by every application instance.
11
+
12
+ ``consume`` must be an atomic insert-if-absent (for example Redis
13
+ ``SET key 1 NX PXAT expires_at``, or a unique-key database insert) and must
14
+ never evict an entry before ``expires_at``. A store that cannot guarantee
15
+ this must raise; the SDK then fails closed with a 503.
16
+ """
17
+
18
+ def consume(self, namespace: str, request_id: str, expires_at: int) -> bool:
19
+ """Return True only for the first consumption of ``request_id`` within
20
+ ``namespace`` before ``expires_at`` (Unix milliseconds)."""
21
+ ...
22
+
23
+
24
+ class InMemoryReplayStore:
25
+ """Development-only replay store. Production must use persistent storage."""
26
+
27
+ def __init__(self) -> None:
28
+ self._entries: dict[str, int] = {}
29
+ self._lock = Lock()
30
+
31
+ def consume(self, namespace: str, request_id: str, expires_at: int) -> bool:
32
+ if isinstance(expires_at, bool) or not isinstance(expires_at, (int, float)) or not math.isfinite(expires_at):
33
+ raise ValueError("Invalid replay expiry")
34
+ now = int(time.time() * 1000)
35
+ key = f"{namespace}\n{request_id}"
36
+ with self._lock:
37
+ self._entries = {entry: expiry for entry, expiry in self._entries.items() if expiry > now}
38
+ if key in self._entries:
39
+ return False
40
+ self._entries[key] = int(expires_at)
41
+ return True