devora-python 0.1.1__py3-none-any.whl → 0.1.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: devora-python
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Devora Python backend SDK for secure impersonation
5
5
  Project-URL: Documentation, https://docs.devora.sh
6
6
  Project-URL: Repository, https://github.com/getdevora/devora-sdks
@@ -34,13 +34,12 @@ Python backend core SDK for Devora customer integrations.
34
34
  ## Requirements
35
35
 
36
36
  - Python `>=3.10,<4.0`
37
- - In production, a replay store shared by every worker (the example below uses
38
- Redis 6.2 or newer, for `SET ... PXAT`)
37
+ - Outbound HTTPS access from your backend to the Devora API
39
38
 
40
39
  ## Install
41
40
 
42
41
  ```bash
43
- pip install devora-python redis
42
+ pip install devora-python
44
43
  ```
45
44
 
46
45
  ## Quick Start
@@ -49,31 +48,32 @@ pip install devora-python redis
49
48
  import os
50
49
  from dataclasses import asdict
51
50
 
52
- import redis
53
51
  from devora_sdk import DEVORA_ENDPOINTS, devora_sdk
54
52
 
55
- # Replay protection shared by every worker and instance (required in production).
56
- redis_client = redis.Redis.from_url(os.environ["REDIS_URL"])
57
-
58
-
59
- class RedisReplayStore:
60
- def consume(self, namespace: str, request_id: str, expires_at: int) -> bool:
61
- # Atomic insert-if-absent kept until expires_at; an exception makes the SDK fail closed (503).
62
- key = f"devora:replay:{namespace}:{request_id}"
63
- return bool(redis_client.set(key, b"1", nx=True, pxat=expires_at))
64
-
65
-
66
53
  sdk = devora_sdk(
67
54
  api_key=os.environ["DEVORA_API_KEY"], # pk_server_live_...
68
55
  secret_key=os.environ["DEVORA_SECRET_KEY"], # sk_server_live_...
69
56
  org_id=os.environ["DEVORA_ORG_ID"],
70
- replay_store=RedisReplayStore(),
71
57
  )
72
58
 
73
59
 
74
60
  @sdk.register(DEVORA_ENDPOINTS.USER_SEARCH)
75
61
  def search_users(req):
76
- return {"users": search_customer_users(req.query.get("term", ""))}
62
+ # Match name, email and the exact user ID with a parameterised query.
63
+ term = req.query.get("term", "")
64
+ limit = int(req.query.get("limit", 10))
65
+ users = search_customer_users(term, limit)
66
+ return {
67
+ "users": [
68
+ {
69
+ "id": u.id,
70
+ "name": u.name,
71
+ "email": u.email,
72
+ "attributes": {"company": u.company, "role": u.role, "plan": u.plan},
73
+ }
74
+ for u in users
75
+ ]
76
+ }
77
77
 
78
78
 
79
79
  @sdk.register(DEVORA_ENDPOINTS.IMPERSONATE)
@@ -86,8 +86,25 @@ def impersonate(req):
86
86
  devora = {**asdict(ctx), "is_impersonation": True}
87
87
  token = create_customer_token(ctx.target_user["id"], devora)
88
88
  return {"token": token}
89
+
90
+
91
+ @sdk.register(DEVORA_ENDPOINTS.TERMINATE)
92
+ def terminate(req):
93
+ session_id = req.params["id"]
94
+ reason = (req.body or {}).get("reason")
95
+ # YOU IMPLEMENT: mark this Devora session revoked so every credential issued for it
96
+ # is rejected, including one issued after this call. Must be idempotent: Devora
97
+ # retries with a new request id (up to 5 attempts over 6 hours).
98
+ auth.revoke_impersonation_session(session_id=session_id, reason=reason)
99
+ return {"success": True}
89
100
  ```
90
101
 
102
+ User search should match name, email and the exact user ID. `attributes` are optional display fields such as company, role or plan: up to 12 per user, lowercase keys like `last_login`, and string, number, boolean or `null` values. Devora drops invalid entries silently; see [Search results and templates](https://docs.devora.sh/guide/search-results) for the limits and how your team lays out results.
103
+
104
+ Each verified request is claimed once from Devora before your handler runs, so
105
+ replay protection needs no storage on your side; your backend only needs
106
+ outbound HTTPS access to the Devora API. See [Replay protection](#replay-protection).
107
+
91
108
  Register every handler, then mount the SDK with the
92
109
  [Django](https://pypi.org/project/devora-django/) or
93
110
  [FastAPI](https://pypi.org/project/devora-fastapi/) adapter. For any other
@@ -108,32 +125,50 @@ def handle_devora(method: str, path: str, query: str, body: bytes, headers) -> t
108
125
  return status, result # send as JSON with Cache-Control: private, no-store
109
126
  ```
