sesame-sdk 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sesame_sdk-0.1.0/.gitignore +6 -0
- sesame_sdk-0.1.0/LICENSE +21 -0
- sesame_sdk-0.1.0/PKG-INFO +166 -0
- sesame_sdk-0.1.0/PUBLISHING.md +63 -0
- sesame_sdk-0.1.0/README.md +136 -0
- sesame_sdk-0.1.0/pyproject.toml +61 -0
- sesame_sdk-0.1.0/src/sesame_sdk/__init__.py +34 -0
- sesame_sdk-0.1.0/src/sesame_sdk/approval.py +77 -0
- sesame_sdk-0.1.0/src/sesame_sdk/approvals.py +53 -0
- sesame_sdk-0.1.0/src/sesame_sdk/client.py +40 -0
- sesame_sdk-0.1.0/src/sesame_sdk/decorator.py +67 -0
- sesame_sdk-0.1.0/src/sesame_sdk/exceptions.py +36 -0
- sesame_sdk-0.1.0/src/sesame_sdk/models.py +40 -0
- sesame_sdk-0.1.0/src/sesame_sdk/transport.py +53 -0
- sesame_sdk-0.1.0/src/sesame_sdk/webhook.py +60 -0
sesame_sdk-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sesame
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sesame-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for the Sesame human-in-the-loop approval API
|
|
5
|
+
Project-URL: Homepage, https://github.com/lavanpuri1999/sesame
|
|
6
|
+
Project-URL: Repository, https://github.com/lavanpuri1999/sesame
|
|
7
|
+
Project-URL: Issues, https://github.com/lavanpuri1999/sesame/issues
|
|
8
|
+
Author: Sesame
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agents,approval,authorization,human-in-the-loop,sesame,webhook
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Security
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.12
|
|
23
|
+
Requires-Dist: httpx>=0.27
|
|
24
|
+
Requires-Dist: pydantic>=2.6
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# sesame-sdk
|
|
32
|
+
|
|
33
|
+
Python SDK for the [Sesame](https://getsesame.dev) human-in-the-loop approval API.
|
|
34
|
+
|
|
35
|
+
Drop an approval gate in front of any sensitive operation in your backend. Sesame
|
|
36
|
+
triggers a request that a human approves (via Telegram + push), and your code blocks
|
|
37
|
+
until the decision lands — or reacts asynchronously via a webhook.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install sesame-sdk
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Requires Python 3.12+. Runtime deps: `httpx`, `pydantic`.
|
|
44
|
+
|
|
45
|
+
## Configuration
|
|
46
|
+
|
|
47
|
+
The client reads credentials from the environment, or you can pass them explicitly:
|
|
48
|
+
|
|
49
|
+
| Env var | Default | Purpose |
|
|
50
|
+
| ------------------- | ------------------------ | -------------------------------- |
|
|
51
|
+
| `SESAME_API_KEY` | _(required)_ | `sk_live_...` API key |
|
|
52
|
+
| `SESAME_BROKER_URL` | `http://localhost:8000` | Broker base URL |
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from sesame_sdk import Sesame
|
|
56
|
+
|
|
57
|
+
client = Sesame() # from env
|
|
58
|
+
client = Sesame(api_key="sk_live_...", base_url="https://broker.example.com")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Gate a function with `@require_approval`
|
|
62
|
+
|
|
63
|
+
The wrapped function only runs if a human approves. Denied/expired raises
|
|
64
|
+
`ApprovalDenied`; no decision before `timeout` raises `ApprovalTimeout`.
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from sesame_sdk import require_approval, ApprovalDenied
|
|
68
|
+
|
|
69
|
+
@require_approval(
|
|
70
|
+
"payments.refund",
|
|
71
|
+
summary=lambda amount, **_: f"Refund ${amount/100:.2f} to customer",
|
|
72
|
+
timeout=300,
|
|
73
|
+
)
|
|
74
|
+
def issue_refund(amount: int, customer_id: str) -> None:
|
|
75
|
+
stripe.Refund.create(amount=amount, customer=customer_id)
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
issue_refund(4200, customer_id="cus_123")
|
|
79
|
+
except ApprovalDenied:
|
|
80
|
+
log.warning("refund rejected by operator")
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`summary` may be a static string or a callable receiving the wrapped function's
|
|
84
|
+
arguments. Omit it for a sensible default built from the action and function name.
|
|
85
|
+
Pass `client=Sesame(...)` to use a specific client; otherwise a module-level default
|
|
86
|
+
is built lazily from the environment.
|
|
87
|
+
|
|
88
|
+
## Trigger and wait explicitly
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from sesame_sdk import Sesame
|
|
92
|
+
|
|
93
|
+
client = Sesame()
|
|
94
|
+
approval = client.approvals.trigger(
|
|
95
|
+
action="db.delete",
|
|
96
|
+
summary="Delete 1,204 rows from prod.orders",
|
|
97
|
+
reason="GDPR erasure request #882",
|
|
98
|
+
context={"table": "orders", "rows": 1204},
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
approval.wait(timeout=300) # blocks on the broker's long-poll until decided
|
|
102
|
+
if approval.approved:
|
|
103
|
+
run_deletion()
|
|
104
|
+
else:
|
|
105
|
+
print("decision:", approval.status) # "denied" or "expired"
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Receive decisions via webhook
|
|
109
|
+
|
|
110
|
+
When you pass a `callback_url`, the broker POSTs to it on every terminal decision.
|
|
111
|
+
Verify the signature before trusting the body — `verify_webhook` recomputes the
|
|
112
|
+
HMAC-SHA256 over the *raw* request body and rejects stale timestamps.
|
|
113
|
+
|
|
114
|
+
### Flask
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from flask import Flask, request, abort
|
|
118
|
+
from sesame_sdk import verify_webhook, WebhookVerificationError
|
|
119
|
+
|
|
120
|
+
app = Flask(__name__)
|
|
121
|
+
WEBHOOK_SECRET = os.environ["SESAME_WEBHOOK_SECRET"]
|
|
122
|
+
|
|
123
|
+
@app.post("/sesame/webhook")
|
|
124
|
+
def sesame_webhook():
|
|
125
|
+
try:
|
|
126
|
+
payload = verify_webhook(request.headers, request.get_data(), WEBHOOK_SECRET)
|
|
127
|
+
except WebhookVerificationError:
|
|
128
|
+
abort(400)
|
|
129
|
+
# payload: {"approval_id", "action", "status", "decided_at", "requester_label", "dedup_key"?}
|
|
130
|
+
handle_decision(payload["approval_id"], payload["status"])
|
|
131
|
+
return "", 204
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### FastAPI
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from fastapi import FastAPI, Request, Response, HTTPException
|
|
138
|
+
from sesame_sdk import verify_webhook, WebhookVerificationError
|
|
139
|
+
|
|
140
|
+
app = FastAPI()
|
|
141
|
+
|
|
142
|
+
@app.post("/sesame/webhook")
|
|
143
|
+
async def sesame_webhook(request: Request):
|
|
144
|
+
raw = await request.body() # must be the exact bytes the broker signed
|
|
145
|
+
try:
|
|
146
|
+
payload = verify_webhook(request.headers, raw, WEBHOOK_SECRET)
|
|
147
|
+
except WebhookVerificationError:
|
|
148
|
+
raise HTTPException(status_code=400, detail="bad signature")
|
|
149
|
+
handle_decision(payload["approval_id"], payload["status"])
|
|
150
|
+
return Response(status_code=204)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Exceptions
|
|
154
|
+
|
|
155
|
+
All inherit from `ApprovalError`:
|
|
156
|
+
|
|
157
|
+
- `ApprovalDenied` — terminal `denied`/`expired` (carries `.approval_id`, `.status`)
|
|
158
|
+
- `ApprovalTimeout` — no decision before the caller's timeout
|
|
159
|
+
- `SesameAuthError` — broker rejected the API key (HTTP 401)
|
|
160
|
+
- `NotFoundError` — unknown approval id (HTTP 404)
|
|
161
|
+
- `WebhookVerificationError` — bad/missing signature, stale timestamp, or bad JSON
|
|
162
|
+
|
|
163
|
+
## Notes
|
|
164
|
+
|
|
165
|
+
This SDK is synchronous for v1. An async client may be added later; until then, run
|
|
166
|
+
`approval.wait()` in a worker thread if you need to avoid blocking an event loop.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Publishing `sesame-sdk` to PyPI
|
|
2
|
+
|
|
3
|
+
> These steps are documented but **not run automatically**. Publishing requires a
|
|
4
|
+
> PyPI API token and explicit sign-off from a maintainer.
|
|
5
|
+
|
|
6
|
+
## Prerequisites
|
|
7
|
+
|
|
8
|
+
- [ ] **A license has been chosen.** No `LICENSE` file currently exists in the repo.
|
|
9
|
+
A license is a product/legal decision and must be made before the first public
|
|
10
|
+
release. Once chosen: add the `LICENSE` file to this package, restore the
|
|
11
|
+
`license` field in `pyproject.toml`, and add the matching `License ::` trove
|
|
12
|
+
classifier.
|
|
13
|
+
- [ ] `version` in `pyproject.toml` bumped (PyPI rejects re-uploading an existing version).
|
|
14
|
+
- [ ] A PyPI account with an API token (`pypi-...`). Store it securely; never commit it.
|
|
15
|
+
|
|
16
|
+
## 1. Build the artifacts
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
uv build
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
This produces both distributions in `dist/`:
|
|
23
|
+
|
|
24
|
+
- `dist/sesame_sdk-<version>-py3-none-any.whl`
|
|
25
|
+
- `dist/sesame_sdk-<version>.tar.gz`
|
|
26
|
+
|
|
27
|
+
## 2. (Recommended) Verify the build
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
uvx twine check dist/*
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## 3. Publish
|
|
34
|
+
|
|
35
|
+
Using uv (preferred):
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
# Requires PyPI token — pass via env or --token. Do NOT commit the token.
|
|
39
|
+
UV_PUBLISH_TOKEN="pypi-..." uv publish
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Or with twine:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
twine upload dist/* # prompts for username (__token__) + password (the pypi-... token)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Optionally test against TestPyPI first:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
UV_PUBLISH_TOKEN="pypi-..." uv publish --publish-url https://test.pypi.org/legacy/
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## 4. Verify the published package
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install sesame-sdk
|
|
58
|
+
python -c "import sesame_sdk; print(sesame_sdk.__version__)"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
**Do not run the publish command without explicit maintainer sign-off and a chosen license.**
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# sesame-sdk
|
|
2
|
+
|
|
3
|
+
Python SDK for the [Sesame](https://getsesame.dev) human-in-the-loop approval API.
|
|
4
|
+
|
|
5
|
+
Drop an approval gate in front of any sensitive operation in your backend. Sesame
|
|
6
|
+
triggers a request that a human approves (via Telegram + push), and your code blocks
|
|
7
|
+
until the decision lands — or reacts asynchronously via a webhook.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install sesame-sdk
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Requires Python 3.12+. Runtime deps: `httpx`, `pydantic`.
|
|
14
|
+
|
|
15
|
+
## Configuration
|
|
16
|
+
|
|
17
|
+
The client reads credentials from the environment, or you can pass them explicitly:
|
|
18
|
+
|
|
19
|
+
| Env var | Default | Purpose |
|
|
20
|
+
| ------------------- | ------------------------ | -------------------------------- |
|
|
21
|
+
| `SESAME_API_KEY` | _(required)_ | `sk_live_...` API key |
|
|
22
|
+
| `SESAME_BROKER_URL` | `http://localhost:8000` | Broker base URL |
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from sesame_sdk import Sesame
|
|
26
|
+
|
|
27
|
+
client = Sesame() # from env
|
|
28
|
+
client = Sesame(api_key="sk_live_...", base_url="https://broker.example.com")
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Gate a function with `@require_approval`
|
|
32
|
+
|
|
33
|
+
The wrapped function only runs if a human approves. Denied/expired raises
|
|
34
|
+
`ApprovalDenied`; no decision before `timeout` raises `ApprovalTimeout`.
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from sesame_sdk import require_approval, ApprovalDenied
|
|
38
|
+
|
|
39
|
+
@require_approval(
|
|
40
|
+
"payments.refund",
|
|
41
|
+
summary=lambda amount, **_: f"Refund ${amount/100:.2f} to customer",
|
|
42
|
+
timeout=300,
|
|
43
|
+
)
|
|
44
|
+
def issue_refund(amount: int, customer_id: str) -> None:
|
|
45
|
+
stripe.Refund.create(amount=amount, customer=customer_id)
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
issue_refund(4200, customer_id="cus_123")
|
|
49
|
+
except ApprovalDenied:
|
|
50
|
+
log.warning("refund rejected by operator")
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`summary` may be a static string or a callable receiving the wrapped function's
|
|
54
|
+
arguments. Omit it for a sensible default built from the action and function name.
|
|
55
|
+
Pass `client=Sesame(...)` to use a specific client; otherwise a module-level default
|
|
56
|
+
is built lazily from the environment.
|
|
57
|
+
|
|
58
|
+
## Trigger and wait explicitly
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from sesame_sdk import Sesame
|
|
62
|
+
|
|
63
|
+
client = Sesame()
|
|
64
|
+
approval = client.approvals.trigger(
|
|
65
|
+
action="db.delete",
|
|
66
|
+
summary="Delete 1,204 rows from prod.orders",
|
|
67
|
+
reason="GDPR erasure request #882",
|
|
68
|
+
context={"table": "orders", "rows": 1204},
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
approval.wait(timeout=300) # blocks on the broker's long-poll until decided
|
|
72
|
+
if approval.approved:
|
|
73
|
+
run_deletion()
|
|
74
|
+
else:
|
|
75
|
+
print("decision:", approval.status) # "denied" or "expired"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Receive decisions via webhook
|
|
79
|
+
|
|
80
|
+
When you pass a `callback_url`, the broker POSTs to it on every terminal decision.
|
|
81
|
+
Verify the signature before trusting the body — `verify_webhook` recomputes the
|
|
82
|
+
HMAC-SHA256 over the *raw* request body and rejects stale timestamps.
|
|
83
|
+
|
|
84
|
+
### Flask
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from flask import Flask, request, abort
|
|
88
|
+
from sesame_sdk import verify_webhook, WebhookVerificationError
|
|
89
|
+
|
|
90
|
+
app = Flask(__name__)
|
|
91
|
+
WEBHOOK_SECRET = os.environ["SESAME_WEBHOOK_SECRET"]
|
|
92
|
+
|
|
93
|
+
@app.post("/sesame/webhook")
|
|
94
|
+
def sesame_webhook():
|
|
95
|
+
try:
|
|
96
|
+
payload = verify_webhook(request.headers, request.get_data(), WEBHOOK_SECRET)
|
|
97
|
+
except WebhookVerificationError:
|
|
98
|
+
abort(400)
|
|
99
|
+
# payload: {"approval_id", "action", "status", "decided_at", "requester_label", "dedup_key"?}
|
|
100
|
+
handle_decision(payload["approval_id"], payload["status"])
|
|
101
|
+
return "", 204
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### FastAPI
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from fastapi import FastAPI, Request, Response, HTTPException
|
|
108
|
+
from sesame_sdk import verify_webhook, WebhookVerificationError
|
|
109
|
+
|
|
110
|
+
app = FastAPI()
|
|
111
|
+
|
|
112
|
+
@app.post("/sesame/webhook")
|
|
113
|
+
async def sesame_webhook(request: Request):
|
|
114
|
+
raw = await request.body() # must be the exact bytes the broker signed
|
|
115
|
+
try:
|
|
116
|
+
payload = verify_webhook(request.headers, raw, WEBHOOK_SECRET)
|
|
117
|
+
except WebhookVerificationError:
|
|
118
|
+
raise HTTPException(status_code=400, detail="bad signature")
|
|
119
|
+
handle_decision(payload["approval_id"], payload["status"])
|
|
120
|
+
return Response(status_code=204)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Exceptions
|
|
124
|
+
|
|
125
|
+
All inherit from `ApprovalError`:
|
|
126
|
+
|
|
127
|
+
- `ApprovalDenied` — terminal `denied`/`expired` (carries `.approval_id`, `.status`)
|
|
128
|
+
- `ApprovalTimeout` — no decision before the caller's timeout
|
|
129
|
+
- `SesameAuthError` — broker rejected the API key (HTTP 401)
|
|
130
|
+
- `NotFoundError` — unknown approval id (HTTP 404)
|
|
131
|
+
- `WebhookVerificationError` — bad/missing signature, stale timestamp, or bad JSON
|
|
132
|
+
|
|
133
|
+
## Notes
|
|
134
|
+
|
|
135
|
+
This SDK is synchronous for v1. An async client may be added later; until then, run
|
|
136
|
+
`approval.wait()` in a worker thread if you need to avoid blocking an event loop.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sesame-sdk"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python SDK for the Sesame human-in-the-loop approval API"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
authors = [{ name = "Sesame" }]
|
|
8
|
+
keywords = [
|
|
9
|
+
"sesame",
|
|
10
|
+
"approval",
|
|
11
|
+
"human-in-the-loop",
|
|
12
|
+
"webhook",
|
|
13
|
+
"authorization",
|
|
14
|
+
"agents",
|
|
15
|
+
]
|
|
16
|
+
license = { text = "MIT" }
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 4 - Beta",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
26
|
+
"Topic :: Security",
|
|
27
|
+
"Typing :: Typed",
|
|
28
|
+
]
|
|
29
|
+
dependencies = [
|
|
30
|
+
"httpx>=0.27",
|
|
31
|
+
"pydantic>=2.6",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/lavanpuri1999/sesame"
|
|
36
|
+
Repository = "https://github.com/lavanpuri1999/sesame"
|
|
37
|
+
Issues = "https://github.com/lavanpuri1999/sesame/issues"
|
|
38
|
+
|
|
39
|
+
[project.optional-dependencies]
|
|
40
|
+
dev = [
|
|
41
|
+
"pytest>=8.0",
|
|
42
|
+
"respx>=0.21",
|
|
43
|
+
"ruff>=0.4",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[build-system]
|
|
47
|
+
requires = ["hatchling"]
|
|
48
|
+
build-backend = "hatchling.build"
|
|
49
|
+
|
|
50
|
+
[tool.hatch.build.targets.wheel]
|
|
51
|
+
packages = ["src/sesame_sdk"]
|
|
52
|
+
|
|
53
|
+
[tool.hatch.build.targets.sdist]
|
|
54
|
+
include = [
|
|
55
|
+
"src/sesame_sdk",
|
|
56
|
+
"README.md",
|
|
57
|
+
"PUBLISHING.md",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
[tool.pytest.ini_options]
|
|
61
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .approval import Approval
|
|
4
|
+
from .client import Sesame
|
|
5
|
+
from .decorator import require_approval
|
|
6
|
+
from .exceptions import (
|
|
7
|
+
ApprovalDenied,
|
|
8
|
+
ApprovalError,
|
|
9
|
+
ApprovalTimeout,
|
|
10
|
+
NotFoundError,
|
|
11
|
+
SesameAuthError,
|
|
12
|
+
WebhookVerificationError,
|
|
13
|
+
)
|
|
14
|
+
from .models import ApprovalState, TriggerResponse, WebhookPayload
|
|
15
|
+
from .webhook import verify_webhook
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Sesame",
|
|
21
|
+
"Approval",
|
|
22
|
+
"require_approval",
|
|
23
|
+
"verify_webhook",
|
|
24
|
+
"ApprovalError",
|
|
25
|
+
"ApprovalDenied",
|
|
26
|
+
"ApprovalTimeout",
|
|
27
|
+
"WebhookVerificationError",
|
|
28
|
+
"SesameAuthError",
|
|
29
|
+
"NotFoundError",
|
|
30
|
+
"ApprovalState",
|
|
31
|
+
"TriggerResponse",
|
|
32
|
+
"WebhookPayload",
|
|
33
|
+
"__version__",
|
|
34
|
+
]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from .exceptions import ApprovalTimeout
|
|
8
|
+
from .models import TERMINAL_STATUSES, ApprovalState, TriggerResponse
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .approvals import Approvals
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Approval:
|
|
15
|
+
"""A single approval request, with helpers to block until it is decided."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
approval_id: str,
|
|
20
|
+
status: str,
|
|
21
|
+
approvals: Approvals,
|
|
22
|
+
expires_at: datetime | None = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
self.approval_id = approval_id
|
|
25
|
+
self.status = status
|
|
26
|
+
self.expires_at = expires_at
|
|
27
|
+
self._approvals = approvals
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def _from_trigger(cls, data: TriggerResponse, approvals: Approvals) -> Approval:
|
|
31
|
+
return cls(data.approval_id, data.status, approvals)
|
|
32
|
+
|
|
33
|
+
def _apply(self, state: ApprovalState) -> None:
|
|
34
|
+
self.status = state.status
|
|
35
|
+
self.expires_at = state.expires_at
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def approved(self) -> bool:
|
|
39
|
+
return self.status == "approved"
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def is_terminal(self) -> bool:
|
|
43
|
+
return self.status in TERMINAL_STATUSES
|
|
44
|
+
|
|
45
|
+
def refresh(self, *, wait: bool = False) -> Approval:
|
|
46
|
+
"""Fetch current state from the broker. `wait=True` long-polls server-side."""
|
|
47
|
+
self._apply(self._approvals._get(self.approval_id, wait=wait))
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
def wait(self, timeout: float = 300.0, poll_interval: float = 2.0) -> Approval:
|
|
51
|
+
"""Block until the approval reaches a terminal state or `timeout` seconds elapse.
|
|
52
|
+
|
|
53
|
+
The broker's `?wait=true` already long-polls (~25-30s), so each iteration mostly
|
|
54
|
+
re-issues that long-poll; `poll_interval` is only a floor between cheap returns
|
|
55
|
+
(e.g. an immediate "still pending") to avoid hammering the broker.
|
|
56
|
+
"""
|
|
57
|
+
deadline = time.monotonic() + timeout
|
|
58
|
+
while True:
|
|
59
|
+
if self.is_terminal:
|
|
60
|
+
return self
|
|
61
|
+
if time.monotonic() >= deadline:
|
|
62
|
+
raise ApprovalTimeout(
|
|
63
|
+
f"Approval {self.approval_id} not decided within {timeout}s (status={self.status})",
|
|
64
|
+
approval_id=self.approval_id,
|
|
65
|
+
)
|
|
66
|
+
started = time.monotonic()
|
|
67
|
+
self.refresh(wait=True)
|
|
68
|
+
if self.is_terminal:
|
|
69
|
+
return self
|
|
70
|
+
# Floor the loop only if the long-poll returned faster than poll_interval.
|
|
71
|
+
elapsed = time.monotonic() - started
|
|
72
|
+
remaining = min(poll_interval - elapsed, deadline - time.monotonic())
|
|
73
|
+
if remaining > 0:
|
|
74
|
+
time.sleep(remaining)
|
|
75
|
+
|
|
76
|
+
def __repr__(self) -> str:
|
|
77
|
+
return f"Approval(approval_id={self.approval_id!r}, status={self.status!r})"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .approval import Approval
|
|
6
|
+
from .models import ApprovalState, TriggerResponse
|
|
7
|
+
from .transport import Transport
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Approvals:
|
|
11
|
+
"""Resource handle for the /v1/approvals endpoints."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, transport: Transport) -> None:
|
|
14
|
+
self._transport = transport
|
|
15
|
+
|
|
16
|
+
def trigger(
|
|
17
|
+
self,
|
|
18
|
+
action: str,
|
|
19
|
+
summary: str,
|
|
20
|
+
*,
|
|
21
|
+
reason: str | None = None,
|
|
22
|
+
dedup_key: str | None = None,
|
|
23
|
+
callback_url: str | None = None,
|
|
24
|
+
severity: str | None = None,
|
|
25
|
+
context: dict[str, Any] | None = None,
|
|
26
|
+
) -> Approval:
|
|
27
|
+
"""Request a human approval. Returns immediately with a pending Approval."""
|
|
28
|
+
body: dict[str, Any] = {"action": action, "summary": summary}
|
|
29
|
+
optional = {
|
|
30
|
+
"reason": reason,
|
|
31
|
+
"dedup_key": dedup_key,
|
|
32
|
+
"callback_url": callback_url,
|
|
33
|
+
"severity": severity,
|
|
34
|
+
"context": context,
|
|
35
|
+
}
|
|
36
|
+
body.update({k: v for k, v in optional.items() if v is not None})
|
|
37
|
+
|
|
38
|
+
data = self._transport.request("POST", "/v1/approvals", json=body)
|
|
39
|
+
return Approval._from_trigger(TriggerResponse.model_validate(data), self)
|
|
40
|
+
|
|
41
|
+
def get(self, approval_id: str, *, wait: bool = False) -> Approval:
|
|
42
|
+
"""Fetch the current state of an approval as an Approval object."""
|
|
43
|
+
state = self._get(approval_id, wait=wait)
|
|
44
|
+
return Approval(
|
|
45
|
+
state.approval_id, state.status, self, expires_at=state.expires_at
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def _get(self, approval_id: str, *, wait: bool) -> ApprovalState:
|
|
49
|
+
params = {"wait": "true"} if wait else None
|
|
50
|
+
data = self._transport.request(
|
|
51
|
+
"GET", f"/v1/approvals/{approval_id}", params=params
|
|
52
|
+
)
|
|
53
|
+
return ApprovalState.model_validate(data)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from .approvals import Approvals
|
|
6
|
+
from .transport import Transport
|
|
7
|
+
|
|
8
|
+
DEFAULT_BASE_URL = "http://localhost:8000"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Sesame:
|
|
12
|
+
"""Client for the Sesame human-in-the-loop approval API.
|
|
13
|
+
|
|
14
|
+
api_key falls back to $SESAME_API_KEY, base_url to $SESAME_BROKER_URL (then localhost).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
api_key: str | None = None,
|
|
20
|
+
base_url: str | None = None,
|
|
21
|
+
timeout: float = 30.0,
|
|
22
|
+
) -> None:
|
|
23
|
+
api_key = api_key or os.environ.get("SESAME_API_KEY")
|
|
24
|
+
if not api_key:
|
|
25
|
+
raise ValueError(
|
|
26
|
+
"No Sesame API key provided. Pass api_key=... or set the SESAME_API_KEY environment variable."
|
|
27
|
+
)
|
|
28
|
+
base_url = base_url or os.environ.get("SESAME_BROKER_URL") or DEFAULT_BASE_URL
|
|
29
|
+
|
|
30
|
+
self._transport = Transport(base_url=base_url, api_key=api_key, timeout=timeout)
|
|
31
|
+
self.approvals = Approvals(self._transport)
|
|
32
|
+
|
|
33
|
+
def close(self) -> None:
|
|
34
|
+
self._transport.close()
|
|
35
|
+
|
|
36
|
+
def __enter__(self) -> Sesame:
|
|
37
|
+
return self
|
|
38
|
+
|
|
39
|
+
def __exit__(self, *_exc: object) -> None:
|
|
40
|
+
self.close()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
from typing import Any, Callable, TypeVar
|
|
5
|
+
|
|
6
|
+
from .client import Sesame
|
|
7
|
+
from .exceptions import ApprovalDenied
|
|
8
|
+
|
|
9
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
10
|
+
|
|
11
|
+
# Lazily-built module-level client, shared by decorators that don't pass their own.
|
|
12
|
+
_default_client: Sesame | None = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _get_default_client() -> Sesame:
|
|
16
|
+
global _default_client
|
|
17
|
+
if _default_client is None:
|
|
18
|
+
_default_client = Sesame()
|
|
19
|
+
return _default_client
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def require_approval(
|
|
23
|
+
action: str,
|
|
24
|
+
*,
|
|
25
|
+
summary: str | Callable[..., str] | None = None,
|
|
26
|
+
reason: str | None = None,
|
|
27
|
+
timeout: float = 300.0,
|
|
28
|
+
client: Sesame | None = None,
|
|
29
|
+
) -> Callable[[F], F]:
|
|
30
|
+
"""Gate a sync function behind a human approval.
|
|
31
|
+
|
|
32
|
+
Before each call the wrapped function triggers an approval and blocks until decided.
|
|
33
|
+
Denied/expired -> ApprovalDenied; no decision in time -> ApprovalTimeout (from wait()).
|
|
34
|
+
`summary` may be a static string or a callable receiving the same (*args, **kwargs).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def decorator(func: F) -> F:
|
|
38
|
+
@functools.wraps(func)
|
|
39
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
40
|
+
active = client or _get_default_client()
|
|
41
|
+
|
|
42
|
+
if callable(summary):
|
|
43
|
+
resolved_summary = summary(*args, **kwargs)
|
|
44
|
+
elif summary is not None:
|
|
45
|
+
resolved_summary = summary
|
|
46
|
+
else:
|
|
47
|
+
resolved_summary = (
|
|
48
|
+
f"Approval required for {action} (via {func.__name__})"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
approval = active.approvals.trigger(
|
|
52
|
+
action,
|
|
53
|
+
resolved_summary,
|
|
54
|
+
reason=reason,
|
|
55
|
+
)
|
|
56
|
+
approval.wait(timeout=timeout)
|
|
57
|
+
if not approval.approved:
|
|
58
|
+
raise ApprovalDenied(
|
|
59
|
+
f"Approval for {action!r} was {approval.status}",
|
|
60
|
+
approval_id=approval.approval_id,
|
|
61
|
+
status=approval.status,
|
|
62
|
+
)
|
|
63
|
+
return func(*args, **kwargs)
|
|
64
|
+
|
|
65
|
+
return wrapper # type: ignore[return-value]
|
|
66
|
+
|
|
67
|
+
return decorator
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ApprovalError(Exception):
|
|
5
|
+
"""Base class for all Sesame SDK errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SesameAuthError(ApprovalError):
|
|
9
|
+
"""Raised when the broker rejects the API key (HTTP 401)."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NotFoundError(ApprovalError):
|
|
13
|
+
"""Raised when an approval id is unknown to the broker (HTTP 404)."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ApprovalDenied(ApprovalError):
|
|
17
|
+
"""Raised when an approval reached a terminal non-approved state (denied/expired)."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self, message: str, *, approval_id: str | None = None, status: str | None = None
|
|
21
|
+
) -> None:
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.approval_id = approval_id
|
|
24
|
+
self.status = status
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ApprovalTimeout(ApprovalError):
|
|
28
|
+
"""Raised when waiting for a decision exceeded the caller's timeout."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, message: str, *, approval_id: str | None = None) -> None:
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
self.approval_id = approval_id
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class WebhookVerificationError(ApprovalError):
|
|
36
|
+
"""Raised when a webhook signature/timestamp fails verification."""
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict
|
|
6
|
+
|
|
7
|
+
# Terminal states: no further decision can change them.
|
|
8
|
+
TERMINAL_STATUSES = frozenset({"approved", "denied", "expired"})
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TriggerResponse(BaseModel):
|
|
12
|
+
"""Response body from POST /v1/approvals (202)."""
|
|
13
|
+
|
|
14
|
+
model_config = ConfigDict(extra="ignore")
|
|
15
|
+
|
|
16
|
+
approval_id: str
|
|
17
|
+
status: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ApprovalState(BaseModel):
|
|
21
|
+
"""Response body from GET /v1/approvals/{id} (200)."""
|
|
22
|
+
|
|
23
|
+
model_config = ConfigDict(extra="ignore")
|
|
24
|
+
|
|
25
|
+
approval_id: str
|
|
26
|
+
status: str
|
|
27
|
+
expires_at: datetime | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class WebhookPayload(BaseModel):
|
|
31
|
+
"""Body the broker POSTs to a caller's callback_url on a terminal decision."""
|
|
32
|
+
|
|
33
|
+
model_config = ConfigDict(extra="ignore")
|
|
34
|
+
|
|
35
|
+
approval_id: str
|
|
36
|
+
action: str
|
|
37
|
+
status: str
|
|
38
|
+
decided_at: datetime
|
|
39
|
+
requester_label: str | None = None
|
|
40
|
+
dedup_key: str | None = None
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from .exceptions import ApprovalError, NotFoundError, SesameAuthError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _detail(response: httpx.Response) -> str:
|
|
11
|
+
"""Pull the broker's `{"detail": ...}` message, falling back to raw text."""
|
|
12
|
+
try:
|
|
13
|
+
body = response.json()
|
|
14
|
+
except ValueError:
|
|
15
|
+
return response.text or response.reason_phrase
|
|
16
|
+
if isinstance(body, dict) and "detail" in body:
|
|
17
|
+
return str(body["detail"])
|
|
18
|
+
return response.text or response.reason_phrase
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Transport:
|
|
22
|
+
"""Thin httpx.Client wrapper that sets auth once and maps errors to SDK exceptions."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, base_url: str, api_key: str, timeout: float) -> None:
|
|
25
|
+
self._client = httpx.Client(
|
|
26
|
+
base_url=base_url.rstrip("/"),
|
|
27
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
28
|
+
timeout=timeout,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
32
|
+
response = self._client.request(method, path, **kwargs)
|
|
33
|
+
return self._handle(response)
|
|
34
|
+
|
|
35
|
+
def _handle(self, response: httpx.Response) -> dict[str, Any]:
|
|
36
|
+
if response.is_success:
|
|
37
|
+
return response.json()
|
|
38
|
+
if response.status_code == 401:
|
|
39
|
+
raise SesameAuthError(_detail(response))
|
|
40
|
+
if response.status_code == 404:
|
|
41
|
+
raise NotFoundError(_detail(response))
|
|
42
|
+
raise ApprovalError(
|
|
43
|
+
f"Sesame request failed ({response.status_code}): {_detail(response)}"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def close(self) -> None:
|
|
47
|
+
self._client.close()
|
|
48
|
+
|
|
49
|
+
def __enter__(self) -> Transport:
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
def __exit__(self, *_exc: object) -> None:
|
|
53
|
+
self.close()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Mapping
|
|
8
|
+
|
|
9
|
+
from .exceptions import WebhookVerificationError
|
|
10
|
+
|
|
11
|
+
SIGNATURE_HEADER = "X-Sesame-Signature"
|
|
12
|
+
TIMESTAMP_HEADER = "X-Sesame-Timestamp"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _get_header(headers: Mapping[str, str], name: str) -> str | None:
|
|
16
|
+
# HTTP header lookups are case-insensitive; callers may hand us a plain dict.
|
|
17
|
+
target = name.lower()
|
|
18
|
+
for key, value in headers.items():
|
|
19
|
+
if key.lower() == target:
|
|
20
|
+
return value
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def verify_webhook(
|
|
25
|
+
headers: Mapping[str, str],
|
|
26
|
+
body: bytes | str,
|
|
27
|
+
secret: str,
|
|
28
|
+
*,
|
|
29
|
+
tolerance: int = 300,
|
|
30
|
+
) -> dict[str, Any]:
|
|
31
|
+
"""Verify a Sesame webhook and return its parsed JSON payload.
|
|
32
|
+
|
|
33
|
+
Recomputes HMAC-SHA256 over the *exact* raw body and constant-time compares it to
|
|
34
|
+
X-Sesame-Signature; rejects bodies whose X-Sesame-Timestamp is older than `tolerance`
|
|
35
|
+
seconds. Raises WebhookVerificationError on any failure.
|
|
36
|
+
"""
|
|
37
|
+
raw = body.encode("utf-8") if isinstance(body, str) else body
|
|
38
|
+
|
|
39
|
+
signature = _get_header(headers, SIGNATURE_HEADER)
|
|
40
|
+
if not signature:
|
|
41
|
+
raise WebhookVerificationError(f"Missing {SIGNATURE_HEADER} header")
|
|
42
|
+
|
|
43
|
+
timestamp = _get_header(headers, TIMESTAMP_HEADER)
|
|
44
|
+
if not timestamp:
|
|
45
|
+
raise WebhookVerificationError(f"Missing {TIMESTAMP_HEADER} header")
|
|
46
|
+
try:
|
|
47
|
+
ts = int(timestamp)
|
|
48
|
+
except ValueError as exc:
|
|
49
|
+
raise WebhookVerificationError(f"Invalid {TIMESTAMP_HEADER} header") from exc
|
|
50
|
+
if abs(time.time() - ts) > tolerance:
|
|
51
|
+
raise WebhookVerificationError("Webhook timestamp outside tolerance window")
|
|
52
|
+
|
|
53
|
+
expected = hmac.new(secret.encode("utf-8"), raw, hashlib.sha256).hexdigest()
|
|
54
|
+
if not hmac.compare_digest(expected, signature):
|
|
55
|
+
raise WebhookVerificationError("Webhook signature mismatch")
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
return json.loads(raw)
|
|
59
|
+
except ValueError as exc:
|
|
60
|
+
raise WebhookVerificationError("Webhook body is not valid JSON") from exc
|