keble-scraper-api 0.2.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.
Files changed (42) hide show
  1. keble_scraper_api/__init__.py +3 -0
  2. keble_scraper_api/api/dependencies.py +100 -0
  3. keble_scraper_api/api/errors.py +72 -0
  4. keble_scraper_api/api/router.py +473 -0
  5. keble_scraper_api/api/schemas.py +26 -0
  6. keble_scraper_api/application/admin.py +285 -0
  7. keble_scraper_api/application/artifacts.py +393 -0
  8. keble_scraper_api/application/auth.py +324 -0
  9. keble_scraper_api/application/failures.py +427 -0
  10. keble_scraper_api/application/fetching.py +72 -0
  11. keble_scraper_api/application/jobs.py +891 -0
  12. keble_scraper_api/application/proxy_health.py +126 -0
  13. keble_scraper_api/application/scheduling.py +24 -0
  14. keble_scraper_api/artifact_namespace.py +76 -0
  15. keble_scraper_api/container.py +361 -0
  16. keble_scraper_api/errors.py +25 -0
  17. keble_scraper_api/infrastructure/artifact_store.py +585 -0
  18. keble_scraper_api/infrastructure/callbacks.py +285 -0
  19. keble_scraper_api/infrastructure/celery_factory.py +63 -0
  20. keble_scraper_api/infrastructure/cloudflare.py +259 -0
  21. keble_scraper_api/infrastructure/credentials.py +75 -0
  22. keble_scraper_api/infrastructure/dispatcher.py +57 -0
  23. keble_scraper_api/infrastructure/http_fetcher.py +351 -0
  24. keble_scraper_api/infrastructure/mongo.py +2345 -0
  25. keble_scraper_api/infrastructure/proxy_router.py +460 -0
  26. keble_scraper_api/infrastructure/request_profiles.py +220 -0
  27. keble_scraper_api/infrastructure/url_policy.py +341 -0
  28. keble_scraper_api/main.py +162 -0
  29. keble_scraper_api/maintenance/__init__.py +1 -0
  30. keble_scraper_api/maintenance/reset_pre_release_state.py +743 -0
  31. keble_scraper_api/models.py +473 -0
  32. keble_scraper_api/protocols.py +947 -0
  33. keble_scraper_api/settings.py +484 -0
  34. keble_scraper_api/telemetry.py +109 -0
  35. keble_scraper_api/workers/celery_app.py +48 -0
  36. keble_scraper_api/workers/cli.py +106 -0
  37. keble_scraper_api/workers/runtime.py +78 -0
  38. keble_scraper_api/workers/tasks.py +97 -0
  39. keble_scraper_api-0.2.0.dist-info/METADATA +160 -0
  40. keble_scraper_api-0.2.0.dist-info/RECORD +42 -0
  41. keble_scraper_api-0.2.0.dist-info/WHEEL +4 -0
  42. keble_scraper_api-0.2.0.dist-info/entry_points.txt +4 -0
