svc-infra 0.1.706__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 (227) hide show
  1. svc_infra/apf_payments/models.py +47 -108
  2. svc_infra/apf_payments/provider/__init__.py +2 -2
  3. svc_infra/apf_payments/provider/aiydan.py +42 -100
  4. svc_infra/apf_payments/provider/base.py +10 -26
  5. svc_infra/apf_payments/provider/registry.py +3 -5
  6. svc_infra/apf_payments/provider/stripe.py +63 -135
  7. svc_infra/apf_payments/schemas.py +82 -90
  8. svc_infra/apf_payments/service.py +40 -86
  9. svc_infra/apf_payments/settings.py +10 -13
  10. svc_infra/api/__init__.py +13 -13
  11. svc_infra/api/fastapi/__init__.py +19 -0
  12. svc_infra/api/fastapi/admin/add.py +13 -18
  13. svc_infra/api/fastapi/apf_payments/router.py +47 -84
  14. svc_infra/api/fastapi/apf_payments/setup.py +7 -13
  15. svc_infra/api/fastapi/auth/__init__.py +1 -1
  16. svc_infra/api/fastapi/auth/_cookies.py +3 -9
  17. svc_infra/api/fastapi/auth/add.py +4 -8
  18. svc_infra/api/fastapi/auth/gaurd.py +9 -26
  19. svc_infra/api/fastapi/auth/mfa/models.py +4 -7
  20. svc_infra/api/fastapi/auth/mfa/pre_auth.py +3 -3
  21. svc_infra/api/fastapi/auth/mfa/router.py +9 -15
  22. svc_infra/api/fastapi/auth/mfa/security.py +3 -5
  23. svc_infra/api/fastapi/auth/mfa/utils.py +3 -2
  24. svc_infra/api/fastapi/auth/mfa/verify.py +2 -9
  25. svc_infra/api/fastapi/auth/providers.py +4 -6
  26. svc_infra/api/fastapi/auth/routers/apikey_router.py +16 -18
  27. svc_infra/api/fastapi/auth/routers/oauth_router.py +37 -85
  28. svc_infra/api/fastapi/auth/routers/session_router.py +3 -6
  29. svc_infra/api/fastapi/auth/security.py +17 -28
  30. svc_infra/api/fastapi/auth/sender.py +1 -3
  31. svc_infra/api/fastapi/auth/settings.py +18 -19
  32. svc_infra/api/fastapi/auth/state.py +6 -7
  33. svc_infra/api/fastapi/auth/ws_security.py +2 -2
  34. svc_infra/api/fastapi/billing/router.py +6 -8
  35. svc_infra/api/fastapi/db/http.py +10 -11
  36. svc_infra/api/fastapi/db/nosql/mongo/add.py +5 -15
  37. svc_infra/api/fastapi/db/nosql/mongo/crud_router.py +14 -15
  38. svc_infra/api/fastapi/db/sql/add.py +6 -14
  39. svc_infra/api/fastapi/db/sql/crud_router.py +27 -40
  40. svc_infra/api/fastapi/db/sql/health.py +1 -3
  41. svc_infra/api/fastapi/db/sql/session.py +4 -5
  42. svc_infra/api/fastapi/db/sql/users.py +8 -11
  43. svc_infra/api/fastapi/dependencies/ratelimit.py +4 -6
  44. svc_infra/api/fastapi/docs/add.py +13 -23
  45. svc_infra/api/fastapi/docs/landing.py +6 -8
  46. svc_infra/api/fastapi/docs/scoped.py +34 -42
  47. svc_infra/api/fastapi/dual/dualize.py +1 -1
  48. svc_infra/api/fastapi/dual/protected.py +12 -21
  49. svc_infra/api/fastapi/dual/router.py +14 -31
  50. svc_infra/api/fastapi/ease.py +57 -13
  51. svc_infra/api/fastapi/http/conditional.py +3 -5
  52. svc_infra/api/fastapi/middleware/errors/catchall.py +2 -6
  53. svc_infra/api/fastapi/middleware/errors/exceptions.py +1 -4
  54. svc_infra/api/fastapi/middleware/errors/handlers.py +12 -18
  55. svc_infra/api/fastapi/middleware/graceful_shutdown.py +4 -13
  56. svc_infra/api/fastapi/middleware/idempotency.py +11 -16
  57. svc_infra/api/fastapi/middleware/idempotency_store.py +14 -14
  58. svc_infra/api/fastapi/middleware/optimistic_lock.py +5 -8
  59. svc_infra/api/fastapi/middleware/ratelimit.py +8 -8
  60. svc_infra/api/fastapi/middleware/ratelimit_store.py +7 -8
  61. svc_infra/api/fastapi/middleware/request_id.py +1 -3
  62. svc_infra/api/fastapi/middleware/timeout.py +9 -10
  63. svc_infra/api/fastapi/object_router.py +1060 -0
  64. svc_infra/api/fastapi/openapi/apply.py +5 -6
  65. svc_infra/api/fastapi/openapi/conventions.py +4 -4
  66. svc_infra/api/fastapi/openapi/mutators.py +13 -31
  67. svc_infra/api/fastapi/openapi/pipeline.py +2 -2
  68. svc_infra/api/fastapi/openapi/responses.py +4 -6
  69. svc_infra/api/fastapi/openapi/security.py +1 -3
  70. svc_infra/api/fastapi/ops/add.py +7 -9
  71. svc_infra/api/fastapi/pagination.py +25 -37
  72. svc_infra/api/fastapi/routers/__init__.py +16 -38
  73. svc_infra/api/fastapi/setup.py +13 -31
  74. svc_infra/api/fastapi/tenancy/add.py +3 -2
  75. svc_infra/api/fastapi/tenancy/context.py +8 -7
  76. svc_infra/api/fastapi/versioned.py +3 -2
  77. svc_infra/app/env.py +5 -7
  78. svc_infra/app/logging/add.py +2 -1
  79. svc_infra/app/logging/filter.py +1 -1
  80. svc_infra/app/logging/formats.py +3 -2
  81. svc_infra/app/root.py +3 -3
  82. svc_infra/billing/__init__.py +19 -2
  83. svc_infra/billing/async_service.py +27 -7
  84. svc_infra/billing/jobs.py +23 -33
  85. svc_infra/billing/models.py +21 -52
  86. svc_infra/billing/quotas.py +5 -7
  87. svc_infra/billing/schemas.py +4 -6
  88. svc_infra/cache/__init__.py +12 -5
  89. svc_infra/cache/add.py +6 -9
  90. svc_infra/cache/backend.py +6 -5
  91. svc_infra/cache/decorators.py +17 -28
  92. svc_infra/cache/keys.py +2 -2
  93. svc_infra/cache/recache.py +22 -35
  94. svc_infra/cache/resources.py +8 -16
  95. svc_infra/cache/ttl.py +2 -3
  96. svc_infra/cache/utils.py +5 -6
  97. svc_infra/cli/__init__.py +4 -12
  98. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +11 -10
  99. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +6 -9
  100. svc_infra/cli/cmds/db/ops_cmds.py +3 -6
  101. svc_infra/cli/cmds/db/sql/alembic_cmds.py +24 -41
  102. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +9 -17
  103. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +10 -10
  104. svc_infra/cli/cmds/docs/docs_cmds.py +7 -10
  105. svc_infra/cli/cmds/dx/dx_cmds.py +5 -11
  106. svc_infra/cli/cmds/jobs/jobs_cmds.py +2 -7
  107. svc_infra/cli/cmds/obs/obs_cmds.py +4 -7
  108. svc_infra/cli/cmds/sdk/sdk_cmds.py +5 -15
  109. svc_infra/cli/foundation/runner.py +6 -11
  110. svc_infra/cli/foundation/typer_bootstrap.py +1 -2
  111. svc_infra/data/__init__.py +83 -0
  112. svc_infra/data/add.py +5 -5
  113. svc_infra/data/backup.py +8 -10
  114. svc_infra/data/erasure.py +3 -2
  115. svc_infra/data/fixtures.py +3 -3
  116. svc_infra/data/retention.py +8 -13
  117. svc_infra/db/crud_schema.py +9 -8
  118. svc_infra/db/nosql/__init__.py +0 -1
  119. svc_infra/db/nosql/constants.py +1 -1
  120. svc_infra/db/nosql/core.py +7 -14
  121. svc_infra/db/nosql/indexes.py +11 -10
  122. svc_infra/db/nosql/management.py +3 -3
  123. svc_infra/db/nosql/mongo/client.py +3 -3
  124. svc_infra/db/nosql/mongo/settings.py +2 -6
  125. svc_infra/db/nosql/repository.py +27 -28
  126. svc_infra/db/nosql/resource.py +15 -20
  127. svc_infra/db/nosql/scaffold.py +13 -17
  128. svc_infra/db/nosql/service.py +3 -4
  129. svc_infra/db/nosql/service_with_hooks.py +4 -3
  130. svc_infra/db/nosql/types.py +2 -6
  131. svc_infra/db/nosql/utils.py +4 -4
  132. svc_infra/db/ops.py +14 -18
  133. svc_infra/db/outbox.py +15 -18
  134. svc_infra/db/sql/apikey.py +12 -21
  135. svc_infra/db/sql/authref.py +3 -7
  136. svc_infra/db/sql/constants.py +9 -9
  137. svc_infra/db/sql/core.py +11 -11
  138. svc_infra/db/sql/management.py +2 -6
  139. svc_infra/db/sql/repository.py +17 -24
  140. svc_infra/db/sql/resource.py +14 -13
  141. svc_infra/db/sql/scaffold.py +13 -17
  142. svc_infra/db/sql/service.py +7 -16
  143. svc_infra/db/sql/service_with_hooks.py +4 -3
  144. svc_infra/db/sql/tenant.py +6 -14
  145. svc_infra/db/sql/uniq.py +8 -7
  146. svc_infra/db/sql/uniq_hooks.py +14 -19
  147. svc_infra/db/sql/utils.py +24 -53
  148. svc_infra/db/utils.py +3 -3
  149. svc_infra/deploy/__init__.py +8 -15
  150. svc_infra/documents/add.py +7 -8
  151. svc_infra/documents/ease.py +8 -8
  152. svc_infra/documents/models.py +3 -3
  153. svc_infra/documents/storage.py +11 -13
  154. svc_infra/dx/__init__.py +58 -0
  155. svc_infra/dx/add.py +1 -3
  156. svc_infra/dx/changelog.py +2 -2
  157. svc_infra/dx/checks.py +1 -1
  158. svc_infra/health/__init__.py +15 -16
  159. svc_infra/http/client.py +10 -14
  160. svc_infra/jobs/__init__.py +79 -0
  161. svc_infra/jobs/builtins/outbox_processor.py +3 -5
  162. svc_infra/jobs/builtins/webhook_delivery.py +1 -3
  163. svc_infra/jobs/loader.py +4 -5
  164. svc_infra/jobs/queue.py +14 -24
  165. svc_infra/jobs/redis_queue.py +20 -34
  166. svc_infra/jobs/runner.py +7 -11
  167. svc_infra/jobs/scheduler.py +5 -5
  168. svc_infra/jobs/worker.py +1 -1
  169. svc_infra/loaders/base.py +5 -4
  170. svc_infra/loaders/github.py +1 -3
  171. svc_infra/loaders/url.py +3 -9
  172. svc_infra/logging/__init__.py +7 -6
  173. svc_infra/mcp/__init__.py +82 -0
  174. svc_infra/mcp/svc_infra_mcp.py +2 -2
  175. svc_infra/obs/add.py +4 -3
  176. svc_infra/obs/cloud_dash.py +1 -1
  177. svc_infra/obs/metrics/__init__.py +3 -3
  178. svc_infra/obs/metrics/asgi.py +9 -14
  179. svc_infra/obs/metrics/base.py +13 -13
  180. svc_infra/obs/metrics/http.py +5 -9
  181. svc_infra/obs/metrics/sqlalchemy.py +9 -12
  182. svc_infra/obs/metrics.py +3 -3
  183. svc_infra/obs/settings.py +2 -6
  184. svc_infra/resilience/__init__.py +44 -0
  185. svc_infra/resilience/circuit_breaker.py +328 -0
  186. svc_infra/resilience/retry.py +289 -0
  187. svc_infra/security/__init__.py +167 -0
  188. svc_infra/security/add.py +5 -9
  189. svc_infra/security/audit.py +14 -17
  190. svc_infra/security/audit_service.py +9 -9
  191. svc_infra/security/hibp.py +3 -6
  192. svc_infra/security/jwt_rotation.py +7 -10
  193. svc_infra/security/lockout.py +12 -11
  194. svc_infra/security/models.py +37 -46
  195. svc_infra/security/oauth_models.py +8 -8
  196. svc_infra/security/org_invites.py +11 -13
  197. svc_infra/security/passwords.py +4 -6
  198. svc_infra/security/permissions.py +8 -7
  199. svc_infra/security/session.py +6 -7
  200. svc_infra/security/signed_cookies.py +9 -9
  201. svc_infra/storage/add.py +5 -8
  202. svc_infra/storage/backends/local.py +13 -21
  203. svc_infra/storage/backends/memory.py +4 -7
  204. svc_infra/storage/backends/s3.py +17 -36
  205. svc_infra/storage/base.py +2 -2
  206. svc_infra/storage/easy.py +4 -8
  207. svc_infra/storage/settings.py +16 -18
  208. svc_infra/testing/__init__.py +36 -39
  209. svc_infra/utils.py +169 -8
  210. svc_infra/webhooks/__init__.py +1 -1
  211. svc_infra/webhooks/add.py +17 -29
  212. svc_infra/webhooks/encryption.py +2 -2
  213. svc_infra/webhooks/fastapi.py +2 -4
  214. svc_infra/webhooks/router.py +3 -3
  215. svc_infra/webhooks/service.py +5 -6
  216. svc_infra/webhooks/signing.py +5 -5
  217. svc_infra/websocket/add.py +2 -3
  218. svc_infra/websocket/client.py +3 -2
  219. svc_infra/websocket/config.py +6 -18
  220. svc_infra/websocket/manager.py +9 -10
  221. {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/METADATA +11 -5
  222. svc_infra-1.1.0.dist-info/RECORD +364 -0
  223. svc_infra/billing/service.py +0 -123
  224. svc_infra-0.1.706.dist-info/RECORD +0 -357
  225. {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/LICENSE +0 -0
  226. {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/WHEEL +0 -0
  227. {svc_infra-0.1.706.dist-info → svc_infra-1.1.0.dist-info}/entry_points.txt +0 -0
@@ -3,8 +3,8 @@ from __future__ import annotations
3
3
  import base64
4
4
  import hashlib
5
5
  import secrets
6
- from datetime import datetime, timedelta, timezone
7
- from typing import Any, Dict, Literal, cast
6
+ from datetime import UTC, datetime, timedelta
7
+ from typing import Any, Literal, cast
8
8
  from urllib.parse import urlencode, urlparse
9
9
 
10
10
  import jwt
@@ -44,9 +44,7 @@ def _gen_pkce_pair() -> tuple[str, str]:
44
44
  return verifier, challenge
45
45
 
46
46
 
47
- def _validate_redirect(
48
- url: str, allow_hosts: list[str], *, require_https: bool
49
- ) -> None:
47
+ def _validate_redirect(url: str, allow_hosts: list[str], *, require_https: bool) -> None:
50
48
  """Validate that a redirect URL is allowed and secure."""
51
49
  p = urlparse(url)
52
50
  if not p.netloc:
@@ -71,13 +69,13 @@ def _coerce_expires_at(token: dict | None) -> datetime | None:
71
69
  v = float(token["expires_at"])
72
70
  if v > 1e12: # ms -> s
73
71
  v /= 1000.0
74
- return datetime.fromtimestamp(v, tz=timezone.utc)
72
+ return datetime.fromtimestamp(v, tz=UTC)
75
73
  except Exception:
76
74
  pass
77
75
  if token.get("expires_in") is not None:
78
76
  try:
79
77
  secs = int(token["expires_in"])
80
- return datetime.now(timezone.utc) + timedelta(seconds=secs)
78
+ return datetime.now(UTC) + timedelta(seconds=secs)
81
79
  except Exception:
82
80
  pass
83
81
  return None
@@ -86,11 +84,7 @@ def _coerce_expires_at(token: dict | None) -> datetime | None:
86
84
  def _cookie_name(st) -> str:
87
85
  """Get the cookie name with appropriate security prefix."""
88
86
  name = getattr(st, "auth_cookie_name", "svc_auth")
89
- if (
90
- st.session_cookie_secure
91
- and not st.session_cookie_domain
92
- and not name.startswith("__Host-")
93
- ):
87
+ if st.session_cookie_secure and not st.session_cookie_domain and not name.startswith("__Host-"):
94
88
  name = "__Host-" + name
95
89
  return name
96
90
 
@@ -101,9 +95,7 @@ def _cookie_domain(st):
101
95
  return d or None
102
96
 
103
97
 
104
- def _register_oauth_providers(
105
- oauth: OAuth, providers: Dict[str, Dict[str, Any]]
106
- ) -> None:
98
+ def _register_oauth_providers(oauth: OAuth, providers: dict[str, dict[str, Any]]) -> None:
107
99
  """Register all OAuth providers with the OAuth client."""
108
100
  for name, cfg in providers.items():
109
101
  kind = cfg.get("kind")
@@ -202,9 +194,7 @@ async def _extract_user_info_github(
202
194
  """Extract user information from GitHub provider."""
203
195
  u = (await client.get("user", token=token)).json()
204
196
  emails_resp = (await client.get("user/emails", token=token)).json()
205
- primary = next(
206
- (e for e in emails_resp if e.get("primary") and e.get("verified")), None
207
- )
197
+ primary = next((e for e in emails_resp if e.get("primary") and e.get("verified")), None)
208
198
 
209
199
  if not primary:
210
200
  raise HTTPException(400, "unverified_email")
@@ -212,9 +202,7 @@ async def _extract_user_info_github(
212
202
  email = primary["email"]
213
203
  email_verified = True
214
204
  full_name = u.get("name") or u.get("login")
215
- provider_user_id = (
216
- str(u.get("id")) if isinstance(u, dict) and u.get("id") is not None else None
217
- )
205
+ provider_user_id = str(u.get("id")) if isinstance(u, dict) and u.get("id") is not None else None
218
206
 
219
207
  return email, full_name, provider_user_id, email_verified, {"user": u}
220
208
 
@@ -229,9 +217,7 @@ async def _extract_user_info_linkedin(
229
217
  )
230
218
 
231
219
  em = (
232
- await client.get(
233
- "emailAddress?q=members&projection=(elements*(handle~))", token=token
234
- )
220
+ await client.get("emailAddress?q=members&projection=(elements*(handle~))", token=token)
235
221
  ).json()
236
222
 
237
223
  email = None
@@ -270,15 +256,9 @@ async def _extract_user_info_from_provider(
270
256
  raise HTTPException(400, "Unsupported provider kind")
271
257
 
272
258
 
273
- async def _find_or_create_user(
274
- session, user_model, email: str, full_name: str | None
275
- ) -> Any:
259
+ async def _find_or_create_user(session, user_model, email: str, full_name: str | None) -> Any:
276
260
  """Find existing user by email or create a new one."""
277
- existing = (
278
- (await session.execute(select(user_model).filter_by(email=email)))
279
- .scalars()
280
- .first()
281
- )
261
+ existing = (await session.execute(select(user_model).filter_by(email=email))).scalars().first()
282
262
 
283
263
  if existing:
284
264
  return existing
@@ -300,7 +280,7 @@ async def _find_or_create_user(
300
280
  user.password_hash = PasswordHelper().hash(random_password)
301
281
 
302
282
  if full_name and hasattr(user, "full_name"):
303
- setattr(user, "full_name", full_name)
283
+ user.full_name = full_name
304
284
 
305
285
  session.add(user)
306
286
  await session.flush() # ensure user.id exists
@@ -365,11 +345,11 @@ async def _update_provider_account(
365
345
  expires_at = _coerce_expires_at(tok)
366
346
 
367
347
  if not link:
368
- values = dict(
369
- user_id=user.id,
370
- provider=provider,
371
- provider_account_id=provider_user_id,
372
- )
348
+ values = {
349
+ "user_id": user.id,
350
+ "provider": provider,
351
+ "provider_account_id": provider_user_id,
352
+ }
373
353
  if hasattr(provider_account_model, "access_token"):
374
354
  values["access_token"] = access_token
375
355
  if hasattr(provider_account_model, "refresh_token"):
@@ -384,18 +364,10 @@ async def _update_provider_account(
384
364
  else:
385
365
  # Update existing link if values have changed
386
366
  dirty = False
387
- if (
388
- hasattr(link, "access_token")
389
- and access_token
390
- and link.access_token != access_token
391
- ):
367
+ if hasattr(link, "access_token") and access_token and link.access_token != access_token:
392
368
  link.access_token = access_token
393
369
  dirty = True
394
- if (
395
- hasattr(link, "refresh_token")
396
- and refresh_token
397
- and link.refresh_token != refresh_token
398
- ):
370
+ if hasattr(link, "refresh_token") and refresh_token and link.refresh_token != refresh_token:
399
371
  link.refresh_token = refresh_token
400
372
  dirty = True
401
373
  if hasattr(link, "expires_at") and expires_at and link.expires_at != expires_at:
@@ -408,24 +380,18 @@ async def _update_provider_account(
408
380
  await session.flush()
409
381
 
410
382
 
411
- def _determine_final_redirect_url(
412
- request: Request, provider: str, post_login_redirect: str
413
- ) -> str:
383
+ def _determine_final_redirect_url(request: Request, provider: str, post_login_redirect: str) -> str:
414
384
  """Determine the final redirect URL after successful authentication."""
415
385
  st = get_auth_settings()
416
386
  # Prioritize the parameter passed to the router over settings
417
387
  redirect_url = str(post_login_redirect or getattr(st, "post_login_redirect", "/"))
418
- allow_hosts = parse_redirect_allow_hosts(
419
- getattr(st, "redirect_allow_hosts_raw", None)
420
- )
388
+ allow_hosts = parse_redirect_allow_hosts(getattr(st, "redirect_allow_hosts_raw", None))
421
389
  require_https = bool(getattr(st, "session_cookie_secure", False))
422
390
 
423
391
  _validate_redirect(redirect_url, allow_hosts, require_https=require_https)
424
392
 
425
393
  # Prefer ?next or the stashed value from /login
426
- nxt = request.query_params.get("next") or request.session.pop(
427
- f"oauth:{provider}:next", None
428
- )
394
+ nxt = request.query_params.get("next") or request.session.pop(f"oauth:{provider}:next", None)
429
395
  if nxt:
430
396
  try:
431
397
  _validate_redirect(nxt, allow_hosts, require_https=require_https)
@@ -436,9 +402,7 @@ def _determine_final_redirect_url(
436
402
  return redirect_url
437
403
 
438
404
 
439
- async def _validate_oauth_state(
440
- request: Request, provider: str
441
- ) -> tuple[str | None, str | None]:
405
+ async def _validate_oauth_state(request: Request, provider: str) -> tuple[str | None, str | None]:
442
406
  """Validate OAuth state and extract session values."""
443
407
  provided_state = request.query_params.get("state")
444
408
  expected_state = request.session.pop(f"oauth:{provider}:state", None)
@@ -451,9 +415,7 @@ async def _validate_oauth_state(
451
415
  return verifier, nonce
452
416
 
453
417
 
454
- async def _exchange_code_for_token(
455
- client, request: Request, verifier: str | None, provider: str
456
- ):
418
+ async def _exchange_code_for_token(client, request: Request, verifier: str | None, provider: str):
457
419
  """Exchange OAuth authorization code for access token."""
458
420
  try:
459
421
  return await client.authorize_access_token(request, code_verifier=verifier)
@@ -500,9 +462,7 @@ async def _validate_and_decode_jwt_token(raw_token: str) -> str:
500
462
  """Validate and decode JWT token to extract user ID."""
501
463
  st = get_auth_settings()
502
464
  jwt_settings = getattr(st, "jwt", None)
503
- jwt_secret = (
504
- getattr(jwt_settings, "secret", None) if jwt_settings is not None else None
505
- )
465
+ jwt_secret = getattr(jwt_settings, "secret", None) if jwt_settings is not None else None
506
466
  if jwt_secret:
507
467
  secret = jwt_secret.get_secret_value()
508
468
  else:
@@ -522,7 +482,7 @@ async def _validate_and_decode_jwt_token(raw_token: str) -> str:
522
482
  user_id = payload.get("sub")
523
483
  if not user_id:
524
484
  raise HTTPException(401, "invalid_token")
525
- return cast(str, user_id)
485
+ return cast("str", user_id)
526
486
  except Exception:
527
487
  raise HTTPException(401, "invalid_token")
528
488
 
@@ -539,12 +499,10 @@ async def _set_cookie_on_response(
539
499
  jwt_token = await strategy.write_token(user)
540
500
 
541
501
  same_site_lit = cast(
542
- Literal["lax", "strict", "none"], str(st.session_cookie_samesite).lower()
502
+ "Literal['lax', 'strict', 'none']", str(st.session_cookie_samesite).lower()
543
503
  )
544
504
  if same_site_lit == "none" and not bool(st.session_cookie_secure):
545
- raise HTTPException(
546
- 500, "session_cookie_samesite=None requires session_cookie_secure=True"
547
- )
505
+ raise HTTPException(500, "session_cookie_samesite=None requires session_cookie_secure=True")
548
506
 
549
507
  # Access/Auth cookie (short-lived JWT)
550
508
  resp.set_cookie(
@@ -586,15 +544,13 @@ async def _handle_mfa_redirect(
586
544
 
587
545
  pre = await get_mfa_pre_jwt_writer().write(user)
588
546
  qs = urlencode({"mfa": "required", "pre_token": pre})
589
- return RedirectResponse(
590
- url=f"{redirect_url}?{qs}", status_code=status.HTTP_302_FOUND
591
- )
547
+ return RedirectResponse(url=f"{redirect_url}?{qs}", status_code=status.HTTP_302_FOUND)
592
548
 
593
549
 
594
550
  def oauth_router_with_backend(
595
551
  user_model: type,
596
552
  auth_backend: AuthenticationBackend,
597
- providers: Dict[str, Dict[str, Any]],
553
+ providers: dict[str, dict[str, Any]],
598
554
  post_login_redirect: str = "/",
599
555
  provider_account_model: type | None = None,
600
556
  auth_policy: AuthPolicy | None = None,
@@ -612,7 +568,7 @@ def oauth_router_with_backend(
612
568
  def _create_oauth_router(
613
569
  user_model: type,
614
570
  auth_backend: AuthenticationBackend,
615
- providers: Dict[str, Dict[str, Any]],
571
+ providers: dict[str, dict[str, Any]],
616
572
  post_login_redirect: str = "/",
617
573
  provider_account_model: type | None = None,
618
574
  auth_policy: AuthPolicy | None = None,
@@ -697,9 +653,7 @@ def _create_oauth_router(
697
653
  provider_user_id,
698
654
  email_verified,
699
655
  raw_claims,
700
- ) = await _extract_user_info_from_provider(
701
- request, client, token, provider, cfg, nonce
702
- )
656
+ ) = await _extract_user_info_from_provider(request, client, token, provider, cfg, nonce)
703
657
 
704
658
  if email_verified is False:
705
659
  raise HTTPException(400, "unverified_email")
@@ -726,9 +680,7 @@ def _create_oauth_router(
726
680
  raise HTTPException(401, "account_disabled")
727
681
 
728
682
  # Determine final redirect URL
729
- redirect_url = _determine_final_redirect_url(
730
- request, provider, post_login_redirect
731
- )
683
+ redirect_url = _determine_final_redirect_url(request, provider, post_login_redirect)
732
684
 
733
685
  # Handle MFA if required (do NOT set last_login yet; do it after MFA)
734
686
  mfa_response = await _handle_mfa_redirect(policy, user, redirect_url)
@@ -737,7 +689,7 @@ def _create_oauth_router(
737
689
  return mfa_response
738
690
 
739
691
  # NEW: set last_login only when we are actually logging in now
740
- user.last_login = datetime.now(timezone.utc)
692
+ user.last_login = datetime.now(UTC)
741
693
  await session.commit()
742
694
 
743
695
  # Create session + initial refresh token
@@ -804,7 +756,7 @@ def _create_oauth_router(
804
756
  user_id = await _validate_and_decode_jwt_token(raw_auth)
805
757
 
806
758
  # Load user
807
- user = await cast(Any, session).get(user_model, user_id)
759
+ user = await cast("Any", session).get(user_model, user_id)
808
760
  if not user:
809
761
  raise HTTPException(401, "invalid_token")
810
762
 
@@ -832,7 +784,7 @@ def _create_oauth_router(
832
784
  if (
833
785
  not found
834
786
  or found.revoked_at
835
- or (found.expires_at and found.expires_at < datetime.now(timezone.utc))
787
+ or (found.expires_at and found.expires_at < datetime.now(UTC))
836
788
  ):
837
789
  raise HTTPException(401, "invalid_refresh_token")
838
790
 
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from datetime import datetime, timezone
4
- from typing import List
3
+ from datetime import UTC, datetime
5
4
 
6
5
  from fastapi import APIRouter, HTTPException
7
6
  from sqlalchemy import select
@@ -20,9 +19,7 @@ def build_session_router() -> APIRouter:
20
19
  response_model=list[dict],
21
20
  dependencies=[RequirePermission("security.session.list")],
22
21
  )
23
- async def list_my_sessions(
24
- identity: Identity, session: SqlSessionDep
25
- ) -> List[dict]:
22
+ async def list_my_sessions(identity: Identity, session: SqlSessionDep) -> list[dict]:
26
23
  stmt = select(AuthSession).where(AuthSession.user_id == identity.user.id)
27
24
  rows = (await session.execute(stmt)).scalars().all()
28
25
  return [
@@ -52,7 +49,7 @@ def build_session_router() -> APIRouter:
52
49
  raise HTTPException(403, "forbidden")
53
50
  if s.revoked_at:
54
51
  return # already revoked
55
- s.revoked_at = datetime.now(timezone.utc)
52
+ s.revoked_at = datetime.now(UTC)
56
53
  s.revoke_reason = "user_revoked"
57
54
  # Revoke all refresh tokens for this session
58
55
  for rt in s.refresh_tokens:
@@ -1,7 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
- from datetime import datetime, timezone
4
- from typing import Annotated, Any, Callable, Optional, cast
3
+ from collections.abc import Callable
4
+ from datetime import UTC, datetime
5
+ from typing import Annotated, Any, cast
5
6
 
6
7
  from fastapi import Depends, HTTPException, Request
7
8
  from fastapi.security import APIKeyCookie, APIKeyHeader, OAuth2PasswordBearer
@@ -16,12 +17,8 @@ from svc_infra.db.sql.apikey import get_apikey_model
16
17
 
17
18
  # ---------- OpenAPI security schemes (appear in docs) ----------
18
19
  auth_login_path = USER_PREFIX + LOGIN_PATH
19
- oauth2_scheme_optional = OAuth2PasswordBearer(
20
- tokenUrl=auth_login_path, auto_error=False
21
- )
22
- cookie_auth_optional = APIKeyCookie(
23
- name=get_auth_settings().auth_cookie_name, auto_error=False
24
- )
20
+ oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl=auth_login_path, auto_error=False)
21
+ cookie_auth_optional = APIKeyCookie(name=get_auth_settings().auth_cookie_name, auto_error=False)
25
22
  api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
26
23
 
27
24
 
@@ -47,7 +44,7 @@ class Principal:
47
44
  async def resolve_api_key(
48
45
  request: Request,
49
46
  session: SqlSessionDep,
50
- ) -> Optional[Principal]:
47
+ ) -> Principal | None:
51
48
  raw = (request.headers.get("x-api-key") or "").strip()
52
49
  if not raw:
53
50
  return None
@@ -77,26 +74,20 @@ async def resolve_api_key(
77
74
  raise HTTPException(401, "invalid_api_key")
78
75
  if not apikey.active:
79
76
  raise HTTPException(401, "api_key_revoked")
80
- if apikey.expires_at and datetime.now(timezone.utc) > apikey.expires_at:
77
+ if apikey.expires_at and datetime.now(UTC) > apikey.expires_at:
81
78
  raise HTTPException(401, "api_key_expired")
82
79
 
83
80
  apikey.mark_used()
84
81
  await session.flush()
85
- return Principal(
86
- user=apikey.user, scopes=apikey.scopes, via="api_key", api_key=apikey
87
- )
82
+ return Principal(user=apikey.user, scopes=apikey.scopes, via="api_key", api_key=apikey)
88
83
 
89
84
 
90
85
  async def resolve_bearer_or_cookie_principal(
91
86
  request: Request, session: SqlSessionDep
92
- ) -> Optional[Principal]:
87
+ ) -> Principal | None:
93
88
  st = get_auth_settings()
94
89
  raw_auth = (request.headers.get("authorization") or "").strip()
95
- token = (
96
- raw_auth.split(" ", 1)[1].strip()
97
- if raw_auth.lower().startswith("bearer ")
98
- else ""
99
- )
90
+ token = raw_auth.split(" ", 1)[1].strip() if raw_auth.lower().startswith("bearer ") else ""
100
91
  if not token:
101
92
  token = (request.cookies.get(st.auth_cookie_name) or "").strip()
102
93
  if not token:
@@ -126,7 +117,7 @@ async def resolve_bearer_or_cookie_principal(
126
117
  if not user:
127
118
  return None
128
119
 
129
- db_user = await cast(Any, session).get(UserModel, user.id)
120
+ db_user = await cast("Any", session).get(UserModel, user.id)
130
121
  if not db_user:
131
122
  return None
132
123
  if not getattr(db_user, "is_active", True):
@@ -142,8 +133,8 @@ async def resolve_bearer_or_cookie_principal(
142
133
  async def _current_principal(
143
134
  request: Request,
144
135
  session: SqlSessionDep,
145
- jwt_or_cookie: Optional[Principal] = Depends(resolve_bearer_or_cookie_principal),
146
- ak: Optional[Principal] = Depends(resolve_api_key),
136
+ jwt_or_cookie: Principal | None = Depends(resolve_bearer_or_cookie_principal),
137
+ ak: Principal | None = Depends(resolve_api_key),
147
138
  ) -> Principal:
148
139
  if jwt_or_cookie:
149
140
  return jwt_or_cookie
@@ -155,9 +146,9 @@ async def _current_principal(
155
146
  async def _optional_principal(
156
147
  request: Request,
157
148
  session: SqlSessionDep,
158
- jwt_or_cookie: Optional[Principal] = Depends(resolve_bearer_or_cookie_principal),
159
- ak: Optional[Principal] = Depends(resolve_api_key),
160
- ) -> Optional[Principal]:
149
+ jwt_or_cookie: Principal | None = Depends(resolve_bearer_or_cookie_principal),
150
+ ak: Principal | None = Depends(resolve_api_key),
151
+ ) -> Principal | None:
161
152
  return jwt_or_cookie or ak or None
162
153
 
163
154
 
@@ -173,9 +164,7 @@ AllowIdentity = Depends(_optional_principal) # same, but optional
173
164
  # ---------- DX: small guard factories ----------
174
165
  def RequireRoles(*roles: str, resolver: Callable[[Any], list[str]] | None = None):
175
166
  async def _guard(p: Identity):
176
- have = set(
177
- (resolver(p.user) if resolver else getattr(p.user, "roles", []) or [])
178
- )
167
+ have = set(resolver(p.user) if resolver else getattr(p.user, "roles", []) or [])
179
168
  if not set(roles).issubset(have):
180
169
  raise HTTPException(403, "forbidden")
181
170
  return p
@@ -20,9 +20,7 @@ class ConsoleSender:
20
20
 
21
21
 
22
22
  class SMTPSender:
23
- def __init__(
24
- self, host: str, port: int, username: str, password: str, from_addr: str
25
- ) -> None:
23
+ def __init__(self, host: str, port: int, username: str, password: str, from_addr: str) -> None:
26
24
  self.host = host
27
25
  self.port = port
28
26
  self.username = username
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
- from typing import List, Optional
5
4
 
6
5
  from pydantic import AnyHttpUrl, BaseModel, Field, SecretStr
7
6
  from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -19,7 +18,7 @@ class JWTSettings(BaseModel):
19
18
  secret: SecretStr
20
19
  lifetime_seconds: int = 60 * 60 * 24 * 7
21
20
  # Optional older secrets accepted for verification during rotation window
22
- old_secrets: List[SecretStr] = Field(default_factory=list)
21
+ old_secrets: list[SecretStr] = Field(default_factory=list)
23
22
 
24
23
 
25
24
  class PasswordClient(BaseModel):
@@ -29,10 +28,10 @@ class PasswordClient(BaseModel):
29
28
 
30
29
  class AuthSettings(BaseSettings):
31
30
  # ---- JWT ----
32
- jwt: Optional[JWTSettings] = None
31
+ jwt: JWTSettings | None = None
33
32
 
34
33
  # ---- Password login ----
35
- password_clients: List[PasswordClient] = Field(default_factory=list)
34
+ password_clients: list[PasswordClient] = Field(default_factory=list)
36
35
  require_client_secret_on_password_login: bool = False
37
36
 
38
37
  # ---- MFA / TOTP ----
@@ -50,26 +49,26 @@ class AuthSettings(BaseSettings):
50
49
  email_otp_attempts: int = 5
51
50
 
52
51
  # ---- Email/SMTP (verification, reset, etc.) ----
53
- smtp_host: Optional[str] = None
52
+ smtp_host: str | None = None
54
53
  smtp_port: int = 587
55
- smtp_username: Optional[str] = None
56
- smtp_password: Optional[SecretStr] = None
57
- smtp_from: Optional[str] = None
54
+ smtp_username: str | None = None
55
+ smtp_password: SecretStr | None = None
56
+ smtp_from: str | None = None
58
57
 
59
58
  # Dev convenience: auto-verify users without sending email
60
59
  auto_verify_in_dev: bool = True
61
60
 
62
61
  # ---- Built-in provider creds (optional) ----
63
- google_client_id: Optional[str] = None
64
- google_client_secret: Optional[SecretStr] = None
65
- github_client_id: Optional[str] = None
66
- github_client_secret: Optional[SecretStr] = None
67
- ms_client_id: Optional[str] = None
68
- ms_client_secret: Optional[SecretStr] = None
69
- ms_tenant: Optional[str] = None
70
- li_client_id: Optional[str] = None
71
- li_client_secret: Optional[SecretStr] = None
72
- oidc_providers: List[OIDCProvider] = Field(default_factory=list)
62
+ google_client_id: str | None = None
63
+ google_client_secret: SecretStr | None = None
64
+ github_client_id: str | None = None
65
+ github_client_secret: SecretStr | None = None
66
+ ms_client_id: str | None = None
67
+ ms_client_secret: SecretStr | None = None
68
+ ms_tenant: str | None = None
69
+ li_client_id: str | None = None
70
+ li_client_secret: SecretStr | None = None
71
+ oidc_providers: list[OIDCProvider] = Field(default_factory=list)
73
72
 
74
73
  # ---- Redirect + cookie settings ----
75
74
  post_login_redirect: AnyHttpUrl | str = "http://localhost:3000/app"
@@ -79,7 +78,7 @@ class AuthSettings(BaseSettings):
79
78
  auth_cookie_name: str = "svc_auth"
80
79
  session_cookie_secure: bool = False
81
80
  session_cookie_samesite: str = "lax"
82
- session_cookie_domain: Optional[str] = None
81
+ session_cookie_domain: str | None = None
83
82
  session_cookie_max_age_seconds: int = 60 * 60 * 4
84
83
 
85
84
  model_config = SettingsConfigDict(
@@ -1,11 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any, Callable, Optional
3
+ from collections.abc import Callable
4
+ from typing import Any
4
5
 
5
- _UserModel: Optional[type] = None
6
- _GetStrategy: Optional[Callable[[], Any]] = None
6
+ _UserModel: type | None = None
7
+ _GetStrategy: Callable[[], Any] | None = None
7
8
  _AuthPrefix: str = "/auth"
8
- _UserScopeResolver: Optional[Callable[[Any], list[str]]] = None
9
+ _UserScopeResolver: Callable[[Any], list[str]] | None = None
9
10
 
10
11
 
11
12
  def set_auth_state(
@@ -19,9 +20,7 @@ def set_auth_state(
19
20
 
20
21
  def get_auth_state() -> tuple[type, Callable[[], Any], str]:
21
22
  if _UserModel is None or _GetStrategy is None:
22
- raise RuntimeError(
23
- "Auth state not initialized; call set_auth_state() in add_auth_users()."
24
- )
23
+ raise RuntimeError("Auth state not initialized; call set_auth_state() in add_auth_users().")
25
24
  return _UserModel, _GetStrategy, _AuthPrefix
26
25
 
27
26
 
@@ -113,7 +113,7 @@ def _decode_jwt(token: str) -> dict:
113
113
 
114
114
  secret = settings.jwt.secret.get_secret_value()
115
115
  old_secrets = [s.get_secret_value() for s in (settings.jwt.old_secrets or [])]
116
- all_secrets = [secret] + old_secrets
116
+ all_secrets = [secret, *old_secrets]
117
117
 
118
118
  last_error: Exception | None = None
119
119
 
@@ -125,7 +125,7 @@ def _decode_jwt(token: str) -> dict:
125
125
  algorithms=["HS256"],
126
126
  options={"require": ["sub", "exp"]},
127
127
  )
128
- return cast(dict[Any, Any], payload)
128
+ return cast("dict[Any, Any]", payload)
129
129
  except jwt.ExpiredSignatureError:
130
130
  raise WebSocketException(
131
131
  code=status.WS_1008_POLICY_VIOLATION,
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
- from datetime import datetime, timezone
4
- from typing import Annotated, Optional
3
+ from datetime import UTC, datetime
4
+ from typing import Annotated
5
5
 
6
6
  from fastapi import APIRouter, Depends, Response, status
7
7
 
@@ -35,7 +35,7 @@ async def record_usage(
35
35
  svc: Annotated[AsyncBillingService, Depends(get_service)],
36
36
  response: Response,
37
37
  ):
38
- at = data.at or datetime.now(tz=timezone.utc)
38
+ at = data.at or datetime.now(tz=UTC)
39
39
  evt_id = await svc.record_usage(
40
40
  metric=data.metric,
41
41
  amount=int(data.amount),
@@ -54,13 +54,11 @@ async def record_usage(
54
54
  )
55
55
  async def list_aggregates(
56
56
  metric: str,
57
- date_from: Optional[datetime] = None,
58
- date_to: Optional[datetime] = None,
57
+ date_from: datetime | None = None,
58
+ date_to: datetime | None = None,
59
59
  svc: Annotated[AsyncBillingService, Depends(get_service)] = None, # type: ignore[assignment]
60
60
  ):
61
- rows = await svc.list_daily_aggregates(
62
- metric=metric, date_from=date_from, date_to=date_to
63
- )
61
+ rows = await svc.list_daily_aggregates(metric=metric, date_from=date_from, date_to=date_to)
64
62
  items = [
65
63
  UsageAggregateRow(
66
64
  period_start=r.period_start,