svc-infra 0.1.595__py3-none-any.whl → 1.1.0__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.

Files changed (274) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/models.py +68 -38
  3. svc_infra/apf_payments/provider/__init__.py +2 -2
  4. svc_infra/apf_payments/provider/aiydan.py +39 -23
  5. svc_infra/apf_payments/provider/base.py +8 -3
  6. svc_infra/apf_payments/provider/registry.py +3 -5
  7. svc_infra/apf_payments/provider/stripe.py +74 -52
  8. svc_infra/apf_payments/schemas.py +84 -83
  9. svc_infra/apf_payments/service.py +27 -16
  10. svc_infra/apf_payments/settings.py +12 -11
  11. svc_infra/api/__init__.py +61 -0
  12. svc_infra/api/fastapi/__init__.py +34 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +240 -0
  15. svc_infra/api/fastapi/apf_payments/router.py +94 -73
  16. svc_infra/api/fastapi/apf_payments/setup.py +10 -9
  17. svc_infra/api/fastapi/auth/__init__.py +65 -0
  18. svc_infra/api/fastapi/auth/_cookies.py +1 -3
  19. svc_infra/api/fastapi/auth/add.py +14 -15
  20. svc_infra/api/fastapi/auth/gaurd.py +32 -20
  21. svc_infra/api/fastapi/auth/mfa/models.py +3 -4
  22. svc_infra/api/fastapi/auth/mfa/pre_auth.py +13 -9
  23. svc_infra/api/fastapi/auth/mfa/router.py +9 -8
  24. svc_infra/api/fastapi/auth/mfa/security.py +4 -7
  25. svc_infra/api/fastapi/auth/mfa/utils.py +5 -3
  26. svc_infra/api/fastapi/auth/policy.py +0 -1
  27. svc_infra/api/fastapi/auth/providers.py +3 -3
  28. svc_infra/api/fastapi/auth/routers/apikey_router.py +19 -21
  29. svc_infra/api/fastapi/auth/routers/oauth_router.py +98 -52
  30. svc_infra/api/fastapi/auth/routers/session_router.py +6 -5
  31. svc_infra/api/fastapi/auth/security.py +25 -15
  32. svc_infra/api/fastapi/auth/sender.py +5 -0
  33. svc_infra/api/fastapi/auth/settings.py +18 -19
  34. svc_infra/api/fastapi/auth/state.py +5 -4
  35. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  36. svc_infra/api/fastapi/billing/router.py +71 -0
  37. svc_infra/api/fastapi/billing/setup.py +19 -0
  38. svc_infra/api/fastapi/cache/add.py +9 -5
  39. svc_infra/api/fastapi/db/__init__.py +5 -1
  40. svc_infra/api/fastapi/db/http.py +10 -9
  41. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  42. svc_infra/api/fastapi/db/nosql/mongo/add.py +35 -30
  43. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +39 -21
  44. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  45. svc_infra/api/fastapi/db/sql/add.py +62 -25
  46. svc_infra/api/fastapi/db/sql/crud_router.py +205 -30
  47. svc_infra/api/fastapi/db/sql/session.py +19 -2
  48. svc_infra/api/fastapi/db/sql/users.py +18 -9
  49. svc_infra/api/fastapi/dependencies/ratelimit.py +76 -14
  50. svc_infra/api/fastapi/docs/add.py +163 -0
  51. svc_infra/api/fastapi/docs/landing.py +6 -6
  52. svc_infra/api/fastapi/docs/scoped.py +75 -36
  53. svc_infra/api/fastapi/dual/__init__.py +12 -2
  54. svc_infra/api/fastapi/dual/dualize.py +2 -2
  55. svc_infra/api/fastapi/dual/protected.py +123 -10
  56. svc_infra/api/fastapi/dual/public.py +25 -0
  57. svc_infra/api/fastapi/dual/router.py +18 -8
  58. svc_infra/api/fastapi/dx.py +33 -2
  59. svc_infra/api/fastapi/ease.py +59 -7
  60. svc_infra/api/fastapi/http/concurrency.py +2 -1
  61. svc_infra/api/fastapi/http/conditional.py +2 -2
  62. svc_infra/api/fastapi/middleware/debug.py +4 -1
  63. svc_infra/api/fastapi/middleware/errors/exceptions.py +2 -5
  64. svc_infra/api/fastapi/middleware/errors/handlers.py +50 -10
  65. svc_infra/api/fastapi/middleware/graceful_shutdown.py +95 -0
  66. svc_infra/api/fastapi/middleware/idempotency.py +190 -68
  67. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  68. svc_infra/api/fastapi/middleware/optimistic_lock.py +39 -0
  69. svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
  70. svc_infra/api/fastapi/middleware/ratelimit_store.py +45 -13
  71. svc_infra/api/fastapi/middleware/request_id.py +24 -10
  72. svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
  73. svc_infra/api/fastapi/middleware/timeout.py +176 -0
  74. svc_infra/api/fastapi/object_router.py +1060 -0
  75. svc_infra/api/fastapi/openapi/apply.py +4 -3
  76. svc_infra/api/fastapi/openapi/conventions.py +13 -6
  77. svc_infra/api/fastapi/openapi/mutators.py +144 -17
  78. svc_infra/api/fastapi/openapi/pipeline.py +2 -2
  79. svc_infra/api/fastapi/openapi/responses.py +4 -6
  80. svc_infra/api/fastapi/openapi/security.py +1 -1
  81. svc_infra/api/fastapi/ops/add.py +73 -0
  82. svc_infra/api/fastapi/pagination.py +47 -32
  83. svc_infra/api/fastapi/routers/__init__.py +16 -10
  84. svc_infra/api/fastapi/routers/ping.py +1 -0
  85. svc_infra/api/fastapi/setup.py +167 -54
  86. svc_infra/api/fastapi/tenancy/add.py +20 -0
  87. svc_infra/api/fastapi/tenancy/context.py +113 -0
  88. svc_infra/api/fastapi/versioned.py +102 -0
  89. svc_infra/app/README.md +5 -5
  90. svc_infra/app/__init__.py +3 -1
  91. svc_infra/app/env.py +70 -4
  92. svc_infra/app/logging/add.py +10 -2
  93. svc_infra/app/logging/filter.py +1 -1
  94. svc_infra/app/logging/formats.py +13 -5
  95. svc_infra/app/root.py +3 -3
  96. svc_infra/billing/__init__.py +40 -0
  97. svc_infra/billing/async_service.py +167 -0
  98. svc_infra/billing/jobs.py +231 -0
  99. svc_infra/billing/models.py +146 -0
  100. svc_infra/billing/quotas.py +101 -0
  101. svc_infra/billing/schemas.py +34 -0
  102. svc_infra/bundled_docs/README.md +5 -0
  103. svc_infra/bundled_docs/__init__.py +1 -0
  104. svc_infra/bundled_docs/getting-started.md +6 -0
  105. svc_infra/cache/__init__.py +21 -5
  106. svc_infra/cache/add.py +167 -0
  107. svc_infra/cache/backend.py +9 -7
  108. svc_infra/cache/decorators.py +75 -20
  109. svc_infra/cache/demo.py +2 -2
  110. svc_infra/cache/keys.py +26 -6
  111. svc_infra/cache/recache.py +26 -27
  112. svc_infra/cache/resources.py +6 -5
  113. svc_infra/cache/tags.py +19 -44
  114. svc_infra/cache/ttl.py +2 -3
  115. svc_infra/cache/utils.py +4 -3
  116. svc_infra/cli/__init__.py +44 -8
  117. svc_infra/cli/__main__.py +4 -0
  118. svc_infra/cli/cmds/__init__.py +39 -2
  119. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +18 -14
  120. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +9 -10
  121. svc_infra/cli/cmds/db/ops_cmds.py +267 -0
  122. svc_infra/cli/cmds/db/sql/alembic_cmds.py +97 -29
  123. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  124. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +13 -13
  125. svc_infra/cli/cmds/docs/docs_cmds.py +139 -0
  126. svc_infra/cli/cmds/dx/__init__.py +12 -0
  127. svc_infra/cli/cmds/dx/dx_cmds.py +110 -0
  128. svc_infra/cli/cmds/health/__init__.py +179 -0
  129. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  130. svc_infra/cli/cmds/help.py +4 -0
  131. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  132. svc_infra/cli/cmds/jobs/jobs_cmds.py +42 -0
  133. svc_infra/cli/cmds/obs/obs_cmds.py +31 -13
  134. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  135. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  136. svc_infra/cli/foundation/runner.py +4 -5
  137. svc_infra/cli/foundation/typer_bootstrap.py +1 -2
  138. svc_infra/data/__init__.py +83 -0
  139. svc_infra/data/add.py +61 -0
  140. svc_infra/data/backup.py +56 -0
  141. svc_infra/data/erasure.py +46 -0
  142. svc_infra/data/fixtures.py +42 -0
  143. svc_infra/data/retention.py +56 -0
  144. svc_infra/db/__init__.py +15 -0
  145. svc_infra/db/crud_schema.py +14 -13
  146. svc_infra/db/inbox.py +67 -0
  147. svc_infra/db/nosql/__init__.py +2 -0
  148. svc_infra/db/nosql/constants.py +1 -1
  149. svc_infra/db/nosql/core.py +19 -5
  150. svc_infra/db/nosql/indexes.py +12 -9
  151. svc_infra/db/nosql/management.py +4 -4
  152. svc_infra/db/nosql/mongo/README.md +13 -13
  153. svc_infra/db/nosql/mongo/client.py +21 -4
  154. svc_infra/db/nosql/mongo/settings.py +1 -1
  155. svc_infra/db/nosql/repository.py +46 -27
  156. svc_infra/db/nosql/resource.py +28 -16
  157. svc_infra/db/nosql/scaffold.py +14 -12
  158. svc_infra/db/nosql/service.py +2 -1
  159. svc_infra/db/nosql/service_with_hooks.py +4 -3
  160. svc_infra/db/nosql/utils.py +4 -4
  161. svc_infra/db/ops.py +380 -0
  162. svc_infra/db/outbox.py +105 -0
  163. svc_infra/db/sql/apikey.py +34 -15
  164. svc_infra/db/sql/authref.py +8 -6
  165. svc_infra/db/sql/constants.py +5 -1
  166. svc_infra/db/sql/core.py +13 -13
  167. svc_infra/db/sql/management.py +5 -6
  168. svc_infra/db/sql/repository.py +92 -26
  169. svc_infra/db/sql/resource.py +18 -12
  170. svc_infra/db/sql/scaffold.py +11 -11
  171. svc_infra/db/sql/service.py +2 -1
  172. svc_infra/db/sql/service_with_hooks.py +4 -3
  173. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  174. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  175. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  176. svc_infra/db/sql/tenant.py +80 -0
  177. svc_infra/db/sql/uniq.py +8 -7
  178. svc_infra/db/sql/uniq_hooks.py +12 -11
  179. svc_infra/db/sql/utils.py +105 -47
  180. svc_infra/db/sql/versioning.py +14 -0
  181. svc_infra/db/utils.py +3 -3
  182. svc_infra/deploy/__init__.py +531 -0
  183. svc_infra/documents/__init__.py +100 -0
  184. svc_infra/documents/add.py +263 -0
  185. svc_infra/documents/ease.py +233 -0
  186. svc_infra/documents/models.py +114 -0
  187. svc_infra/documents/storage.py +262 -0
  188. svc_infra/dx/__init__.py +58 -0
  189. svc_infra/dx/add.py +63 -0
  190. svc_infra/dx/changelog.py +74 -0
  191. svc_infra/dx/checks.py +68 -0
  192. svc_infra/exceptions.py +141 -0
  193. svc_infra/health/__init__.py +863 -0
  194. svc_infra/http/__init__.py +13 -0
  195. svc_infra/http/client.py +101 -0
  196. svc_infra/jobs/__init__.py +79 -0
  197. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  198. svc_infra/jobs/builtins/webhook_delivery.py +93 -0
  199. svc_infra/jobs/easy.py +33 -0
  200. svc_infra/jobs/loader.py +49 -0
  201. svc_infra/jobs/queue.py +106 -0
  202. svc_infra/jobs/redis_queue.py +242 -0
  203. svc_infra/jobs/runner.py +75 -0
  204. svc_infra/jobs/scheduler.py +53 -0
  205. svc_infra/jobs/worker.py +40 -0
  206. svc_infra/loaders/__init__.py +186 -0
  207. svc_infra/loaders/base.py +143 -0
  208. svc_infra/loaders/github.py +309 -0
  209. svc_infra/loaders/models.py +147 -0
  210. svc_infra/loaders/url.py +229 -0
  211. svc_infra/logging/__init__.py +375 -0
  212. svc_infra/mcp/__init__.py +82 -0
  213. svc_infra/mcp/svc_infra_mcp.py +91 -33
  214. svc_infra/obs/README.md +2 -0
  215. svc_infra/obs/add.py +68 -11
  216. svc_infra/obs/cloud_dash.py +2 -1
  217. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  218. svc_infra/obs/metrics/__init__.py +6 -7
  219. svc_infra/obs/metrics/asgi.py +8 -7
  220. svc_infra/obs/metrics/base.py +13 -13
  221. svc_infra/obs/metrics/http.py +3 -3
  222. svc_infra/obs/metrics/sqlalchemy.py +14 -13
  223. svc_infra/obs/metrics.py +9 -8
  224. svc_infra/resilience/__init__.py +44 -0
  225. svc_infra/resilience/circuit_breaker.py +328 -0
  226. svc_infra/resilience/retry.py +289 -0
  227. svc_infra/security/__init__.py +167 -0
  228. svc_infra/security/add.py +213 -0
  229. svc_infra/security/audit.py +97 -18
  230. svc_infra/security/audit_service.py +10 -9
  231. svc_infra/security/headers.py +15 -2
  232. svc_infra/security/hibp.py +14 -7
  233. svc_infra/security/jwt_rotation.py +78 -29
  234. svc_infra/security/lockout.py +23 -16
  235. svc_infra/security/models.py +77 -44
  236. svc_infra/security/oauth_models.py +73 -0
  237. svc_infra/security/org_invites.py +12 -12
  238. svc_infra/security/passwords.py +3 -3
  239. svc_infra/security/permissions.py +31 -7
  240. svc_infra/security/session.py +7 -8
  241. svc_infra/security/signed_cookies.py +26 -6
  242. svc_infra/storage/__init__.py +93 -0
  243. svc_infra/storage/add.py +250 -0
  244. svc_infra/storage/backends/__init__.py +11 -0
  245. svc_infra/storage/backends/local.py +331 -0
  246. svc_infra/storage/backends/memory.py +213 -0
  247. svc_infra/storage/backends/s3.py +334 -0
  248. svc_infra/storage/base.py +239 -0
  249. svc_infra/storage/easy.py +181 -0
  250. svc_infra/storage/settings.py +193 -0
  251. svc_infra/testing/__init__.py +682 -0
  252. svc_infra/utils.py +170 -5
  253. svc_infra/webhooks/__init__.py +69 -0
  254. svc_infra/webhooks/add.py +327 -0
  255. svc_infra/webhooks/encryption.py +115 -0
  256. svc_infra/webhooks/fastapi.py +37 -0
  257. svc_infra/webhooks/router.py +55 -0
  258. svc_infra/webhooks/service.py +69 -0
  259. svc_infra/webhooks/signing.py +34 -0
  260. svc_infra/websocket/__init__.py +79 -0
  261. svc_infra/websocket/add.py +139 -0
  262. svc_infra/websocket/client.py +283 -0
  263. svc_infra/websocket/config.py +57 -0
  264. svc_infra/websocket/easy.py +76 -0
  265. svc_infra/websocket/exceptions.py +61 -0
  266. svc_infra/websocket/manager.py +343 -0
  267. svc_infra/websocket/models.py +49 -0
  268. svc_infra-1.1.0.dist-info/LICENSE +21 -0
  269. svc_infra-1.1.0.dist-info/METADATA +362 -0
  270. svc_infra-1.1.0.dist-info/RECORD +364 -0
  271. svc_infra-0.1.595.dist-info/METADATA +0 -80
  272. svc_infra-0.1.595.dist-info/RECORD +0 -253
  273. {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
  274. {svc_infra-0.1.595.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
@@ -1,7 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Optional
4
-
5
3
  from sqlalchemy import select
6
4
  from sqlalchemy.ext.asyncio import AsyncSession
7
5
 
@@ -19,6 +17,7 @@ from .models import (
19
17
  PaySetupIntent,
20
18
  PaySubscription,
21
19
  )
20
+ from .provider.base import ProviderAdapter
22
21
  from .provider.registry import get_provider_registry
23
22
  from .schemas import (
24
23
  BalanceSnapshotOut,
@@ -77,18 +76,18 @@ class PaymentsService:
77
76
  session: AsyncSession,
78
77
  *,
79
78
  tenant_id: str,
80
- provider_name: Optional[str] = None,
79
+ provider_name: str | None = None,
81
80
  ):
82
81
  if not tenant_id:
83
82
  raise ValueError("tenant_id is required for PaymentsService")
84
83
  self.session = session
85
84
  self.tenant_id = tenant_id
86
85
  self._provider_name = (provider_name or _default_provider_name()).lower()
87
- self._adapter = None # resolved on first use
86
+ self._adapter: ProviderAdapter | None = None # resolved on first use
88
87
 
89
88
  # --- internal helpers -----------------------------------------------------
90
89
 
91
- def _get_adapter(self):
90
+ def _get_adapter(self) -> ProviderAdapter:
92
91
  if self._adapter is not None:
93
92
  return self._adapter
94
93
  reg = get_provider_registry()
@@ -143,7 +142,7 @@ class PaymentsService:
143
142
 
144
143
  # --- Intents --------------------------------------------------------------
145
144
 
146
- async def create_intent(self, user_id: Optional[str], data: IntentCreateIn) -> IntentOut:
145
+ async def create_intent(self, user_id: str | None, data: IntentCreateIn) -> IntentOut:
147
146
  adapter = self._get_adapter()
148
147
  out = await adapter.create_intent(data, user_id=user_id)
149
148
  self.session.add(
@@ -491,7 +490,8 @@ class PaymentsService:
491
490
 
492
491
  async def capture_intent(self, provider_intent_id: str, data: CaptureIn) -> IntentOut:
493
492
  out = await self._get_adapter().capture_intent(
494
- provider_intent_id, amount=int(data.amount) if data.amount is not None else None
493
+ provider_intent_id,
494
+ amount=int(data.amount) if data.amount is not None else None,
495
495
  )
496
496
  pi = await self.session.scalar(
497
497
  select(PayIntent).where(PayIntent.provider_intent_id == provider_intent_id)
@@ -623,8 +623,8 @@ class PaymentsService:
623
623
 
624
624
  # --- Disputes -------------------------------------------------------------
625
625
  async def list_disputes(
626
- self, *, status: Optional[str], limit: int, cursor: Optional[str]
627
- ) -> tuple[list[DisputeOut], Optional[str]]:
626
+ self, *, status: str | None, limit: int, cursor: str | None
627
+ ) -> tuple[list[DisputeOut], str | None]:
628
628
  return await self._get_adapter().list_disputes(status=status, limit=limit, cursor=cursor)
629
629
 
630
630
  async def get_dispute(self, provider_dispute_id: str) -> DisputeOut:
@@ -668,8 +668,8 @@ class PaymentsService:
668
668
 
669
669
  # --- Payouts --------------------------------------------------------------
670
670
  async def list_payouts(
671
- self, *, limit: int, cursor: Optional[str]
672
- ) -> tuple[list[PayoutOut], Optional[str]]:
671
+ self, *, limit: int, cursor: str | None
672
+ ) -> tuple[list[PayoutOut], str | None]:
673
673
  return await self._get_adapter().list_payouts(limit=limit, cursor=cursor)
674
674
 
675
675
  async def get_payout(self, provider_payout_id: str) -> PayoutOut:
@@ -699,7 +699,7 @@ class PaymentsService:
699
699
 
700
700
  # --- Webhook replay -------------------------------------------------------
701
701
  async def replay_webhooks(
702
- self, since: Optional[str], until: Optional[str], event_ids: list[str]
702
+ self, since: str | None, until: str | None, event_ids: list[str]
703
703
  ) -> int:
704
704
  from datetime import datetime
705
705
 
@@ -730,7 +730,10 @@ class PaymentsService:
730
730
  adapter = self._get_adapter()
731
731
  try:
732
732
  return await adapter.list_customers(
733
- provider=f.provider, user_id=f.user_id, limit=f.limit or 50, cursor=f.cursor
733
+ provider=f.provider,
734
+ user_id=f.user_id,
735
+ limit=f.limit or 50,
736
+ cursor=f.cursor,
734
737
  )
735
738
  except NotImplementedError:
736
739
  # Fallback to local DB listing
@@ -813,7 +816,10 @@ class PaymentsService:
813
816
  cursor: str | None,
814
817
  ) -> tuple[list[PriceOut], str | None]:
815
818
  return await self._get_adapter().list_prices(
816
- provider_product_id=provider_product_id, active=active, limit=limit, cursor=cursor
819
+ provider_product_id=provider_product_id,
820
+ active=active,
821
+ limit=limit,
822
+ cursor=cursor,
817
823
  )
818
824
 
819
825
  async def update_price(self, provider_price_id: str, data: PriceUpdateIn) -> PriceOut:
@@ -838,7 +844,10 @@ class PaymentsService:
838
844
  cursor: str | None,
839
845
  ) -> tuple[list[SubscriptionOut], str | None]:
840
846
  return await self._get_adapter().list_subscriptions(
841
- customer_provider_id=customer_provider_id, status=status, limit=limit, cursor=cursor
847
+ customer_provider_id=customer_provider_id,
848
+ status=status,
849
+ limit=limit,
850
+ cursor=cursor,
842
851
  )
843
852
 
844
853
  # ---- Payment Methods (get/update) ----
@@ -868,7 +877,9 @@ class PaymentsService:
868
877
  self, *, provider_payment_intent_id: str | None, limit: int, cursor: str | None
869
878
  ) -> tuple[list[RefundOut], str | None]:
870
879
  return await self._get_adapter().list_refunds(
871
- provider_payment_intent_id=provider_payment_intent_id, limit=limit, cursor=cursor
880
+ provider_payment_intent_id=provider_payment_intent_id,
881
+ limit=limit,
882
+ cursor=cursor,
872
883
  )
873
884
 
874
885
  async def get_refund(self, provider_refund_id: str) -> RefundOut:
@@ -1,13 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
- from typing import Optional
5
4
 
6
5
  from pydantic import BaseModel, SecretStr
7
6
 
8
7
  STRIPE_KEY = os.getenv("STRIPE_SECRET") or os.getenv("STRIPE_API_KEY")
9
8
  STRIPE_WH = os.getenv("STRIPE_WH_SECRET")