110
127
 
111
- `async_process_request` runs signature verification, the replay store and
128
+ `async_process_request` runs signature verification, the request claim and
112
129
  synchronous handlers in a worker thread, so it never blocks the event loop.
113
130
  `process_request` cannot run `async def` handlers.
114
131
 
115
- ### Production replay store
116
-
117
- Every signed request carries a single-use id. In production the SDK needs a
118
- shared, atomic `replay_store` so a captured request cannot be replayed against
119
- another worker or instance. The environment comes from `environment=`, else
120
- `DEVORA_ENV`, else `NODE_ENV`. Anything other than `development` or `test`
121
- (including unset) is production, and production refuses to start without a
122
- `replay_store`. Any store whose `consume` is an atomic insert-if-absent works;
123
- see the
124
- [replay-store contract](https://github.com/getdevora/devora-sdks/blob/main/SIGNING.md#replay-store).
125
-
126
- **Local development only:** to run without Redis, omit `replay_store` and set
127
- `DEVORA_ENV=development` (or pass `environment="development"`). The SDK then
128
- keeps request ids in memory, which protects a single process only. Never use
129
- this in production.
130
-
131
- `consume` must be a regular method returning `True` or `False`; the SDK calls
132
- it synchronously, including from `async_process_request` (in a worker thread).
133
- Use a synchronous client such as `redis.Redis`: an `async def consume` fails
134
- closed with a 503. Keep it a single short round trip, and do not let the store
135
- evict these keys before they expire. A unique-key database insert with an
136
- expiry column works too.
132
+ ### Replay protection
133
+
134
+ Every signed request carries a single-use id. After the signature verifies, the
135
+ SDK claims that id from Devora with one signed call
136
+ (`POST /api/sdk/request-claim`, `REQUEST_CLAIM_TIMEOUT_SECONDS = 3.0`); the
137
+ handler runs only after a successful claim. Only requests whose signature
138
+ verified are ever claimed, so unauthenticated traffic cannot use up request
139
+ ids. The claim can fail with:
140
+
141
+ | Status | `errorCode` | Cause |
142
+ | ------ | --------------------------- | ------------------------------------------------------------------------ |
143
+ | 401 | `REPLAYED_REQUEST` | Devora already claimed this request id (the request was already processed) |
144
+ | 409 | `SESSION_NOT_STARTABLE` | Start request for a session Devora is no longer starting |
145
+ | 401 | `TIMESTAMP_EXPIRED` | Devora says the request is too old to claim |
146
+ | 503 | `REQUEST_CLAIM_UNAVAILABLE` | Devora could not be reached or gave no clear answer (fails closed) |
147
+
148
+ Devora's **Test connection** check is claimed too, so it also proves your
149
+ backend can reach the Devora API. See
150
+ [SIGNING.md](https://github.com/getdevora/devora-sdks/blob/main/SIGNING.md#request-claims).
151
+
152
+ ### Session termination
153
+
154
+ Devora calls `DELETE /impersonate/:id/terminate` when a session ends. The
155
+ session id is `req.params["id"]` (also `req.session_id`), and the body is
156
+ `{"reason": ..., "terminatedBy": ...}`. `terminatedBy` is the external user id
157
+ of the person who ended the session and is absent for automatic ends. `reason`
158
+ is one of `user_ended`, `time_limit`, `admin_terminated`, `superseded`,
159
+ `request_revoked`, `membership_revoked`, `role_downgraded`,
160
+ `workos_session_revoked`, `principal_erased`, `organization_erased`,
161
+ `start_failed` (Devora sent the start request, and your handler may have issued
162
+ a token, but the session never started) or `not_started` (your handler issued a
163
+ token but the impersonation link was never opened).
164
+
165
+ Devora retries a failed terminate call (up to 5 attempts over 6 hours, with
166
+ backoff), each as a new signed request with a new request id, so the handler
167
+ must be idempotent; any 2xx counts as delivered. A 4xx other than 408, 425 or 429 is
168
+ not retried; when overloaded, answer 429 or 503 with `Retry-After`. Store the
169
+ Devora session id with the credential you mint and revoke by session, so a
170
+ credential minted by a slow start handler after the terminate is still rejected. See
171
+ [Session lifecycle & cleanup](https://docs.devora.sh/guide/session-lifecycle).
137
172
 
138
173
  The guard's session-liveness checker caches at most 1,024 results and permits at
139
174
  most 64 distinct concurrent lookups per checker. Requests for the same session
@@ -158,7 +193,6 @@ sdk = devora_sdk(
158
193
  api_key=os.environ["DEVORA_API_KEY"],
159
194
  secret_key=os.environ["DEVORA_SECRET_KEY"],
160
195
  org_id=os.environ["DEVORA_ORG_ID"],
161
- replay_store=RedisReplayStore(),
162
196
  prefetch_scope_config=True,
163
197
  )
164
198
  ```
@@ -0,0 +1,19 @@
1
+ devora_sdk/__init__.py,sha256=0ZJLVYd6y88yiRaM2H7lZL3-L1vrlmkcbxY2kYE9HgM,2249
2
+ devora_sdk/api_url_gen.py,sha256=wfYa1I23mbENK9IoDQrw0ZT_6ivkszRI9Jhs48yTcYI,116
3
+ devora_sdk/browser_session.py,sha256=Get6voUbs4dg9ZvqJtBgiMr4gOqtHZh-Gi9xHX6E-tY,2007
4
+ devora_sdk/constants.py,sha256=dO5IOTkg1HyzSKCvjC9tjXPXLwqUTzyhg6Hrim0Lw2w,1756
5
+ devora_sdk/guard.py,sha256=idlLUrt-HKstvuLJvwy4oCiW0BQ90n_WD3XiYMS6lCU,18284
6
+ devora_sdk/handler.py,sha256=6HtoFkdfnfVNKC0lBLkWaJnIvq7fU_yVFdfm9awBSAQ,10261
7
+ devora_sdk/hmac.py,sha256=a0OyYEij39j_rWXsgIyKsbgydl0dpVgiIs-BYZR198k,1889
8
+ devora_sdk/models.py,sha256=MKHn41GRPy35VMElLe9JFkmSzfU8D8f0leQqWIp6RwU,2516
9
+ devora_sdk/policy.py,sha256=MykEDCuCSvH9mso73ShsEdN5XtnMiJRHmBY4c0LuEZM,6999
10
+ devora_sdk/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
11
+ devora_sdk/sdk.py,sha256=iZGYqDYt7wSmVckeuG14UO3M2a_gGmuA551KxDl4d0o,14734
12
+ devora_sdk/security.py,sha256=gfKwz_rF9tGXFejNcwOPBqlczK_g1AZrKZe1SMxdKCA,1437
13
+ devora_sdk/signing.py,sha256=9y_zEP3k8iHiLg31xoRBzU8L3stbEB6rAeaYu7HTtUs,8564
14
+ devora_sdk/transport.py,sha256=plTQ0J4B_-LGUs6waOqBk8wL4P06KuS_LilyEYr9sW4,5528
15
+ devora_sdk/utils.py,sha256=DG5pf3xPO147xLgsTMiBoDxyoaF3hAcnepS99_QIuDc,5584
16
+ devora_python-0.1.2.dist-info/METADATA,sha256=HRJxADCl0i5l2KxzTK6kRc_4797fEWxn74x1KFnG3X8,9135
17
+ devora_python-0.1.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
18
+ devora_python-0.1.2.dist-info/licenses/LICENSE,sha256=ZrqlZJezuSZhSOx4R8S5JOwwQ-sLmssj2XW7-3coRjA,1063
19
+ devora_python-0.1.2.dist-info/RECORD,,
devora_sdk/__init__.py CHANGED
@@ -34,13 +34,15 @@ from .models import (
34
34
  DevoraImpersonationContext,
35
35
  DevoraRequest,
36
36
  DevoraResponse,
37
+ DevoraUser,
38
+ DevoraUserAttributeValue,
37
39
  SDKRoute,
38
40
  SDKStats,
41
+ UserSearchResponse,
39
42
  ValidationResult,
40
43
  )
41
44
  from .policy import ScopeConfig, ScopeConfigFetcher
42
45
  from .sdk import DevoraBackendSDK, devora_sdk
43
- from .replay import InMemoryReplayStore, ReplayStore
44
46
  from .browser_session import resolve_browser_session
45
47
  from .constants import BROWSER_SESSION_BRIDGE_PATH
46
48
 
@@ -54,13 +56,14 @@ __all__ = [
54
56
  "DevoraImpersonationContext",
55
57
  "DevoraRequest",
56
58
  "DevoraResponse",
59
+ "DevoraUser",
60
+ "DevoraUserAttributeValue",
57
61
  "GuardDecision",
58
62
  "ImpersonationContext",
59
- "InMemoryReplayStore",
60
63
  "ProcessRequestOptions",
61
- "ReplayStore",
62
64
  "SDKRoute",
63
65
  "SDKStats",
66
+ "UserSearchResponse",
64
67
  "ScopeConfig",
65
68
  "ScopeEndpoint",
66
69
  "ScopeConfigFetcher",
devora_sdk/constants.py CHANGED
@@ -5,7 +5,6 @@ from .api_url_gen import DEVORA_API_ORIGIN
5
5
 
6
6
  class DEVORA_ENDPOINTS:
7
7
  USER_SEARCH = "/user/search"
8
- USER_BY_ID = "/user/:id"
9
8
  IMPERSONATE = "/impersonate/:id"
10
9
  TERMINATE = "/impersonate/:id/terminate"
11
10
  TEST = "/test"
@@ -14,7 +13,6 @@ class DEVORA_ENDPOINTS:
14
13
 
15
14
  ENDPOINT_METHODS = {
16
15
  DEVORA_ENDPOINTS.USER_SEARCH: "GET",
17
- DEVORA_ENDPOINTS.USER_BY_ID: "GET",
18
16
  DEVORA_ENDPOINTS.IMPERSONATE: "POST",
19
17
  DEVORA_ENDPOINTS.TERMINATE: "DELETE",
20
18
  DEVORA_ENDPOINTS.TEST: "GET",
@@ -34,7 +32,7 @@ class SECURITY_HEADERS:
34
32
  SESSION_ID = "x-devora-session-id"
35
33
 
36
34
 
37
- SDK_VERSION = "0.1.1"
35
+ SDK_VERSION = "0.1.2"
38
36
  DEFAULT_API_URL = DEVORA_API_ORIGIN
39
37
  DEFAULT_TIMESTAMP_TOLERANCE_SECONDS = 300
40
38
  DEFAULT_MAX_BODY_SIZE_BYTES = 1024 * 1024
@@ -45,5 +43,12 @@ WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
45
43
  BROWSER_SESSION_BRIDGE_PATH = "/api/devora/browser-session"
46
44
  BROWSER_RESUME_CODE_ENDPOINT = "/api/sdk/browser-resume-code"
47
45
  BROWSER_RESUME_ENDPOINT = "/api/sdk/browser-resume"
46
+
47
+ # Single use of signed Devora -> customer requests (see @devorash/core REQUEST_CLAIM):
48
+ # after verifying a signature the SDK claims the request id from Devora, and the
49
+ # handler runs only for the first claim. Customers need no storage of their own.
50
+ REQUEST_CLAIM_ENDPOINT = "/api/sdk/request-claim"
51
+ # Total deadline in seconds, leaving room for the handler within Devora's own deadline.
52
+ REQUEST_CLAIM_TIMEOUT_SECONDS = 3.0
48
53
  # \Z, not $: $ also matches before a trailing newline.
49
54
  TAB_REF_PATTERN = r"^[A-Za-z0-9_-]{4,96}\Z"
devora_sdk/handler.py CHANGED
@@ -84,10 +84,10 @@ async def async_process_request(
84
84
  import asyncio
85
85
 
86
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
87
+ # _prepare_request verifies the HMAC signature and claims the request id
88
+ # from Devora over the network, so this can block. Run it off the event
89
+ # loop rather than stalling every other request this async server is
90
+ # handling. Using the stdlib executor
91
91
  # (rather than anyio/Starlette's threadpool) keeps this module usable from
92
92
  # any asyncio-based framework, not just FastAPI.
93
93
  prepared = await loop.run_in_executor(None, _prepare_request, sdk, routes, request, options)
devora_sdk/models.py CHANGED
@@ -1,7 +1,30 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass, field
4
- from typing import Any, Callable, Mapping, MutableMapping, Optional
4
+ from typing import Any, Callable, Mapping, MutableMapping, Optional, TypedDict, Union
5
+
6
+ # An extra value shown in Devora's search results; None means "not set".
7
+ DevoraUserAttributeValue = Optional[Union[str, int, float, bool]]
8
+
9
+
10
+ class DevoraUser(TypedDict, total=False):
11
+ """A user returned from your USER_SEARCH handler (``id`` is required).
12
+
13
+ ``attributes`` holds extra display fields such as company, role or plan: keys
14
+ are lowercase letters, digits and underscores starting with a letter (e.g.
15
+ ``last_login``); at most 12; strings up to 120 characters. Never include
16
+ secrets: keys that look like passwords, tokens, keys or card data are dropped.
17
+ """
18
+
19
+ id: str
20
+ name: str
21
+ email: str
22
+ avatar: str
23
+ attributes: dict[str, DevoraUserAttributeValue]
24
+
25
+
26
+ class UserSearchResponse(TypedDict):
27
+ users: list[DevoraUser]
5
28
 
6
29
 
7
30
  @dataclass(frozen=True)
devora_sdk/sdk.py CHANGED
@@ -1,10 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
- import inspect
5
- import os
6
4
  import re
7
- import time
8
5
  from typing import Any, Callable, Optional
9
6
 
10
7
  from .constants import (
@@ -15,6 +12,8 @@ from .constants import (
15
12
  PROTECTED_ENDPOINTS,
16
13
  SDK_VERSION,
17
14
  BROWSER_RESUME_CODE_ENDPOINT,
15
+ REQUEST_CLAIM_ENDPOINT,
16
+ REQUEST_CLAIM_TIMEOUT_SECONDS,
18
17
  TAB_REF_PATTERN,
19
18
  )
20
19
  from .hmac import sha256_hex, sign_request, signature_matches
@@ -25,23 +24,18 @@ from .signing import (
25
24
  CUSTOMER_TO_DEVORA,
26
25
  DEVORA_TO_CUSTOMER,
27
26
  build_canonical_string,
27
+ get_single_header,
28
28
  has_identity_content_encoding,
29
29
  is_valid_signed_path,
30
30
  is_valid_signed_query,
31
31
  matches,
32
32
  parse_signature_headers,
33
- replay_expires_at_ms,
34
- replay_namespace,
33
+ parse_verified_json_body,
35
34
  )
36
35
  from .transport import ControlPlaneError, control_plane_request
37
- from .replay import InMemoryReplayStore, ReplayStore
38
36
 
39
-
40
- def resolve_environment(explicit: Optional[str] = None) -> str:
41
- """Explicit option first, then DEVORA_ENV / NODE_ENV as hints, else production."""
42
- raw = (explicit or os.environ.get("DEVORA_ENV") or os.environ.get("NODE_ENV") or "production")
43
- raw = str(raw).strip().lower()
44
- return raw if raw in ("development", "test") else "production"
37
+ # A start request: ``POST .../impersonate/:id`` (path is relative to the SDK mount).
38
+ _START_REQUEST_PATH = re.compile(r"/impersonate/[^/]+\Z")
45
39
 
46
40
 
47
41
  class DevoraBackendSDK:
@@ -54,8 +48,6 @@ class DevoraBackendSDK:
54
48
  collect_stats: bool = False,
55
49
  debug: bool = False,
56
50
  api_url: Optional[str] = None,
57
- replay_store: Optional[ReplayStore] = None,
58
- environment: Optional[str] = None,
59
51
  prefetch_scope_config: bool = False,
60
52
  **_ignored_options: Any,
61
53
  ) -> None:
@@ -77,17 +69,6 @@ class DevoraBackendSDK:
77
69
  self.debug = debug
78
70
  self.stats = SDKStats()
79
71
  self._routes: list[SDKRoute] = []
80
- # Fail closed: anything that is not explicitly a development/test runtime
81
- # is production, and production must share replay protection across
82
- # processes. Explicit option first, then DEVORA_ENV / NODE_ENV as hints.
83
- self.environment = resolve_environment(environment)
84
- if self.environment == "production" and replay_store is None:
85
- raise ValueError(
86
- "Devora SDK: replay_store is required in production. Pass a persistent "
87
- "ReplayStore, or environment='development' (InMemoryReplayStore) for local "
88
- "development only."
89
- )
90
- self._replay_store = replay_store or InMemoryReplayStore()
91
72
  self._scope_config = ScopeConfigFetcher(
92
73
  api_key=api_key,
93
74
  api_url=self.api_url,
@@ -156,7 +137,8 @@ class DevoraBackendSDK:
156
137
  ``path`` is relative to the SDK mount and still percent-encoded; ``query``
157
138
  is everything after the first ``?``; ``body`` is the raw bytes; ``headers``
158
139
  is a mapping or a list of ``(name, value)`` pairs (duplicates are
159
- rejected). Steps before the replay store never touch it.
140
+ rejected). A verified request is then claimed once from Devora; only a
141
+ request whose signature verified is ever claimed.
160
142
  """
161
143
  tolerance = self.timestamp_tolerance if timestamp_tolerance is None else timestamp_tolerance
162
144
  if not is_valid_timestamp_tolerance(tolerance):
@@ -198,24 +180,20 @@ class DevoraBackendSDK:
198
180
  if not signature_matches(self._secret_key, canonical, parsed["signature"]):
199
181
  return ValidationResult(valid=False, error="Invalid HMAC signature", error_code="INVALID_SIGNATURE")
200
182
 
201
- try:
202
- fresh = self._replay_store.consume(
203
- replay_namespace(DEVORA_TO_CUSTOMER, parsed["key_id"]),
204
- parsed["request_id"],
205
- replay_expires_at_ms(sent_at, int(time.time()), tolerance),
206
- )
207
- except Exception:
208
- return ValidationResult(
209
- valid=False, error="Replay protection is unavailable", error_code="REPLAY_STORE_UNAVAILABLE"
183
+ # A start request names the session it starts; Devora lets it be claimed
184
+ # only while that session is still starting. (A start request without a
185
+ # valid session ID is claimed plainly; the handler then refuses it.)
186
+ session_id: Optional[str] = None
187
+ if method == "POST" and _START_REQUEST_PATH.search(path):
188
+ body_ok, parsed_body = parse_verified_json_body(
189
+ bytes(body), get_single_header(headers, "content-type") or None
210
190
  )
211
- if not isinstance(fresh, bool):
212
- if inspect.iscoroutine(fresh):
213
- fresh.close()
214
- return ValidationResult(
215
- valid=False, error="Replay store must return a boolean decision", error_code="REPLAY_STORE_UNAVAILABLE"
216
- )
217
- if not fresh:
218
- return ValidationResult(valid=False, error="Request was already processed", error_code="REPLAYED_REQUEST")
191
+ value = parsed_body.get("sessionId") if body_ok and isinstance(parsed_body, dict) else None
192
+ if isinstance(value, str) and value and len(value) <= 128:
193
+ session_id = value
194
+ rejection = self._claim_request(parsed["request_id"], parsed["sent_at"], session_id)
195
+ if rejection is not None:
196
+ return rejection
219
197
  return ValidationResult(
220
198
  valid=True,
221
199
  org_id=parsed["org_id"],
@@ -233,7 +211,41 @@ class DevoraBackendSDK:
233
211
  def refresh_scope_config(self) -> Optional[ScopeConfig]:
234
212
  return self._scope_config.refresh()
235
213
 
236
- def _signed_devora_post(self, path: str, payload: dict[str, Any]) -> tuple[int, Optional[dict[str, Any]]]:
214
+ def _claim_request(
215
+ self, request_id: str, sent_at: str, session_id: Optional[str]
216
+ ) -> Optional[ValidationResult]:
217
+ """Claim a verified request id from Devora: ``None`` for the first claim,
218
+ otherwise the rejection. Anything but a clear answer fails closed."""
219
+ payload: dict[str, Any] = {"requestId": request_id, "sentAt": sent_at}
220
+ if session_id is not None:
221
+ payload["sessionId"] = session_id
222
+ try:
223
+ status, reply = self._signed_devora_post(
224
+ REQUEST_CLAIM_ENDPOINT, payload, timeout=REQUEST_CLAIM_TIMEOUT_SECONDS
225
+ )
226
+ except (ControlPlaneError, ValueError):
227
+ status, reply = 0, None
228
+ data = reply.get("data") if reply else None
229
+ if 200 <= status < 300 and reply and reply.get("success") is True and isinstance(data, dict) and data.get("claimed") is True:
230
+ return None
231
+ code = reply.get("errorCode") if reply and status == 409 else None
232
+ if code == "REPLAYED_REQUEST":
233
+ return ValidationResult(valid=False, error="Request was already processed", error_code="REPLAYED_REQUEST")
234
+ if code == "SESSION_NOT_STARTABLE":
235
+ return ValidationResult(
236
+ valid=False, error="Devora is no longer starting this session", error_code="SESSION_NOT_STARTABLE"
237
+ )
238
+ if code == "TIMESTAMP_EXPIRED":
239
+ return ValidationResult(valid=False, error="Request is too old", error_code="TIMESTAMP_EXPIRED")
240
+ return ValidationResult(
241
+ valid=False,
242
+ error="Devora could not confirm this request is new",
243
+ error_code="REQUEST_CLAIM_UNAVAILABLE",
244
+ )
245
+
246
+ def _signed_devora_post(
247
+ self, path: str, payload: dict[str, Any], timeout: float = 5.0
248
+ ) -> tuple[int, Optional[dict[str, Any]]]:
237
249
  """POST a signed JSON body to a Devora SDK endpoint (customer-to-devora)."""
238
250
  body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
239
251
  headers = sign_request(
@@ -245,7 +257,7 @@ class DevoraBackendSDK:
245
257
  path=path,
246
258
  body=body,
247
259
  )
248
- status, json_body, _ = control_plane_request(f"{self.api_url}{path}", "POST", headers, body)
260
+ status, json_body, _ = control_plane_request(f"{self.api_url}{path}", "POST", headers, body, timeout=timeout)
249
261
  return status, json_body
250
262
 
251
263
  def get_session_status(self, session_id: str) -> Optional[dict[str, Any]]:
@@ -341,8 +353,6 @@ def devora_sdk(
341
353
  collect_stats: bool = False,
342
354
  debug: bool = False,
343
355
  api_url: Optional[str] = None,
344
- replay_store: Optional[ReplayStore] = None,
345
- environment: Optional[str] = None,
346
356
  prefetch_scope_config: bool = False,
347
357
  **_ignored_options: Any,
348
358
  ) -> DevoraBackendSDK:
@@ -354,8 +364,6 @@ def devora_sdk(
354
364
  collect_stats=collect_stats,
355
365
  debug=debug,
356
366
  api_url=api_url,
357
- replay_store=replay_store,
358
- environment=environment,
359
367
  prefetch_scope_config=prefetch_scope_config,
360
368
  )
361
369
 
devora_sdk/signing.py CHANGED
@@ -170,15 +170,6 @@ def has_identity_content_encoding(headers: HeaderInput) -> bool:
170
170
  return value is None or value == "identity"
171
171
 
172
172
 
173
- def replay_namespace(direction: str, key_id: str) -> str:
174
- return f"v{VERSION}:{direction}:{key_id}"
175
-
176
-
177
- def replay_expires_at_ms(sent_at: int, now_seconds: int, tolerance_seconds: int) -> int:
178
- """Covers the floor second of the timestamp check plus one second of jitter."""
179
- return (max(now_seconds, sent_at) + tolerance_seconds + 2) * 1000
180
-
181
-
182
173
  def parse_verified_query(query: str) -> dict[str, Union[str, list[str]]]:
183
174
  """Parse a verified raw query. Repeated keys become lists in wire order."""
184
175
  result: dict[str, Union[str, list[str]]] = {}
devora_sdk/utils.py CHANGED
@@ -64,10 +64,12 @@ def get_error_status_code(error_code: Optional[str]) -> int:
64
64
  "IMPERSONATION_SESSION_ENDED",
65
65
  ):
66
66
  return 401
67
- if error_code in ("IMPERSONATION_POLICY_UNAVAILABLE", "REPLAY_STORE_UNAVAILABLE"):
67
+ if error_code in ("IMPERSONATION_POLICY_UNAVAILABLE", "REQUEST_CLAIM_UNAVAILABLE"):
68
68
  return 503
69
69
  if error_code == "REPLAYED_REQUEST":
70
70
  return 401
71
+ if error_code == "SESSION_NOT_STARTABLE":
72
+ return 409
71
73
  if error_code == "UNSUPPORTED_CONTENT_ENCODING":
72
74
  return 415
73
75
  if error_code in ("IMPERSONATION_ENDPOINT_BLOCKED", "IMPERSONATION_SCOPE_VIOLATION"):
@@ -1,20 +0,0 @@
1
- devora_sdk/__init__.py,sha256=X3_377Yyj4fesPUdFmVRG_4MLQCMqaVyjl1uQkXLlUg,2214
2
- devora_sdk/api_url_gen.py,sha256=wfYa1I23mbENK9IoDQrw0ZT_6ivkszRI9Jhs48yTcYI,116
3
- devora_sdk/browser_session.py,sha256=Get6voUbs4dg9ZvqJtBgiMr4gOqtHZh-Gi9xHX6E-tY,2007
4
- devora_sdk/constants.py,sha256=HBoHYI5dqPL34Eg5pjGCnSw-e8mzjwJ65dLpyprcCDw,1395
5
- devora_sdk/guard.py,sha256=idlLUrt-HKstvuLJvwy4oCiW0BQ90n_WD3XiYMS6lCU,18284
6
- devora_sdk/handler.py,sha256=Tk98Z7c26gORnlE1CQoKrI9CnwGXuW56Is5oV0cI4K4,10295
7
- devora_sdk/hmac.py,sha256=a0OyYEij39j_rWXsgIyKsbgydl0dpVgiIs-BYZR198k,1889
8
- devora_sdk/models.py,sha256=D-uVBWElbkZGkBMeSlUjrO3aeY8baVEJTGhfuGUZT_Y,1768
9
- devora_sdk/policy.py,sha256=MykEDCuCSvH9mso73ShsEdN5XtnMiJRHmBY4c0LuEZM,6999
10
- devora_sdk/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
11
- devora_sdk/replay.py,sha256=A0SWPt1ByPZKPGk6mEU2i-rk8eLIEj8yP2aQl6dKPCA,1428
12
- devora_sdk/sdk.py,sha256=njEEq-H6KKdEyhVa5MwsxcAZjr8xWBf4z-VjWv8-l_4,14162
13
- devora_sdk/security.py,sha256=gfKwz_rF9tGXFejNcwOPBqlczK_g1AZrKZe1SMxdKCA,1437
14
- devora_sdk/signing.py,sha256=TzB_DEDX6hkN5cZeJVXXp7Q6Z8OKFXLD8GP9OvopZuA,8906
15
- devora_sdk/transport.py,sha256=plTQ0J4B_-LGUs6waOqBk8wL4P06KuS_LilyEYr9sW4,5528
16
- devora_sdk/utils.py,sha256=ZC1kmHMB6RVocv1uqpG9srRsQciOO0SzZEKyswY5NLk,5527
17
- devora_python-0.1.1.dist-info/METADATA,sha256=fvnBQv-yW_0I3UWUcVcFumBw-VHH3o71g6Fv6j37Zos,6879
18
- devora_python-0.1.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
19
- devora_python-0.1.1.dist-info/licenses/LICENSE,sha256=ZrqlZJezuSZhSOx4R8S5JOwwQ-sLmssj2XW7-3coRjA,1063
20
- devora_python-0.1.1.dist-info/RECORD,,
devora_sdk/replay.py DELETED
@@ -1,41 +0,0 @@
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