devora-python 0.1.0__tar.gz → 0.1.2__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.
Files changed (23) hide show
  1. devora_python-0.1.2/PKG-INFO +201 -0
  2. devora_python-0.1.2/README.md +178 -0
  3. {devora_python-0.1.0 → devora_python-0.1.2}/pyproject.toml +1 -1
  4. {devora_python-0.1.0 → devora_python-0.1.2}/src/devora_sdk/__init__.py +6 -3
  5. {devora_python-0.1.0 → devora_python-0.1.2}/src/devora_sdk/constants.py +8 -3
  6. {devora_python-0.1.0 → devora_python-0.1.2}/src/devora_sdk/handler.py +4 -4
  7. {devora_python-0.1.0 → devora_python-0.1.2}/src/devora_sdk/models.py +24 -1
  8. {devora_python-0.1.0 → devora_python-0.1.2}/src/devora_sdk/sdk.py +57 -49
  9. {devora_python-0.1.0 → devora_python-0.1.2}/src/devora_sdk/signing.py +0 -9
  10. {devora_python-0.1.0 → devora_python-0.1.2}/src/devora_sdk/utils.py +3 -1
  11. devora_python-0.1.0/PKG-INFO +0 -132
  12. devora_python-0.1.0/README.md +0 -109
  13. devora_python-0.1.0/src/devora_sdk/replay.py +0 -41
  14. {devora_python-0.1.0 → devora_python-0.1.2}/.gitignore +0 -0
  15. {devora_python-0.1.0 → devora_python-0.1.2}/LICENSE +0 -0
  16. {devora_python-0.1.0 → devora_python-0.1.2}/src/devora_sdk/api_url_gen.py +0 -0
  17. {devora_python-0.1.0 → devora_python-0.1.2}/src/devora_sdk/browser_session.py +0 -0
  18. {devora_python-0.1.0 → devora_python-0.1.2}/src/devora_sdk/guard.py +0 -0
  19. {devora_python-0.1.0 → devora_python-0.1.2}/src/devora_sdk/hmac.py +0 -0
  20. {devora_python-0.1.0 → devora_python-0.1.2}/src/devora_sdk/policy.py +0 -0
  21. {devora_python-0.1.0 → devora_python-0.1.2}/src/devora_sdk/py.typed +0 -0
  22. {devora_python-0.1.0 → devora_python-0.1.2}/src/devora_sdk/security.py +0 -0
  23. {devora_python-0.1.0 → devora_python-0.1.2}/src/devora_sdk/transport.py +0 -0
