svc-infra 0.1.595__py3-none-any.whl → 0.1.706__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 (256) hide show
  1. svc_infra/__init__.py +58 -2
  2. svc_infra/apf_payments/models.py +133 -42
  3. svc_infra/apf_payments/provider/aiydan.py +121 -47
  4. svc_infra/apf_payments/provider/base.py +30 -9
  5. svc_infra/apf_payments/provider/stripe.py +156 -62
  6. svc_infra/apf_payments/schemas.py +18 -9
  7. svc_infra/apf_payments/service.py +98 -41
  8. svc_infra/apf_payments/settings.py +5 -1
  9. svc_infra/api/__init__.py +61 -0
  10. svc_infra/api/fastapi/__init__.py +15 -0
  11. svc_infra/api/fastapi/admin/__init__.py +3 -0
  12. svc_infra/api/fastapi/admin/add.py +245 -0
  13. svc_infra/api/fastapi/apf_payments/router.py +128 -70
  14. svc_infra/api/fastapi/apf_payments/setup.py +13 -6
  15. svc_infra/api/fastapi/auth/__init__.py +65 -0
  16. svc_infra/api/fastapi/auth/_cookies.py +6 -2
  17. svc_infra/api/fastapi/auth/add.py +17 -14
  18. svc_infra/api/fastapi/auth/gaurd.py +45 -16
  19. svc_infra/api/fastapi/auth/mfa/models.py +3 -1
  20. svc_infra/api/fastapi/auth/mfa/pre_auth.py +10 -6
  21. svc_infra/api/fastapi/auth/mfa/router.py +15 -8
  22. svc_infra/api/fastapi/auth/mfa/security.py +1 -2
  23. svc_infra/api/fastapi/auth/mfa/utils.py +2 -1
  24. svc_infra/api/fastapi/auth/mfa/verify.py +9 -2
  25. svc_infra/api/fastapi/auth/policy.py +0 -1
  26. svc_infra/api/fastapi/auth/providers.py +3 -1
  27. svc_infra/api/fastapi/auth/routers/apikey_router.py +6 -6
  28. svc_infra/api/fastapi/auth/routers/oauth_router.py +146 -52
  29. svc_infra/api/fastapi/auth/routers/session_router.py +6 -2
  30. svc_infra/api/fastapi/auth/security.py +31 -10
  31. svc_infra/api/fastapi/auth/sender.py +8 -1
  32. svc_infra/api/fastapi/auth/state.py +3 -1
  33. svc_infra/api/fastapi/auth/ws_security.py +275 -0
  34. svc_infra/api/fastapi/billing/router.py +73 -0
  35. svc_infra/api/fastapi/billing/setup.py +19 -0
  36. svc_infra/api/fastapi/cache/add.py +9 -5
  37. svc_infra/api/fastapi/db/__init__.py +5 -1
  38. svc_infra/api/fastapi/db/http.py +3 -1
  39. svc_infra/api/fastapi/db/nosql/__init__.py +39 -1
  40. svc_infra/api/fastapi/db/nosql/mongo/add.py +47 -32
  41. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +30 -11
  42. svc_infra/api/fastapi/db/sql/__init__.py +5 -1
  43. svc_infra/api/fastapi/db/sql/add.py +71 -26
  44. svc_infra/api/fastapi/db/sql/crud_router.py +210 -22
  45. svc_infra/api/fastapi/db/sql/health.py +3 -1
  46. svc_infra/api/fastapi/db/sql/session.py +18 -0
  47. svc_infra/api/fastapi/db/sql/users.py +18 -6
  48. svc_infra/api/fastapi/dependencies/ratelimit.py +78 -14
  49. svc_infra/api/fastapi/docs/add.py +173 -0
  50. svc_infra/api/fastapi/docs/landing.py +4 -2
  51. svc_infra/api/fastapi/docs/scoped.py +62 -15
  52. svc_infra/api/fastapi/dual/__init__.py +12 -2
  53. svc_infra/api/fastapi/dual/dualize.py +1 -1
  54. svc_infra/api/fastapi/dual/protected.py +126 -4
  55. svc_infra/api/fastapi/dual/public.py +25 -0
  56. svc_infra/api/fastapi/dual/router.py +40 -13
  57. svc_infra/api/fastapi/dx.py +33 -2
  58. svc_infra/api/fastapi/ease.py +10 -2
  59. svc_infra/api/fastapi/http/concurrency.py +2 -1
  60. svc_infra/api/fastapi/http/conditional.py +3 -1
  61. svc_infra/api/fastapi/middleware/debug.py +4 -1
  62. svc_infra/api/fastapi/middleware/errors/catchall.py +6 -2
  63. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -1
  64. svc_infra/api/fastapi/middleware/errors/handlers.py +54 -8
  65. svc_infra/api/fastapi/middleware/graceful_shutdown.py +104 -0
  66. svc_infra/api/fastapi/middleware/idempotency.py +197 -70
  67. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  68. svc_infra/api/fastapi/middleware/optimistic_lock.py +42 -0
  69. svc_infra/api/fastapi/middleware/ratelimit.py +125 -28
  70. svc_infra/api/fastapi/middleware/ratelimit_store.py +43 -10
  71. svc_infra/api/fastapi/middleware/request_id.py +27 -11
  72. svc_infra/api/fastapi/middleware/request_size_limit.py +3 -3
  73. svc_infra/api/fastapi/middleware/timeout.py +177 -0
  74. svc_infra/api/fastapi/openapi/apply.py +5 -3
  75. svc_infra/api/fastapi/openapi/conventions.py +9 -2
  76. svc_infra/api/fastapi/openapi/mutators.py +165 -20
  77. svc_infra/api/fastapi/openapi/pipeline.py +1 -1
  78. svc_infra/api/fastapi/openapi/security.py +3 -1
  79. svc_infra/api/fastapi/ops/add.py +75 -0
  80. svc_infra/api/fastapi/pagination.py +47 -20
  81. svc_infra/api/fastapi/routers/__init__.py +43 -15
  82. svc_infra/api/fastapi/routers/ping.py +1 -0
  83. svc_infra/api/fastapi/setup.py +188 -57
  84. svc_infra/api/fastapi/tenancy/add.py +19 -0
  85. svc_infra/api/fastapi/tenancy/context.py +112 -0
  86. svc_infra/api/fastapi/versioned.py +101 -0
  87. svc_infra/app/README.md +5 -5
  88. svc_infra/app/__init__.py +3 -1
  89. svc_infra/app/env.py +69 -1
  90. svc_infra/app/logging/add.py +9 -2
  91. svc_infra/app/logging/formats.py +12 -5
  92. svc_infra/billing/__init__.py +23 -0
  93. svc_infra/billing/async_service.py +147 -0
  94. svc_infra/billing/jobs.py +241 -0
  95. svc_infra/billing/models.py +177 -0
  96. svc_infra/billing/quotas.py +103 -0
  97. svc_infra/billing/schemas.py +36 -0
  98. svc_infra/billing/service.py +123 -0
  99. svc_infra/bundled_docs/README.md +5 -0
  100. svc_infra/bundled_docs/__init__.py +1 -0
  101. svc_infra/bundled_docs/getting-started.md +6 -0
  102. svc_infra/cache/__init__.py +9 -0
  103. svc_infra/cache/add.py +170 -0
  104. svc_infra/cache/backend.py +7 -6
  105. svc_infra/cache/decorators.py +81 -15
  106. svc_infra/cache/demo.py +2 -2
  107. svc_infra/cache/keys.py +24 -4
  108. svc_infra/cache/recache.py +26 -14
  109. svc_infra/cache/resources.py +14 -5
  110. svc_infra/cache/tags.py +19 -44
  111. svc_infra/cache/utils.py +3 -1
  112. svc_infra/cli/__init__.py +52 -8
  113. svc_infra/cli/__main__.py +4 -0
  114. svc_infra/cli/cmds/__init__.py +39 -2
  115. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +7 -4
  116. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +7 -5
  117. svc_infra/cli/cmds/db/ops_cmds.py +270 -0
  118. svc_infra/cli/cmds/db/sql/alembic_cmds.py +103 -18
  119. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +88 -0
  120. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +3 -3
  121. svc_infra/cli/cmds/docs/docs_cmds.py +142 -0
  122. svc_infra/cli/cmds/dx/__init__.py +12 -0
  123. svc_infra/cli/cmds/dx/dx_cmds.py +116 -0
  124. svc_infra/cli/cmds/health/__init__.py +179 -0
  125. svc_infra/cli/cmds/health/health_cmds.py +8 -0
  126. svc_infra/cli/cmds/help.py +4 -0
  127. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  128. svc_infra/cli/cmds/jobs/jobs_cmds.py +47 -0
  129. svc_infra/cli/cmds/obs/obs_cmds.py +36 -15
  130. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  131. svc_infra/cli/cmds/sdk/sdk_cmds.py +112 -0
  132. svc_infra/cli/foundation/runner.py +6 -2
  133. svc_infra/data/add.py +61 -0
  134. svc_infra/data/backup.py +58 -0
  135. svc_infra/data/erasure.py +45 -0
  136. svc_infra/data/fixtures.py +42 -0
  137. svc_infra/data/retention.py +61 -0
  138. svc_infra/db/__init__.py +15 -0
  139. svc_infra/db/crud_schema.py +9 -9
  140. svc_infra/db/inbox.py +67 -0
  141. svc_infra/db/nosql/__init__.py +3 -0
  142. svc_infra/db/nosql/core.py +30 -9
  143. svc_infra/db/nosql/indexes.py +3 -1
  144. svc_infra/db/nosql/management.py +1 -1
  145. svc_infra/db/nosql/mongo/README.md +13 -13
  146. svc_infra/db/nosql/mongo/client.py +19 -2
  147. svc_infra/db/nosql/mongo/settings.py +6 -2
  148. svc_infra/db/nosql/repository.py +35 -15
  149. svc_infra/db/nosql/resource.py +20 -3
  150. svc_infra/db/nosql/scaffold.py +9 -3
  151. svc_infra/db/nosql/service.py +3 -1
  152. svc_infra/db/nosql/types.py +6 -2
  153. svc_infra/db/ops.py +384 -0
  154. svc_infra/db/outbox.py +108 -0
  155. svc_infra/db/sql/apikey.py +37 -9
  156. svc_infra/db/sql/authref.py +9 -3
  157. svc_infra/db/sql/constants.py +12 -8
  158. svc_infra/db/sql/core.py +2 -2
  159. svc_infra/db/sql/management.py +11 -8
  160. svc_infra/db/sql/repository.py +99 -26
  161. svc_infra/db/sql/resource.py +5 -0
  162. svc_infra/db/sql/scaffold.py +6 -2
  163. svc_infra/db/sql/service.py +15 -5
  164. svc_infra/db/sql/templates/models_schemas/auth/models.py.tmpl +7 -56
  165. svc_infra/db/sql/templates/setup/env_async.py.tmpl +34 -12
  166. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +29 -7
  167. svc_infra/db/sql/tenant.py +88 -0
  168. svc_infra/db/sql/uniq_hooks.py +9 -3
  169. svc_infra/db/sql/utils.py +138 -51
  170. svc_infra/db/sql/versioning.py +14 -0
  171. svc_infra/deploy/__init__.py +538 -0
  172. svc_infra/documents/__init__.py +100 -0
  173. svc_infra/documents/add.py +264 -0
  174. svc_infra/documents/ease.py +233 -0
  175. svc_infra/documents/models.py +114 -0
  176. svc_infra/documents/storage.py +264 -0
  177. svc_infra/dx/add.py +65 -0
  178. svc_infra/dx/changelog.py +74 -0
  179. svc_infra/dx/checks.py +68 -0
  180. svc_infra/exceptions.py +141 -0
  181. svc_infra/health/__init__.py +864 -0
  182. svc_infra/http/__init__.py +13 -0
  183. svc_infra/http/client.py +105 -0
  184. svc_infra/jobs/builtins/outbox_processor.py +40 -0
  185. svc_infra/jobs/builtins/webhook_delivery.py +95 -0
  186. svc_infra/jobs/easy.py +33 -0
  187. svc_infra/jobs/loader.py +50 -0
  188. svc_infra/jobs/queue.py +116 -0
  189. svc_infra/jobs/redis_queue.py +256 -0
  190. svc_infra/jobs/runner.py +79 -0
  191. svc_infra/jobs/scheduler.py +53 -0
  192. svc_infra/jobs/worker.py +40 -0
  193. svc_infra/loaders/__init__.py +186 -0
  194. svc_infra/loaders/base.py +142 -0
  195. svc_infra/loaders/github.py +311 -0
  196. svc_infra/loaders/models.py +147 -0
  197. svc_infra/loaders/url.py +235 -0
  198. svc_infra/logging/__init__.py +374 -0
  199. svc_infra/mcp/svc_infra_mcp.py +91 -33
  200. svc_infra/obs/README.md +2 -0
  201. svc_infra/obs/add.py +65 -9
  202. svc_infra/obs/cloud_dash.py +2 -1
  203. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  204. svc_infra/obs/metrics/__init__.py +3 -4
  205. svc_infra/obs/metrics/asgi.py +13 -7
  206. svc_infra/obs/metrics/http.py +9 -5
  207. svc_infra/obs/metrics/sqlalchemy.py +13 -9
  208. svc_infra/obs/metrics.py +6 -5
  209. svc_infra/obs/settings.py +6 -2
  210. svc_infra/security/add.py +217 -0
  211. svc_infra/security/audit.py +92 -10
  212. svc_infra/security/audit_service.py +4 -3
  213. svc_infra/security/headers.py +15 -2
  214. svc_infra/security/hibp.py +14 -4
  215. svc_infra/security/jwt_rotation.py +74 -22
  216. svc_infra/security/lockout.py +11 -5
  217. svc_infra/security/models.py +54 -12
  218. svc_infra/security/oauth_models.py +73 -0
  219. svc_infra/security/org_invites.py +5 -3
  220. svc_infra/security/passwords.py +3 -1
  221. svc_infra/security/permissions.py +25 -2
  222. svc_infra/security/session.py +1 -1
  223. svc_infra/security/signed_cookies.py +21 -1
  224. svc_infra/storage/__init__.py +93 -0
  225. svc_infra/storage/add.py +253 -0
  226. svc_infra/storage/backends/__init__.py +11 -0
  227. svc_infra/storage/backends/local.py +339 -0
  228. svc_infra/storage/backends/memory.py +216 -0
  229. svc_infra/storage/backends/s3.py +353 -0
  230. svc_infra/storage/base.py +239 -0
  231. svc_infra/storage/easy.py +185 -0
  232. svc_infra/storage/settings.py +195 -0
  233. svc_infra/testing/__init__.py +685 -0
  234. svc_infra/utils.py +7 -3
  235. svc_infra/webhooks/__init__.py +69 -0
  236. svc_infra/webhooks/add.py +339 -0
  237. svc_infra/webhooks/encryption.py +115 -0
  238. svc_infra/webhooks/fastapi.py +39 -0
  239. svc_infra/webhooks/router.py +55 -0
  240. svc_infra/webhooks/service.py +70 -0
  241. svc_infra/webhooks/signing.py +34 -0
  242. svc_infra/websocket/__init__.py +79 -0
  243. svc_infra/websocket/add.py +140 -0
  244. svc_infra/websocket/client.py +282 -0
  245. svc_infra/websocket/config.py +69 -0
  246. svc_infra/websocket/easy.py +76 -0
  247. svc_infra/websocket/exceptions.py +61 -0
  248. svc_infra/websocket/manager.py +344 -0
  249. svc_infra/websocket/models.py +49 -0
  250. svc_infra-0.1.706.dist-info/LICENSE +21 -0
  251. svc_infra-0.1.706.dist-info/METADATA +356 -0
  252. svc_infra-0.1.706.dist-info/RECORD +357 -0
  253. svc_infra-0.1.595.dist-info/METADATA +0 -80
  254. svc_infra-0.1.595.dist-info/RECORD +0 -253
  255. {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/WHEEL +0 -0
  256. {svc_infra-0.1.595.dist-info → svc_infra-0.1.706.dist-info}/entry_points.txt +0 -0
@@ -19,6 +19,7 @@ from .models import (
19
19
  PaySetupIntent,
20
20
  PaySubscription,
21
21
  )
22
+ from .provider.base import ProviderAdapter
22
23
  from .provider.registry import get_provider_registry
23
24
  from .schemas import (
24
25
  BalanceSnapshotOut,
@@ -84,11 +85,11 @@ class PaymentsService:
84
85
  self.session = session
85
86
  self.tenant_id = tenant_id
86
87
  self._provider_name = (provider_name or _default_provider_name()).lower()
87
- self._adapter = None # resolved on first use
88
+ self._adapter: ProviderAdapter | None = None # resolved on first use
88
89
 
89
90
  # --- internal helpers -----------------------------------------------------
90
91
 
91
- def _get_adapter(self):
92
+ def _get_adapter(self) -> ProviderAdapter:
92
93
  if self._adapter is not None:
93
94
  return self._adapter
94
95
  reg = get_provider_registry()
@@ -143,7 +144,9 @@ class PaymentsService:
143
144
 
144
145
  # --- Intents --------------------------------------------------------------
145
146
 
146
- async def create_intent(self, user_id: Optional[str], data: IntentCreateIn) -> IntentOut:
147
+ async def create_intent(
148
+ self, user_id: Optional[str], data: IntentCreateIn
149
+ ) -> IntentOut:
147
150
  adapter = self._get_adapter()
148
151
  out = await adapter.create_intent(data, user_id=user_id)
149
152
  self.session.add(
@@ -214,7 +217,9 @@ class PaymentsService:
214
217
 
215
218
  # --- Webhooks -------------------------------------------------------------
216
219
 
217
- async def handle_webhook(self, provider: str, signature: str | None, payload: bytes) -> dict:
220
+ async def handle_webhook(
221
+ self, provider: str, signature: str | None, payload: bytes
222
+ ) -> dict:
218
223
  adapter = self._get_adapter()
219
224
  parsed = await adapter.verify_and_parse_webhook(signature, payload)
220
225
  self.session.add(
@@ -312,7 +317,9 @@ class PaymentsService:
312
317
  )
313
318
  )
314
319
 
315
- async def attach_payment_method(self, data: PaymentMethodAttachIn) -> PaymentMethodOut:
320
+ async def attach_payment_method(
321
+ self, data: PaymentMethodAttachIn
322
+ ) -> PaymentMethodOut:
316
323
  out = await self._get_adapter().attach_payment_method(data)
317
324
  # Upsert locally for quick listing
318
325
  pm = PayPaymentMethod(
@@ -329,7 +336,9 @@ class PaymentsService:
329
336
  self.session.add(pm)
330
337
  return out
331
338
 
332
- async def list_payment_methods(self, provider_customer_id: str) -> list[PaymentMethodOut]:
339
+ async def list_payment_methods(
340
+ self, provider_customer_id: str
341
+ ) -> list[PaymentMethodOut]:
333
342
  return await self._get_adapter().list_payment_methods(provider_customer_id)
334
343
 
335
344
  async def detach_payment_method(self, provider_method_id: str) -> PaymentMethodOut:
@@ -392,14 +401,18 @@ class PaymentsService:
392
401
  async def update_subscription(
393
402
  self, provider_subscription_id: str, data: SubscriptionUpdateIn
394
403
  ) -> SubscriptionOut:
395
- out = await self._get_adapter().update_subscription(provider_subscription_id, data)
404
+ out = await self._get_adapter().update_subscription(
405
+ provider_subscription_id, data
406
+ )
396
407
  # Optionally reflect status/quantity locally (query + update if exists)
397
408
  return out
398
409
 
399
410
  async def cancel_subscription(
400
411
  self, provider_subscription_id: str, at_period_end: bool = True
401
412
  ) -> SubscriptionOut:
402
- out = await self._get_adapter().cancel_subscription(provider_subscription_id, at_period_end)
413
+ out = await self._get_adapter().cancel_subscription(
414
+ provider_subscription_id, at_period_end
415
+ )
403
416
  return out
404
417
 
405
418
  # --- Invoices ---
@@ -444,15 +457,15 @@ class PaymentsService:
444
457
  q = select(
445
458
  func.date_trunc("day", LedgerEntry.ts).label("day"),
446
459
  LedgerEntry.currency,
447
- func.sum(func.case((LedgerEntry.kind == "payment", LedgerEntry.amount), else_=0)).label(
448
- "gross"
449
- ),
450
- func.sum(func.case((LedgerEntry.kind == "refund", LedgerEntry.amount), else_=0)).label(
451
- "refunds"
452
- ),
453
- func.sum(func.case((LedgerEntry.kind == "fee", LedgerEntry.amount), else_=0)).label(
454
- "fees"
455
- ),
460
+ func.sum(
461
+ func.case((LedgerEntry.kind == "payment", LedgerEntry.amount), else_=0)
462
+ ).label("gross"),
463
+ func.sum(
464
+ func.case((LedgerEntry.kind == "refund", LedgerEntry.amount), else_=0)
465
+ ).label("refunds"),
466
+ func.sum(
467
+ func.case((LedgerEntry.kind == "fee", LedgerEntry.amount), else_=0)
468
+ ).label("fees"),
456
469
  func.count().label("count"),
457
470
  )
458
471
  if date_from:
@@ -465,9 +478,9 @@ class PaymentsService:
465
478
  q = q.where(LedgerEntry.ts <= datetime.fromisoformat(date_to))
466
479
  except Exception:
467
480
  pass
468
- q = q.group_by(func.date_trunc("day", LedgerEntry.ts), LedgerEntry.currency).order_by(
469
- func.date_trunc("day", LedgerEntry.ts).desc()
470
- )
481
+ q = q.group_by(
482
+ func.date_trunc("day", LedgerEntry.ts), LedgerEntry.currency
483
+ ).order_by(func.date_trunc("day", LedgerEntry.ts).desc())
471
484
 
472
485
  rows = (await self.session.execute(q)).all()
473
486
  out: list[StatementRow] = []
@@ -489,9 +502,12 @@ class PaymentsService:
489
502
  )
490
503
  return out
491
504
 
492
- async def capture_intent(self, provider_intent_id: str, data: CaptureIn) -> IntentOut:
505
+ async def capture_intent(
506
+ self, provider_intent_id: str, data: CaptureIn
507
+ ) -> IntentOut:
493
508
  out = await self._get_adapter().capture_intent(
494
- provider_intent_id, amount=int(data.amount) if data.amount is not None else None
509
+ provider_intent_id,
510
+ amount=int(data.amount) if data.amount is not None else None,
495
511
  )
496
512
  pi = await self.session.scalar(
497
513
  select(PayIntent).where(PayIntent.provider_intent_id == provider_intent_id)
@@ -523,7 +539,9 @@ class PaymentsService:
523
539
  )
524
540
  return out
525
541
 
526
- async def list_intents(self, f: IntentListFilter) -> tuple[list[IntentOut], str | None]:
542
+ async def list_intents(
543
+ self, f: IntentListFilter
544
+ ) -> tuple[list[IntentOut], str | None]:
527
545
  return await self._get_adapter().list_intents(
528
546
  customer_provider_id=f.customer_provider_id,
529
547
  status=f.status,
@@ -535,9 +553,13 @@ class PaymentsService:
535
553
  async def add_invoice_line_item(
536
554
  self, provider_invoice_id: str, data: InvoiceLineItemIn
537
555
  ) -> InvoiceOut:
538
- return await self._get_adapter().add_invoice_line_item(provider_invoice_id, data)
556
+ return await self._get_adapter().add_invoice_line_item(
557
+ provider_invoice_id, data
558
+ )
539
559
 
540
- async def list_invoices(self, f: InvoicesListFilter) -> tuple[list[InvoiceOut], str | None]:
560
+ async def list_invoices(
561
+ self, f: InvoicesListFilter
562
+ ) -> tuple[list[InvoiceOut], str | None]:
541
563
  return await self._get_adapter().list_invoices(
542
564
  customer_provider_id=f.customer_provider_id,
543
565
  status=f.status,
@@ -574,7 +596,9 @@ class PaymentsService:
574
596
  )
575
597
  return out
576
598
 
577
- async def confirm_setup_intent(self, provider_setup_intent_id: str) -> SetupIntentOut:
599
+ async def confirm_setup_intent(
600
+ self, provider_setup_intent_id: str
601
+ ) -> SetupIntentOut:
578
602
  out = await self._get_adapter().confirm_setup_intent(provider_setup_intent_id)
579
603
  row = await self.session.scalar(
580
604
  select(PaySetupIntent).where(
@@ -625,13 +649,17 @@ class PaymentsService:
625
649
  async def list_disputes(
626
650
  self, *, status: Optional[str], limit: int, cursor: Optional[str]
627
651
  ) -> tuple[list[DisputeOut], Optional[str]]:
628
- return await self._get_adapter().list_disputes(status=status, limit=limit, cursor=cursor)
652
+ return await self._get_adapter().list_disputes(
653
+ status=status, limit=limit, cursor=cursor
654
+ )
629
655
 
630
656
  async def get_dispute(self, provider_dispute_id: str) -> DisputeOut:
631
657
  out = await self._get_adapter().get_dispute(provider_dispute_id)
632
658
  # Upsert locally
633
659
  row = await self.session.scalar(
634
- select(PayDispute).where(PayDispute.provider_dispute_id == provider_dispute_id)
660
+ select(PayDispute).where(
661
+ PayDispute.provider_dispute_id == provider_dispute_id
662
+ )
635
663
  )
636
664
  if row:
637
665
  row.status = out.status
@@ -652,11 +680,17 @@ class PaymentsService:
652
680
  )
653
681
  return out
654
682
 
655
- async def submit_dispute_evidence(self, provider_dispute_id: str, evidence: dict) -> DisputeOut:
656
- out = await self._get_adapter().submit_dispute_evidence(provider_dispute_id, evidence)
683
+ async def submit_dispute_evidence(
684
+ self, provider_dispute_id: str, evidence: dict
685
+ ) -> DisputeOut:
686
+ out = await self._get_adapter().submit_dispute_evidence(
687
+ provider_dispute_id, evidence
688
+ )
657
689
  # reflect status
658
690
  row = await self.session.scalar(
659
- select(PayDispute).where(PayDispute.provider_dispute_id == provider_dispute_id)
691
+ select(PayDispute).where(
692
+ PayDispute.provider_dispute_id == provider_dispute_id
693
+ )
660
694
  )
661
695
  if row:
662
696
  row.status = out.status
@@ -726,11 +760,16 @@ class PaymentsService:
726
760
  return len(rows)
727
761
 
728
762
  # ---- Customers ----
729
- async def list_customers(self, f: CustomersListFilter) -> tuple[list[CustomerOut], str | None]:
763
+ async def list_customers(
764
+ self, f: CustomersListFilter
765
+ ) -> tuple[list[CustomerOut], str | None]:
730
766
  adapter = self._get_adapter()
731
767
  try:
732
768
  return await adapter.list_customers(
733
- provider=f.provider, user_id=f.user_id, limit=f.limit or 50, cursor=f.cursor
769
+ provider=f.provider,
770
+ user_id=f.user_id,
771
+ limit=f.limit or 50,
772
+ cursor=f.cursor,
734
773
  )
735
774
  except NotImplementedError:
736
775
  # Fallback to local DB listing
@@ -766,7 +805,9 @@ class PaymentsService:
766
805
  raise RuntimeError("Customer not found")
767
806
  # upsert locally
768
807
  row = await self.session.scalar(
769
- select(PayCustomer).where(PayCustomer.provider_customer_id == provider_customer_id)
808
+ select(PayCustomer).where(
809
+ PayCustomer.provider_customer_id == provider_customer_id
810
+ )
770
811
  )
771
812
  if not row:
772
813
  self.session.add(
@@ -786,13 +827,19 @@ class PaymentsService:
786
827
  async def list_products(
787
828
  self, *, active: bool | None, limit: int, cursor: str | None
788
829
  ) -> tuple[list[ProductOut], str | None]:
789
- return await self._get_adapter().list_products(active=active, limit=limit, cursor=cursor)
830
+ return await self._get_adapter().list_products(
831
+ active=active, limit=limit, cursor=cursor
832
+ )
790
833
 
791
- async def update_product(self, provider_product_id: str, data: ProductUpdateIn) -> ProductOut:
834
+ async def update_product(
835
+ self, provider_product_id: str, data: ProductUpdateIn
836
+ ) -> ProductOut:
792
837
  out = await self._get_adapter().update_product(provider_product_id, data)
793
838
  # reflect DB
794
839
  row = await self.session.scalar(
795
- select(PayProduct).where(PayProduct.provider_product_id == provider_product_id)
840
+ select(PayProduct).where(
841
+ PayProduct.provider_product_id == provider_product_id
842
+ )
796
843
  )
797
844
  if row:
798
845
  if data.name is not None:
@@ -813,10 +860,15 @@ class PaymentsService:
813
860
  cursor: str | None,
814
861
  ) -> tuple[list[PriceOut], str | None]:
815
862
  return await self._get_adapter().list_prices(
816
- provider_product_id=provider_product_id, active=active, limit=limit, cursor=cursor
863
+ provider_product_id=provider_product_id,
864
+ active=active,
865
+ limit=limit,
866
+ cursor=cursor,
817
867
  )
818
868
 
819
- async def update_price(self, provider_price_id: str, data: PriceUpdateIn) -> PriceOut:
869
+ async def update_price(
870
+ self, provider_price_id: str, data: PriceUpdateIn
871
+ ) -> PriceOut:
820
872
  out = await self._get_adapter().update_price(provider_price_id, data)
821
873
  row = await self.session.scalar(
822
874
  select(PayPrice).where(PayPrice.provider_price_id == provider_price_id)
@@ -838,7 +890,10 @@ class PaymentsService:
838
890
  cursor: str | None,
839
891
  ) -> tuple[list[SubscriptionOut], str | None]:
840
892
  return await self._get_adapter().list_subscriptions(
841
- customer_provider_id=customer_provider_id, status=status, limit=limit, cursor=cursor
893
+ customer_provider_id=customer_provider_id,
894
+ status=status,
895
+ limit=limit,
896
+ cursor=cursor,
842
897
  )
843
898
 
844
899
  # ---- Payment Methods (get/update) ----
@@ -868,7 +923,9 @@ class PaymentsService:
868
923
  self, *, provider_payment_intent_id: str | None, limit: int, cursor: str | None
869
924
  ) -> tuple[list[RefundOut], str | None]:
870
925
  return await self._get_adapter().list_refunds(
871
- provider_payment_intent_id=provider_payment_intent_id, limit=limit, cursor=cursor
926
+ provider_payment_intent_id=provider_payment_intent_id,
927
+ limit=limit,
928
+ cursor=cursor,
872
929
  )
873
930
 
874
931
  async def get_refund(self, provider_refund_id: str) -> RefundOut:
@@ -7,7 +7,11 @@ from pydantic import BaseModel, SecretStr
7
7
 
8
8
  STRIPE_KEY = os.getenv("STRIPE_SECRET") or os.getenv("STRIPE_API_KEY")
9
9
  STRIPE_WH = os.getenv("STRIPE_WH_SECRET")
10
- PROVIDER = (os.getenv("APF_PAYMENTS_PROVIDER") or os.getenv("PAYMENTS_PROVIDER", "stripe")).lower()
10
+ PROVIDER = (
11
+ os.getenv("APF_PAYMENTS_PROVIDER")
12
+ or os.getenv("PAYMENTS_PROVIDER", "stripe")
13
+ or "stripe"
14
+ ).lower()
11
15
 
12
16
  AIYDAN_KEY = os.getenv("AIYDAN_API_KEY")
13
17
  AIYDAN_CLIENT_KEY = os.getenv("AIYDAN_CLIENT_KEY")
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
+ # Dual routers
11
+ DualAPIRouter,
12
+ dualize_protected,
13
+ dualize_public,
14
+ dualize_user,
15
+ # Service setup
16
+ ServiceInfo,
17
+ APIVersionSpec,
18
+ setup_service_api,
19
+ easy_service_api,
20
+ easy_service_app,
21
+ setup_caching,
22
+ # Health checks
23
+ add_startup_probe,
24
+ add_health_routes,
25
+ add_dependency_health,
26
+ check_database,
27
+ check_redis,
28
+ check_url,
29
+ # Pagination
30
+ use_pagination,
31
+ text_filter,
32
+ sort_by,
33
+ cursor_window,
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
+ ]
@@ -5,6 +5,14 @@ from svc_infra.api.fastapi.dual import (
5
5
  dualize_user,
6
6
  )
7
7
  from svc_infra.api.fastapi.openapi.models import APIVersionSpec, ServiceInfo
8
+ from svc_infra.health import (
9
+ add_dependency_health,
10
+ add_health_routes,
11
+ add_startup_probe,
12
+ check_database,
13
+ check_redis,
14
+ check_url,
15
+ )
8
16
 
9
17
  from .cache.add import setup_caching
10
18
  from .ease import easy_service_api, easy_service_app
@@ -18,6 +26,13 @@ __all__ = [
18
26
  "dualize_protected",
19
27
  "ServiceInfo",
20
28
  "APIVersionSpec",
29
+ # Health
30
+ "add_startup_probe",
31
+ "add_health_routes",
32
+ "add_dependency_health",
33
+ "check_database",
34
+ "check_redis",
35
+ "check_url",
21
36
  # Ease
22
37
  "setup_service_api",
23
38
  "easy_service_api",
@@ -0,0 +1,3 @@
1
+ from .add import add_admin, admin_router
2
+
3
+ __all__ = ["add_admin", "admin_router"]
@@ -0,0 +1,245 @@
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 hashlib import sha256
11
+ from types import SimpleNamespace
12
+ from typing import Any, Callable, Optional, cast
13
+
14
+ from fastapi import APIRouter, Depends, HTTPException, Request, Response
15
+
16
+ from ....app.env import get_current_environment, require_secret
17
+ from ....security.permissions import RequirePermission
18
+ from ..auth.security import Identity, Principal, _current_principal
19
+ from ..auth.state import get_auth_state
20
+ from ..db.sql.session import SqlSessionDep
21
+ from ..dual.protected import roles_router
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ def _b64u(data: bytes) -> str:
27
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
28
+
29
+
30
+ def _b64u_decode(s: str) -> bytes:
31
+ pad = "=" * ((4 - len(s) % 4) % 4)
32
+ return base64.urlsafe_b64decode(s + pad)
33
+
34
+
35
+ def _sign(payload: dict, *, secret: str) -> str:
36
+ body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
37
+ sig = hmac.new(secret.encode("utf-8"), body, sha256).digest()
38
+ return _b64u(body) + "." + _b64u(sig)
39
+
40
+
41
+ def _verify(token: str, *, secret: str) -> dict:
42
+ try:
43
+ b64_body, b64_sig = token.split(".", 1)
44
+ body = _b64u_decode(b64_body)
45
+ exp_sig = _b64u_decode(b64_sig)
46
+ got_sig = hmac.new(secret.encode("utf-8"), body, sha256).digest()
47
+ if not hmac.compare_digest(exp_sig, got_sig):
48
+ raise ValueError("bad_signature")
49
+ payload = json.loads(body)
50
+ if int(payload.get("exp", 0)) < int(time.time()):
51
+ raise ValueError("expired")
52
+ return cast(dict[Any, Any], payload)
53
+ except Exception as e:
54
+ raise ValueError("invalid_token") from e
55
+
56
+
57
+ def admin_router(*, dependencies: Optional[list[Any]] = None, **kwargs) -> APIRouter:
58
+ """Role-gated admin router for coarse access control.
59
+
60
+ Use permission guards inside endpoints for fine-grained control.
61
+ """
62
+
63
+ return cast(APIRouter, roles_router("admin", **kwargs))
64
+
65
+
66
+ def add_admin(
67
+ app,
68
+ *,
69
+ base_path: str = "/admin",
70
+ enable_impersonation: bool = True,
71
+ secret: Optional[str] = None,
72
+ ttl_seconds: int = 15 * 60,
73
+ cookie_name: str = "impersonation",
74
+ impersonation_user_getter: Optional[Callable[[Any, str], Any]] = None,
75
+ ) -> None:
76
+ """Wire admin surfaces with sensible defaults.
77
+
78
+ - Mounts an admin router under base_path.
79
+ - Optionally enables impersonation start/stop endpoints guarded by permissions.
80
+ - Registers a dependency override to honor impersonation cookie globally (idempotent).
81
+
82
+ impersonation_user_getter: optional callable (request, user_id) -> user object.
83
+ If omitted, defaults to loading from SQLAlchemy User model returned by get_auth_state().
84
+ """
85
+
86
+ # Idempotency: only mount once per app instance
87
+ if getattr(app.state, "_admin_added", False):
88
+ return
89
+
90
+ env = get_current_environment()
91
+ _secret = require_secret(
92
+ secret or os.getenv("ADMIN_IMPERSONATION_SECRET") or os.getenv("APP_SECRET"),
93
+ "ADMIN_IMPERSONATION_SECRET or APP_SECRET",
94
+ dev_default="dev-only-admin-impersonation-secret-not-for-production",
95
+ )
96
+ _ttl = int(os.getenv("ADMIN_IMPERSONATION_TTL", str(ttl_seconds)))
97
+ _cookie = os.getenv("ADMIN_IMPERSONATION_COOKIE", cookie_name)
98
+
99
+ r = admin_router(prefix=base_path, tags=["admin"]) # role-gated
100
+
101
+ async def _default_user_getter(
102
+ request: Request, user_id: str, session: SqlSessionDep
103
+ ):
104
+ try:
105
+ UserModel, _, _ = get_auth_state()
106
+ except Exception:
107
+ # Fallback: simple shim if auth state not configured
108
+ return SimpleNamespace(id=user_id)
109
+ obj = await cast(Any, session).get(UserModel, user_id)
110
+ if not obj:
111
+ raise HTTPException(404, "user_not_found")
112
+ return obj
113
+
114
+ user_getter = impersonation_user_getter
115
+
116
+ @r.post(
117
+ "/impersonate/start",
118
+ status_code=204,
119
+ dependencies=[RequirePermission("admin.impersonate")],
120
+ )
121
+ async def start_impersonation(
122
+ body: dict,
123
+ request: Request,
124
+ response: Response,
125
+ session: SqlSessionDep,
126
+ identity: Identity,
127
+ ):
128
+ target_id = (body or {}).get("user_id")
129
+ reason = (body or {}).get("reason", "")
130
+ if not target_id:
131
+ raise HTTPException(422, "user_id_required")
132
+ # Load target for validation (custom getter or default)
133
+ _res = (
134
+ user_getter(request, target_id)
135
+ if user_getter
136
+ else _default_user_getter(request, target_id, session)
137
+ )
138
+ target = await _res if inspect.isawaitable(_res) else _res
139
+ actor: Principal = identity
140
+ payload = {
141
+ "actor_id": getattr(getattr(actor, "user", None), "id", None),
142
+ "target_id": str(getattr(target, "id", target_id)),
143
+ "iat": int(time.time()),
144
+ "exp": int(time.time()) + _ttl,
145
+ "nonce": _b64u(os.urandom(8)),
146
+ }
147
+ token = _sign(payload, secret=_secret)
148
+ response.set_cookie(
149
+ key=_cookie,
150
+ value=token,
151
+ httponly=True,
152
+ samesite="lax",
153
+ secure=(env in ("prod", "production")),
154
+ path="/",
155
+ max_age=_ttl,
156
+ )
157
+ logger.info(
158
+ "admin.impersonation.started",
159
+ extra={
160
+ "actor_id": payload["actor_id"],
161
+ "target_id": payload["target_id"],
162
+ "reason": reason,
163
+ "expires_in": _ttl,
164
+ },
165
+ )
166
+ # Re-compose override now to wrap any late overrides set by tests/harness
167
+ try:
168
+ _compose_override()
169
+ except Exception:
170
+ pass
171
+
172
+ @r.post("/impersonate/stop", status_code=204)
173
+ async def stop_impersonation(response: Response):
174
+ response.delete_cookie(_cookie, path="/")
175
+ logger.info("admin.impersonation.stopped")
176
+
177
+ app.include_router(r)
178
+
179
+ # Dependency override: wrap the base principal to honor impersonation cookie.
180
+ # Compose with any existing override (e.g., acceptance app/test harness) and
181
+ # re-compose at startup to capture late overrides.
182
+ def _compose_override():
183
+ existing = app.dependency_overrides.get(_current_principal)
184
+ if existing and getattr(existing, "_is_admin_impersonation_override", False):
185
+ dep_provider = getattr(
186
+ existing, "_admin_impersonation_base", _current_principal
187
+ )
188
+ else:
189
+ dep_provider = existing or _current_principal
190
+
191
+ async def _override_current_principal(
192
+ request: Request,
193
+ session: SqlSessionDep,
194
+ base: Principal = Depends(dep_provider),
195
+ ) -> Principal:
196
+ token = request.cookies.get(_cookie) if request else None
197
+ if not token:
198
+ return base
199
+ try:
200
+ payload = _verify(token, secret=_secret)
201
+ except Exception:
202
+ return base
203
+ # Load target user
204
+ target_id = payload.get("target_id")
205
+ if not target_id:
206
+ return base
207
+ # Preserve actor roles/claims so permissions remain that of the actor
208
+ actor_user = getattr(base, "user", None)
209
+ actor_roles = getattr(actor_user, "roles", []) or []
210
+ _res = (
211
+ user_getter(request, target_id)
212
+ if user_getter
213
+ else _default_user_getter(request, target_id, session)
214
+ )
215
+ target = await _res if inspect.isawaitable(_res) else _res
216
+ # Swap user but keep actor for audit if needed
217
+ setattr(base, "actor", getattr(base, "user", None))
218
+ # If target lacks roles, inherit actor roles to maintain permission checks
219
+ try:
220
+ if not getattr(target, "roles", None):
221
+ setattr(target, "roles", actor_roles)
222
+ except Exception:
223
+ # Best-effort; if target object is immutable, fallback by wrapping
224
+ target = SimpleNamespace(
225
+ id=getattr(target, "id", target_id), roles=actor_roles
226
+ )
227
+ base.user = target
228
+ base.via = "impersonated"
229
+ return base
230
+
231
+ app.dependency_overrides[_current_principal] = _override_current_principal
232
+ _override_current_principal._is_admin_impersonation_override = True # type: ignore[attr-defined]
233
+ _override_current_principal._admin_impersonation_base = dep_provider # type: ignore[attr-defined]
234
+
235
+ # Compose now (best-effort) and again on startup to wrap any later overrides
236
+ _compose_override()
237
+ try:
238
+ app.add_event_handler("startup", _compose_override)
239
+ except Exception:
240
+ # Best-effort; if app doesn't support event handlers, we already composed once
241
+ pass
242
+ app.state._admin_added = True
243
+
244
+
245
+ # no extra helpers