svc-infra 0.1.562__py3-none-any.whl → 0.1.654__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.
Files changed (175) hide show
  1. svc_infra/apf_payments/README.md +732 -0
  2. svc_infra/apf_payments/models.py +142 -4
  3. svc_infra/apf_payments/provider/__init__.py +4 -0
  4. svc_infra/apf_payments/provider/aiydan.py +797 -0
  5. svc_infra/apf_payments/provider/base.py +178 -12
  6. svc_infra/apf_payments/provider/stripe.py +757 -48
  7. svc_infra/apf_payments/schemas.py +163 -1
  8. svc_infra/apf_payments/service.py +582 -42
  9. svc_infra/apf_payments/settings.py +22 -2
  10. svc_infra/api/fastapi/admin/__init__.py +3 -0
  11. svc_infra/api/fastapi/admin/add.py +231 -0
  12. svc_infra/api/fastapi/apf_payments/router.py +792 -73
  13. svc_infra/api/fastapi/apf_payments/setup.py +13 -4
  14. svc_infra/api/fastapi/auth/add.py +10 -4
  15. svc_infra/api/fastapi/auth/gaurd.py +67 -5
  16. svc_infra/api/fastapi/auth/routers/oauth_router.py +74 -34
  17. svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
  18. svc_infra/api/fastapi/auth/settings.py +2 -0
  19. svc_infra/api/fastapi/billing/router.py +64 -0
  20. svc_infra/api/fastapi/billing/setup.py +19 -0
  21. svc_infra/api/fastapi/cache/add.py +9 -5
  22. svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
  23. svc_infra/api/fastapi/db/sql/add.py +40 -18
  24. svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
  25. svc_infra/api/fastapi/db/sql/session.py +16 -0
  26. svc_infra/api/fastapi/db/sql/users.py +13 -1
  27. svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
  28. svc_infra/api/fastapi/docs/add.py +160 -0
  29. svc_infra/api/fastapi/docs/landing.py +1 -1
  30. svc_infra/api/fastapi/docs/scoped.py +41 -6
  31. svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
  32. svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
  33. svc_infra/api/fastapi/middleware/idempotency.py +82 -42
  34. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  35. svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
  36. svc_infra/api/fastapi/middleware/ratelimit.py +84 -11
  37. svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
  38. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  39. svc_infra/api/fastapi/middleware/timeout.py +148 -0
  40. svc_infra/api/fastapi/openapi/mutators.py +244 -38
  41. svc_infra/api/fastapi/ops/add.py +73 -0
  42. svc_infra/api/fastapi/pagination.py +133 -32
  43. svc_infra/api/fastapi/routers/ping.py +1 -0
  44. svc_infra/api/fastapi/setup.py +23 -14
  45. svc_infra/api/fastapi/tenancy/add.py +19 -0
  46. svc_infra/api/fastapi/tenancy/context.py +112 -0
  47. svc_infra/api/fastapi/versioned.py +101 -0
  48. svc_infra/app/README.md +5 -5
  49. svc_infra/billing/__init__.py +23 -0
  50. svc_infra/billing/async_service.py +147 -0
  51. svc_infra/billing/jobs.py +230 -0
  52. svc_infra/billing/models.py +131 -0
  53. svc_infra/billing/quotas.py +101 -0
  54. svc_infra/billing/schemas.py +33 -0
  55. svc_infra/billing/service.py +115 -0
  56. svc_infra/bundled_docs/README.md +5 -0
  57. svc_infra/bundled_docs/__init__.py +1 -0
  58. svc_infra/bundled_docs/getting-started.md +6 -0
  59. svc_infra/cache/__init__.py +4 -0
  60. svc_infra/cache/add.py +158 -0
  61. svc_infra/cache/backend.py +5 -2
  62. svc_infra/cache/decorators.py +19 -1
  63. svc_infra/cache/keys.py +24 -4
  64. svc_infra/cli/__init__.py +32 -8
  65. svc_infra/cli/__main__.py +4 -0
  66. svc_infra/cli/cmds/__init__.py +10 -0
  67. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
  68. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
  69. svc_infra/cli/cmds/db/sql/alembic_cmds.py +80 -11
  70. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  71. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  72. svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
  73. svc_infra/cli/cmds/dx/__init__.py +12 -0
  74. svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
  75. svc_infra/cli/cmds/help.py +4 -0
  76. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  77. svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -0
  78. svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
  79. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  80. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  81. svc_infra/data/add.py +61 -0
  82. svc_infra/data/backup.py +53 -0
  83. svc_infra/data/erasure.py +45 -0
  84. svc_infra/data/fixtures.py +40 -0
  85. svc_infra/data/retention.py +55 -0
  86. svc_infra/db/inbox.py +67 -0
  87. svc_infra/db/nosql/mongo/README.md +13 -13
  88. svc_infra/db/outbox.py +104 -0
  89. svc_infra/db/sql/repository.py +52 -12
  90. svc_infra/db/sql/resource.py +5 -0
  91. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  92. svc_infra/db/sql/templates/setup/env_async.py.tmpl +13 -8
  93. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +9 -5
  94. svc_infra/db/sql/tenant.py +79 -0
  95. svc_infra/db/sql/utils.py +18 -4
  96. svc_infra/db/sql/versioning.py +14 -0
  97. svc_infra/docs/acceptance-matrix.md +71 -0
  98. svc_infra/docs/acceptance.md +44 -0
  99. svc_infra/docs/admin.md +425 -0
  100. svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
  101. svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
  102. svc_infra/docs/adr/0004-tenancy-model.md +42 -0
  103. svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
  104. svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
  105. svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
  106. svc_infra/docs/adr/0008-billing-primitives.md +143 -0
  107. svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
  108. svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
  109. svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
  110. svc_infra/docs/api.md +59 -0
  111. svc_infra/docs/auth.md +11 -0
  112. svc_infra/docs/billing.md +190 -0
  113. svc_infra/docs/cache.md +76 -0
  114. svc_infra/docs/cli.md +74 -0
  115. svc_infra/docs/contributing.md +34 -0
  116. svc_infra/docs/data-lifecycle.md +52 -0
  117. svc_infra/docs/database.md +14 -0
  118. svc_infra/docs/docs-and-sdks.md +62 -0
  119. svc_infra/docs/environment.md +114 -0
  120. svc_infra/docs/getting-started.md +63 -0
  121. svc_infra/docs/idempotency.md +111 -0
  122. svc_infra/docs/jobs.md +67 -0
  123. svc_infra/docs/observability.md +16 -0
  124. svc_infra/docs/ops.md +37 -0
  125. svc_infra/docs/rate-limiting.md +125 -0
  126. svc_infra/docs/repo-review.md +48 -0
  127. svc_infra/docs/security.md +176 -0
  128. svc_infra/docs/tenancy.md +35 -0
  129. svc_infra/docs/timeouts-and-resource-limits.md +147 -0
  130. svc_infra/docs/versioned-integrations.md +146 -0
  131. svc_infra/docs/webhooks.md +112 -0
  132. svc_infra/dx/add.py +63 -0
  133. svc_infra/dx/changelog.py +74 -0
  134. svc_infra/dx/checks.py +67 -0
  135. svc_infra/http/__init__.py +13 -0
  136. svc_infra/http/client.py +72 -0
  137. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  138. svc_infra/jobs/builtins/webhook_delivery.py +90 -0
  139. svc_infra/jobs/easy.py +32 -0
  140. svc_infra/jobs/loader.py +45 -0
  141. svc_infra/jobs/queue.py +81 -0
  142. svc_infra/jobs/redis_queue.py +191 -0
  143. svc_infra/jobs/runner.py +75 -0
  144. svc_infra/jobs/scheduler.py +41 -0
  145. svc_infra/jobs/worker.py +40 -0
  146. svc_infra/mcp/svc_infra_mcp.py +85 -28
  147. svc_infra/obs/README.md +2 -0
  148. svc_infra/obs/add.py +54 -7
  149. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  150. svc_infra/obs/metrics/__init__.py +53 -0
  151. svc_infra/obs/metrics.py +52 -0
  152. svc_infra/security/add.py +201 -0
  153. svc_infra/security/audit.py +130 -0
  154. svc_infra/security/audit_service.py +73 -0
  155. svc_infra/security/headers.py +52 -0
  156. svc_infra/security/hibp.py +95 -0
  157. svc_infra/security/jwt_rotation.py +53 -0
  158. svc_infra/security/lockout.py +96 -0
  159. svc_infra/security/models.py +255 -0
  160. svc_infra/security/org_invites.py +128 -0
  161. svc_infra/security/passwords.py +77 -0
  162. svc_infra/security/permissions.py +149 -0
  163. svc_infra/security/session.py +98 -0
  164. svc_infra/security/signed_cookies.py +80 -0
  165. svc_infra/webhooks/__init__.py +16 -0
  166. svc_infra/webhooks/add.py +322 -0
  167. svc_infra/webhooks/fastapi.py +37 -0
  168. svc_infra/webhooks/router.py +55 -0
  169. svc_infra/webhooks/service.py +67 -0
  170. svc_infra/webhooks/signing.py +30 -0
  171. svc_infra-0.1.654.dist-info/METADATA +154 -0
  172. {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/RECORD +174 -56
  173. svc_infra-0.1.562.dist-info/METADATA +0 -79
  174. {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
  175. {svc_infra-0.1.562.dist-info → svc_infra-0.1.654.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from datetime import datetime, timedelta, timezone
5
+ from typing import Optional
6
+
7
+ try:
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+ except Exception: # pragma: no cover
10
+ AsyncSession = object # type: ignore
11
+
12
+ from svc_infra.security.models import (
13
+ AuthSession,
14
+ RefreshToken,
15
+ RefreshTokenRevocation,
16
+ generate_refresh_token,
17
+ hash_refresh_token,
18
+ rotate_refresh_token,
19
+ )
20
+
21
+ DEFAULT_REFRESH_TTL_MINUTES = 60 * 24 * 7 # 7 days
22
+
23
+
24
+ async def issue_session_and_refresh(
25
+ db: AsyncSession,
26
+ *,
27
+ user_id: uuid.UUID,
28
+ tenant_id: Optional[str] = None,
29
+ user_agent: Optional[str] = None,
30
+ ip_hash: Optional[str] = None,
31
+ ttl_minutes: int = DEFAULT_REFRESH_TTL_MINUTES,
32
+ ) -> tuple[str, RefreshToken]:
33
+ """Persist a new AuthSession + initial RefreshToken and return raw refresh token.
34
+
35
+ Returns: (raw_refresh_token, RefreshToken model instance)
36
+ """
37
+ session_row = AuthSession(
38
+ user_id=user_id,
39
+ tenant_id=tenant_id,
40
+ user_agent=user_agent,
41
+ ip_hash=ip_hash,
42
+ )
43
+ db.add(session_row)
44
+ raw = generate_refresh_token()
45
+ token_hash = hash_refresh_token(raw)
46
+ expires_at = datetime.now(timezone.utc) + timedelta(minutes=ttl_minutes)
47
+ rt = RefreshToken(
48
+ session=session_row,
49
+ token_hash=token_hash,
50
+ expires_at=expires_at,
51
+ )
52
+ db.add(rt)
53
+ await db.flush()
54
+ return raw, rt
55
+
56
+
57
+ async def rotate_session_refresh(
58
+ db: AsyncSession,
59
+ *,
60
+ current: RefreshToken,
61
+ ttl_minutes: int = DEFAULT_REFRESH_TTL_MINUTES,
62
+ ) -> tuple[str, RefreshToken]:
63
+ """Rotate a session's refresh token: mark current rotated, create new, add revocation record.
64
+
65
+ Returns: (new_raw_refresh_token, new_refresh_token_model)
66
+ """
67
+ rotation_ts = datetime.now(timezone.utc)
68
+ if current.revoked_at:
69
+ raise ValueError("refresh token already revoked")
70
+ if current.expires_at and current.expires_at <= rotation_ts:
71
+ raise ValueError("refresh token expired")
72
+ new_raw, new_hash, expires_at = rotate_refresh_token(
73
+ current.token_hash, ttl_minutes=ttl_minutes
74
+ )
75
+ current.rotated_at = rotation_ts
76
+ current.revoked_at = rotation_ts
77
+ current.revoke_reason = "rotated"
78
+ if current.expires_at is None or current.expires_at > rotation_ts:
79
+ current.expires_at = rotation_ts
80
+ # create revocation entry for old hash
81
+ db.add(
82
+ RefreshTokenRevocation(
83
+ token_hash=current.token_hash,
84
+ revoked_at=rotation_ts,
85
+ reason="rotated",
86
+ )
87
+ )
88
+ new_row = RefreshToken(
89
+ session=current.session,
90
+ token_hash=new_hash,
91
+ expires_at=expires_at,
92
+ )
93
+ db.add(new_row)
94
+ await db.flush()
95
+ return new_raw, new_row
96
+
97
+
98
+ __all__ = ["issue_session_and_refresh", "rotate_session_refresh"]
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hmac
5
+ import json
6
+ import time
7
+ from hashlib import sha256
8
+ from typing import Any, Dict, List, Optional, Tuple
9
+
10
+
11
+ def _b64e(b: bytes) -> str:
12
+ return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
13
+
14
+
15
+ def _b64d(s: str) -> bytes:
16
+ pad = "=" * (-len(s) % 4)
17
+ return base64.urlsafe_b64decode((s + pad).encode())
18
+
19
+
20
+ def _sign(data: bytes, key: bytes) -> str:
21
+ return _b64e(hmac.new(key, data, sha256).digest())
22
+
23
+
24
+ def _now() -> int:
25
+ return int(time.time())
26
+
27
+
28
+ def sign_cookie(
29
+ payload: Dict[str, Any],
30
+ *,
31
+ key: str,
32
+ expires_in: Optional[int] = None,
33
+ ) -> str:
34
+ """Produce a compact signed cookie value with optional expiry.
35
+
36
+ Format: base64url(json).base64url(hmac)
37
+ If expires_in is provided, 'exp' epoch seconds is injected into payload prior to signing.
38
+ """
39
+ body = dict(payload)
40
+ if expires_in is not None:
41
+ body.setdefault("exp", _now() + int(expires_in))
42
+ data = json.dumps(body, separators=(",", ":"), sort_keys=True).encode()
43
+ sig = _sign(data, key.encode())
44
+ return f"{_b64e(data)}.{sig}"
45
+
46
+
47
+ def verify_cookie(
48
+ value: str,
49
+ *,
50
+ key: str,
51
+ old_keys: Optional[List[str]] = None,
52
+ ) -> Tuple[bool, Optional[Dict[str, Any]]]:
53
+ """Verify a signed cookie against the primary key or any old key.
54
+
55
+ Returns (ok, payload). If ok is False, payload will be None.
56
+ Rejects if exp is present and in the past.
57
+ """
58
+ if not value or "." not in value:
59
+ return False, None
60
+ body_b64, sig = value.split(".", 1)
61
+ try:
62
+ data = _b64d(body_b64)
63
+ expected = _sign(data, key.encode())
64
+ if not hmac.compare_digest(sig, expected):
65
+ # try old keys
66
+ for k in old_keys or []:
67
+ if hmac.compare_digest(sig, _sign(data, k.encode())):
68
+ break
69
+ else:
70
+ return False, None
71
+ payload = json.loads(data.decode())
72
+ # Expire when current time reaches or exceeds exp
73
+ if "exp" in payload and _now() >= int(payload["exp"]):
74
+ return False, None
75
+ return True, payload
76
+ except Exception:
77
+ return False, None
78
+
79
+
80
+ __all__ = ["sign_cookie", "verify_cookie"]
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING: # pragma: no cover - for type checkers only
6
+ from .add import add_webhooks
7
+
8
+ __all__ = ["add_webhooks"]
9
+
10
+
11
+ def __getattr__(name: str):
12
+ if name == "add_webhooks":
13
+ from .add import add_webhooks
14
+
15
+ return add_webhooks
16
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,322 @@
1
+ from __future__ import annotations
2
+
3
+ """FastAPI integration helpers for the webhooks router.
4
+
5
+ The :func:`add_webhooks` helper wires the public router into an app and makes
6
+ sure dependency overrides share a single set of stores instead of the in-file
7
+ defaults that create a new in-memory object per request. Callers can:
8
+
9
+ * rely on the in-memory defaults (suitable for tests / local usage);
10
+ * configure persistent stores through environment variables; or
11
+ * provide concrete instances / factories explicitly via keyword arguments.
12
+
13
+ When queue / scheduler objects are provided the helper also wires up the
14
+ standard outbox tick task and webhook delivery job handler so the caller only
15
+ needs to start their existing worker loop.
16
+ """
17
+
18
+ import json
19
+ import logging
20
+ import os
21
+ from collections.abc import Callable, Mapping
22
+ from datetime import datetime, timezone
23
+ from typing import Any, Protocol, TypeVar, overload
24
+
25
+ from fastapi import FastAPI
26
+
27
+ from svc_infra.db.inbox import InboxStore, InMemoryInboxStore
28
+ from svc_infra.db.outbox import InMemoryOutboxStore, OutboxMessage, OutboxStore
29
+ from svc_infra.jobs.builtins.outbox_processor import make_outbox_tick
30
+ from svc_infra.jobs.builtins.webhook_delivery import make_webhook_handler
31
+ from svc_infra.jobs.queue import JobQueue
32
+ from svc_infra.jobs.scheduler import InMemoryScheduler
33
+
34
+ from . import router as router_module
35
+ from .service import InMemoryWebhookSubscriptions
36
+
37
+ try: # Optional dependency – only required when redis backends are selected.
38
+ from redis import Redis
39
+ except Exception: # pragma: no cover - redis is optional in most test runs.
40
+ Redis = None # type: ignore
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+
45
+ T = TypeVar("T")
46
+
47
+
48
+ class _Factory(Protocol[T]):
49
+ def __call__(self) -> T:
50
+ ...
51
+
52
+
53
+ class RedisOutboxStore(OutboxStore):
54
+ """Minimal Redis-backed outbox implementation used by :func:`add_webhooks`.
55
+
56
+ The implementation is intentionally lightweight – it keeps message payloads
57
+ in Redis hashes and a FIFO list of message identifiers. It fulfils the
58
+ contract expected by :func:`make_outbox_tick` while remaining simple enough
59
+ for environments where a fully fledged SQL implementation is unavailable.
60
+ """
61
+
62
+ def __init__(self, client: "Redis", *, prefix: str = "webhooks:outbox"):
63
+ if Redis is None: # pragma: no cover - defensive guard
64
+ raise RuntimeError("redis-py is required for RedisOutboxStore")
65
+ self._client = client
66
+ self._prefix = prefix.rstrip(":")
67
+
68
+ # Redis key helpers -------------------------------------------------
69
+ @property
70
+ def _seq_key(self) -> str:
71
+ return f"{self._prefix}:seq"
72
+
73
+ @property
74
+ def _queue_key(self) -> str:
75
+ return f"{self._prefix}:queue"
76
+
77
+ def _msg_key(self, msg_id: int) -> str:
78
+ return f"{self._prefix}:msg:{msg_id}"
79
+
80
+ # Protocol methods --------------------------------------------------
81
+ def enqueue(self, topic: str, payload: dict[str, Any]) -> OutboxMessage:
82
+ msg_id = int(self._client.incr(self._seq_key))
83
+ created_at = datetime.now(timezone.utc)
84
+ record = {
85
+ "id": msg_id,
86
+ "topic": topic,
87
+ "payload": json.dumps(payload),
88
+ "created_at": created_at.isoformat(),
89
+ "attempts": 0,
90
+ "processed_at": "",
91
+ }
92
+ self._client.hset(self._msg_key(msg_id), mapping=record)
93
+ self._client.rpush(self._queue_key, msg_id)
94
+ return OutboxMessage(id=msg_id, topic=topic, payload=payload, created_at=created_at)
95
+
96
+ def fetch_next(
97
+ self, *, topics: list[str] | tuple[str, ...] | set[str] | None = None
98
+ ) -> OutboxMessage | None:
99
+ allowed = set(topics) if topics else None
100
+ ids = self._client.lrange(self._queue_key, 0, -1)
101
+ for raw_id in ids:
102
+ msg_id = int(raw_id)
103
+ msg = self._client.hgetall(self._msg_key(msg_id))
104
+ if not msg:
105
+ continue
106
+ topic = msg.get(b"topic")
107
+ if topic is None:
108
+ continue
109
+ topic_str = topic.decode()
110
+ if allowed is not None and topic_str not in allowed:
111
+ continue
112
+ attempts = int(msg.get(b"attempts", 0))
113
+ processed_raw = msg.get(b"processed_at") or b""
114
+ if processed_raw:
115
+ continue
116
+ if attempts > 0:
117
+ continue
118
+ payload_raw = msg.get(b"payload") or b"{}"
119
+ payload = json.loads(payload_raw.decode())
120
+ created_raw = msg.get(b"created_at") or ""
121
+ created_at = (
122
+ datetime.fromisoformat(created_raw.decode()) if created_raw else datetime.now(timezone.utc)
123
+ )
124
+ return OutboxMessage(
125
+ id=msg_id,
126
+ topic=topic_str,
127
+ payload=payload,
128
+ created_at=created_at,
129
+ attempts=attempts,
130
+ )
131
+ return None
132
+
133
+ def mark_processed(self, msg_id: int) -> None:
134
+ key = self._msg_key(msg_id)
135
+ if not self._client.exists(key):
136
+ return
137
+ self._client.hset(key, "processed_at", datetime.now(timezone.utc).isoformat())
138
+
139
+ def mark_failed(self, msg_id: int) -> None:
140
+ key = self._msg_key(msg_id)
141
+ self._client.hincrby(key, "attempts", 1)
142
+
143
+
144
+ class RedisInboxStore(InboxStore):
145
+ """Lightweight Redis dedupe store for webhook deliveries."""
146
+
147
+ def __init__(self, client: "Redis", *, prefix: str = "webhooks:inbox"):
148
+ if Redis is None: # pragma: no cover - defensive guard
149
+ raise RuntimeError("redis-py is required for RedisInboxStore")
150
+ self._client = client
151
+ self._prefix = prefix.rstrip(":")
152
+
153
+ def _key(self, key: str) -> str:
154
+ return f"{self._prefix}:{key}"
155
+
156
+ def mark_if_new(self, key: str, ttl_seconds: int = 24 * 3600) -> bool:
157
+ return bool(self._client.set(self._key(key), 1, nx=True, ex=ttl_seconds))
158
+
159
+ def purge_expired(self) -> int:
160
+ # Redis takes care of expirations. We return 0 to satisfy the interface.
161
+ return 0
162
+
163
+ def is_marked(self, key: str) -> bool:
164
+ return bool(self._client.exists(self._key(key)))
165
+
166
+
167
+ def _is_factory(obj: Any) -> bool:
168
+ return callable(obj) and not isinstance(obj, (str, bytes, bytearray))
169
+
170
+
171
+ def _resolve_value(value: T | _Factory[T] | None, default_factory: _Factory[T]) -> T:
172
+ if value is None:
173
+ return default_factory()
174
+ if _is_factory(value):
175
+ return value() # type: ignore[return-value]
176
+ return value # type: ignore[return-value]
177
+
178
+
179
+ def _build_redis_client(env: Mapping[str, str]) -> "Redis" | None:
180
+ if Redis is None:
181
+ logger.warning("Redis backend requested but redis-py is not installed; falling back to in-memory stores")
182
+ return None
183
+ url = env.get("REDIS_URL", "redis://localhost:6379/0")
184
+ return Redis.from_url(url)
185
+
186
+
187
+ def _default_outbox(env: Mapping[str, str]) -> OutboxStore:
188
+ backend = (env.get("WEBHOOKS_OUTBOX") or "memory").lower()
189
+ if backend == "redis":
190
+ client = _build_redis_client(env)
191
+ if client is not None:
192
+ logger.info("Using Redis outbox store for webhooks")
193
+ return RedisOutboxStore(client)
194
+ elif backend == "sql": # pragma: no cover - SQL backend is currently a placeholder
195
+ logger.warning(
196
+ "WEBHOOKS_OUTBOX=sql specified but SQL backend is not implemented; falling back to in-memory store"
197
+ )
198
+ return InMemoryOutboxStore()
199
+
200
+
201
+ def _default_inbox(env: Mapping[str, str]) -> InboxStore:
202
+ backend = (env.get("WEBHOOKS_INBOX") or "memory").lower()
203
+ if backend == "redis":
204
+ client = _build_redis_client(env)
205
+ if client is not None:
206
+ logger.info("Using Redis inbox store for webhooks")
207
+ return RedisInboxStore(client)
208
+ return InMemoryInboxStore()
209
+
210
+
211
+ def _default_subscriptions() -> InMemoryWebhookSubscriptions:
212
+ return InMemoryWebhookSubscriptions()
213
+
214
+
215
+ def _subscription_lookup(subs: InMemoryWebhookSubscriptions) -> tuple[Callable[[str], str], Callable[[str], str]]:
216
+ def _get_url(topic: str) -> str:
217
+ items = subs.get_for_topic(topic)
218
+ if not items:
219
+ raise LookupError(f"No webhook subscription for topic '{topic}'")
220
+ return items[0].url
221
+
222
+ def _get_secret(topic: str) -> str:
223
+ items = subs.get_for_topic(topic)
224
+ if not items:
225
+ raise LookupError(f"No webhook subscription for topic '{topic}'")
226
+ return items[0].secret
227
+
228
+ return _get_url, _get_secret
229
+
230
+
231
+ @overload
232
+ def add_webhooks(
233
+ app: FastAPI,
234
+ *,
235
+ outbox: OutboxStore | _Factory[OutboxStore] | None = ...,
236
+ inbox: InboxStore | _Factory[InboxStore] | None = ...,
237
+ subscriptions: InMemoryWebhookSubscriptions | _Factory[InMemoryWebhookSubscriptions] | None = ...,
238
+ queue: JobQueue | None = ...,
239
+ scheduler: InMemoryScheduler | None = ...,
240
+ schedule_tick: bool = ...,
241
+ env: Mapping[str, str] = ...,
242
+ ) -> None:
243
+ ...
244
+
245
+
246
+ def add_webhooks(
247
+ app: FastAPI,
248
+ *,
249
+ outbox: OutboxStore | _Factory[OutboxStore] | None = None,
250
+ inbox: InboxStore | _Factory[InboxStore] | None = None,
251
+ subscriptions: InMemoryWebhookSubscriptions | _Factory[InMemoryWebhookSubscriptions] | None = None,
252
+ queue: JobQueue | None = None,
253
+ scheduler: InMemoryScheduler | None = None,
254
+ schedule_tick: bool = True,
255
+ env: Mapping[str, str] = os.environ,
256
+ ) -> None:
257
+ """Attach the shared webhooks router and stores to a FastAPI app.
258
+
259
+ Parameters
260
+ ----------
261
+ app:
262
+ The FastAPI application to configure.
263
+ outbox / inbox / subscriptions:
264
+ Optional instances or callables returning instances to use. When left
265
+ as ``None`` the helper chooses sensible defaults: in-memory stores for
266
+ local runs or Redis-backed stores when ``WEBHOOKS_OUTBOX`` /
267
+ ``WEBHOOKS_INBOX`` are set to ``"redis"`` and ``REDIS_URL`` is
268
+ available.
269
+ queue / scheduler:
270
+ Provide these when you want :func:`make_outbox_tick` and the webhook
271
+ delivery handler registered for you. The tick task is scheduled every
272
+ second by default; disable that registration by passing
273
+ ``schedule_tick=False``.
274
+ env:
275
+ Mapping used to resolve environment-driven defaults. Defaults to
276
+ :data:`os.environ` so standard environment variables Just Work.
277
+
278
+ Side effects
279
+ ------------
280
+ * ``app.include_router`` is invoked for :mod:`svc_infra.webhooks.router`.
281
+ * ``app.dependency_overrides`` is populated so router dependencies reuse the
282
+ shared stores.
283
+ * References are stored on ``app.state`` for further customisation:
284
+ ``webhooks_outbox``, ``webhooks_inbox``, ``webhooks_subscriptions``,
285
+ ``webhooks_outbox_tick`` (when a queue is present) and
286
+ ``webhooks_delivery_handler`` (when queue+inbox are present).
287
+ """
288
+
289
+ resolved_outbox = _resolve_value(outbox, lambda: _default_outbox(env))
290
+ resolved_inbox = _resolve_value(inbox, lambda: _default_inbox(env))
291
+ resolved_subs = _resolve_value(subscriptions, _default_subscriptions)
292
+
293
+ app.state.webhooks_outbox = resolved_outbox
294
+ app.state.webhooks_inbox = resolved_inbox
295
+ app.state.webhooks_subscriptions = resolved_subs
296
+
297
+ app.include_router(router_module.router)
298
+
299
+ app.dependency_overrides[router_module.get_outbox] = lambda: resolved_outbox
300
+ app.dependency_overrides[router_module.get_subs] = lambda: resolved_subs
301
+
302
+ outbox_tick = None
303
+ if queue is not None:
304
+ outbox_tick = make_outbox_tick(resolved_outbox, queue)
305
+ app.state.webhooks_outbox_tick = outbox_tick
306
+ if scheduler is not None and schedule_tick:
307
+ scheduler.add_task("webhooks.outbox", 1, outbox_tick)
308
+
309
+ url_lookup, secret_lookup = _subscription_lookup(resolved_subs)
310
+ handler = make_webhook_handler(
311
+ outbox=resolved_outbox,
312
+ inbox=resolved_inbox,
313
+ get_webhook_url_for_topic=url_lookup,
314
+ get_secret_for_topic=secret_lookup,
315
+ )
316
+ app.state.webhooks_delivery_handler = handler
317
+ elif scheduler is not None and schedule_tick:
318
+ logger.warning("Scheduler provided without queue; skipping outbox tick registration")
319
+
320
+
321
+ __all__ = ["add_webhooks"]
322
+
@@ -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,67 @@
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
+ from uuid import uuid4
7
+
8
+ from svc_infra.db.outbox import OutboxStore
9
+
10
+
11
+ @dataclass
12
+ class WebhookSubscription:
13
+ topic: str
14
+ url: str
15
+ secret: str
16
+ id: str = field(default_factory=lambda: uuid4().hex)
17
+
18
+
19
+ class InMemoryWebhookSubscriptions:
20
+ def __init__(self):
21
+ self._subs: Dict[str, List[WebhookSubscription]] = {}
22
+
23
+ def add(self, topic: str, url: str, secret: str) -> None:
24
+ # Upsert semantics per (topic, url): if a subscription already exists
25
+ # for this topic and URL, rotate its secret instead of adding a new row.
26
+ # This mirrors typical real-world secret rotation flows where the
27
+ # endpoint remains the same but the signing secret changes.
28
+ lst = self._subs.setdefault(topic, [])
29
+ for sub in lst:
30
+ if sub.url == url:
31
+ sub.secret = secret
32
+ return
33
+ lst.append(WebhookSubscription(topic, url, secret))
34
+
35
+ def get_for_topic(self, topic: str) -> List[WebhookSubscription]:
36
+ return list(self._subs.get(topic, []))
37
+
38
+
39
+ class WebhookService:
40
+ def __init__(self, outbox: OutboxStore, subs: InMemoryWebhookSubscriptions):
41
+ self._outbox = outbox
42
+ self._subs = subs
43
+
44
+ def publish(self, topic: str, payload: Dict, *, version: int = 1) -> int:
45
+ created_at = datetime.now(timezone.utc).isoformat()
46
+ base_event = {
47
+ "topic": topic,
48
+ "payload": payload,
49
+ "version": version,
50
+ "created_at": created_at,
51
+ }
52
+ # For each subscription, enqueue an outbox message with subscriber identity
53
+ last_id = 0
54
+ for sub in self._subs.get_for_topic(topic):
55
+ event = dict(base_event)
56
+ msg_payload = {
57
+ "event": event,
58
+ "subscription": {
59
+ "id": sub.id,
60
+ "topic": sub.topic,
61
+ "url": sub.url,
62
+ "secret": sub.secret,
63
+ },
64
+ }
65
+ msg = self._outbox.enqueue(topic, msg_payload)
66
+ last_id = msg.id
67
+ return last_id