10
- PROVIDER = (os.getenv("APF_PAYMENTS_PROVIDER") or os.getenv("PAYMENTS_PROVIDER", "stripe")).lower()
9
+ PROVIDER = (
10
+ os.getenv("APF_PAYMENTS_PROVIDER") or os.getenv("PAYMENTS_PROVIDER", "stripe") or "stripe"
11
+ ).lower()
11
12
 
12
13
  AIYDAN_KEY = os.getenv("AIYDAN_API_KEY")
13
14
  AIYDAN_CLIENT_KEY = os.getenv("AIYDAN_CLIENT_KEY")
@@ -19,23 +20,23 @@ AIYDAN_WH = os.getenv("AIYDAN_WH_SECRET")
19
20
 
20
21
  class StripeConfig(BaseModel):
21
22
  secret_key: SecretStr
22
- webhook_secret: Optional[SecretStr] = None
23
+ webhook_secret: SecretStr | None = None
23
24
 
24
25
 
25
26
  class AiydanConfig(BaseModel):
26
27
  api_key: SecretStr
27
- client_key: Optional[SecretStr] = None
28
- merchant_account: Optional[str] = None
29
- hmac_key: Optional[SecretStr] = None
30
- base_url: Optional[str] = None
31
- webhook_secret: Optional[SecretStr] = None
28
+ client_key: SecretStr | None = None
29
+ merchant_account: str | None = None
30
+ hmac_key: SecretStr | None = None
31
+ base_url: str | None = None
32
+ webhook_secret: SecretStr | None = None
32
33
 