@@ -0,0 +1,201 @@
1
+ Metadata-Version: 2.5
2
+ Name: devora-python
3
+ Version: 0.1.2
4
+ Summary: Devora Python backend SDK for secure impersonation
5
+ Project-URL: Documentation, https://docs.devora.sh
6
+ Project-URL: Repository, https://github.com/getdevora/devora-sdks
7
+ Project-URL: Issues, https://github.com/getdevora/devora-sdks/issues
8
+ Author: Devora
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: backend,devora,impersonation,sdk,security
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: <4.0,>=3.10
22
+ Description-Content-Type: text/markdown
23
+
24
+ # devora-python
25
+
26
+ Recording, masking and capture policy are configured in the Devora dashboard and
27
+ authorized server-side for each session. This backend SDK takes no capture
28
+ settings, and `devora_sdk()` ignores keyword arguments it does not recognize,
29
+ so check option names carefully. See
30
+ [capture settings](https://github.com/getdevora/devora-sdks/blob/main/SETTINGS.md).
31
+
32
+ Python backend core SDK for Devora customer integrations.
33
+
34
+ ## Requirements
35
+
36
+ - Python `>=3.10,<4.0`
37
+ - Outbound HTTPS access from your backend to the Devora API
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install devora-python
43
+ ```
44
+
45
+ ## Quick Start
46
+
47
+ ```python
48
+ import os
49
+ from dataclasses import asdict
50
+
51
+ from devora_sdk import DEVORA_ENDPOINTS, devora_sdk
52
+
53
+ sdk = devora_sdk(
54
+ api_key=os.environ["DEVORA_API_KEY"], # pk_server_live_...
55
+ secret_key=os.environ["DEVORA_SECRET_KEY"], # sk_server_live_...
56
+ org_id=os.environ["DEVORA_ORG_ID"],
57
+ )
58
+
59
+
60
+ @sdk.register(DEVORA_ENDPOINTS.USER_SEARCH)
61
+ def search_users(req):
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
+
78
+
79
+ @sdk.register(DEVORA_ENDPOINTS.IMPERSONATE)
80
+ def impersonate(req):
81
+ ctx = req.devora_context
82
+ if not ctx:
83
+ raise ValueError("Missing impersonation context")
84
+ # Store the whole verified context in the token: the scope guard reads every
85
+ # field of it back. Expire the token no later than ctx.expires_at.
86
+ devora = {**asdict(ctx), "is_impersonation": True}
87
+ token = create_customer_token(ctx.target_user["id"], devora)
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}
100
+ ```
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
+
108
+ Register every handler, then mount the SDK with the
109
+ [Django](https://pypi.org/project/devora-django/) or
110
+ [FastAPI](https://pypi.org/project/devora-fastapi/) adapter. For any other
111
+ framework, pass the request exactly as received to `process_request` (sync) or
112
+ `await async_process_request` (async):
113
+
114
+ ```python
115
+ from devora_sdk import AdapterRequest, process_request
116
+ from devora_sdk.utils import get_error_status_code
117
+
118
+
119
+ def handle_devora(method: str, path: str, query: str, body: bytes, headers) -> tuple[int, dict]:
120
+ # path: still percent-encoded and relative to your Devora mount, e.g. "/user/search"
121
+ # query: the raw query string without "?"; body: the raw bytes (cap the read yourself)
122
+ # headers: a mapping, or (name, value) pairs so duplicate headers stay visible
123
+ result = process_request(sdk, sdk.get_routes(), AdapterRequest(method, path, query, body, headers))
124
+ status = 200 if result["success"] else get_error_status_code(result.get("errorCode"))
125
+ return status, result # send as JSON with Cache-Control: private, no-store
126
+ ```
127
+
128
+ `async_process_request` runs signature verification, the request claim and
129
+ synchronous handlers in a worker thread, so it never blocks the event loop.
130
+ `process_request` cannot run `async def` handlers.
131
+
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).
172
+
173
+ The guard's session-liveness checker caches at most 1,024 results and permits at
174
+ most 64 distinct concurrent lookups per checker. Requests for the same session
175
+ share a lookup. Live/ended results use the configured TTL (five seconds by
176
+ default); unavailable results use at most one second. Cache hits never extend a
177
+ verdict's lifetime. Capacity exhaustion follows the configured unavailable
178
+ policy, which denies access by default.
179
+
180
+ ### Cold-start latency
181
+
182
+ The impersonation guard's scope policy is fetched lazily on first use, so the
183
+ first request handled by a freshly started worker process pays for that fetch
184
+ synchronously (and any request racing it gets a `503
185
+ IMPERSONATION_POLICY_UNAVAILABLE` rather than waiting). If your deployment
186
+ runs multiple worker processes (gunicorn, uWSGI, etc.), pass
187
+ `prefetch_scope_config=True` to start that fetch in a background thread as soon
188
+ as `devora_sdk(...)` is constructed, so the cache is usually warm by the first
189
+ request:
190
+
191
+ ```python
192
+ sdk = devora_sdk(
193
+ api_key=os.environ["DEVORA_API_KEY"],
194
+ secret_key=os.environ["DEVORA_SECRET_KEY"],
195
+ org_id=os.environ["DEVORA_ORG_ID"],
196
+ prefetch_scope_config=True,
197
+ )
198
+ ```
199
+
200
+ Options, error codes and the guard are documented in the
201
+ [Python reference](https://docs.devora.sh/reference/python).
@@ -0,0 +1,178 @@
1
+ # devora-python
2
+
3
+ Recording, masking and capture policy are configured in the Devora dashboard and
4
+ authorized server-side for each session. This backend SDK takes no capture
5
+ settings, and `devora_sdk()` ignores keyword arguments it does not recognize,
6
+ so check option names carefully. See
7
+ [capture settings](https://github.com/getdevora/devora-sdks/blob/main/SETTINGS.md).
8
+
9
+ Python backend core SDK for Devora customer integrations.
10
+
11
+ ## Requirements
12
+
13
+ - Python `>=3.10,<4.0`
14
+ - Outbound HTTPS access from your backend to the Devora API
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install devora-python
20
+ ```
21
+
22
+ ## Quick Start
23
+
24
+ ```python
25
+ import os
26
+ from dataclasses import asdict
27
+
28
+ from devora_sdk import DEVORA_ENDPOINTS, devora_sdk
29
+
30
+ sdk = devora_sdk(
31
+ api_key=os.environ["DEVORA_API_KEY"], # pk_server_live_...
32
+ secret_key=os.environ["DEVORA_SECRET_KEY"], # sk_server_live_...
33
+ org_id=os.environ["DEVORA_ORG_ID"],
34
+ )
35
+
36
+
37
+ @sdk.register(DEVORA_ENDPOINTS.USER_SEARCH)
38
+ def search_users(req):
39
+ # Match name, email and the exact user ID with a parameterised query.
40
+ term = req.query.get("term", "")
41
+ limit = int(req.query.get("limit", 10))
42
+ users = search_customer_users(term, limit)
43
+ return {
44
+ "users": [
45
+ {
46
+ "id": u.id,
47
+ "name": u.name,
48
+ "email": u.email,
49
+ "attributes": {"company": u.company, "role": u.role, "plan": u.plan},
50
+ }
51
+ for u in users
52
+ ]
53
+ }
54
+
55
+
56
+ @sdk.register(DEVORA_ENDPOINTS.IMPERSONATE)
57
+ def impersonate(req):
58
+ ctx = req.devora_context
59
+ if not ctx:
60
+ raise ValueError("Missing impersonation context")
61
+ # Store the whole verified context in the token: the scope guard reads every
62
+ # field of it back. Expire the token no later than ctx.expires_at.
63
+ devora = {**asdict(ctx), "is_impersonation": True}
64
+ token = create_customer_token(ctx.target_user["id"], devora)
65
+ return {"token": token}
66
+
67
+
68
+ @sdk.register(DEVORA_ENDPOINTS.TERMINATE)
69
+ def terminate(req):
70
+ session_id = req.params["id"]
71
+ reason = (req.body or {}).get("reason")
72
+ # YOU IMPLEMENT: mark this Devora session revoked so every credential issued for it
73
+ # is rejected, including one issued after this call. Must be idempotent: Devora
74
+ # retries with a new request id (up to 5 attempts over 6 hours).
75
+ auth.revoke_impersonation_session(session_id=session_id, reason=reason)
76
+ return {"success": True}
77
+ ```
78
+
79
+ 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.
80
+
81
+ Each verified request is claimed once from Devora before your handler runs, so
82
+ replay protection needs no storage on your side; your backend only needs
83
+ outbound HTTPS access to the Devora API. See [Replay protection](#replay-protection).
84
+
85
+ Register every handler, then mount the SDK with the
86
+ [Django](https://pypi.org/project/devora-django/) or
87
+ [FastAPI](https://pypi.org/project/devora-fastapi/) adapter. For any other
88
+ framework, pass the request exactly as received to `process_request` (sync) or
89
+ `await async_process_request` (async):
90
+
91
+ ```python
92
+ from devora_sdk import AdapterRequest, process_request
93
+ from devora_sdk.utils import get_error_status_code
94
+
95
+
96
+ def handle_devora(method: str, path: str, query: str, body: bytes, headers) -> tuple[int, dict]:
97
+ # path: still percent-encoded and relative to your Devora mount, e.g. "/user/search"
98
+ # query: the raw query string without "?"; body: the raw bytes (cap the read yourself)
99
+ # headers: a mapping, or (name, value) pairs so duplicate headers stay visible
100
+ result = process_request(sdk, sdk.get_routes(), AdapterRequest(method, path, query, body, headers))
101
+ status = 200 if result["success"] else get_error_status_code(result.get("errorCode"))
102
+ return status, result # send as JSON with Cache-Control: private, no-store
103
+ ```
104
+
105
+ `async_process_request` runs signature verification, the request claim and
106
+ synchronous handlers in a worker thread, so it never blocks the event loop.
107
+ `process_request` cannot run `async def` handlers.
108
+
109
+ ### Replay protection
110
+
111
+ Every signed request carries a single-use id. After the signature verifies, the
112
+ SDK claims that id from Devora with one signed call
113
+ (`POST /api/sdk/request-claim`, `REQUEST_CLAIM_TIMEOUT_SECONDS = 3.0`); the
114
+ handler runs only after a successful claim. Only requests whose signature
115
+ verified are ever claimed, so unauthenticated traffic cannot use up request
116
+ ids. The claim can fail with:
117
+
118
+ | Status | `errorCode` | Cause |
119
+ | ------ | --------------------------- | ------------------------------------------------------------------------ |
120
+ | 401 | `REPLAYED_REQUEST` | Devora already claimed this request id (the request was already processed) |
121
+ | 409 | `SESSION_NOT_STARTABLE` | Start request for a session Devora is no longer starting |
122
+ | 401 | `TIMESTAMP_EXPIRED` | Devora says the request is too old to claim |
123
+ | 503 | `REQUEST_CLAIM_UNAVAILABLE` | Devora could not be reached or gave no clear answer (fails closed) |
124
+
125
+ Devora's **Test connection** check is claimed too, so it also proves your
126
+ backend can reach the Devora API. See
127
+ [SIGNING.md](https://github.com/getdevora/devora-sdks/blob/main/SIGNING.md#request-claims).
128
+
129
+ ### Session termination
130
+
131
+ Devora calls `DELETE /impersonate/:id/terminate` when a session ends. The
132
+ session id is `req.params["id"]` (also `req.session_id`), and the body is
133
+ `{"reason": ..., "terminatedBy": ...}`. `terminatedBy` is the external user id
134
+ of the person who ended the session and is absent for automatic ends. `reason`
135
+ is one of `user_ended`, `time_limit`, `admin_terminated`, `superseded`,
136
+ `request_revoked`, `membership_revoked`, `role_downgraded`,
137
+ `workos_session_revoked`, `principal_erased`, `organization_erased`,
138
+ `start_failed` (Devora sent the start request, and your handler may have issued
139
+ a token, but the session never started) or `not_started` (your handler issued a
140
+ token but the impersonation link was never opened).
141
+
142
+ Devora retries a failed terminate call (up to 5 attempts over 6 hours, with
143
+ backoff), each as a new signed request with a new request id, so the handler
144
+ must be idempotent; any 2xx counts as delivered. A 4xx other than 408, 425 or 429 is
145
+ not retried; when overloaded, answer 429 or 503 with `Retry-After`. Store the
146
+ Devora session id with the credential you mint and revoke by session, so a
147
+ credential minted by a slow start handler after the terminate is still rejected. See
148
+ [Session lifecycle & cleanup](https://docs.devora.sh/guide/session-lifecycle).
149
+
150
+ The guard's session-liveness checker caches at most 1,024 results and permits at
151
+ most 64 distinct concurrent lookups per checker. Requests for the same session
152
+ share a lookup. Live/ended results use the configured TTL (five seconds by
153
+ default); unavailable results use at most one second. Cache hits never extend a
154
+ verdict's lifetime. Capacity exhaustion follows the configured unavailable
155
+ policy, which denies access by default.
156
+
157
+ ### Cold-start latency
158
+
159
+ The impersonation guard's scope policy is fetched lazily on first use, so the
160
+ first request handled by a freshly started worker process pays for that fetch
161
+ synchronously (and any request racing it gets a `503
162
+ IMPERSONATION_POLICY_UNAVAILABLE` rather than waiting). If your deployment
163
+ runs multiple worker processes (gunicorn, uWSGI, etc.), pass
164
+ `prefetch_scope_config=True` to start that fetch in a background thread as soon
165
+ as `devora_sdk(...)` is constructed, so the cache is usually warm by the first
166
+ request:
167
+
168
+ ```python
169
+ sdk = devora_sdk(
170
+ api_key=os.environ["DEVORA_API_KEY"],
171
+ secret_key=os.environ["DEVORA_SECRET_KEY"],
172
+ org_id=os.environ["DEVORA_ORG_ID"],
173
+ prefetch_scope_config=True,
174
+ )
175
+ ```
176
+
177
+ Options, error codes and the guard are documented in the
178
+ [Python reference](https://docs.devora.sh/reference/python).
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "devora-python"
7
- version = "0.1.0"
7
+ version = "0.1.2"
8
8
  description = "Devora Python backend SDK for secure impersonation"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10,<4.0"
@@ -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",
@@ -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.0"
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"
@@ -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)
@@ -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)
@@ -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
 
@@ -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]]] = {}
@@ -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,132 +0,0 @@
1
- Metadata-Version: 2.5
2
- Name: devora-python
3
- Version: 0.1.0
4
- Summary: Devora Python backend SDK for secure impersonation
5
- Project-URL: Documentation, https://docs.devora.sh
6
- Project-URL: Repository, https://github.com/getdevora/devora-sdks
7
- Project-URL: Issues, https://github.com/getdevora/devora-sdks/issues
8
- Author: Devora
9
- License-Expression: MIT
10
- License-File: LICENSE
11
- Keywords: backend,devora,impersonation,sdk,security
12
- Classifier: Development Status :: 5 - Production/Stable
13
- Classifier: Intended Audience :: Developers
14
- Classifier: Programming Language :: Python :: 3
15
- Classifier: Programming Language :: Python :: 3.10
16
- Classifier: Programming Language :: Python :: 3.11
17
- Classifier: Programming Language :: Python :: 3.12
18
- Classifier: Programming Language :: Python :: 3.13
19
- Classifier: Programming Language :: Python :: 3.14
20
- Classifier: Typing :: Typed
21
- Requires-Python: <4.0,>=3.10
22
- Description-Content-Type: text/markdown
23
-
24
- # devora-python
25
-
26
- Recording, masking and activity preferences are configured in Devora Settings.
27
- SDK initialization overrides are ignored. New sessions retain the server's policy
28
- snapshot across exchange and resume. Developer privacy labels take effect only
29
- when selected in Settings; sensitive-field protection remains mandatory.
30
- See [migration details](https://github.com/getdevora/devora-sdks/blob/main/SETTINGS.md).
31
-
32
- Python backend core SDK for Devora customer integrations.
33
-
34
- ## Requirements
35
-
36
- - Python `>=3.10,<4.0`
37
-
38
- ## Install
39
-
40
- ```bash
41
- pip install devora-python
42
- ```
43
-
44
- ## Quick Start
45
-
46
- ```python
47
- from devora_sdk import DEVORA_ENDPOINTS, devora_sdk
48
-
49
- sdk = devora_sdk(
50
- api_key="pk_server_live_...",
51
- secret_key="sk_server_live_...",
52
- org_id="org_...",
53
- )
54
-
55
- @sdk.register(DEVORA_ENDPOINTS.USER_SEARCH)
56
- def search_users(req):
57
- return {"users": search_customer_users(req.query.get("term", ""))}
58
-
59
- @sdk.register(DEVORA_ENDPOINTS.IMPERSONATE)
60
- def impersonate(req):
61
- context = req.devora_context
62
- token = create_customer_token(context.target_user["id"], context)
63
- return {"token": token}
64
- ```
65
-
66
- Use `process_request()` for sync frameworks, `async_process_request()` for
67
- async frameworks, or use the Django and FastAPI adapter packages.
68
-
69
- ### Production replay store
70
-
71
- Every signed request carries a single-use id. In production the SDK needs a
72
- shared, atomic `replay_store` so a captured request cannot be replayed against
73
- another worker or instance; the in-memory store is for
74
- `environment="development"` or `"test"` only, and any other environment refuses
75
- to start without one.
76
-
77
- ```python
78
- import os
79
-
80
- import redis
81
- from devora_sdk import devora_sdk
82
-
83
- client = redis.Redis.from_url(os.environ["REDIS_URL"])
84
-
85
-
86
- class RedisReplayStore:
87
- def consume(self, namespace: str, request_id: str, expires_at: int) -> bool:
88
- # Atomic insert-if-absent that lives until expires_at (Unix ms).
89
- # Exceptions propagate: the SDK then fails closed with a 503.
90
- return bool(
91
- client.set(f"devora:replay:{namespace}:{request_id}", b"1", nx=True, pxat=expires_at)
92
- )
93
-
94
-
95
- sdk = devora_sdk(
96
- api_key=os.environ["DEVORA_API_KEY"],
97
- secret_key=os.environ["DEVORA_SECRET_KEY"],
98
- org_id=os.environ["DEVORA_ORG_ID"],
99
- replay_store=RedisReplayStore(),
100
- )
101
- ```
102
-
103
- `consume` is called synchronously, including from `async_process_request`;
104
- keep it a single short round trip. Do not let the store evict these keys
105
- before they expire. A unique-key database insert with an expiry column works
106
- too.
107
-
108
- The guard's session-liveness checker caches at most 1,024 results and permits at
109
- most 64 distinct concurrent lookups per checker. Requests for the same session
110
- share a lookup. Live/ended results use the configured TTL (five seconds by
111
- default); unavailable results use at most one second. Cache hits never extend a
112
- verdict's lifetime. Capacity exhaustion follows the configured unavailable
113
- policy, which denies access by default.
114
-
115
- ### Cold-start latency
116
-
117
- The impersonation guard's scope policy is fetched lazily on first use, so the
118
- first request handled by a freshly started worker process pays for that fetch
119
- synchronously (and any request racing it gets a `503
120
- IMPERSONATION_POLICY_UNAVAILABLE` rather than waiting). If your deployment
121
- runs multiple worker processes (gunicorn, uWSGI, etc.), pass
122
- `prefetch_scope_config=True` to warm the cache in the background as soon as
123
- `devora_sdk(...)` is constructed, before the process starts serving traffic:
124
-
125
- ```python
126
- sdk = devora_sdk(
127
- api_key="pk_server_live_...",
128
- secret_key="sk_server_live_...",
129
- org_id="org_...",
130
- prefetch_scope_config=True,
131
- )
132
- ```
@@ -1,109 +0,0 @@
1
- # devora-python
2
-
3
- Recording, masking and activity preferences are configured in Devora Settings.
4
- SDK initialization overrides are ignored. New sessions retain the server's policy
5
- snapshot across exchange and resume. Developer privacy labels take effect only
6
- when selected in Settings; sensitive-field protection remains mandatory.
7
- See [migration details](https://github.com/getdevora/devora-sdks/blob/main/SETTINGS.md).
8
-
9
- Python backend core SDK for Devora customer integrations.
10
-
11
- ## Requirements
12
-
13
- - Python `>=3.10,<4.0`
14
-
15
- ## Install
16
-
17
- ```bash
18
- pip install devora-python
19
- ```
20
-
21
- ## Quick Start
22
-
23
- ```python
24
- from devora_sdk import DEVORA_ENDPOINTS, devora_sdk
25
-
26
- sdk = devora_sdk(
27
- api_key="pk_server_live_...",
28
- secret_key="sk_server_live_...",
29
- org_id="org_...",
30
- )
31
-
32
- @sdk.register(DEVORA_ENDPOINTS.USER_SEARCH)
33
- def search_users(req):
34
- return {"users": search_customer_users(req.query.get("term", ""))}
35
-
36
- @sdk.register(DEVORA_ENDPOINTS.IMPERSONATE)
37
- def impersonate(req):
38
- context = req.devora_context
39
- token = create_customer_token(context.target_user["id"], context)
40
- return {"token": token}
41
- ```
42
-
43
- Use `process_request()` for sync frameworks, `async_process_request()` for
44
- async frameworks, or use the Django and FastAPI adapter packages.
45
-
46
- ### Production replay store
47
-
48
- Every signed request carries a single-use id. In production the SDK needs a
49
- shared, atomic `replay_store` so a captured request cannot be replayed against
50
- another worker or instance; the in-memory store is for
51
- `environment="development"` or `"test"` only, and any other environment refuses
52
- to start without one.
53
-
54
- ```python
55
- import os
56
-
57
- import redis
58
- from devora_sdk import devora_sdk
59
-
60
- client = redis.Redis.from_url(os.environ["REDIS_URL"])
61
-
62
-
63
- class RedisReplayStore:
64
- def consume(self, namespace: str, request_id: str, expires_at: int) -> bool:
65
- # Atomic insert-if-absent that lives until expires_at (Unix ms).
66
- # Exceptions propagate: the SDK then fails closed with a 503.
67
- return bool(
68
- client.set(f"devora:replay:{namespace}:{request_id}", b"1", nx=True, pxat=expires_at)
69
- )
70
-
71
-
72
- sdk = devora_sdk(
73
- api_key=os.environ["DEVORA_API_KEY"],
74
- secret_key=os.environ["DEVORA_SECRET_KEY"],
75
- org_id=os.environ["DEVORA_ORG_ID"],
76
- replay_store=RedisReplayStore(),
77
- )
78
- ```
79
-
80
- `consume` is called synchronously, including from `async_process_request`;
81
- keep it a single short round trip. Do not let the store evict these keys
82
- before they expire. A unique-key database insert with an expiry column works
83
- too.
84
-
85
- The guard's session-liveness checker caches at most 1,024 results and permits at
86
- most 64 distinct concurrent lookups per checker. Requests for the same session
87
- share a lookup. Live/ended results use the configured TTL (five seconds by
88
- default); unavailable results use at most one second. Cache hits never extend a
89
- verdict's lifetime. Capacity exhaustion follows the configured unavailable
90
- policy, which denies access by default.
91
-
92
- ### Cold-start latency
93
-
94
- The impersonation guard's scope policy is fetched lazily on first use, so the
95
- first request handled by a freshly started worker process pays for that fetch
96
- synchronously (and any request racing it gets a `503
97
- IMPERSONATION_POLICY_UNAVAILABLE` rather than waiting). If your deployment
98
- runs multiple worker processes (gunicorn, uWSGI, etc.), pass
99
- `prefetch_scope_config=True` to warm the cache in the background as soon as
100
- `devora_sdk(...)` is constructed, before the process starts serving traffic:
101
-
102
- ```python
103
- sdk = devora_sdk(
104
- api_key="pk_server_live_...",
105
- secret_key="sk_server_live_...",
106
- org_id="org_...",
107
- prefetch_scope_config=True,
108
- )
109
- ```
@@ -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
File without changes
File without changes