svc-infra 0.1.597__py3-none-any.whl → 0.1.598__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.
Potentially problematic release.
This version of svc-infra might be problematic. Click here for more details.
- svc_infra/db/inbox.py +12 -0
- svc_infra/jobs/builtins/webhook_delivery.py +35 -16
- svc_infra/webhooks/__init__.py +1 -0
- svc_infra/webhooks/fastapi.py +37 -0
- svc_infra/webhooks/router.py +55 -0
- svc_infra/webhooks/service.py +59 -0
- svc_infra/webhooks/signing.py +30 -0
- {svc_infra-0.1.597.dist-info → svc_infra-0.1.598.dist-info}/METADATA +1 -1
- {svc_infra-0.1.597.dist-info → svc_infra-0.1.598.dist-info}/RECORD +11 -6
- {svc_infra-0.1.597.dist-info → svc_infra-0.1.598.dist-info}/WHEEL +0 -0
- {svc_infra-0.1.597.dist-info → svc_infra-0.1.598.dist-info}/entry_points.txt +0 -0
svc_infra/db/inbox.py
CHANGED
|
@@ -13,6 +13,10 @@ class InboxStore(Protocol):
|
|
|
13
13
|
"""Optional: remove expired keys, return number purged."""
|
|
14
14
|
...
|
|
15
15
|
|
|
16
|
+
def is_marked(self, key: str) -> bool:
|
|
17
|
+
"""Return True if key is already marked (not expired), without modifying it."""
|
|
18
|
+
...
|
|
19
|
+
|
|
16
20
|
|
|
17
21
|
class InMemoryInboxStore:
|
|
18
22
|
def __init__(self) -> None:
|
|
@@ -33,6 +37,11 @@ class InMemoryInboxStore:
|
|
|
33
37
|
self._keys.pop(k, None)
|
|
34
38
|
return len(to_del)
|
|
35
39
|
|
|
40
|
+
def is_marked(self, key: str) -> bool:
|
|
41
|
+
now = time.time()
|
|
42
|
+
exp = self._keys.get(key)
|
|
43
|
+
return bool(exp and exp > now)
|
|
44
|
+
|
|
36
45
|
|
|
37
46
|
class SqlInboxStore:
|
|
38
47
|
"""Skeleton for a SQL-backed inbox store (dedupe table).
|
|
@@ -53,3 +62,6 @@ class SqlInboxStore:
|
|
|
53
62
|
|
|
54
63
|
def purge_expired(self) -> int: # pragma: no cover - skeleton
|
|
55
64
|
raise NotImplementedError
|
|
65
|
+
|
|
66
|
+
def is_marked(self, key: str) -> bool: # pragma: no cover - skeleton
|
|
67
|
+
raise NotImplementedError
|
|
@@ -1,20 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import hashlib
|
|
4
|
-
import hmac
|
|
5
|
-
import json
|
|
6
|
-
from typing import Dict
|
|
7
|
-
|
|
8
3
|
import httpx
|
|
9
4
|
|
|
10
5
|
from svc_infra.db.inbox import InboxStore
|
|
11
6
|
from svc_infra.db.outbox import OutboxStore
|
|
12
7
|
from svc_infra.jobs.queue import Job
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def _compute_signature(secret: str, payload: Dict) -> str:
|
|
16
|
-
body = json.dumps(payload, separators=(",", ":")).encode()
|
|
17
|
-
return hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
|
8
|
+
from svc_infra.webhooks.signing import sign
|
|
18
9
|
|
|
19
10
|
|
|
20
11
|
def make_webhook_handler(
|
|
@@ -39,18 +30,46 @@ def make_webhook_handler(
|
|
|
39
30
|
if not outbox_id or not topic:
|
|
40
31
|
# Nothing we can do; ack to avoid poison loop
|
|
41
32
|
return
|
|
42
|
-
# dedupe
|
|
33
|
+
# dedupe marker key (marked after successful delivery)
|
|
43
34
|
key = f"webhook:{outbox_id}"
|
|
44
|
-
if
|
|
35
|
+
if inbox.is_marked(key):
|
|
45
36
|
# already delivered
|
|
46
37
|
outbox.mark_processed(int(outbox_id))
|
|
47
38
|
return
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
39
|
+
event = payload.get("event") if isinstance(payload, dict) else None
|
|
40
|
+
subscription = payload.get("subscription") if isinstance(payload, dict) else None
|
|
41
|
+
if event is not None and subscription is not None:
|
|
42
|
+
delivery_payload = event
|
|
43
|
+
url = subscription.get("url") or get_webhook_url_for_topic(topic)
|
|
44
|
+
secret = subscription.get("secret") or get_secret_for_topic(topic)
|
|
45
|
+
subscription_id = subscription.get("id")
|
|
46
|
+
else:
|
|
47
|
+
delivery_payload = payload
|
|
48
|
+
url = get_webhook_url_for_topic(topic)
|
|
49
|
+
secret = get_secret_for_topic(topic)
|
|
50
|
+
subscription_id = None
|
|
51
|
+
sig = sign(secret, delivery_payload)
|
|
52
|
+
headers = {
|
|
53
|
+
header_name: sig,
|
|
54
|
+
"X-Event-Id": str(outbox_id),
|
|
55
|
+
"X-Topic": str(topic),
|
|
56
|
+
"X-Attempt": str(job.attempts or 1),
|
|
57
|
+
"X-Signature-Alg": "hmac-sha256",
|
|
58
|
+
"X-Signature-Version": "v1",
|
|
59
|
+
}
|
|
60
|
+
if subscription_id:
|
|
61
|
+
headers["X-Webhook-Subscription"] = str(subscription_id)
|
|
62
|
+
# include event payload version if present
|
|
63
|
+
version = None
|
|
64
|
+
if isinstance(delivery_payload, dict):
|
|
65
|
+
version = delivery_payload.get("version")
|
|
66
|
+
if version is not None:
|
|
67
|
+
headers["X-Payload-Version"] = str(version)
|
|
51
68
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
52
|
-
resp = await client.post(url, json=
|
|
69
|
+
resp = await client.post(url, json=delivery_payload, headers=headers)
|
|
53
70
|
if 200 <= resp.status_code < 300:
|
|
71
|
+
# record delivery and mark processed
|
|
72
|
+
inbox.mark_if_new(key, ttl_seconds=24 * 3600)
|
|
54
73
|
outbox.mark_processed(int(outbox_id))
|
|
55
74
|
return
|
|
56
75
|
# allow retry on non-2xx: raise to trigger fail/backoff
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Callable, Sequence
|
|
4
|
+
|
|
5
|
+
from fastapi import HTTPException, Request, status
|
|
6
|
+
|
|
7
|
+
from .signing import verify, verify_any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def require_signature(
|
|
11
|
+
secrets_provider: Callable[[], str | Sequence[str]],
|
|
12
|
+
*,
|
|
13
|
+
header_name: str = "X-Signature",
|
|
14
|
+
):
|
|
15
|
+
async def _dep(request: Request):
|
|
16
|
+
sig = request.headers.get(header_name)
|
|
17
|
+
if not sig:
|
|
18
|
+
raise HTTPException(
|
|
19
|
+
status_code=status.HTTP_401_UNAUTHORIZED, detail="missing signature"
|
|
20
|
+
)
|
|
21
|
+
try:
|
|
22
|
+
body = await request.json()
|
|
23
|
+
except Exception:
|
|
24
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid JSON body")
|
|
25
|
+
secrets = secrets_provider()
|
|
26
|
+
ok = False
|
|
27
|
+
if isinstance(secrets, str):
|
|
28
|
+
ok = verify(secrets, body, sig)
|
|
29
|
+
else:
|
|
30
|
+
ok = verify_any(secrets, body, sig)
|
|
31
|
+
if not ok:
|
|
32
|
+
raise HTTPException(
|
|
33
|
+
status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid signature"
|
|
34
|
+
)
|
|
35
|
+
return body
|
|
36
|
+
|
|
37
|
+
return _dep
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
6
|
+
|
|
7
|
+
from svc_infra.db.outbox import InMemoryOutboxStore, OutboxStore
|
|
8
|
+
|
|
9
|
+
from .service import InMemoryWebhookSubscriptions, WebhookService
|
|
10
|
+
|
|
11
|
+
router = APIRouter(prefix="/_webhooks", tags=["webhooks"])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_outbox() -> OutboxStore:
|
|
15
|
+
# For now expose an in-memory default. Apps can override via DI.
|
|
16
|
+
# In production, provide a proper store through dependency override.
|
|
17
|
+
return InMemoryOutboxStore()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_subs() -> InMemoryWebhookSubscriptions:
|
|
21
|
+
return InMemoryWebhookSubscriptions()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_service(
|
|
25
|
+
outbox: OutboxStore = Depends(get_outbox),
|
|
26
|
+
subs: InMemoryWebhookSubscriptions = Depends(get_subs),
|
|
27
|
+
) -> WebhookService:
|
|
28
|
+
return WebhookService(outbox=outbox, subs=subs)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@router.post("/subscriptions")
|
|
32
|
+
def add_subscription(
|
|
33
|
+
body: Dict[str, Any],
|
|
34
|
+
subs: InMemoryWebhookSubscriptions = Depends(get_subs),
|
|
35
|
+
):
|
|
36
|
+
topic = body.get("topic")
|
|
37
|
+
url = body.get("url")
|
|
38
|
+
secret = body.get("secret")
|
|
39
|
+
if not topic or not url or not secret:
|
|
40
|
+
raise HTTPException(status_code=400, detail="Missing topic/url/secret")
|
|
41
|
+
subs.add(topic, url, secret)
|
|
42
|
+
return {"ok": True}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@router.post("/test-fire")
|
|
46
|
+
def test_fire(
|
|
47
|
+
body: Dict[str, Any],
|
|
48
|
+
svc: WebhookService = Depends(get_service),
|
|
49
|
+
):
|
|
50
|
+
topic = body.get("topic")
|
|
51
|
+
payload = body.get("payload") or {}
|
|
52
|
+
if not topic:
|
|
53
|
+
raise HTTPException(status_code=400, detail="Missing topic")
|
|
54
|
+
outbox_id = svc.publish(topic, payload)
|
|
55
|
+
return {"ok": True, "outbox_id": outbox_id}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Dict, List
|
|
6
|
+
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
from svc_infra.db.outbox import OutboxStore
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class WebhookSubscription:
|
|
14
|
+
topic: str
|
|
15
|
+
url: str
|
|
16
|
+
secret: str
|
|
17
|
+
id: str = field(default_factory=lambda: uuid4().hex)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class InMemoryWebhookSubscriptions:
|
|
21
|
+
def __init__(self):
|
|
22
|
+
self._subs: Dict[str, List[WebhookSubscription]] = {}
|
|
23
|
+
|
|
24
|
+
def add(self, topic: str, url: str, secret: str) -> None:
|
|
25
|
+
self._subs.setdefault(topic, []).append(WebhookSubscription(topic, url, secret))
|
|
26
|
+
|
|
27
|
+
def get_for_topic(self, topic: str) -> List[WebhookSubscription]:
|
|
28
|
+
return list(self._subs.get(topic, []))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class WebhookService:
|
|
32
|
+
def __init__(self, outbox: OutboxStore, subs: InMemoryWebhookSubscriptions):
|
|
33
|
+
self._outbox = outbox
|
|
34
|
+
self._subs = subs
|
|
35
|
+
|
|
36
|
+
def publish(self, topic: str, payload: Dict, *, version: int = 1) -> int:
|
|
37
|
+
created_at = datetime.now(timezone.utc).isoformat()
|
|
38
|
+
base_event = {
|
|
39
|
+
"topic": topic,
|
|
40
|
+
"payload": payload,
|
|
41
|
+
"version": version,
|
|
42
|
+
"created_at": created_at,
|
|
43
|
+
}
|
|
44
|
+
# For each subscription, enqueue an outbox message with subscriber identity
|
|
45
|
+
last_id = 0
|
|
46
|
+
for sub in self._subs.get_for_topic(topic):
|
|
47
|
+
event = dict(base_event)
|
|
48
|
+
msg_payload = {
|
|
49
|
+
"event": event,
|
|
50
|
+
"subscription": {
|
|
51
|
+
"id": sub.id,
|
|
52
|
+
"topic": sub.topic,
|
|
53
|
+
"url": sub.url,
|
|
54
|
+
"secret": sub.secret,
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
msg = self._outbox.enqueue(topic, msg_payload)
|
|
58
|
+
last_id = msg.id
|
|
59
|
+
return last_id
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
from typing import Dict, Iterable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def canonical_body(payload: Dict) -> bytes:
|
|
10
|
+
return json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def sign(secret: str, payload: Dict) -> str:
|
|
14
|
+
body = canonical_body(payload)
|
|
15
|
+
return hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def verify(secret: str, payload: Dict, signature: str) -> bool:
|
|
19
|
+
expected = sign(secret, payload)
|
|
20
|
+
try:
|
|
21
|
+
return hmac.compare_digest(expected, signature)
|
|
22
|
+
except Exception:
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def verify_any(secrets: Iterable[str], payload: Dict, signature: str) -> bool:
|
|
27
|
+
for s in secrets:
|
|
28
|
+
if verify(s, payload, signature):
|
|
29
|
+
return True
|
|
30
|
+
return False
|
|
@@ -141,7 +141,7 @@ svc_infra/cli/foundation/runner.py,sha256=RbfjKwb3aHk1Y0MYU8xMpKRpIqRVMVr8GuL2ED
|
|
|
141
141
|
svc_infra/cli/foundation/typer_bootstrap.py,sha256=KapgH1R-qON9FuYH1KYlVx_5sJvjmAGl25pB61XCpm4,985
|
|
142
142
|
svc_infra/db/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
143
143
|
svc_infra/db/crud_schema.py,sha256=-fv-Om1lHVt6lcNbie6A2kRcPex4SDByUPfks6SpmUc,2521
|
|
144
|
-
svc_infra/db/inbox.py,sha256=
|
|
144
|
+
svc_infra/db/inbox.py,sha256=drxLRLHaMRrCDgo_8wj12do80wDh5ssHV6LGkaM98no,1996
|
|
145
145
|
svc_infra/db/nosql/__init__.py,sha256=5ETPHk-KYUtc-efuGzDFQmWkT0xFtYy8YWOHobMZhvM,154
|
|
146
146
|
svc_infra/db/nosql/base.py,sha256=p47VVpwWvGNkyWe5RDSmGaUFyZovcyNqirMqoHFQ4QU,230
|
|
147
147
|
svc_infra/db/nosql/constants.py,sha256=Z9bJImxwb8D7vovASFegv8XMwaWcM28tsKJV2SjywXE,416
|
|
@@ -195,7 +195,7 @@ svc_infra/db/sql/utils.py,sha256=nzuDcDhnVNehx5Y9BZLgxw8fvpfYbxTfXQsgnznVf4w,328
|
|
|
195
195
|
svc_infra/db/sql/versioning.py,sha256=okZu2ad5RAFXNLXJgGpcQvZ5bc6gPjRWzwiBT0rEJJw,400
|
|
196
196
|
svc_infra/db/utils.py,sha256=aTD49VJSEu319kIWJ1uijUoP51co4lNJ3S0_tvuyGio,802
|
|
197
197
|
svc_infra/jobs/builtins/outbox_processor.py,sha256=VZoehNyjdaV_MmV74WMcbZR6z9E3VFMtZC-pxEwK0x0,1247
|
|
198
|
-
svc_infra/jobs/builtins/webhook_delivery.py,sha256=
|
|
198
|
+
svc_infra/jobs/builtins/webhook_delivery.py,sha256=z_cl6YKwnduGjGaB8ZoUpKhFcEAhUZqqBma8v2FO1so,2982
|
|
199
199
|
svc_infra/jobs/easy.py,sha256=eix-OxWeE3vdkY3GGNoYM0GAyOxc928SpiSzMkr9k0A,977
|
|
200
200
|
svc_infra/jobs/loader.py,sha256=LFO6gOacj6rT698vkDg0YfcHDRTue4zus3Nl9QrS5R0,1164
|
|
201
201
|
svc_infra/jobs/queue.py,sha256=PS5f4CJm5_K7icojTxZOwC6uKw3O2M-jE111u85ySbA,2288
|
|
@@ -263,7 +263,12 @@ svc_infra/security/permissions.py,sha256=fQm7-OcJJkWsScDcjS2gwmqaW93zQqltaHRl6bv
|
|
|
263
263
|
svc_infra/security/session.py,sha256=JkClqoZ-Moo9yqHzCREXMVSpzyjbn2Zh6zCjtWO93Ik,2848
|
|
264
264
|
svc_infra/security/signed_cookies.py,sha256=2t61BgjsBaTzU46bt7IUJo7lwDRE9_eS4vmAQXJ8mlY,2219
|
|
265
265
|
svc_infra/utils.py,sha256=VX1yjTx61-YvAymyRhGy18DhybiVdPddiYD_FlKTbJU,952
|
|
266
|
-
svc_infra
|
|
267
|
-
svc_infra
|
|
268
|
-
svc_infra
|
|
269
|
-
svc_infra
|
|
266
|
+
svc_infra/webhooks/__init__.py,sha256=U4S_2y3zgLZVfMenHRaJFBW8yqh2mUBuI291LGQVOJ8,35
|
|
267
|
+
svc_infra/webhooks/fastapi.py,sha256=BCNvGNxukf6dC2a4i-6en-PrjBGV19YvCWOot5lXWsA,1101
|
|
268
|
+
svc_infra/webhooks/router.py,sha256=6JvAVPMEth_xxHX-IsIOcyMgHX7g1H0OVxVXKLuMp9w,1596
|
|
269
|
+
svc_infra/webhooks/service.py,sha256=hWgiJRXKBwKunJOx91C7EcLUkotDtD3Xp0RT6vj2IC0,1797
|
|
270
|
+
svc_infra/webhooks/signing.py,sha256=NCwdZzmravUe7HVIK_uXK0qqf12FG-_MVsgPvOw6lsM,784
|
|
271
|
+
svc_infra-0.1.598.dist-info/METADATA,sha256=OwFbEqh9yMLpWr5rcN3Bp0M_ywJypISbUxabiMuQZY8,3527
|
|
272
|
+
svc_infra-0.1.598.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
|
|
273
|
+
svc_infra-0.1.598.dist-info/entry_points.txt,sha256=6x_nZOsjvn6hRZsMgZLgTasaCSKCgAjsGhACe_CiP0U,48
|
|
274
|
+
svc_infra-0.1.598.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|