33
34
 
34
35
  class PaymentsSettings(BaseModel):
35
36
  default_provider: str = PROVIDER
36
37
 
37
38
  # optional multi-tenant/provider map hook can be added later
38
- stripe: Optional[StripeConfig] = (
39
+ stripe: StripeConfig | None = (
39
40
  StripeConfig(
40
41
  secret_key=SecretStr(STRIPE_KEY),
41
42
  webhook_secret=SecretStr(STRIPE_WH) if STRIPE_WH else None,
@@ -43,7 +44,7 @@ class PaymentsSettings(BaseModel):
43
44
  if STRIPE_KEY
44
45
  else None
45
46
  )
46
- aiydan: Optional[AiydanConfig] = (
47
+ aiydan: AiydanConfig | None = (
47
48
  AiydanConfig(
48
49
  api_key=SecretStr(AIYDAN_KEY),
49
50
  client_key=SecretStr(AIYDAN_CLIENT_KEY) if AIYDAN_CLIENT_KEY else None,
@@ -57,7 +58,7 @@ class PaymentsSettings(BaseModel):
57
58
  )
58
59
 
59
60
 
60
- _SETTINGS: Optional[PaymentsSettings] = None
61
+ _SETTINGS: PaymentsSettings | None = None
61
62
 
62
63
 
63
64
  def get_payments_settings() -> PaymentsSettings:
svc_infra/api/__init__.py CHANGED
@@ -0,0 +1,61 @@
1
+ """svc-infra API module.
2
+
3
+ Re-exports key API utilities from svc_infra.api.fastapi for convenient imports.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ # Re-export from fastapi submodule
9
+ from svc_infra.api.fastapi import (
10
+ APIVersionSpec,
11
+ # Dual routers
12
+ DualAPIRouter,
13
+ # Service setup
14
+ ServiceInfo,
15
+ add_dependency_health,
16
+ add_health_routes,
17
+ # Health checks
18
+ add_startup_probe,
19
+ check_database,
20
+ check_redis,
21
+ check_url,
22
+ cursor_window,
23
+ dualize_protected,
24
+ dualize_public,
25
+ dualize_user,
26
+ easy_service_api,
27
+ easy_service_app,
28
+ setup_caching,
29
+ setup_service_api,
30
+ sort_by,
31
+ text_filter,
32
+ # Pagination
33
+ use_pagination,
34
+ )
35
+
36
+ __all__ = [
37
+ # Dual routers
38
+ "DualAPIRouter",
39
+ "dualize_protected",
40
+ "dualize_public",
41
+ "dualize_user",
42
+ # Service setup
43
+ "ServiceInfo",
44
+ "APIVersionSpec",
45
+ "setup_service_api",
46
+ "easy_service_api",
47
+ "easy_service_app",
48
+ "setup_caching",
49
+ # Health checks
50
+ "add_startup_probe",
51
+ "add_health_routes",
52
+ "add_dependency_health",
53
+ "check_database",
54
+ "check_redis",
55
+ "check_url",
56
+ # Pagination
57
+ "use_pagination",
58
+ "text_filter",
59
+ "sort_by",
60
+ "cursor_window",
61
+ ]
@@ -4,7 +4,25 @@ from svc_infra.api.fastapi.dual import (
4
4
  dualize_public,
5
5
  dualize_user,
6
6
  )
7
+ from svc_infra.api.fastapi.object_router import (
8
+ DEFAULT_EXCEPTION_MAP,
9
+ STATUS_TITLES,
10
+ endpoint,
11
+ endpoint_exclude,
12
+ map_exception_to_http,
13
+ router_from_object,
14
+ router_from_object_with_websocket,
15
+ websocket_endpoint,
16
+ )
7
17
  from svc_infra.api.fastapi.openapi.models import APIVersionSpec, ServiceInfo
18
+ from svc_infra.health import (
19
+ add_dependency_health,
20
+ add_health_routes,
21
+ add_startup_probe,
22
+ check_database,
23
+ check_redis,
24
+ check_url,
25
+ )
8
26
 
9
27
  from .cache.add import setup_caching
10
28
  from .ease import easy_service_api, easy_service_app
@@ -18,6 +36,13 @@ __all__ = [
18
36
  "dualize_protected",
19
37
  "ServiceInfo",
20
38
  "APIVersionSpec",
39
+ # Health
40
+ "add_startup_probe",
41
+ "add_health_routes",
42
+ "add_dependency_health",
43
+ "check_database",
44
+ "check_redis",
45
+ "check_url",
21
46
  # Ease
22
47
  "setup_service_api",
23
48
  "easy_service_api",
@@ -28,4 +53,13 @@ __all__ = [
28
53
  "text_filter",
29
54
  "sort_by",
30
55
  "cursor_window",
56
+ # Object Router
57
+ "router_from_object",
58
+ "router_from_object_with_websocket",
59
+ "endpoint",
60
+ "endpoint_exclude",
61
+ "websocket_endpoint",
62
+ "map_exception_to_http",
63
+ "DEFAULT_EXCEPTION_MAP",
64
+ "STATUS_TITLES",
31
65
  ]
@@ -0,0 +1,3 @@
1
+ from .add import add_admin, admin_router
2
+
3
+ __all__ = ["add_admin", "admin_router"]
@@ -0,0 +1,240 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hmac
5
+ import inspect
6
+ import json
7
+ import logging
8
+ import os
9
+ import time
10
+ from collections.abc import Callable
11
+ from hashlib import sha256
12
+ from types import SimpleNamespace
13
+ from typing import Any, cast
14
+
15
+ from fastapi import APIRouter, Depends, HTTPException, Request, Response
16
+
17
+ from ....app.env import get_current_environment, require_secret
18
+ from ....security.permissions import RequirePermission
19
+ from ..auth.security import Identity, Principal, _current_principal
20
+ from ..auth.state import get_auth_state
21
+ from ..db.sql.session import SqlSessionDep
22
+ from ..dual.protected import roles_router
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ def _b64u(data: bytes) -> str:
28
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
29
+
30
+
31
+ def _b64u_decode(s: str) -> bytes:
32
+ pad = "=" * ((4 - len(s) % 4) % 4)
33
+ return base64.urlsafe_b64decode(s + pad)
34
+
35
+
36
+ def _sign(payload: dict, *, secret: str) -> str:
37
+ body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
38
+ sig = hmac.new(secret.encode("utf-8"), body, sha256).digest()
39
+ return _b64u(body) + "." + _b64u(sig)
40
+
41
+
42
+ def _verify(token: str, *, secret: str) -> dict:
43
+ try:
44
+ b64_body, b64_sig = token.split(".", 1)
45
+ body = _b64u_decode(b64_body)
46
+ exp_sig = _b64u_decode(b64_sig)
47
+ got_sig = hmac.new(secret.encode("utf-8"), body, sha256).digest()
48
+ if not hmac.compare_digest(exp_sig, got_sig):
49
+ raise ValueError("bad_signature")
50
+ payload = json.loads(body)
51
+ if int(payload.get("exp", 0)) < int(time.time()):
52
+ raise ValueError("expired")
53
+ return cast("dict[Any, Any]", payload)
54
+ except Exception as e:
55
+ raise ValueError("invalid_token") from e
56
+
57
+
58
+ def admin_router(*, dependencies: list[Any] | None = None, **kwargs) -> APIRouter:
59
+ """Role-gated admin router for coarse access control.
60
+
61
+ Use permission guards inside endpoints for fine-grained control.
62
+ """
63
+
64
+ return cast("APIRouter", roles_router("admin", **kwargs))
65
+
66
+
67
+ def add_admin(
68
+ app,
69
+ *,
70
+ base_path: str = "/admin",
71
+ enable_impersonation: bool = True,
72
+ secret: str | None = None,
73
+ ttl_seconds: int = 15 * 60,
74
+ cookie_name: str = "impersonation",
75
+ impersonation_user_getter: Callable[[Any, str], Any] | None = None,
76
+ ) -> None:
77
+ """Wire admin surfaces with sensible defaults.
78
+
79
+ - Mounts an admin router under base_path.
80
+ - Optionally enables impersonation start/stop endpoints guarded by permissions.
81
+ - Registers a dependency override to honor impersonation cookie globally (idempotent).
82
+
83
+ impersonation_user_getter: optional callable (request, user_id) -> user object.
84
+ If omitted, defaults to loading from SQLAlchemy User model returned by get_auth_state().
85
+ """
86
+
87
+ # Idempotency: only mount once per app instance
88
+ if getattr(app.state, "_admin_added", False):
89
+ return
90
+
91
+ env = get_current_environment()
92
+ _secret = require_secret(
93
+ secret or os.getenv("ADMIN_IMPERSONATION_SECRET") or os.getenv("APP_SECRET"),
94
+ "ADMIN_IMPERSONATION_SECRET or APP_SECRET",
95
+ dev_default="dev-only-admin-impersonation-secret-not-for-production",
96
+ )
97
+ _ttl = int(os.getenv("ADMIN_IMPERSONATION_TTL", str(ttl_seconds)))
98
+ _cookie = os.getenv("ADMIN_IMPERSONATION_COOKIE", cookie_name)
99
+
100
+ r = admin_router(prefix=base_path, tags=["admin"]) # role-gated
101
+
102
+ async def _default_user_getter(request: Request, user_id: str, session: SqlSessionDep):
103
+ try:
104
+ UserModel, _, _ = get_auth_state()
105
+ except Exception:
106
+ # Fallback: simple shim if auth state not configured
107
+ return SimpleNamespace(id=user_id)
108
+ obj = await cast("Any", session).get(UserModel, user_id)
109
+ if not obj:
110
+ raise HTTPException(404, "user_not_found")
111
+ return obj
112
+
113
+ user_getter = impersonation_user_getter
114
+
115
+ @r.post(
116
+ "/impersonate/start",
117
+ status_code=204,
118
+ dependencies=[RequirePermission("admin.impersonate")],
119
+ )
120
+ async def start_impersonation(
121
+ body: dict,
122
+ request: Request,
123
+ response: Response,
124
+ session: SqlSessionDep,
125
+ identity: Identity,
126
+ ):
127
+ target_id = (body or {}).get("user_id")
128
+ reason = (body or {}).get("reason", "")
129
+ if not target_id:
130
+ raise HTTPException(422, "user_id_required")
131
+ # Load target for validation (custom getter or default)
132
+ _res = (
133
+ user_getter(request, target_id)
134
+ if user_getter
135
+ else _default_user_getter(request, target_id, session)
136
+ )
137
+ target = await _res if inspect.isawaitable(_res) else _res
138
+ actor: Principal = identity
139
+ payload = {
140
+ "actor_id": getattr(getattr(actor, "user", None), "id", None),
141
+ "target_id": str(getattr(target, "id", target_id)),
142
+ "iat": int(time.time()),
143
+ "exp": int(time.time()) + _ttl,
144
+ "nonce": _b64u(os.urandom(8)),
145
+ }
146
+ token = _sign(payload, secret=_secret)
147
+ response.set_cookie(
148
+ key=_cookie,
149
+ value=token,
150
+ httponly=True,
151
+ samesite="lax",
152
+ secure=(env in ("prod", "production")),
153
+ path="/",
154
+ max_age=_ttl,
155
+ )
156
+ logger.info(
157
+ "admin.impersonation.started",
158
+ extra={
159
+ "actor_id": payload["actor_id"],
160
+ "target_id": payload["target_id"],
161
+ "reason": reason,
162
+ "expires_in": _ttl,
163
+ },
164
+ )
165
+ # Re-compose override now to wrap any late overrides set by tests/harness
166
+ try:
167
+ _compose_override()
168
+ except Exception:
169
+ pass
170
+
171
+ @r.post("/impersonate/stop", status_code=204)
172
+ async def stop_impersonation(response: Response):
173
+ response.delete_cookie(_cookie, path="/")
174
+ logger.info("admin.impersonation.stopped")
175
+
176
+ app.include_router(r)
177
+
178
+ # Dependency override: wrap the base principal to honor impersonation cookie.
179
+ # Compose with any existing override (e.g., acceptance app/test harness) and
180
+ # re-compose at startup to capture late overrides.
181
+ def _compose_override():
182
+ existing = app.dependency_overrides.get(_current_principal)
183
+ if existing and getattr(existing, "_is_admin_impersonation_override", False):
184
+ dep_provider = getattr(existing, "_admin_impersonation_base", _current_principal)
185
+ else:
186
+ dep_provider = existing or _current_principal
187
+
188
+ async def _override_current_principal(
189
+ request: Request,
190
+ session: SqlSessionDep,
191
+ base: Principal = Depends(dep_provider),
192
+ ) -> Principal:
193
+ token = request.cookies.get(_cookie) if request else None
194
+ if not token:
195
+ return base
196
+ try:
197
+ payload = _verify(token, secret=_secret)
198
+ except Exception:
199
+ return base
200
+ # Load target user
201
+ target_id = payload.get("target_id")
202
+ if not target_id:
203
+ return base
204
+ # Preserve actor roles/claims so permissions remain that of the actor
205
+ actor_user = getattr(base, "user", None)
206
+ actor_roles = getattr(actor_user, "roles", []) or []
207
+ _res = (
208
+ user_getter(request, target_id)
209
+ if user_getter
210
+ else _default_user_getter(request, target_id, session)
211
+ )
212
+ target = await _res if inspect.isawaitable(_res) else _res
213
+ # Swap user but keep actor for audit if needed
214
+ base.actor = getattr(base, "user", None) # type: ignore[attr-defined]
215
+ # If target lacks roles, inherit actor roles to maintain permission checks
216
+ try:
217
+ if not getattr(target, "roles", None):
218
+ target.roles = actor_roles
219
+ except Exception:
220
+ # Best-effort; if target object is immutable, fallback by wrapping
221
+ target = SimpleNamespace(id=getattr(target, "id", target_id), roles=actor_roles)
222
+ base.user = target
223
+ base.via = "impersonated"
224
+ return base
225
+
226
+ app.dependency_overrides[_current_principal] = _override_current_principal
227
+ _override_current_principal._is_admin_impersonation_override = True # type: ignore[attr-defined]
228
+ _override_current_principal._admin_impersonation_base = dep_provider # type: ignore[attr-defined]
229
+
230
+ # Compose now (best-effort) and again on startup to wrap any later overrides
231
+ _compose_override()
232
+ try:
233
+ app.add_event_handler("startup", _compose_override)
234
+ except Exception:
235
+ # Best-effort; if app doesn't support event handlers, we already composed once
236
+ pass
237
+ app.state._admin_added = True
238
+
239
+
240
+ # no extra helpers