svc-infra 0.1.506__py3-none-any.whl → 0.1.654__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. svc_infra/apf_payments/README.md +732 -0
  2. svc_infra/apf_payments/alembic.py +11 -0
  3. svc_infra/apf_payments/models.py +339 -0
  4. svc_infra/apf_payments/provider/__init__.py +4 -0
  5. svc_infra/apf_payments/provider/aiydan.py +797 -0
  6. svc_infra/apf_payments/provider/base.py +270 -0
  7. svc_infra/apf_payments/provider/registry.py +31 -0
  8. svc_infra/apf_payments/provider/stripe.py +873 -0
  9. svc_infra/apf_payments/schemas.py +333 -0
  10. svc_infra/apf_payments/service.py +892 -0
  11. svc_infra/apf_payments/settings.py +67 -0
  12. svc_infra/api/fastapi/__init__.py +6 -0
  13. svc_infra/api/fastapi/admin/__init__.py +3 -0
  14. svc_infra/api/fastapi/admin/add.py +231 -0
  15. svc_infra/api/fastapi/apf_payments/__init__.py +0 -0
  16. svc_infra/api/fastapi/apf_payments/router.py +1082 -0
  17. svc_infra/api/fastapi/apf_payments/setup.py +73 -0
  18. svc_infra/api/fastapi/auth/add.py +15 -6
  19. svc_infra/api/fastapi/auth/gaurd.py +67 -5
  20. svc_infra/api/fastapi/auth/mfa/router.py +18 -9
  21. svc_infra/api/fastapi/auth/routers/account.py +3 -2
  22. svc_infra/api/fastapi/auth/routers/apikey_router.py +11 -5
  23. svc_infra/api/fastapi/auth/routers/oauth_router.py +82 -37
  24. svc_infra/api/fastapi/auth/routers/session_router.py +63 -0
  25. svc_infra/api/fastapi/auth/security.py +3 -1
  26. svc_infra/api/fastapi/auth/settings.py +2 -0
  27. svc_infra/api/fastapi/auth/state.py +1 -1
  28. svc_infra/api/fastapi/billing/router.py +64 -0
  29. svc_infra/api/fastapi/billing/setup.py +19 -0
  30. svc_infra/api/fastapi/cache/add.py +9 -5
  31. svc_infra/api/fastapi/db/nosql/mongo/add.py +33 -27
  32. svc_infra/api/fastapi/db/sql/add.py +40 -18
  33. svc_infra/api/fastapi/db/sql/crud_router.py +176 -14
  34. svc_infra/api/fastapi/db/sql/session.py +16 -0
  35. svc_infra/api/fastapi/db/sql/users.py +14 -2
  36. svc_infra/api/fastapi/dependencies/ratelimit.py +116 -0
  37. svc_infra/api/fastapi/docs/add.py +160 -0
  38. svc_infra/api/fastapi/docs/landing.py +1 -1
  39. svc_infra/api/fastapi/docs/scoped.py +254 -0
  40. svc_infra/api/fastapi/dual/dualize.py +38 -33
  41. svc_infra/api/fastapi/dual/router.py +48 -1
  42. svc_infra/api/fastapi/dx.py +3 -3
  43. svc_infra/api/fastapi/http/__init__.py +0 -0
  44. svc_infra/api/fastapi/http/concurrency.py +14 -0
  45. svc_infra/api/fastapi/http/conditional.py +33 -0
  46. svc_infra/api/fastapi/http/deprecation.py +21 -0
  47. svc_infra/api/fastapi/middleware/errors/handlers.py +45 -7
  48. svc_infra/api/fastapi/middleware/graceful_shutdown.py +87 -0
  49. svc_infra/api/fastapi/middleware/idempotency.py +116 -0
  50. svc_infra/api/fastapi/middleware/idempotency_store.py +187 -0
  51. svc_infra/api/fastapi/middleware/optimistic_lock.py +37 -0
  52. svc_infra/api/fastapi/middleware/ratelimit.py +119 -0
  53. svc_infra/api/fastapi/middleware/ratelimit_store.py +84 -0
  54. svc_infra/api/fastapi/middleware/request_id.py +23 -0
  55. svc_infra/api/fastapi/middleware/request_size_limit.py +36 -0
  56. svc_infra/api/fastapi/middleware/timeout.py +148 -0
  57. svc_infra/api/fastapi/openapi/mutators.py +768 -55
  58. svc_infra/api/fastapi/ops/add.py +73 -0
  59. svc_infra/api/fastapi/pagination.py +363 -0
  60. svc_infra/api/fastapi/paths/auth.py +14 -14
  61. svc_infra/api/fastapi/paths/prefix.py +0 -1
  62. svc_infra/api/fastapi/paths/user.py +1 -1
  63. svc_infra/api/fastapi/routers/ping.py +1 -0
  64. svc_infra/api/fastapi/setup.py +48 -15
  65. svc_infra/api/fastapi/tenancy/add.py +19 -0
  66. svc_infra/api/fastapi/tenancy/context.py +112 -0
  67. svc_infra/api/fastapi/versioned.py +101 -0
  68. svc_infra/app/README.md +5 -5
  69. svc_infra/billing/__init__.py +23 -0
  70. svc_infra/billing/async_service.py +147 -0
  71. svc_infra/billing/jobs.py +230 -0
  72. svc_infra/billing/models.py +131 -0
  73. svc_infra/billing/quotas.py +101 -0
  74. svc_infra/billing/schemas.py +33 -0
  75. svc_infra/billing/service.py +115 -0
  76. svc_infra/bundled_docs/README.md +5 -0
  77. svc_infra/bundled_docs/__init__.py +1 -0
  78. svc_infra/bundled_docs/getting-started.md +6 -0
  79. svc_infra/cache/__init__.py +4 -0
  80. svc_infra/cache/add.py +158 -0
  81. svc_infra/cache/backend.py +5 -2
  82. svc_infra/cache/decorators.py +19 -1
  83. svc_infra/cache/keys.py +24 -4
  84. svc_infra/cli/__init__.py +32 -8
  85. svc_infra/cli/__main__.py +4 -0
  86. svc_infra/cli/cmds/__init__.py +10 -0
  87. svc_infra/cli/cmds/db/nosql/mongo/mongo_cmds.py +4 -3
  88. svc_infra/cli/cmds/db/nosql/mongo/mongo_scaffold_cmds.py +4 -4
  89. svc_infra/cli/cmds/db/sql/alembic_cmds.py +120 -14
  90. svc_infra/cli/cmds/db/sql/sql_export_cmds.py +80 -0
  91. svc_infra/cli/cmds/db/sql/sql_scaffold_cmds.py +5 -4
  92. svc_infra/cli/cmds/docs/docs_cmds.py +140 -0
  93. svc_infra/cli/cmds/dx/__init__.py +12 -0
  94. svc_infra/cli/cmds/dx/dx_cmds.py +99 -0
  95. svc_infra/cli/cmds/help.py +4 -0
  96. svc_infra/cli/cmds/jobs/__init__.py +1 -0
  97. svc_infra/cli/cmds/jobs/jobs_cmds.py +43 -0
  98. svc_infra/cli/cmds/obs/obs_cmds.py +4 -3
  99. svc_infra/cli/cmds/sdk/__init__.py +0 -0
  100. svc_infra/cli/cmds/sdk/sdk_cmds.py +102 -0
  101. svc_infra/data/add.py +61 -0
  102. svc_infra/data/backup.py +53 -0
  103. svc_infra/data/erasure.py +45 -0
  104. svc_infra/data/fixtures.py +40 -0
  105. svc_infra/data/retention.py +55 -0
  106. svc_infra/db/inbox.py +67 -0
  107. svc_infra/db/nosql/mongo/README.md +13 -13
  108. svc_infra/db/outbox.py +104 -0
  109. svc_infra/db/sql/apikey.py +1 -1
  110. svc_infra/db/sql/authref.py +61 -0
  111. svc_infra/db/sql/core.py +2 -2
  112. svc_infra/db/sql/repository.py +52 -12
  113. svc_infra/db/sql/resource.py +5 -0
  114. svc_infra/db/sql/scaffold.py +16 -4
  115. svc_infra/db/sql/templates/models_schemas/auth/schemas.py.tmpl +1 -1
  116. svc_infra/db/sql/templates/setup/env_async.py.tmpl +199 -76
  117. svc_infra/db/sql/templates/setup/env_sync.py.tmpl +231 -79
  118. svc_infra/db/sql/tenant.py +79 -0
  119. svc_infra/db/sql/utils.py +18 -4
  120. svc_infra/db/sql/versioning.py +14 -0
  121. svc_infra/docs/acceptance-matrix.md +71 -0
  122. svc_infra/docs/acceptance.md +44 -0
  123. svc_infra/docs/admin.md +425 -0
  124. svc_infra/docs/adr/0002-background-jobs-and-scheduling.md +40 -0
  125. svc_infra/docs/adr/0003-webhooks-framework.md +24 -0
  126. svc_infra/docs/adr/0004-tenancy-model.md +42 -0
  127. svc_infra/docs/adr/0005-data-lifecycle.md +86 -0
  128. svc_infra/docs/adr/0006-ops-slos-and-metrics.md +47 -0
  129. svc_infra/docs/adr/0007-docs-and-sdks.md +83 -0
  130. svc_infra/docs/adr/0008-billing-primitives.md +143 -0
  131. svc_infra/docs/adr/0009-acceptance-harness.md +40 -0
  132. svc_infra/docs/adr/0010-timeouts-and-resource-limits.md +54 -0
  133. svc_infra/docs/adr/0011-admin-scope-and-impersonation.md +73 -0
  134. svc_infra/docs/api.md +59 -0
  135. svc_infra/docs/auth.md +11 -0
  136. svc_infra/docs/billing.md +190 -0
  137. svc_infra/docs/cache.md +76 -0
  138. svc_infra/docs/cli.md +74 -0
  139. svc_infra/docs/contributing.md +34 -0
  140. svc_infra/docs/data-lifecycle.md +52 -0
  141. svc_infra/docs/database.md +14 -0
  142. svc_infra/docs/docs-and-sdks.md +62 -0
  143. svc_infra/docs/environment.md +114 -0
  144. svc_infra/docs/getting-started.md +63 -0
  145. svc_infra/docs/idempotency.md +111 -0
  146. svc_infra/docs/jobs.md +67 -0
  147. svc_infra/docs/observability.md +16 -0
  148. svc_infra/docs/ops.md +37 -0
  149. svc_infra/docs/rate-limiting.md +125 -0
  150. svc_infra/docs/repo-review.md +48 -0
  151. svc_infra/docs/security.md +176 -0
  152. svc_infra/docs/tenancy.md +35 -0
  153. svc_infra/docs/timeouts-and-resource-limits.md +147 -0
  154. svc_infra/docs/versioned-integrations.md +146 -0
  155. svc_infra/docs/webhooks.md +112 -0
  156. svc_infra/dx/add.py +63 -0
  157. svc_infra/dx/changelog.py +74 -0
  158. svc_infra/dx/checks.py +67 -0
  159. svc_infra/http/__init__.py +13 -0
  160. svc_infra/http/client.py +72 -0
  161. svc_infra/jobs/builtins/outbox_processor.py +38 -0
  162. svc_infra/jobs/builtins/webhook_delivery.py +90 -0
  163. svc_infra/jobs/easy.py +32 -0
  164. svc_infra/jobs/loader.py +45 -0
  165. svc_infra/jobs/queue.py +81 -0
  166. svc_infra/jobs/redis_queue.py +191 -0
  167. svc_infra/jobs/runner.py +75 -0
  168. svc_infra/jobs/scheduler.py +41 -0
  169. svc_infra/jobs/worker.py +40 -0
  170. svc_infra/mcp/svc_infra_mcp.py +85 -28
  171. svc_infra/obs/README.md +2 -0
  172. svc_infra/obs/add.py +54 -7
  173. svc_infra/obs/grafana/dashboards/http-overview.json +45 -0
  174. svc_infra/obs/metrics/__init__.py +53 -0
  175. svc_infra/obs/metrics.py +52 -0
  176. svc_infra/security/add.py +201 -0
  177. svc_infra/security/audit.py +130 -0
  178. svc_infra/security/audit_service.py +73 -0
  179. svc_infra/security/headers.py +52 -0
  180. svc_infra/security/hibp.py +95 -0
  181. svc_infra/security/jwt_rotation.py +53 -0
  182. svc_infra/security/lockout.py +96 -0
  183. svc_infra/security/models.py +255 -0
  184. svc_infra/security/org_invites.py +128 -0
  185. svc_infra/security/passwords.py +77 -0
  186. svc_infra/security/permissions.py +149 -0
  187. svc_infra/security/session.py +98 -0
  188. svc_infra/security/signed_cookies.py +80 -0
  189. svc_infra/webhooks/__init__.py +16 -0
  190. svc_infra/webhooks/add.py +322 -0
  191. svc_infra/webhooks/fastapi.py +37 -0
  192. svc_infra/webhooks/router.py +55 -0
  193. svc_infra/webhooks/service.py +67 -0
  194. svc_infra/webhooks/signing.py +30 -0
  195. svc_infra-0.1.654.dist-info/METADATA +154 -0
  196. svc_infra-0.1.654.dist-info/RECORD +352 -0
  197. svc_infra/api/fastapi/deps.py +0 -3
  198. svc_infra-0.1.506.dist-info/METADATA +0 -78
  199. svc_infra-0.1.506.dist-info/RECORD +0 -213
  200. /svc_infra/{api/fastapi/schemas → apf_payments}/__init__.py +0 -0
  201. {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/WHEEL +0 -0
  202. {svc_infra-0.1.506.dist-info → svc_infra-0.1.654.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Callable
5
+
6
+ from fastapi import FastAPI, HTTPException, Request
7
+ from starlette.responses import JSONResponse
8
+
9
+
10
+ def add_probes(
11
+ app: FastAPI,
12
+ *,
13
+ prefix: str = "/_ops",
14
+ include_in_schema: bool = False,
15
+ ) -> None:
16
+ """Mount basic liveness/readiness/startup probes under prefix."""
17
+ from svc_infra.api.fastapi.dual.public import public_router
18
+
19
+ router = public_router(prefix=prefix, tags=["ops"], include_in_schema=include_in_schema)
20
+
21
+ @router.get("/live")
22
+ async def live() -> JSONResponse: # noqa: D401, ANN201
23
+ return JSONResponse({"status": "ok"})
24
+
25
+ @router.get("/ready")
26
+ async def ready() -> JSONResponse: # noqa: D401, ANN201
27
+ # In the future, add checks (DB ping, cache ping) via DI hooks.
28
+ return JSONResponse({"status": "ok"})
29
+
30
+ @router.get("/startup")
31
+ async def startup_probe() -> JSONResponse: # noqa: D401, ANN201
32
+ return JSONResponse({"status": "ok"})
33
+
34
+ app.include_router(router)
35
+
36
+
37
+ def add_maintenance_mode(
38
+ app: FastAPI,
39
+ *,
40
+ env_var: str = "MAINTENANCE_MODE",
41
+ exempt_prefixes: tuple[str, ...] | None = None,
42
+ ) -> None:
43
+ """Enable a simple maintenance gate controlled by an env var.
44
+
45
+ When MAINTENANCE_MODE is truthy, all non-GET requests return 503.
46
+ """
47
+
48
+ @app.middleware("http")
49
+ async def _maintenance_gate(request: Request, call_next): # noqa: ANN001, ANN202
50
+ flag = str(os.getenv(env_var, "")).lower() in {"1", "true", "yes", "on"}
51
+ if flag and request.method not in {"GET", "HEAD", "OPTIONS"}:
52
+ path = request.scope.get("path", "")
53
+ if exempt_prefixes and any(path.startswith(p) for p in exempt_prefixes):
54
+ return await call_next(request)
55
+ return JSONResponse({"detail": "maintenance"}, status_code=503)
56
+ return await call_next(request)
57
+
58
+
59
+ def circuit_breaker_dependency(limit: int = 100, window_seconds: int = 60) -> Callable:
60
+ """Return a dependency that can trip rejective errors based on external metrics.
61
+
62
+ This is a placeholder; callers can swap with a provider that tracks failures and opens the
63
+ breaker. Here, we read an env var to simulate an open breaker.
64
+ """
65
+
66
+ async def _dep(_: Request) -> None: # noqa: D401, ANN202
67
+ if str(os.getenv("CIRCUIT_OPEN", "")).lower() in {"1", "true", "yes", "on"}:
68
+ raise HTTPException(status_code=503, detail="circuit open")
69
+
70
+ return _dep
71
+
72
+
73
+ __all__ = ["add_probes", "add_maintenance_mode", "circuit_breaker_dependency"]
@@ -0,0 +1,363 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import contextvars
5
+ import json
6
+ from typing import Any, Callable, Generic, Iterable, List, Optional, Sequence, TypeVar
7
+
8
+ from fastapi import Query, Request
9
+ from pydantic import BaseModel, Field
10
+
11
+ T = TypeVar("T")
12
+
13
+
14
+ # ---------- Core query models ----------
15
+ class CursorParams(BaseModel):
16
+ cursor: Optional[str] = None
17
+ limit: int = 50
18
+
19
+
20
+ class PageParams(BaseModel):
21
+ page: int = 1
22
+ page_size: int = 50
23
+
24
+
25
+ class FilterParams(BaseModel):
26
+ q: Optional[str] = None
27
+ sort: Optional[str] = None
28
+ created_after: Optional[str] = None
29
+ created_before: Optional[str] = None
30
+ updated_after: Optional[str] = None
31
+ updated_before: Optional[str] = None
32
+
33
+
34
+ # ---------- Envelope model ----------
35
+ class Paginated(BaseModel, Generic[T]):
36
+ items: List[T]
37
+ next_cursor: Optional[str] = Field(None, description="Opaque cursor for next page")
38
+ total: Optional[int] = Field(None, description="Total items (optional)")
39
+
40
+
41
+ # ---------- Cursor helpers ----------
42
+ def _encode_cursor(payload: dict) -> str:
43
+ raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
44
+ return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
45
+
46
+
47
+ def decode_cursor(token: Optional[str]) -> dict:
48
+ """Public: decode an incoming cursor token for debugging/ops."""
49
+ if not token:
50
+ return {}
51
+ s = token + "=" * (-len(token) % 4)
52
+ raw = base64.urlsafe_b64decode(s.encode("ascii")).decode("utf-8")
53
+ return json.loads(raw)
54
+
55
+
56
+ # ---------- Context ----------
57
+ class PaginationContext(Generic[T]):
58
+ envelope: bool
59
+ allow_cursor: bool
60
+ allow_page: bool
61
+
62
+ cursor_params: CursorParams | None
63
+ page_params: PageParams | None
64
+ filters: FilterParams | None
65
+ limit_override: int | None
66
+
67
+ def __init__(
68
+ self,
69
+ *,
70
+ envelope: bool,
71
+ allow_cursor: bool,
72
+ allow_page: bool,
73
+ cursor_params: CursorParams | None,
74
+ page_params: PageParams | None,
75
+ filters: FilterParams | None,
76
+ limit_override: int | None = None,
77
+ ):
78
+ self.envelope = envelope
79
+ self.allow_cursor = allow_cursor
80
+ self.allow_page = allow_page
81
+ self.cursor_params = cursor_params
82
+ self.page_params = page_params
83
+ self.filters = filters
84
+ self.limit_override = limit_override
85
+
86
+ @property
87
+ def cursor(self) -> Optional[str]:
88
+ return (self.cursor_params or CursorParams()).cursor if self.allow_cursor else None
89
+
90
+ @property
91
+ def limit(self) -> int:
92
+ # For cursor-based pagination, always honor the requested limit, even on the first page
93
+ # (cursor may be None for the first page).
94
+ if self.allow_cursor and self.cursor_params:
95
+ return self.cursor_params.limit
96
+ if self.allow_page and self.page_params:
97
+ return self.limit_override or self.page_params.page_size
98
+ return 50
99
+
100
+ @property
101
+ def page(self) -> Optional[int]:
102
+ return self.page_params.page if (self.allow_page and self.page_params) else None
103
+
104
+ @property
105
+ def page_size(self) -> Optional[int]:
106
+ return self.page_params.page_size if (self.allow_page and self.page_params) else None
107
+
108
+ @property
109
+ def offset(self) -> int:
110
+ if self.cursor is None and self.allow_page and self.page and self.page_size:
111
+ return (self.page - 1) * self.page_size
112
+ return 0
113
+
114
+ def wrap(
115
+ self, items: list[T], *, next_cursor: Optional[str] = None, total: Optional[int] = None
116
+ ):
117
+ if self.envelope:
118
+ return Paginated[T](items=items, next_cursor=next_cursor, total=total)
119
+ return items
120
+
121
+ def next_cursor_from_last(
122
+ self, items: Sequence[T], *, key: Callable[[T], str | int]
123
+ ) -> Optional[str]:
124
+ if not items:
125
+ return None
126
+ last_key = key(items[-1])
127
+ return _encode_cursor({"after": last_key})
128
+
129
+
130
+ _pagination_ctx: contextvars.ContextVar[PaginationContext] = contextvars.ContextVar(
131
+ "pagination_ctx", default=None
132
+ )
133
+
134
+
135
+ def use_pagination() -> PaginationContext:
136
+ ctx = _pagination_ctx.get()
137
+ if ctx is None:
138
+ # Safe defaults; if a route forgot to install the injector
139
+ ctx = PaginationContext(
140
+ envelope=False,
141
+ allow_cursor=True,
142
+ allow_page=False,
143
+ cursor_params=CursorParams(),
144
+ page_params=None,
145
+ filters=None,
146
+ )
147
+ return ctx
148
+
149
+
150
+ # ---------- Utilities ----------
151
+ def text_filter(items: Iterable[T], q: Optional[str], *getters: Callable[[T], str]) -> list[T]:
152
+ if not q:
153
+ return list(items)
154
+ ql = q.lower()
155
+ out: list[T] = []
156
+ for it in items:
157
+ for g in getters:
158
+ try:
159
+ if ql in (g(it) or "").lower():
160
+ out.append(it)
161
+ break
162
+ except Exception:
163
+ pass
164
+ return out
165
+
166
+
167
+ def sort_by(
168
+ items: Iterable[T],
169
+ *,
170
+ key: Callable[[T], Any],
171
+ desc: bool = False,
172
+ ) -> list[T]:
173
+ return sorted(list(items), key=key, reverse=desc)
174
+
175
+
176
+ def cursor_window(items, *, cursor, limit, key, descending: bool, offset: int = 0):
177
+ # compute start_index
178
+ if cursor:
179
+ payload = decode_cursor(cursor)
180
+ after = payload.get("after")
181
+ ids = [key(x) for x in items]
182
+ if descending:
183
+ start_index = next((i for i, v in enumerate(ids) if v < after), len(items))
184
+ else:
185
+ start_index = next((i for i, v in enumerate(ids) if v > after), len(items))
186
+ else:
187
+ start_index = offset
188
+
189
+ # take limit+1 to see if there’s another page
190
+ slice_ = items[start_index : start_index + limit + 1]
191
+ has_more = len(slice_) > limit
192
+ window = slice_[:limit]
193
+
194
+ next_cur = None
195
+ if has_more and window:
196
+ last_key = key(window[-1])
197
+ next_cur = _encode_cursor({"after": last_key})
198
+
199
+ return window, next_cur
200
+
201
+
202
+ # ---------- Dependency factories ----------
203
+ def make_pagination_injector(
204
+ *,
205
+ envelope: bool,
206
+ allow_cursor: bool,
207
+ allow_page: bool,
208
+ default_limit: int = 50,
209
+ max_limit: int = 200,
210
+ include_filters: bool = False,
211
+ ):
212
+ """
213
+ Returns a dependency with a signature that only includes the relevant query params.
214
+ This keeps the generated OpenAPI in sync with actual behavior.
215
+ """
216
+
217
+ # Cursor-only (common case)
218
+ if allow_cursor and not allow_page and not include_filters:
219
+
220
+ async def _inject(
221
+ request: Request,
222
+ cursor: str | None = Query(None),
223
+ limit: int = Query(default_limit, ge=1, le=max_limit),
224
+ ):
225
+ cur = CursorParams(cursor=cursor, limit=limit)
226
+ _pagination_ctx.set(
227
+ PaginationContext(
228
+ envelope=envelope,
229
+ allow_cursor=True,
230
+ allow_page=False,
231
+ cursor_params=cur,
232
+ page_params=None,
233
+ filters=None,
234
+ )
235
+ )
236
+ return None
237
+
238
+ return _inject
239
+
240
+ # Cursor + filters
241
+ if allow_cursor and not allow_page and include_filters:
242
+
243
+ async def _inject(
244
+ request: Request,
245
+ cursor: str | None = Query(None),
246
+ limit: int = Query(default_limit, ge=1, le=max_limit),
247
+ q: str | None = Query(None),
248
+ sort: str | None = Query(None),
249
+ created_after: str | None = Query(None),
250
+ created_before: str | None = Query(None),
251
+ updated_after: str | None = Query(None),
252
+ updated_before: str | None = Query(None),
253
+ ):
254
+ cur = CursorParams(cursor=cursor, limit=limit)
255
+ flt = FilterParams(
256
+ q=q,
257
+ sort=sort,
258
+ created_after=created_after,
259
+ created_before=created_before,
260
+ updated_after=updated_after,
261
+ updated_before=updated_before,
262
+ )
263
+ _pagination_ctx.set(
264
+ PaginationContext(
265
+ envelope=envelope,
266
+ allow_cursor=True,
267
+ allow_page=False,
268
+ cursor_params=cur,
269
+ page_params=None,
270
+ filters=flt,
271
+ )
272
+ )
273
+ return None
274
+
275
+ return _inject
276
+
277
+ # Page-only
278
+ if not allow_cursor and allow_page:
279
+
280
+ async def _inject(
281
+ request: Request,
282
+ page: int = Query(1, ge=1),
283
+ page_size: int = Query(default_limit, ge=1, le=max_limit),
284
+ ):
285
+ pag = PageParams(page=page, page_size=page_size)
286
+ _pagination_ctx.set(
287
+ PaginationContext(
288
+ envelope=envelope,
289
+ allow_cursor=False,
290
+ allow_page=True,
291
+ cursor_params=None,
292
+ page_params=pag,
293
+ filters=None,
294
+ )
295
+ )
296
+ return None
297
+
298
+ return _inject
299
+
300
+ # Both cursor + page (rare; exposes all)
301
+ async def _inject(
302
+ request: Request,
303
+ cursor: str | None = Query(None),
304
+ limit: int = Query(default_limit, ge=1, le=max_limit),
305
+ page: int = Query(1, ge=1),
306
+ page_size: int = Query(default_limit, ge=1, le=max_limit),
307
+ q: str | None = Query(None),
308
+ sort: str | None = Query(None),
309
+ created_after: str | None = Query(None),
310
+ created_before: str | None = Query(None),
311
+ updated_after: str | None = Query(None),
312
+ updated_before: str | None = Query(None),
313
+ ):
314
+ cur = CursorParams(cursor=cursor, limit=limit) if allow_cursor else None
315
+ pag = PageParams(page=page, page_size=page_size) if allow_page else None
316
+ flt = (
317
+ FilterParams(
318
+ q=q,
319
+ sort=sort,
320
+ created_after=created_after,
321
+ created_before=created_before,
322
+ updated_after=updated_after,
323
+ updated_before=updated_before,
324
+ )
325
+ if include_filters
326
+ else None
327
+ )
328
+
329
+ _pagination_ctx.set(
330
+ PaginationContext(
331
+ envelope=envelope,
332
+ allow_cursor=allow_cursor,
333
+ allow_page=allow_page,
334
+ cursor_params=cur,
335
+ page_params=pag,
336
+ filters=flt,
337
+ )
338
+ )
339
+ return None
340
+
341
+ return _inject
342
+
343
+
344
+ # ----- Convenience helpers for routers -----
345
+ def cursor_pager(
346
+ default_limit: int = 50,
347
+ max_limit: int = 200,
348
+ *,
349
+ envelope: bool = True,
350
+ include_filters: bool = False,
351
+ ):
352
+ """
353
+ The one-liner most routes should use.
354
+ Produces OpenAPI with only: `cursor` and `limit` (plus filters if requested).
355
+ """
356
+ return make_pagination_injector(
357
+ envelope=envelope,
358
+ allow_cursor=True,
359
+ allow_page=False,
360
+ default_limit=default_limit,
361
+ max_limit=max_limit,
362
+ include_filters=include_filters,
363
+ )
@@ -1,19 +1,19 @@
1
1
  # --- API KEYS ---
2
- LIST_KEYS_PATH = "/auth/keys"
3
- CREATE_KEY_PATH = "/auth/keys"
4
- REVOKE_KEY_PATH = "/auth/keys/{key_id}/revoke"
5
- DELETE_KEY_PATH = "/auth/keys/{key_id}"
2
+ LIST_KEYS_PATH = "/keys"
3
+ CREATE_KEY_PATH = "/keys"
4
+ REVOKE_KEY_PATH = "/keys/{key_id}/revoke"
5
+ DELETE_KEY_PATH = "/keys/{key_id}"
6
6
 
7
7
  # --- MFA ---
8
- MFA_START_PATH = "/auth/mfa/start"
9
- MFA_CONFIRM_PATH = "/auth/mfa/confirm"
10
- MFA_DISABLE_PATH = "/auth/mfa/disable"
11
- MFA_STATUS_PATH = "/auth/mfa/status"
12
- MFA_REGENERATE_RECOVERY_PATH = "/auth/mfa/recovery/regenerate"
13
- MFA_VERIFY_PATH = "/auth/mfa/verify"
14
- MFA_SEND_CODE_PATH = "/auth/mfa/send_code"
8
+ MFA_START_PATH = "/mfa/start"
9
+ MFA_CONFIRM_PATH = "/mfa/confirm"
10
+ MFA_DISABLE_PATH = "/mfa/disable"
11
+ MFA_STATUS_PATH = "/mfa/status"
12
+ MFA_REGENERATE_RECOVERY_PATH = "/mfa/recovery/regenerate"
13
+ MFA_VERIFY_PATH = "/mfa/verify"
14
+ MFA_SEND_CODE_PATH = "/mfa/send_code"
15
15
 
16
16
  # --- OAUTH ---
17
- OAUTH_LOGIN_PATH = "/auth/oauth/{provider}/login"
18
- OAUTH_CALLBACK_PATH = "/auth/oauth/{provider}/callback"
19
- OAUTH_REFRESH_PATH = "/auth/oauth/refresh"
17
+ OAUTH_LOGIN_PATH = "/{provider}/login"
18
+ OAUTH_CALLBACK_PATH = "/{provider}/callback"
19
+ OAUTH_REFRESH_PATH = "/refresh"
@@ -1,3 +1,2 @@
1
1
  AUTH_PREFIX = "/auth"
2
- OAUTH_PREFIX = "/auth/oauth"
3
2
  USER_PREFIX = "/users"
@@ -6,5 +6,5 @@ REQUEST_VERIFY_TOKEN_PATH = "/request-verify-token"
6
6
  VERIFY_PATH = "/verify"
7
7
  FORGOT_PASSWORD_PATH = "/forgot-password"
8
8
  RESET_PASSWORD_PATH = "/reset-password"
9
- DISABLE_ACCOUNT_PATH = "/status"
9
+ DISABLE_ACCOUNT_PATH = "/disable"
10
10
  DELETE_ACCOUNT_PATH = "/delete"
@@ -14,6 +14,7 @@ router = public_router(tags=["Health Check"])
14
14
  PING_PATH,
15
15
  status_code=status.HTTP_200_OK,
16
16
  description="Operation to check if the service is up and running",
17
+ operation_id="health_ping_get",
17
18
  )
18
19
  def ping():
19
20
  logging.info("Health check: /ping endpoint accessed. Service is responsive.")
@@ -11,13 +11,22 @@ from fastapi.responses import HTMLResponse
11
11
  from fastapi.routing import APIRoute
12
12
 
13
13
  from svc_infra.api.fastapi.docs.landing import CardSpec, DocTargets, render_index_html
14
+ from svc_infra.api.fastapi.docs.scoped import DOC_SCOPES
14
15
  from svc_infra.api.fastapi.middleware.errors.catchall import CatchAllExceptionMiddleware
15
16
  from svc_infra.api.fastapi.middleware.errors.handlers import register_error_handlers
17
+ from svc_infra.api.fastapi.middleware.graceful_shutdown import install_graceful_shutdown
18
+ from svc_infra.api.fastapi.middleware.idempotency import IdempotencyMiddleware
19
+ from svc_infra.api.fastapi.middleware.ratelimit import SimpleRateLimitMiddleware
20
+ from svc_infra.api.fastapi.middleware.request_id import RequestIdMiddleware
21
+ from svc_infra.api.fastapi.middleware.timeout import (
22
+ BodyReadTimeoutMiddleware,
23
+ HandlerTimeoutMiddleware,
24
+ )
16
25
  from svc_infra.api.fastapi.openapi.models import APIVersionSpec, ServiceInfo
17
26
  from svc_infra.api.fastapi.openapi.mutators import setup_mutators
18
27
  from svc_infra.api.fastapi.openapi.pipeline import apply_mutators
19
28
  from svc_infra.api.fastapi.routers import register_all_routers
20
- from svc_infra.app.env import CURRENT_ENVIRONMENT
29
+ from svc_infra.app.env import CURRENT_ENVIRONMENT, DEV_ENV, LOCAL_ENV
21
30
 
22
31
  logger = logging.getLogger(__name__)
23
32
 
@@ -57,7 +66,8 @@ def _setup_cors(app: FastAPI, public_cors_origins: list[str] | str | None = None
57
66
  elif isinstance(public_cors_origins, str):
58
67
  origins = [o.strip() for o in public_cors_origins.split(",") if o and o.strip()]
59
68
  else:
60
- fallback = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3000")
69
+ # Strict by default: no CORS unless explicitly configured via env or parameter.
70
+ fallback = os.getenv("CORS_ALLOW_ORIGINS", "")
61
71
  origins = [o.strip() for o in fallback.split(",") if o and o.strip()]
62
72
 
63
73
  if not origins:
@@ -72,6 +82,20 @@ def _setup_cors(app: FastAPI, public_cors_origins: list[str] | str | None = None
72
82
  app.add_middleware(CORSMiddleware, **cors_kwargs)
73
83
 
74
84
 
85
+ def _setup_middlewares(app: FastAPI):
86
+ app.add_middleware(RequestIdMiddleware)
87
+ # Timeouts: enforce body read timeout first, then total handler timeout
88
+ app.add_middleware(BodyReadTimeoutMiddleware)
89
+ app.add_middleware(HandlerTimeoutMiddleware)
90
+ app.add_middleware(CatchAllExceptionMiddleware)
91
+ app.add_middleware(IdempotencyMiddleware)
92
+ app.add_middleware(SimpleRateLimitMiddleware)
93
+ register_error_handlers(app)
94
+ _add_route_logger(app)
95
+ # Graceful shutdown: track in-flight and wait on shutdown
96
+ install_graceful_shutdown(app)
97
+
98
+
75
99
  def _coerce_list(value: str | Iterable[str] | None) -> list[str]:
76
100
  if value is None:
77
101
  return []
@@ -85,19 +109,18 @@ def _dump_or_none(model):
85
109
 
86
110
 
87
111
  def _build_child_app(service: ServiceInfo, spec: APIVersionSpec) -> FastAPI:
112
+ title = f"{service.name} • {spec.tag}" if getattr(spec, "tag", None) else service.name
88
113
  child = FastAPI(
89
- title=service.name,
114
+ title=title,
90
115
  version=service.release,
91
- contact=_dump_or_none(service.contact), # FastAPI expects plain dicts
116
+ contact=_dump_or_none(service.contact),
92
117
  license_info=_dump_or_none(service.license),
93
118
  terms_of_service=service.terms_of_service,
94
119
  description=service.description,
95
120
  generate_unique_id_function=_gen_operation_id_factory(),
96
121
  )
97
122
 
98
- child.add_middleware(CatchAllExceptionMiddleware)
99
- register_error_handlers(child)
100
- _add_route_logger(child)
123
+ _setup_middlewares(child)
101
124
 
102
125
  # ---- OpenAPI pipeline (DRY!) ----
103
126
  include_api_key = bool(spec.include_api_key) if spec.include_api_key is not None else False
@@ -133,8 +156,9 @@ def _build_parent_app(
133
156
  public_cors_origins: list[str] | str | None,
134
157
  root_routers: list[str] | str | None,
135
158
  root_server_url: str | None = None,
136
- root_include_api_key: bool = False, # <-- NEW
159
+ root_include_api_key: bool = False,
137
160
  ) -> FastAPI:
161
+ # Root docs are now enabled in all environments to match root card visibility
138
162
  parent = FastAPI(
139
163
  title=service.name,
140
164
  version=service.release,
@@ -148,9 +172,7 @@ def _build_parent_app(
148
172
  )
149
173
 
150
174
  _setup_cors(parent, public_cors_origins)
151
- parent.add_middleware(CatchAllExceptionMiddleware)
152
- register_error_handlers(parent)
153
- _add_route_logger(parent)
175
+ _setup_middlewares(parent)
154
176
 
155
177
  mutators = setup_mutators(
156
178
  service=service,
@@ -219,19 +241,20 @@ def setup_service_api(
219
241
  mount_path = f"/{spec.tag.strip('/')}"
220
242
  parent.mount(mount_path, child, name=spec.tag.strip("/"))
221
243
 
222
- @parent.get("/", include_in_schema=False)
244
+ @parent.get("/", include_in_schema=False, response_class=HTMLResponse)
223
245
  def index():
224
246
  cards: list[CardSpec] = []
247
+ is_local_dev = CURRENT_ENVIRONMENT in (LOCAL_ENV, DEV_ENV)
225
248
 
226
- # Root card first
249
+ # Root card - always show in all environments
227
250
  cards.append(
228
251
  CardSpec(
229
- tag="", # renders as "/"
252
+ tag="",
230
253
  docs=DocTargets(swagger="/docs", redoc="/redoc", openapi_json="/openapi.json"),
231
254
  )
232
255
  )
233
256
 
234
- # One card per version
257
+ # Version cards
235
258
  for spec in versions:
236
259
  tag = spec.tag.strip("/")
237
260
  cards.append(
@@ -245,6 +268,16 @@ def setup_service_api(
245
268
  )
246
269
  )
247
270
 
271
+ if is_local_dev:
272
+ # Scoped cards (auth, payments, etc.)
273
+ for scope, swagger, redoc, openapi_json, title in DOC_SCOPES:
274
+ cards.append(
275
+ CardSpec(
276
+ tag=scope.strip("/"),
277
+ docs=DocTargets(swagger=swagger, redoc=redoc, openapi_json=openapi_json),
278
+ )
279
+ )
280
+
248
281
  html = render_index_html(service_name=service.name, release=service.release, cards=cards)
249
282
  return HTMLResponse(html)
250
283
 
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable, Optional
4
+
5
+ from fastapi import FastAPI
6
+
7
+ from .context import set_tenant_resolver
8
+
9
+
10
+ def add_tenancy(app: FastAPI, *, resolver: Optional[Callable[..., Any]] = None) -> None:
11
+ """Wire tenancy resolver for the application.
12
+
13
+ Provide a resolver(request, identity, header) -> Optional[str] to override
14
+ the default resolution. Pass None to clear a previous override.
15
+ """
16
+ set_tenant_resolver(resolver)
17
+
18
+
19
+ __all__ = ["add_tenancy"]