@@ -0,0 +1,3 @@
1
+ """Keble scraper API, workers, persistence, and operations composition."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,100 @@
1
+ """Runtime container binding and shared request authorization dependencies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from uuid import uuid4
6
+
7
+ from fastapi import Request
8
+
9
+ from keble_scraper_contract import OperatorSession
10
+
11
+ from keble_scraper_api.container import ScraperContainer
12
+
13
+
14
+ class ContainerProvider:
15
+ """Bind one lifespan-created service graph to static route definitions.
16
+
17
+ Side effects if changes:
18
+ - every FastAPI dependency resolves process-owned services here.
19
+ """
20
+
21
+ def __init__(self) -> None:
22
+ """Initialize an intentionally unbound provider for OpenAPI creation.
23
+
24
+ Side effects if changes:
25
+ - pre-start request behavior remains fail-closed.
26
+ """
27
+
28
+ self._container: ScraperContainer | None = None
29
+
30
+ def bind(self, *, container: ScraperContainer) -> None:
31
+ """Install exactly one initialized process container.
32
+
33
+ Side effects if changes:
34
+ - route execution becomes available after lifespan startup.
35
+ """
36
+
37
+ if self._container is not None:
38
+ raise RuntimeError("scraper container is already bound")
39
+ self._container = container
40
+
41
+ def clear(self) -> None:
42
+ """Remove the binding before process resources close.
43
+
44
+ Side effects if changes:
45
+ - shutdown requests fail closed.
46
+ """
47
+
48
+ self._container = None
49
+
50
+ def require(self) -> ScraperContainer:
51
+ """Return initialized services or reject pre-start/post-shutdown access.
52
+
53
+ Side effects if changes:
54
+ - every API endpoint dependency.
55
+ """
56
+
57
+ if self._container is None:
58
+ raise RuntimeError("scraper runtime is not initialized")
59
+ return self._container
60
+
61
+
62
+ def request_id(*, request: Request) -> str:
63
+ """Use caller correlation metadata or generate a server request identity.
64
+
65
+ Side effects if changes:
66
+ - audit correlation; never job/idempotency identity.
67
+ """
68
+
69
+ return request.headers.get("x-request-id") or uuid4().hex
70
+
71
+
72
+ def authorize_service(*, request: Request, provider: ContainerProvider) -> str:
73
+ """Authenticate the single Shopify service audience with current/next keys.
74
+
75
+ Side effects if changes:
76
+ - job submit/read/cancel service access.
77
+ """
78
+
79
+ container = provider.require()
80
+ container.service_keys.authorize(
81
+ supplied=request.headers.get("x-service-key")
82
+ )
83
+ return container.settings.service_caller_identity
84
+
85
+
86
+ async def authorize_operator(
87
+ *,
88
+ request: Request,
89
+ provider: ContainerProvider,
90
+ ) -> OperatorSession:
91
+ """Resolve a backend-managed encrypted HttpOnly operator session.
92
+
93
+ Side effects if changes:
94
+ - every admin read/mutation authorization decision.
95
+ """
96
+
97
+ container = provider.require()
98
+ return await container.auth.session_from_cookie(
99
+ token=request.cookies.get(container.settings.session_cookie_name)
100
+ )
@@ -0,0 +1,72 @@
1
+ """Sanitized FastAPI exception-to-status conversion."""
2
+
3
+ from cryptography.fernet import InvalidToken
4
+ from fastapi import Request
5
+ from fastapi.responses import JSONResponse
6
+
7
+ from keble_scraper_api.api.schemas import ErrorResponse
8
+ from keble_scraper_api.errors import ScraperDomainError, ScraperErrorCode
9
+
10
+
11
+ async def scraper_error_handler(
12
+ request: Request,
13
+ error: ScraperDomainError,
14
+ ) -> JSONResponse:
15
+ """Map one typed domain failure without exposing transport or secret details.
16
+
17
+ Steps:
18
+ 1. select a stable HTTP status from the finite local code;
19
+ 2. serialize only the bounded safe code and message;
20
+ 3. omit exception details, URLs, and credentials.
21
+
22
+ Side effects if changes:
23
+ - all service/admin HTTP error status and bodies.
24
+ """
25
+
26
+ status_by_code = {
27
+ ScraperErrorCode.AUTH_INVALID: 401,
28
+ ScraperErrorCode.AUTH_FORBIDDEN: 403,
29
+ ScraperErrorCode.NOT_FOUND: 404,
30
+ ScraperErrorCode.REVISION_CONFLICT: 409,
31
+ ScraperErrorCode.INVALID_TRANSITION: 409,
32
+ ScraperErrorCode.VALIDATION_ERROR: 422,
33
+ ScraperErrorCode.URL_DENIED: 422,
34
+ ScraperErrorCode.DNS_FAILED: 422,
35
+ ScraperErrorCode.RESPONSE_TOO_LARGE: 413,
36
+ ScraperErrorCode.CONTENT_TYPE_REJECTED: 415,
37
+ ScraperErrorCode.BUDGET_EXHAUSTED: 429,
38
+ ScraperErrorCode.ROUTE_UNAVAILABLE: 503,
39
+ ScraperErrorCode.CONFIGURATION_INVALID: 503,
40
+ }
41
+ status_code = status_by_code.get(error.code, 400)
42
+ body = ErrorResponse(code=error.code.value, message=error.message)
43
+ return JSONResponse(
44
+ status_code=status_code,
45
+ content=body.model_dump(mode="json", by_alias=True),
46
+ )
47
+
48
+
49
+ async def invalid_session_handler(
50
+ request: Request,
51
+ error: InvalidToken,
52
+ ) -> JSONResponse:
53
+ """Map authenticated-decryption rejection to the public auth contract.
54
+
55
+ Steps:
56
+ 1. accept only cryptography's explicit invalid-token signal;
57
+ 2. discard all opaque token/error detail;
58
+ 3. return the same finite 401 body as a missing/expired session.
59
+
60
+ Side effects if changes:
61
+ - malformed or tampered admin cookies remain a safe authentication denial.
62
+ """
63
+
64
+ del request, error
65
+ body = ErrorResponse(
66
+ code=ScraperErrorCode.AUTH_INVALID.value,
67
+ message="operator session is invalid",
68
+ )
69
+ return JSONResponse(
70
+ status_code=401,
71
+ content=body.model_dump(mode="json", by_alias=True),
72
+ )
@@ -0,0 +1,473 @@
1
+ """Thin authenticated FastAPI routes over canonical application services."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from uuid import UUID
7
+
8
+ from fastapi import APIRouter, Query, Request, Response, status
9
+
10
+ from keble_scraper_contract import (
11
+ GoogleLoginCommand,
12
+ OperatorAuthConfig,
13
+ OperatorSession,
14
+ ProxyCandidateCreate,
15
+ ProxyCandidateView,
16
+ RouteBudgetView,
17
+ ScrapeArtifactDownload,
18
+ ScrapeArtifactPin,
19
+ ScrapeArtifactRef,
20
+ ScrapeJobAccepted,
21
+ ScrapeJobCancel,
22
+ ScrapeJobCreate,
23
+ ScrapeJobView,
24
+ )
25
+
26
+ from keble_scraper_api.api.dependencies import (
27
+ ContainerProvider,
28
+ authorize_operator,
29
+ authorize_service,
30
+ request_id,
31
+ )
32
+ from keble_scraper_api.api.schemas import AdminReason
33
+ from keble_scraper_api.application.admin import require_mutator
34
+ from keble_scraper_api.errors import ScraperDomainError, ScraperErrorCode
35
+
36
+
37
+ def create_router(*, provider: ContainerProvider) -> APIRouter:
38
+ """Build service/auth/admin routes against one lifespan provider.
39
+
40
+ Side effects if changes:
41
+ - public OpenAPI and all scraper HTTP behavior.
42
+ """
43
+
44
+ router = APIRouter()
45
+
46
+ @router.get("/health", operation_id="scraper_health")
47
+ async def health() -> dict[str, str]:
48
+ """Report process liveness without privileged dependency calls.
49
+
50
+ Side effects if changes:
51
+ - container liveness probes.
52
+ """
53
+
54
+ return {"status": "ok"}
55
+
56
+ @router.get("/ready", operation_id="scraper_ready")
57
+ async def ready() -> dict[str, str]:
58
+ """Verify Mongo/Redis readiness without vendor calls.
59
+
60
+ Side effects if changes:
61
+ - deployment rollout and rollback decisions.
62
+ """
63
+
64
+ return await provider.require().readiness()
65
+
66
+ @router.post(
67
+ "/api/v1/auth/google",
68
+ response_model=OperatorSession,
69
+ operation_id="scraper_google_login",
70
+ )
71
+ async def google_login(
72
+ command: GoogleLoginCommand,
73
+ response: Response,
74
+ ) -> OperatorSession:
75
+ """Exchange Google through Truth and set the encrypted HttpOnly cookie.
76
+
77
+ Side effects if changes:
78
+ - external Truth auth and browser session cookie.
79
+ """
80
+
81
+ container = provider.require()
82
+ session, cookie = await container.auth.google_login(
83
+ credential=command.credential
84
+ )
85
+ _set_session_cookie(response=response, session=session, cookie=cookie, provider=provider)
86
+ return session
87
+
88
+ @router.get(
89
+ "/api/v1/auth/config",
90
+ response_model=OperatorAuthConfig,
91
+ operation_id="scraper_auth_config",
92
+ )
93
+ async def auth_config() -> OperatorAuthConfig:
94
+ """Return safe sign-in capabilities without credentials or secrets.
95
+
96
+ Side effects if changes:
97
+ - frontend Google/dev sign-in availability.
98
+ """
99
+
100
+ settings = provider.require().settings
101
+ return OperatorAuthConfig(
102
+ google_client_id=settings.google_client_id,
103
+ dev_auth_enabled=(
104
+ settings.environment.value == "development"
105
+ and settings.dev_auth_enabled
106
+ ),
107
+ )
108
+
109
+ @router.post(
110
+ "/api/v1/auth/dev",
111
+ response_model=OperatorSession,
112
+ operation_id="scraper_dev_login",
113
+ )
114
+ async def dev_login(response: Response) -> OperatorSession:
115
+ """Set a visibly local session under the explicit development-only gate.
116
+
117
+ Side effects if changes:
118
+ - local browser session cookie.
119
+ """
120
+
121
+ container = provider.require()
122
+ session, cookie = container.auth.dev_login()
123
+ _set_session_cookie(response=response, session=session, cookie=cookie, provider=provider)
124
+ return session
125
+
126
+ @router.get(
127
+ "/api/v1/auth/session",
128
+ response_model=OperatorSession,
129
+ operation_id="scraper_auth_session",
130
+ )
131
+ async def auth_session(request: Request) -> OperatorSession:
132
+ """Return the safe session view without bearer/cookie contents.
133
+
134
+ Side effects if changes:
135
+ - operations frontend bootstrap authorization.
136
+ """
137
+
138
+ return await authorize_operator(request=request, provider=provider)
139
+
140
+ @router.post(
141
+ "/api/v1/auth/logout",
142
+ status_code=status.HTTP_204_NO_CONTENT,
143
+ operation_id="scraper_logout",
144
+ )
145
+ async def logout(response: Response) -> None:
146
+ """Delete the backend-owned session cookie.
147
+
148
+ Side effects if changes:
149
+ - browser authentication state.
150
+ """
151
+
152
+ container = provider.require()
153
+ response.delete_cookie(
154
+ container.settings.session_cookie_name,
155
+ httponly=True,
156
+ samesite="strict",
157
+ )
158
+
159
+ @router.post(
160
+ "/api/v1/jobs",
161
+ response_model=ScrapeJobAccepted,
162
+ status_code=status.HTTP_202_ACCEPTED,
163
+ operation_id="create_scrape_job",
164
+ )
165
+ async def create_job(command: ScrapeJobCreate, request: Request) -> ScrapeJobAccepted:
166
+ """Create/reuse and asynchronously dispatch one service-owned job.
167
+
168
+ Side effects if changes:
169
+ - Mongo job/audit writes and RabbitMQ publication.
170
+ """
171
+
172
+ caller = authorize_service(request=request, provider=provider)
173
+ return await provider.require().jobs.create(
174
+ caller_identity=caller,
175
+ command=command,
176
+ request_id=request_id(request=request),
177
+ )
178
+
179
+ @router.get(
180
+ "/api/v1/jobs/{job_id}",
181
+ response_model=ScrapeJobView,
182
+ operation_id="get_scrape_job",
183
+ )
184
+ async def get_job(job_id: UUID, request: Request) -> ScrapeJobView:
185
+ """Read one service-authenticated job and verify caller ownership.
186
+
187
+ Side effects if changes:
188
+ - Shopify polling Mongo reads.
189
+ """
190
+
191
+ caller = authorize_service(request=request, provider=provider)
192
+ view = await provider.require().jobs.get(job_id=job_id)
193
+ if view.caller_identity != caller:
194
+ raise ScraperDomainError(
195
+ code=ScraperErrorCode.NOT_FOUND,
196
+ message="scrape job was not found",
197
+ )
198
+ return view
199
+
200
+ @router.post(
201
+ "/api/v1/jobs/{job_id}:cancel",
202
+ response_model=ScrapeJobView,
203
+ operation_id="cancel_scrape_job",
204
+ )
205
+ async def cancel_job(
206
+ job_id: UUID,
207
+ command: ScrapeJobCancel,
208
+ request: Request,
209
+ ) -> ScrapeJobView:
210
+ """Optimistically cancel one caller-owned service job.
211
+
212
+ Side effects if changes:
213
+ - Mongo lifecycle and audit writes.
214
+ """
215
+
216
+ caller = authorize_service(request=request, provider=provider)
217
+ return await provider.require().jobs.cancel_service(
218
+ job_id=job_id,
219
+ command=command,
220
+ caller_identity=caller,
221
+ request_id=request_id(request=request),
222
+ )
223
+
224
+ @router.get(
225
+ "/api/v1/admin/jobs",
226
+ response_model=list[ScrapeJobView],
227
+ operation_id="admin_list_scrape_jobs",
228
+ )
229
+ async def admin_list_jobs(
230
+ request: Request,
231
+ limit: int = Query(default=50, ge=1, le=200),
232
+ before: datetime | None = None,
233
+ ) -> tuple[ScrapeJobView, ...]:
234
+ """List newest jobs for any authenticated operator role.
235
+
236
+ Side effects if changes:
237
+ - operations Mongo reads.
238
+ """
239
+
240
+ await authorize_operator(request=request, provider=provider)
241
+ return await provider.require().jobs.list_admin(limit=limit, before=before)
242
+
243
+ @router.post(
244
+ "/api/v1/admin/jobs",
245
+ response_model=ScrapeJobAccepted,
246
+ status_code=status.HTTP_202_ACCEPTED,
247
+ operation_id="admin_create_scrape_job",
248
+ )
249
+ async def admin_create_job(
250
+ command: ScrapeJobCreate,
251
+ request: Request,
252
+ ) -> ScrapeJobAccepted:
253
+ """Submit one job from the console using the operator as caller identity.
254
+
255
+ Side effects if changes:
256
+ - Mongo job/audit writes and RabbitMQ publication.
257
+ """
258
+
259
+ actor = await authorize_operator(request=request, provider=provider)
260
+ require_mutator(actor=actor)
261
+ return await provider.require().jobs.create(
262
+ caller_identity=f"operator:{actor.user_id}",
263
+ command=command,
264
+ request_id=request_id(request=request),
265
+ actor_role=actor.role.value,
266
+ )
267
+
268
+ @router.post(
269
+ "/api/v1/admin/jobs/{job_id}:cancel",
270
+ response_model=ScrapeJobView,
271
+ operation_id="admin_cancel_scrape_job",
272
+ )
273
+ async def admin_cancel_job(
274
+ job_id: UUID,
275
+ command: ScrapeJobCancel,
276
+ request: Request,
277
+ ) -> ScrapeJobView:
278
+ """Optimistically cancel one job through operator authorization.
279
+
280
+ Side effects if changes:
281
+ - Mongo lifecycle and audit writes.
282
+ """
283
+
284
+ actor = await authorize_operator(request=request, provider=provider)
285
+ return await provider.require().jobs.cancel(
286
+ job_id=job_id,
287
+ command=command,
288
+ actor=actor,
289
+ request_id=request_id(request=request),
290
+ )
291
+
292
+ @router.get(
293
+ "/api/v1/artifacts/{artifact_id}:download",
294
+ response_model=ScrapeArtifactDownload,
295
+ operation_id="download_scrape_artifact",
296
+ )
297
+ async def download_artifact(
298
+ artifact_id: UUID,
299
+ request: Request,
300
+ reason: str = Query(min_length=8, max_length=1_000),
301
+ ) -> ScrapeArtifactDownload:
302
+ """Issue one audited short-lived raw artifact download.
303
+
304
+ Side effects if changes:
305
+ - signed object access and audit write.
306
+ """
307
+
308
+ if request.headers.get("x-service-key") is not None:
309
+ caller = authorize_service(request=request, provider=provider)
310
+ return await provider.require().artifact_admin.download_service(
311
+ artifact_id=artifact_id,
312
+ caller_identity=caller,
313
+ request_id=request_id(request=request),
314
+ reason=reason,
315
+ )
316
+ actor = await authorize_operator(request=request, provider=provider)
317
+ return await provider.require().artifact_admin.download(
318
+ artifact_id=artifact_id,
319
+ actor=actor,
320
+ request_id=request_id(request=request),
321
+ reason=reason,
322
+ )
323
+
324
+ @router.post(
325
+ "/api/v1/artifacts/{artifact_id}:pin",
326
+ status_code=status.HTTP_204_NO_CONTENT,
327
+ operation_id="pin_scrape_artifact",
328
+ )
329
+ async def pin_artifact(
330
+ artifact_id: UUID,
331
+ command: ScrapeArtifactPin,
332
+ request: Request,
333
+ ) -> None:
334
+ """Synchronize one version-fenced service-owned retention pin.
335
+
336
+ Steps:
337
+ 1. Reject a path/body artifact identity mismatch before authorization.
338
+ 2. Authenticate the service and apply its ordered retention command.
339
+
340
+ Side effects if changes:
341
+ - Mongo pin/reference count and stale-delivery fencing;
342
+ - immutable service audit write.
343
+ """
344
+
345
+ if command.artifact_id != artifact_id:
346
+ raise ScraperDomainError(
347
+ code=ScraperErrorCode.VALIDATION_ERROR,
348
+ message="artifact path identity does not match pin command",
349
+ )
350
+ caller = authorize_service(request=request, provider=provider)
351
+ await provider.require().artifact_admin.pin_service(
352
+ pin=command,
353
+ caller_identity=caller,
354
+ request_id=request_id(request=request),
355
+ )
356
+
357
+ @router.get(
358
+ "/api/v1/artifacts/{artifact_id}",
359
+ response_model=ScrapeArtifactRef,
360
+ operation_id="get_scrape_artifact",
361
+ )
362
+ async def get_artifact(artifact_id: UUID, request: Request) -> ScrapeArtifactRef:
363
+ """Return safe artifact metadata to the authenticated Shopify service.
364
+
365
+ Side effects if changes:
366
+ - scraper Mongo read for polling/callback recovery.
367
+ """
368
+
369
+ authorize_service(request=request, provider=provider)
370
+ return await provider.require().artifact_admin.get_service(
371
+ artifact_id=artifact_id
372
+ )
373
+
374
+ @router.get(
375
+ "/api/v1/admin/proxies",
376
+ response_model=list[ProxyCandidateView],
377
+ operation_id="admin_list_proxy_candidates",
378
+ )
379
+ async def list_proxies(
380
+ request: Request,
381
+ limit: int = Query(default=100, ge=1, le=500),
382
+ ) -> tuple[ProxyCandidateView, ...]:
383
+ """List quarantined proxy records without credentials.
384
+
385
+ Side effects if changes:
386
+ - proxy dashboard Mongo reads.
387
+ """
388
+
389
+ await authorize_operator(request=request, provider=provider)
390
+ return await provider.require().proxy_admin.list_candidates(limit=limit)
391
+
392
+ @router.post(
393
+ "/api/v1/admin/proxies",
394
+ response_model=ProxyCandidateView,
395
+ operation_id="admin_import_proxy_candidate",
396
+ )
397
+ async def import_proxy(
398
+ command: ProxyCandidateCreate,
399
+ request: Request,
400
+ ) -> ProxyCandidateView:
401
+ """Import an encrypted candidate into mandatory quarantine.
402
+
403
+ Side effects if changes:
404
+ - proxy Mongo/audit writes.
405
+ """
406
+
407
+ actor = await authorize_operator(request=request, provider=provider)
408
+ return await provider.require().proxy_admin.import_candidate(
409
+ command=command,
410
+ actor=actor,
411
+ request_id=request_id(request=request),
412
+ )
413
+
414
+ @router.get(
415
+ "/api/v1/admin/budgets",
416
+ response_model=list[RouteBudgetView],
417
+ operation_id="admin_list_route_budgets",
418
+ )
419
+ async def list_budgets(request: Request) -> tuple[RouteBudgetView, ...]:
420
+ """Return current route rate/spend gates to any operator.
421
+
422
+ Side effects if changes:
423
+ - Redis cost dashboard reads.
424
+ """
425
+
426
+ await authorize_operator(request=request, provider=provider)
427
+ return await provider.require().proxy_admin.budgets()
428
+
429
+ @router.post(
430
+ "/api/v1/admin/cleanup",
431
+ response_model=dict[str, int],
432
+ operation_id="admin_run_scraper_cleanup",
433
+ )
434
+ async def run_cleanup(command: AdminReason, request: Request) -> dict[str, int]:
435
+ """Run and audit one bounded manual maintenance pass.
436
+
437
+ Side effects if changes:
438
+ - Mongo/OSS cleanup and RabbitMQ redispatch.
439
+ """
440
+
441
+ actor = await authorize_operator(request=request, provider=provider)
442
+ return await provider.require().maintenance_admin.run(
443
+ actor=actor,
444
+ request_id=request_id(request=request),
445
+ reason=command.reason,
446
+ )
447
+
448
+ return router
449
+
450
+
451
+ def _set_session_cookie(
452
+ *,
453
+ response: Response,
454
+ session: OperatorSession,
455
+ cookie: str,
456
+ provider: ContainerProvider,
457
+ ) -> None:
458
+ """Set one secure backend-managed session cookie and no browser token.
459
+
460
+ Side effects if changes:
461
+ - browser session persistence and CSRF/cookie security.
462
+ """
463
+
464
+ settings = provider.require().settings
465
+ response.set_cookie(
466
+ settings.session_cookie_name,
467
+ cookie,
468
+ max_age=max(1, int((session.expires_at - datetime.now(session.expires_at.tzinfo)).total_seconds())),
469
+ httponly=True,
470
+ secure=settings.environment.value in {"staging", "production"},
471
+ samesite="strict",
472
+ path="/",
473
+ )
@@ -0,0 +1,26 @@
1
+ """FastAPI-only bounded command and error schemas."""
2
+
3
+ from pydantic import Field
4
+
5
+ from keble_scraper_contract import ScraperContractModel
6
+
7
+
8
+ class AdminReason(ScraperContractModel):
9
+ """Audited reason required for one sensitive operator action.
10
+
11
+ Side effects if changes:
12
+ - cleanup/download audit evidence and frontend command forms.
13
+ """
14
+
15
+ reason: str = Field(min_length=8, max_length=1_000)
16
+
17
+
18
+ class ErrorResponse(ScraperContractModel):
19
+ """Sanitized typed HTTP error body.
20
+
21
+ Side effects if changes:
22
+ - service clients and operations frontend error presentation.
23
+ """
24
+
25
+ code: str
26
+ message: str