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 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 by outbox_id via inbox
33
+ # dedupe marker key (marked after successful delivery)
43
34
  key = f"webhook:{outbox_id}"
44
- if not inbox.mark_if_new(key, ttl_seconds=24 * 3600):
35
+ if inbox.is_marked(key):
45
36
  # already delivered
46
37
  outbox.mark_processed(int(outbox_id))
47
38
  return
48
- url = get_webhook_url_for_topic(topic)
49
- secret = get_secret_for_topic(topic)
50
- sig = _compute_signature(secret, payload)
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=payload, headers={header_name: sig})
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: svc-infra
3
- Version: 0.1.597
3
+ Version: 0.1.598
4
4
  Summary: Infrastructure for building and deploying prod-ready services
5
5
  License: MIT
6
6
  Keywords: fastapi,sqlalchemy,alembic,auth,infra,async,pydantic
@@ -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=07GHRGN3jCjGVgcjjVGeKKgXkmwvgtwSu_O1Cb1_9hA,1600
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=6D_nmwpPOyrkzx4MM2vrpA0JKGfWbWo4BBavYEXhpDQ,1894
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-0.1.597.dist-info/METADATA,sha256=GN5xri15URUe0_f-BESdwDp7BzNL5DBj5penjmrOcuw,3527
267
- svc_infra-0.1.597.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
268
- svc_infra-0.1.597.dist-info/entry_points.txt,sha256=6x_nZOsjvn6hRZsMgZLgTasaCSKCgAjsGhACe_CiP0U,48
269
- svc_infra-0.1.597.dist-info/RECORD,,
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,,