agentevo-cloud 0.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.
agentevo_cloud/app.py ADDED
@@ -0,0 +1,2091 @@
1
+ """Cloud Control API: FastAPI factory, error model, and routes.
2
+
3
+ Every route authenticates via Bearer tokens except /healthz and the auth
4
+ flows themselves. The effective tenant always derives from the verified
5
+ principal; request-body tenant IDs are consistency assertions only (400 on
6
+ mismatch, never authorization). Quota headers ride every authenticated
7
+ response; metering increments in the request transaction.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import sqlite3
13
+ from collections.abc import Awaitable, Callable, Iterator
14
+ from contextlib import suppress
15
+ from dataclasses import replace
16
+ from datetime import UTC, datetime, timedelta
17
+ from typing import Any
18
+ from uuid import uuid4
19
+
20
+ from fastapi import Depends, FastAPI, Header, Request, Response
21
+ from fastapi.exceptions import RequestValidationError
22
+ from fastapi.responses import JSONResponse
23
+ from starlette.exceptions import HTTPException as StarletteHTTPException
24
+
25
+ from agentevo_cloud.analytics import summarize
26
+ from agentevo_cloud.config import Settings
27
+ from agentevo_cloud.db import Database
28
+ from agentevo_cloud.devices import (
29
+ DEVICE_POLL_INTERVAL,
30
+ DeviceRecord,
31
+ approve_device_code,
32
+ deny_device_code,
33
+ exchange_ci_token,
34
+ link_repo,
35
+ list_device_keys,
36
+ list_devices,
37
+ list_repos,
38
+ poll_device_flow,
39
+ register_device,
40
+ revoke_device,
41
+ start_device_flow,
42
+ unlink_repo,
43
+ )
44
+ from agentevo_cloud.events import ingest_batch
45
+ from agentevo_cloud.jobs import (
46
+ JobState,
47
+ JobType,
48
+ cancel_job,
49
+ create_job,
50
+ get_job,
51
+ list_jobs,
52
+ )
53
+ from agentevo_cloud.managed import ManagedObject, get_managed, index_accepted
54
+ from agentevo_cloud.metering import (
55
+ RETRY_AFTER_HEADER,
56
+ Usage,
57
+ check_rate_limit,
58
+ current_cycle,
59
+ is_billable,
60
+ quota_headers,
61
+ quota_warning,
62
+ record_checked,
63
+ retry_after_for,
64
+ unrecord_billable,
65
+ usage_for,
66
+ )
67
+ from agentevo_cloud.oidc import JwksFetcher, fetch_jwks
68
+ from agentevo_cloud.promote import promote_managed
69
+ from agentevo_cloud.recall import parse_environment, parse_query, recall
70
+ from agentevo_cloud.records import (
71
+ audit,
72
+ idempotency_lookup,
73
+ idempotency_store,
74
+ list_audit,
75
+ )
76
+ from agentevo_cloud.revoke import revoke_object
77
+ from agentevo_cloud.sync import pull_page, push_batch
78
+ from agentevo_cloud.tenants import (
79
+ ADMIN_ROLE,
80
+ OWNER_ROLE,
81
+ add_membership,
82
+ create_tenant,
83
+ create_workspace,
84
+ delete_workspace_data,
85
+ entitlements_for,
86
+ export_workspace_data,
87
+ get_sync_mode,
88
+ get_tenant,
89
+ require_searchable,
90
+ set_sync_mode,
91
+ )
92
+ from agentevo_cloud.tokens import (
93
+ REFRESH_KIND,
94
+ Principal,
95
+ hash_token,
96
+ issue_pat,
97
+ list_tokens,
98
+ revoke_token,
99
+ rotate_refresh,
100
+ verify_token,
101
+ )
102
+ from agentevo_cloud.webhooks import (
103
+ Delivery,
104
+ Sender as WebhookSender,
105
+ WebhookConfig,
106
+ build_event,
107
+ create_config,
108
+ deliver_to_config,
109
+ dispatch,
110
+ get_config,
111
+ list_configs,
112
+ list_deliveries,
113
+ replay_delivery,
114
+ revoke_config,
115
+ rotate_config,
116
+ urllib_sender,
117
+ )
118
+ from agentevo_cloud.workers import (
119
+ DEFAULT_COMPILE_PROJECT,
120
+ ensure_workspace,
121
+ parse_bindings,
122
+ parse_canary_steps,
123
+ parse_fallbacks,
124
+ parse_required_str,
125
+ parse_rollback,
126
+ parse_skill_refs,
127
+ )
128
+ from agentevo_core.errors import HTTP_STATUS, AgentevoError, ErrorCode
129
+ from agentevo_core.objects import LearnedObjectType
130
+ from agentevo_core.trust import TrustState
131
+ from agentevo_local.revoke import RevocationReason
132
+
133
+ REQUEST_ID_HEADER = "X-Request-ID"
134
+
135
+ #: 2xx bounds: _commit_middleware commits successes, rolls back the rest.
136
+ _SUCCESS_MIN = 200
137
+ _SUCCESS_MAX = 300
138
+
139
+
140
+ def create_app(db: Database, settings: Settings) -> FastAPI:
141
+ """Build the Control API application over one database + settings."""
142
+ app = FastAPI(title="Agentevo Control API", version="v1")
143
+ app.state.db = db
144
+ app.state.settings = settings
145
+ app.exception_handler(AgentevoError)(_agentevo_handler)
146
+ app.exception_handler(RequestValidationError)(_validation_handler)
147
+ app.exception_handler(StarletteHTTPException)(_http_handler)
148
+ app.middleware("http")(_metering_middleware)
149
+ app.middleware("http")(_commit_middleware)
150
+ app.middleware("http")(_maintenance_middleware)
151
+ app.get("/healthz")(_healthz)
152
+ app.post("/v1/auth/tokens", status_code=201)(_issue_token)
153
+ app.get("/v1/auth/tokens")(_list_tokens)
154
+ app.delete("/v1/auth/tokens/{token_id}", status_code=204)(_revoke_token)
155
+ app.post("/v1/auth/token/refresh")(_refresh_token)
156
+ app.post("/v1/auth/device/code", status_code=201)(_device_code)
157
+ app.post("/v1/auth/device/token")(_device_token)
158
+ app.post("/v1/auth/device/approve")(_device_approve)
159
+ app.post("/v1/auth/device/deny")(_device_deny)
160
+ app.get("/v1/auth/oidc/config")(_oidc_config)
161
+ app.post("/v1/auth/ci/exchange", status_code=201)(_ci_exchange)
162
+ app.post("/v1/devices", status_code=201)(_register_device)
163
+ app.get("/v1/devices")(_list_devices)
164
+ app.delete("/v1/devices/{device_id}", status_code=204)(_revoke_device)
165
+ _register_workspace_routes(app)
166
+ app.get("/v1/analytics/summary")(_analytics_summary)
167
+ app.post("/v1/webhooks", status_code=201)(_create_webhook)
168
+ app.get("/v1/webhooks")(_list_webhooks)
169
+ app.delete("/v1/webhooks/{config_id}", status_code=204)(_revoke_webhook)
170
+ app.post("/v1/webhooks/{config_id}/rotate")(_rotate_webhook)
171
+ app.post("/v1/webhooks/test")(_test_webhook)
172
+ app.get("/v1/webhooks/{config_id}/deliveries")(_webhook_deliveries)
173
+ app.post("/v1/webhooks/deliveries/{delivery_id}/replay")(_replay_webhook)
174
+ app.post("/v1/sync/push")(_sync_push)
175
+ app.post("/v1/sync/pull")(_sync_pull)
176
+ app.post("/v1/events/batch", status_code=201)(_events_batch)
177
+ app.post("/v1/recall")(_recall)
178
+ _register_object_routes(app)
179
+ _register_job_routes(app)
180
+ _register_compile_routes(app)
181
+ app.get("/v1/revocations")(_revocations)
182
+ app.get("/v1/entitlements")(_entitlements)
183
+ app.get("/v1/usage")(_usage)
184
+ app.get("/v1/audit")(_audit)
185
+ # Re-exported for the provision CLI and tests.
186
+ app.state.create_tenant = create_tenant
187
+ app.state.add_membership = add_membership
188
+ app.state.create_workspace = create_workspace
189
+ return app
190
+
191
+
192
+ def _register_workspace_routes(app: FastAPI) -> None:
193
+ """Workspace repos, BR-011 export/delete, and sync-mode consent."""
194
+ app.post("/v1/workspaces/{slug}/repos", status_code=201)(_link_repo)
195
+ app.get("/v1/workspaces/{slug}/repos")(_list_repos)
196
+ app.delete("/v1/workspaces/{slug}/repos/{repo:path}", status_code=204)(_unlink_repo)
197
+ app.get("/v1/workspaces/{slug}/export")(_export_workspace)
198
+ app.delete("/v1/workspaces/{slug}/cloud-data")(_delete_workspace_data)
199
+ app.get("/v1/workspaces/{slug}/sync-mode")(_get_sync_mode)
200
+ app.post("/v1/workspaces/{slug}/sync-mode")(_set_sync_mode)
201
+
202
+
203
+ def _register_object_routes(app: FastAPI) -> None:
204
+ """Managed object lifecycle: verify, revalidate, promote, fetch versions."""
205
+ app.post("/v1/objects/{object_id}/verify", status_code=202)(_verify_object)
206
+ app.post("/v1/objects/{object_id}/revalidate", status_code=202)(_revalidate_object)
207
+ app.post("/v1/objects/{object_id}/promote")(_promote_object)
208
+ app.post("/v1/objects/{object_id}/revoke")(_revoke_object)
209
+ app.get("/v1/objects/{object_id}/versions/{version}")(_get_object_version)
210
+
211
+
212
+ def _register_job_routes(app: FastAPI) -> None:
213
+ """Async job polling, listing, and best-effort cancellation."""
214
+ app.get("/v1/jobs/{job_id}")(_get_job)
215
+ app.get("/v1/jobs")(_list_jobs)
216
+ app.post("/v1/jobs/{job_id}/cancel")(_cancel_job)
217
+
218
+
219
+ def _register_compile_routes(app: FastAPI) -> None:
220
+ """Async compile submissions (experiences and workflows)."""
221
+ app.post("/v1/experiences/compile", status_code=202)(_compile_experiences)
222
+ app.post("/v1/workflows/compile", status_code=202)(_compile_workflows)
223
+
224
+
225
+ async def _agentevo_handler(request: Request, exc: AgentevoError) -> JSONResponse:
226
+ _ = request
227
+ return _error_response(exc.code, exc.message, exc.details, exc)
228
+
229
+
230
+ async def _validation_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
231
+ _ = request
232
+ details = {"errors": exc.errors()}
233
+ return _error_response(ErrorCode.INVALID_ARGUMENT, "request validation failed", details, None)
234
+
235
+
236
+ async def _http_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
237
+ _ = request
238
+ if exc.status_code == HTTP_STATUS[ErrorCode.NOT_FOUND]:
239
+ return _error_response(ErrorCode.NOT_FOUND, "unknown route", {}, None)
240
+ return _error_response(ErrorCode.INVALID_ARGUMENT, str(exc.detail), {}, None)
241
+
242
+
243
+ #: Methods that keep serving while the control plane is in maintenance.
244
+ _MAINTENANCE_SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
245
+
246
+
247
+ async def _maintenance_middleware(
248
+ request: Request, call_next: Callable[[Request], Awaitable[Response]]
249
+ ) -> Response:
250
+ """Global kill switch: read-only mode when maintenance is set.
251
+
252
+ Registered last so it runs outermost: mutations 503 before metering
253
+ or the database are touched (clients back off; the SDK maps 503 to
254
+ retryable), while status reads, job polls, and revocation reads keep
255
+ serving. The local runtime is unaffected by design.
256
+ """
257
+ settings: Settings = request.app.state.settings
258
+ if settings.maintenance_mode and request.method not in _MAINTENANCE_SAFE_METHODS:
259
+ exc = AgentevoError(
260
+ ErrorCode.TEMPORARILY_UNAVAILABLE,
261
+ "control plane in maintenance",
262
+ {"maintenance": True},
263
+ )
264
+ response = _error_response(exc.code, exc.message, exc.details, exc)
265
+ response.headers[RETRY_AFTER_HEADER] = "60"
266
+ return response
267
+ return await call_next(request)
268
+
269
+
270
+ async def _metering_middleware(
271
+ request: Request, call_next: Callable[[Request], Awaitable[Response]]
272
+ ) -> Response:
273
+ # Header-only by design: metering writes happen in the request
274
+ # transaction, and only _commit_middleware settles it.
275
+ response = await call_next(request)
276
+ usage = getattr(request.state, "usage", None)
277
+ if usage is None:
278
+ return response
279
+ headers = quota_headers(usage)
280
+ for key, value in headers.items():
281
+ response.headers[key] = value
282
+ return response
283
+
284
+
285
+ async def _commit_middleware(
286
+ request: Request, call_next: Callable[[Request], Awaitable[Response]]
287
+ ) -> Response:
288
+ """Settle the request transaction before the response hits the wire.
289
+
290
+ Dependency teardown (where ``Database.connect`` commits) runs after
291
+ response bytes are sent, so without this a client that POSTs then
292
+ immediately reads can observe its own write as missing. Success (2xx)
293
+ commits here; errors roll back (failed attempts never bill); an
294
+ escaping exception rolls back and propagates. Teardown's own commit
295
+ stays as a no-op safety net (and the commit path for non-HTTP use).
296
+ """
297
+ try:
298
+ response = await call_next(request)
299
+ except BaseException:
300
+ conn = getattr(request.state, "conn", None)
301
+ if conn is not None:
302
+ conn.rollback()
303
+ raise
304
+ conn = getattr(request.state, "conn", None)
305
+ if conn is not None:
306
+ if _SUCCESS_MIN <= response.status_code < _SUCCESS_MAX:
307
+ conn.commit()
308
+ else:
309
+ conn.rollback()
310
+ return response
311
+
312
+
313
+ def get_conn(request: Request) -> Iterator[sqlite3.Connection]:
314
+ """Request-scoped database connection."""
315
+ db: Database = request.app.state.db
316
+ with db.connect() as conn:
317
+ request.state.conn = conn
318
+ try:
319
+ yield conn
320
+ finally:
321
+ # Teardown runs before error responses reach middleware (but
322
+ # after success bytes are sent); clearing the handle tells
323
+ # _commit_middleware there is nothing left to settle. The
324
+ # context exit already rolled back on this path.
325
+ request.state.conn = None
326
+
327
+
328
+ def current_principal(
329
+ request: Request,
330
+ authorization: str | None = Header(default=None),
331
+ conn: sqlite3.Connection = Depends(get_conn),
332
+ ) -> Principal:
333
+ """Verified bearer principal (tenant derives from here)."""
334
+ secret = _bearer_secret(authorization)
335
+ principal = verify_token(conn, secret)
336
+ _reject_refresh(principal)
337
+ if get_tenant(conn, principal.tenant_id) is None:
338
+ raise AgentevoError(ErrorCode.UNAUTHENTICATED, "tenant is gone", {})
339
+ request.state.principal = principal
340
+ return principal
341
+
342
+
343
+ def optional_principal(
344
+ request: Request,
345
+ authorization: str | None = Header(default=None),
346
+ conn: sqlite3.Connection = Depends(get_conn),
347
+ ) -> Principal | None:
348
+ """Bearer principal when presented (401 on a bad credential, else None)."""
349
+ if authorization is None:
350
+ return None
351
+ principal = verify_token(conn, _bearer_secret(authorization))
352
+ _reject_refresh(principal)
353
+ if get_tenant(conn, principal.tenant_id) is None:
354
+ raise AgentevoError(ErrorCode.UNAUTHENTICATED, "tenant is gone", {})
355
+ request.state.principal = principal
356
+ request.state.usage = usage_for(conn, principal.tenant_id)
357
+ return principal
358
+
359
+
360
+ def metered(
361
+ request: Request,
362
+ principal: Principal = Depends(current_principal),
363
+ conn: sqlite3.Connection = Depends(get_conn),
364
+ ) -> Principal:
365
+ """Rate guard + attempt metering (AWS-style; replays un-count later)."""
366
+ settings: Settings = request.app.state.settings
367
+ check_rate_limit(conn, f"token:{principal.token_id}", settings.rate_limit_per_minute)
368
+ if not is_billable(request.method, request.url.path):
369
+ request.state.usage = usage_for(conn, principal.tenant_id)
370
+ return principal
371
+ request.state.usage = _record_and_notify(request, conn, principal.tenant_id)
372
+ request.state.billable = True
373
+ return principal
374
+
375
+
376
+ def _record_and_notify(request: Request, conn: sqlite3.Connection, tenant_id: str) -> Usage:
377
+ """Quota-checked metering plus 80%/exhausted webhook notification."""
378
+ try:
379
+ usage = record_checked(conn, tenant_id)
380
+ except AgentevoError as exc:
381
+ if exc.code is ErrorCode.QUOTA_EXCEEDED:
382
+ cycle = current_cycle()
383
+ if not _quota_event_sent(conn, tenant_id, "quota.exhausted", cycle):
384
+ _emit_quota_event(
385
+ request,
386
+ conn,
387
+ tenant_id,
388
+ "quota.exhausted",
389
+ cycle=cycle,
390
+ used=exc.details.get("used", 0),
391
+ quota=exc.details.get("quota"),
392
+ reset_at=str(exc.details.get("reset_at", "")),
393
+ )
394
+ # The 429 below rolls the request transaction back; persist
395
+ # the notification (and the restored counter) first.
396
+ conn.commit()
397
+ raise
398
+ before = replace(usage, used=usage.used - 1)
399
+ if quota_warning(usage) and not quota_warning(before):
400
+ _emit_quota_event(
401
+ request,
402
+ conn,
403
+ tenant_id,
404
+ "quota.warning",
405
+ cycle=usage.cycle,
406
+ used=usage.used,
407
+ quota=usage.quota,
408
+ reset_at=usage.reset_at,
409
+ )
410
+ return usage
411
+
412
+
413
+ def _quota_event_sent(
414
+ conn: sqlite3.Connection, tenant_id: str, event_type: str, cycle: str
415
+ ) -> bool:
416
+ """True when this cycle already recorded the quota event (dedupes 429 storms)."""
417
+ bound = f"{cycle}-01T00:00:00+00:00"
418
+ row = conn.execute(
419
+ "SELECT 1 FROM webhook_deliveries"
420
+ " WHERE tenant_id = ? AND event_type = ? AND created_at >= ? LIMIT 1",
421
+ (tenant_id, event_type, bound),
422
+ ).fetchone()
423
+ return row is not None
424
+
425
+
426
+ def _emit_quota_event( # noqa: PLR0913 - notification carries quota context
427
+ request: Request,
428
+ conn: sqlite3.Connection,
429
+ tenant_id: str,
430
+ event_type: str,
431
+ *,
432
+ cycle: str,
433
+ used: int,
434
+ quota: int | None,
435
+ reset_at: str,
436
+ ) -> None:
437
+ """Best-effort quota fan-out (notification never fails the request)."""
438
+ payload = {"used": used, "quota": quota, "cycle": cycle, "reset_at": reset_at}
439
+ with suppress(Exception):
440
+ settings: Settings = request.app.state.settings
441
+ event = build_event(event_type, tenant_id, f"quota/{cycle}", payload=payload)
442
+ dispatch(
443
+ conn,
444
+ settings,
445
+ tenant_id,
446
+ event,
447
+ sender=_webhook_sender(request),
448
+ timeout_seconds=settings.webhook_timeout_seconds,
449
+ )
450
+
451
+
452
+ def require_scope(scope: str) -> Any:
453
+ """Dependency factory enforcing one token scope (403 when missing)."""
454
+
455
+ def scoped(principal: Principal = Depends(metered)) -> Principal:
456
+ if not principal.allows(scope):
457
+ raise AgentevoError(
458
+ ErrorCode.FORBIDDEN,
459
+ f"token lacks required scope: {scope}",
460
+ {"scope": scope},
461
+ )
462
+ return principal
463
+
464
+ return scoped
465
+
466
+
467
+ CONTROL_READ = require_scope("control:read")
468
+ AUDIT_READ = require_scope("audit:read")
469
+ SYNC_WRITE = require_scope("sync:write")
470
+ SYNC_READ = require_scope("sync:read")
471
+ RECALL_READ = require_scope("recall:read")
472
+ VERIFY_RUN = require_scope("verify:run")
473
+ OBJECT_PROMOTE = require_scope("object:promote")
474
+ OBJECT_REVOKE = require_scope("object:revoke")
475
+
476
+
477
+ def _reject_refresh(principal: Principal) -> None:
478
+ """Refresh tokens rotate; they never authenticate API calls."""
479
+ if principal.kind == REFRESH_KIND:
480
+ raise AgentevoError(ErrorCode.UNAUTHENTICATED, "refresh tokens cannot call this API", {})
481
+
482
+
483
+ def _client_ip(request: Request) -> str:
484
+ client = request.client
485
+ return client.host if client is not None and client.host else "unknown"
486
+
487
+
488
+ def _auth_rate_limit(request: Request, conn: sqlite3.Connection, name: str) -> None:
489
+ """IP-keyed burst guard for the unauthenticated auth flows."""
490
+ settings: Settings = request.app.state.settings
491
+ check_rate_limit(conn, f"auth:{name}:{_client_ip(request)}", settings.rate_limit_per_minute)
492
+
493
+
494
+ def _idempotent( # noqa: PLR0913, PLR0917 - idempotency carries request context
495
+ request: Request,
496
+ conn: sqlite3.Connection,
497
+ tenant_id: str,
498
+ key: str | None,
499
+ body: dict[str, Any],
500
+ compute: Callable[[], tuple[int, dict[str, Any]]],
501
+ ) -> JSONResponse:
502
+ """Honor Idempotency-Key: replay stored responses, un-counting quota.
503
+
504
+ Anonymous bootstrap flows (device start, CI exchange) share the empty
505
+ tenant namespace; their responses carry no tenant data before approval.
506
+ """
507
+ route = f"{request.method} {request.url.path}"
508
+ if key is not None:
509
+ replay = idempotency_lookup(conn, tenant_id, route, key, body)
510
+ if replay is not None:
511
+ if getattr(request.state, "billable", False):
512
+ request.state.usage = unrecord_billable(conn, tenant_id)
513
+ return JSONResponse(status_code=replay.status_code, content=replay.body)
514
+ status, payload = compute()
515
+ if key is not None:
516
+ settings: Settings = request.app.state.settings
517
+ raced = idempotency_store(
518
+ conn,
519
+ tenant_id,
520
+ route,
521
+ key,
522
+ body=body,
523
+ status_code=status,
524
+ response=payload,
525
+ ttl=timedelta(seconds=settings.idempotency_ttl_seconds),
526
+ )
527
+ if raced is not None:
528
+ # A concurrent twin stored first; serve the winner and un-count
529
+ # this attempt exactly like an idempotent replay.
530
+ if getattr(request.state, "billable", False):
531
+ request.state.usage = unrecord_billable(conn, tenant_id)
532
+ return JSONResponse(status_code=raced.status_code, content=raced.body)
533
+ return JSONResponse(status_code=status, content=payload)
534
+
535
+
536
+ def _jwks_fetcher(request: Request) -> JwksFetcher:
537
+ fetcher: JwksFetcher | None = getattr(request.app.state, "jwks_fetcher", None)
538
+ return fetcher or fetch_jwks
539
+
540
+
541
+ def _webhook_sender(request: Request) -> WebhookSender:
542
+ sender: WebhookSender | None = getattr(request.app.state, "webhook_sender", None)
543
+ return sender or urllib_sender
544
+
545
+
546
+ def _healthz() -> dict[str, str]:
547
+ return {"status": "ok", "version": "v1"}
548
+
549
+
550
+ def _issue_token(
551
+ body: dict[str, Any],
552
+ request: Request,
553
+ principal: Principal = Depends(CONTROL_READ),
554
+ conn: sqlite3.Connection = Depends(get_conn),
555
+ idempotency_key: str | None = Header(default=None),
556
+ ) -> JSONResponse:
557
+ name, scopes, workspaces, ttl = _token_body(body)
558
+ if principal.user_id is None:
559
+ raise AgentevoError(ErrorCode.FORBIDDEN, "service tokens cannot issue PATs", {})
560
+
561
+ def compute() -> tuple[int, dict[str, Any]]:
562
+ issued = issue_pat(
563
+ conn,
564
+ principal.tenant_id,
565
+ name,
566
+ scopes,
567
+ user_id=principal.user_id or "",
568
+ workspaces=workspaces,
569
+ ttl=ttl,
570
+ )
571
+ audit(
572
+ conn,
573
+ principal.tenant_id,
574
+ principal.token_id,
575
+ "token_issued",
576
+ issued.record.id,
577
+ details={"scopes": scopes, "name": name},
578
+ )
579
+ return 201, {
580
+ "id": issued.record.id,
581
+ "secret": issued.secret,
582
+ "scopes": list(issued.record.scopes),
583
+ "workspaces": list(issued.record.workspaces),
584
+ "expires_at": issued.record.expires_at,
585
+ }
586
+
587
+ return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
588
+
589
+
590
+ def _list_tokens(
591
+ principal: Principal = Depends(CONTROL_READ),
592
+ conn: sqlite3.Connection = Depends(get_conn),
593
+ ) -> dict[str, Any]:
594
+ return {
595
+ "tokens": [
596
+ {
597
+ "id": record.id,
598
+ "kind": record.kind,
599
+ "name": record.name,
600
+ "scopes": list(record.scopes),
601
+ "workspaces": list(record.workspaces),
602
+ "user_id": record.user_id,
603
+ "expires_at": record.expires_at,
604
+ "revoked_at": record.revoked_at,
605
+ "created_at": record.created_at,
606
+ "last_used_at": record.last_used_at,
607
+ }
608
+ for record in list_tokens(conn, principal.tenant_id)
609
+ ]
610
+ }
611
+
612
+
613
+ def _revoke_token(
614
+ token_id: str,
615
+ principal: Principal = Depends(CONTROL_READ),
616
+ conn: sqlite3.Connection = Depends(get_conn),
617
+ ) -> Response:
618
+ revoke_token(conn, principal.tenant_id, token_id)
619
+ audit(conn, principal.tenant_id, principal.token_id, "token_revoked", token_id, details={})
620
+ return Response(status_code=204)
621
+
622
+
623
+ def _refresh_token(
624
+ body: dict[str, Any],
625
+ request: Request,
626
+ conn: sqlite3.Connection = Depends(get_conn),
627
+ idempotency_key: str | None = Header(default=None),
628
+ ) -> JSONResponse:
629
+ settings: Settings = request.app.state.settings
630
+ _auth_rate_limit(request, conn, "refresh")
631
+ secret = body.get("refresh_token")
632
+ if not isinstance(secret, str) or not secret:
633
+ raise AgentevoError(
634
+ ErrorCode.INVALID_ARGUMENT, "refresh_token must be a non-empty string", {}
635
+ )
636
+
637
+ def compute() -> tuple[int, dict[str, Any]]:
638
+ access, refresh = rotate_refresh(
639
+ conn,
640
+ secret,
641
+ access_ttl=timedelta(seconds=settings.access_token_ttl_seconds),
642
+ refresh_ttl=timedelta(seconds=settings.refresh_token_ttl_seconds),
643
+ )
644
+ return 200, {
645
+ "access_token": access.secret,
646
+ "refresh_token": refresh.secret,
647
+ "expires_in": settings.access_token_ttl_seconds,
648
+ "scopes": list(access.record.scopes),
649
+ }
650
+
651
+ tenant_id = _refresh_owner(conn, secret)
652
+ return _idempotent(request, conn, tenant_id, idempotency_key, body, compute)
653
+
654
+
655
+ def _refresh_owner(conn: sqlite3.Connection, secret: str) -> str:
656
+ """Tenant owning a refresh secret ("" when unknown; rotation decides)."""
657
+ row = conn.execute(
658
+ "SELECT tenant_id FROM tokens WHERE token_hash = ? AND kind = ?",
659
+ (hash_token(secret), REFRESH_KIND),
660
+ ).fetchone()
661
+ return row["tenant_id"] if row is not None else ""
662
+
663
+
664
+ def _entitlements(
665
+ principal: Principal = Depends(CONTROL_READ),
666
+ conn: sqlite3.Connection = Depends(get_conn),
667
+ ) -> dict[str, Any]:
668
+ entitlements = entitlements_for(conn, principal.tenant_id)
669
+ return {
670
+ "tenant_id": entitlements.tenant_id,
671
+ "plan": entitlements.plan,
672
+ "quota_monthly": entitlements.quota_monthly,
673
+ "overage_enabled": entitlements.overage_enabled,
674
+ "scopes_grantable": list(entitlements.scopes_grantable),
675
+ "retention_note": entitlements.retention_note,
676
+ }
677
+
678
+
679
+ def _usage(
680
+ principal: Principal = Depends(CONTROL_READ),
681
+ conn: sqlite3.Connection = Depends(get_conn),
682
+ ) -> dict[str, Any]:
683
+ usage = usage_for(conn, principal.tenant_id)
684
+ return {
685
+ "tenant_id": usage.tenant_id,
686
+ "plan": usage.plan,
687
+ "cycle": usage.cycle,
688
+ "used": usage.used,
689
+ "quota": usage.quota,
690
+ "remaining": usage.remaining,
691
+ "quota_warning": quota_warning(usage),
692
+ "overage_enabled": usage.overage_enabled,
693
+ "reset_at": usage.reset_at,
694
+ }
695
+
696
+
697
+ def _device_code(
698
+ body: dict[str, Any],
699
+ request: Request,
700
+ conn: sqlite3.Connection = Depends(get_conn),
701
+ idempotency_key: str | None = Header(default=None),
702
+ ) -> JSONResponse:
703
+ settings: Settings = request.app.state.settings
704
+ _auth_rate_limit(request, conn, "device-code")
705
+ scopes, workspace = _device_code_body(body)
706
+
707
+ def compute() -> tuple[int, dict[str, Any]]:
708
+ grant = start_device_flow(conn, settings, scopes=scopes, workspace_slug=workspace)
709
+ return 201, {
710
+ "device_code": grant.device_code,
711
+ "user_code": grant.user_code,
712
+ "verification_uri": grant.verification_uri,
713
+ "verification_uri_complete": grant.verification_uri_complete,
714
+ "expires_in": grant.expires_in,
715
+ "interval": grant.interval,
716
+ }
717
+
718
+ return _idempotent(request, conn, "", idempotency_key, body, compute)
719
+
720
+
721
+ def _device_token(
722
+ body: dict[str, Any],
723
+ request: Request,
724
+ conn: sqlite3.Connection = Depends(get_conn),
725
+ ) -> dict[str, Any]:
726
+ settings: Settings = request.app.state.settings
727
+ _auth_rate_limit(request, conn, "device-token")
728
+ code = body.get("device_code")
729
+ if not isinstance(code, str) or not code:
730
+ raise AgentevoError(
731
+ ErrorCode.INVALID_ARGUMENT, "device_code must be a non-empty string", {}
732
+ )
733
+ poll = poll_device_flow(conn, settings, device_code=code)
734
+ if poll.status == "approved" and poll.access is not None and poll.refresh is not None:
735
+ return {
736
+ "status": "approved",
737
+ "access_token": poll.access.secret,
738
+ "refresh_token": poll.refresh.secret,
739
+ "expires_in": settings.access_token_ttl_seconds,
740
+ "scopes": list(poll.access.record.scopes),
741
+ }
742
+ if poll.status == "expired":
743
+ return {"status": "expired"}
744
+ return {"status": "pending", "interval": DEVICE_POLL_INTERVAL}
745
+
746
+
747
+ def _device_approve(
748
+ body: dict[str, Any],
749
+ request: Request,
750
+ principal: Principal | None = Depends(optional_principal),
751
+ conn: sqlite3.Connection = Depends(get_conn),
752
+ idempotency_key: str | None = Header(default=None),
753
+ ) -> JSONResponse:
754
+ settings: Settings = request.app.state.settings
755
+ _auth_rate_limit(request, conn, "device-approve")
756
+ user_code, id_token = _decision_body(body)
757
+ if principal is not None and id_token is not None:
758
+ raise AgentevoError(
759
+ ErrorCode.INVALID_ARGUMENT,
760
+ "approve with exactly one credential: Bearer [REDACTED] or id_token",
761
+ {},
762
+ )
763
+ if principal is not None:
764
+
765
+ def compute() -> tuple[int, dict[str, Any]]:
766
+ flow, identity = approve_device_code(
767
+ conn, settings, user_code=user_code, approver=principal
768
+ )
769
+ audit(
770
+ conn,
771
+ identity.tenant_id,
772
+ identity.user_id,
773
+ "device_approved",
774
+ flow.device_code_hash,
775
+ details={"via": "session", "scopes": list(flow.scopes)},
776
+ )
777
+ return 200, {
778
+ "status": "approved",
779
+ "scopes": list(flow.scopes),
780
+ "workspace": flow.workspace_slug,
781
+ }
782
+
783
+ return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
784
+ flow, identity = approve_device_code(
785
+ conn,
786
+ settings,
787
+ user_code=user_code,
788
+ id_token=id_token,
789
+ jwks_fetcher=_jwks_fetcher(request),
790
+ )
791
+ audit(
792
+ conn,
793
+ identity.tenant_id,
794
+ identity.user_id,
795
+ "device_approved",
796
+ flow.device_code_hash,
797
+ details={"via": "oidc", "scopes": list(flow.scopes)},
798
+ )
799
+ return JSONResponse(
800
+ status_code=200,
801
+ content={
802
+ "status": "approved",
803
+ "scopes": list(flow.scopes),
804
+ "workspace": flow.workspace_slug,
805
+ },
806
+ )
807
+
808
+
809
+ def _device_deny(
810
+ body: dict[str, Any],
811
+ request: Request,
812
+ principal: Principal | None = Depends(optional_principal),
813
+ conn: sqlite3.Connection = Depends(get_conn),
814
+ idempotency_key: str | None = Header(default=None),
815
+ ) -> JSONResponse:
816
+ settings: Settings = request.app.state.settings
817
+ _auth_rate_limit(request, conn, "device-deny")
818
+ user_code, id_token = _decision_body(body)
819
+ if principal is not None and id_token is not None:
820
+ raise AgentevoError(
821
+ ErrorCode.INVALID_ARGUMENT,
822
+ "deny with exactly one credential: Bearer [REDACTED] or id_token",
823
+ {},
824
+ )
825
+ if principal is not None:
826
+
827
+ def compute() -> tuple[int, dict[str, Any]]:
828
+ flow, identity = deny_device_code(
829
+ conn, settings, user_code=user_code, approver=principal
830
+ )
831
+ audit(
832
+ conn,
833
+ identity.tenant_id,
834
+ identity.user_id,
835
+ "device_denied",
836
+ flow.device_code_hash,
837
+ details={"via": "session"},
838
+ )
839
+ return 200, {"status": "denied"}
840
+
841
+ return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
842
+ flow, identity = deny_device_code(
843
+ conn,
844
+ settings,
845
+ user_code=user_code,
846
+ id_token=id_token,
847
+ jwks_fetcher=_jwks_fetcher(request),
848
+ )
849
+ audit(
850
+ conn,
851
+ identity.tenant_id,
852
+ identity.user_id,
853
+ "device_denied",
854
+ flow.device_code_hash,
855
+ details={"via": "oidc"},
856
+ )
857
+ return JSONResponse(status_code=200, content={"status": "denied"})
858
+
859
+
860
+ def _oidc_config(request: Request, conn: sqlite3.Connection = Depends(get_conn)) -> dict[str, Any]:
861
+ settings: Settings = request.app.state.settings
862
+ _auth_rate_limit(request, conn, "oidc-config")
863
+ return {
864
+ "configured": bool(settings.oidc_issuer),
865
+ "issuer": settings.oidc_issuer,
866
+ "client_id": settings.oidc_client_id,
867
+ "audience": settings.oidc_client_id,
868
+ "verification_uri": f"{settings.base_url.rstrip('/')}/device",
869
+ }
870
+
871
+
872
+ def _ci_exchange(
873
+ body: dict[str, Any],
874
+ request: Request,
875
+ conn: sqlite3.Connection = Depends(get_conn),
876
+ idempotency_key: str | None = Header(default=None),
877
+ ) -> JSONResponse:
878
+ settings: Settings = request.app.state.settings
879
+ _auth_rate_limit(request, conn, "ci-exchange")
880
+ credential = body.get("credential")
881
+ if not isinstance(credential, str) or not credential:
882
+ raise AgentevoError(ErrorCode.INVALID_ARGUMENT, "credential must be a non-empty string", {})
883
+
884
+ def compute() -> tuple[int, dict[str, Any]]:
885
+ issued, link = exchange_ci_token(
886
+ conn, settings, credential=credential, jwks_fetcher=_jwks_fetcher(request)
887
+ )
888
+ audit(
889
+ conn,
890
+ link.tenant_id,
891
+ issued.record.id,
892
+ "ci_token_issued",
893
+ link.repo,
894
+ details={"workspace": link.workspace_slug},
895
+ )
896
+ return 201, {
897
+ "token": issued.secret,
898
+ "token_id": issued.record.id,
899
+ "workspace": link.workspace_slug,
900
+ "repository": link.repo,
901
+ "scopes": list(issued.record.scopes),
902
+ "expires_at": issued.record.expires_at,
903
+ }
904
+
905
+ return _idempotent(request, conn, "", idempotency_key, body, compute)
906
+
907
+
908
+ def _register_device(
909
+ body: dict[str, Any],
910
+ request: Request,
911
+ principal: Principal = Depends(CONTROL_READ),
912
+ conn: sqlite3.Connection = Depends(get_conn),
913
+ idempotency_key: str | None = Header(default=None),
914
+ ) -> JSONResponse:
915
+ device_id, name, workspace, public_key, key_id = _device_body(body)
916
+ _require_workspace(principal, workspace)
917
+
918
+ def compute() -> tuple[int, dict[str, Any]]:
919
+ record = register_device(
920
+ conn,
921
+ principal.tenant_id,
922
+ device_id=device_id,
923
+ name=name,
924
+ workspace_slug=workspace,
925
+ public_key=public_key,
926
+ key_id=key_id,
927
+ )
928
+ audit(
929
+ conn,
930
+ principal.tenant_id,
931
+ principal.token_id,
932
+ "device_registered",
933
+ device_id,
934
+ details={"workspace": workspace},
935
+ )
936
+ return 201, _device_json(record)
937
+
938
+ return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
939
+
940
+
941
+ def _list_devices(
942
+ principal: Principal = Depends(CONTROL_READ),
943
+ conn: sqlite3.Connection = Depends(get_conn),
944
+ ) -> dict[str, Any]:
945
+ records = list_devices(conn, principal.tenant_id)
946
+ visible = [
947
+ record
948
+ for record in records
949
+ if not principal.workspaces or record.workspace_slug in principal.workspaces
950
+ ]
951
+ return {"devices": [_device_json(record) for record in visible]}
952
+
953
+
954
+ def _revoke_device(
955
+ device_id: str,
956
+ compromised: bool = False,
957
+ principal: Principal = Depends(CONTROL_READ),
958
+ conn: sqlite3.Connection = Depends(get_conn),
959
+ ) -> Response:
960
+ rows = list_device_keys(conn, principal.tenant_id, device_id)
961
+ if not rows:
962
+ raise AgentevoError(
963
+ ErrorCode.NOT_FOUND, f"unknown device: {device_id}", {"device_id": device_id}
964
+ )
965
+ for row in rows:
966
+ _require_workspace(principal, row.workspace_slug)
967
+ revoked = revoke_device(conn, principal.tenant_id, device_id, compromised=compromised)
968
+ audit(
969
+ conn,
970
+ principal.tenant_id,
971
+ principal.token_id,
972
+ "device_revoked",
973
+ device_id,
974
+ details={"compromised": compromised, "keys": len(revoked)},
975
+ )
976
+ return Response(status_code=204)
977
+
978
+
979
+ def _link_repo( # noqa: PLR0913, PLR0917 - FastAPI handler signature
980
+ slug: str,
981
+ body: dict[str, Any],
982
+ request: Request,
983
+ principal: Principal = Depends(CONTROL_READ),
984
+ conn: sqlite3.Connection = Depends(get_conn),
985
+ idempotency_key: str | None = Header(default=None),
986
+ ) -> JSONResponse:
987
+ _require_workspace(principal, slug)
988
+ repository = body.get("repository")
989
+ if not isinstance(repository, str) or not repository:
990
+ raise AgentevoError(ErrorCode.INVALID_ARGUMENT, "repository must be a non-empty string", {})
991
+ # Moving a link between workspaces needs visibility into both ends;
992
+ # a token scoped to the target alone cannot steal or strand the source.
993
+ for link in list_repos(conn, principal.tenant_id):
994
+ if link.repo == repository and link.workspace_slug != slug:
995
+ _require_workspace(principal, link.workspace_slug)
996
+
997
+ def compute() -> tuple[int, dict[str, Any]]:
998
+ link = link_repo(conn, principal.tenant_id, repository, slug)
999
+ audit(
1000
+ conn,
1001
+ principal.tenant_id,
1002
+ principal.token_id,
1003
+ "repo_linked",
1004
+ repository,
1005
+ details={"workspace": slug},
1006
+ )
1007
+ return 201, {"repository": link.repo, "workspace": link.workspace_slug}
1008
+
1009
+ return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
1010
+
1011
+
1012
+ def _list_repos(
1013
+ slug: str,
1014
+ principal: Principal = Depends(CONTROL_READ),
1015
+ conn: sqlite3.Connection = Depends(get_conn),
1016
+ ) -> dict[str, Any]:
1017
+ _require_workspace(principal, slug)
1018
+ links = [link for link in list_repos(conn, principal.tenant_id) if link.workspace_slug == slug]
1019
+ return {
1020
+ "repos": [{"repository": link.repo, "workspace": link.workspace_slug} for link in links]
1021
+ }
1022
+
1023
+
1024
+ def _unlink_repo(
1025
+ slug: str,
1026
+ repo: str,
1027
+ principal: Principal = Depends(CONTROL_READ),
1028
+ conn: sqlite3.Connection = Depends(get_conn),
1029
+ ) -> Response:
1030
+ _require_workspace(principal, slug)
1031
+ links = list_repos(conn, principal.tenant_id)
1032
+ current = next((link.workspace_slug for link in links if link.repo == repo), None)
1033
+ if current is not None and current != slug:
1034
+ # Wrong-workspace path: 404 without revealing the true workspace.
1035
+ raise AgentevoError(ErrorCode.NOT_FOUND, f"unknown repository link: {repo}", {})
1036
+ unlink_repo(conn, principal.tenant_id, repo)
1037
+ audit(
1038
+ conn,
1039
+ principal.tenant_id,
1040
+ principal.token_id,
1041
+ "repo_unlinked",
1042
+ repo,
1043
+ details={"workspace": slug},
1044
+ )
1045
+ return Response(status_code=204)
1046
+
1047
+
1048
+ def _create_webhook(
1049
+ body: dict[str, Any],
1050
+ request: Request,
1051
+ principal: Principal = Depends(CONTROL_READ),
1052
+ conn: sqlite3.Connection = Depends(get_conn),
1053
+ idempotency_key: str | None = Header(default=None),
1054
+ ) -> JSONResponse:
1055
+ settings: Settings = request.app.state.settings
1056
+ url, events = _webhook_body(body)
1057
+
1058
+ def compute() -> tuple[int, dict[str, Any]]:
1059
+ issued = create_config(conn, settings, principal.tenant_id, url, events)
1060
+ audit(
1061
+ conn,
1062
+ principal.tenant_id,
1063
+ principal.token_id,
1064
+ "webhook_created",
1065
+ issued.config.id,
1066
+ details={"events": list(issued.config.events)},
1067
+ )
1068
+ payload = _webhook_json(issued.config)
1069
+ payload["secret"] = issued.secret
1070
+ return 201, payload
1071
+
1072
+ return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
1073
+
1074
+
1075
+ def _list_webhooks(
1076
+ principal: Principal = Depends(CONTROL_READ),
1077
+ conn: sqlite3.Connection = Depends(get_conn),
1078
+ ) -> dict[str, Any]:
1079
+ return {"webhooks": [_webhook_json(item) for item in list_configs(conn, principal.tenant_id)]}
1080
+
1081
+
1082
+ def _revoke_webhook(
1083
+ config_id: str,
1084
+ principal: Principal = Depends(CONTROL_READ),
1085
+ conn: sqlite3.Connection = Depends(get_conn),
1086
+ ) -> Response:
1087
+ revoke_config(conn, principal.tenant_id, config_id)
1088
+ audit(
1089
+ conn,
1090
+ principal.tenant_id,
1091
+ principal.token_id,
1092
+ "webhook_revoked",
1093
+ config_id,
1094
+ details={},
1095
+ )
1096
+ return Response(status_code=204)
1097
+
1098
+
1099
+ def _rotate_webhook( # noqa: PLR0913, PLR0917 - FastAPI handler signature
1100
+ config_id: str,
1101
+ body: dict[str, Any],
1102
+ request: Request,
1103
+ principal: Principal = Depends(CONTROL_READ),
1104
+ conn: sqlite3.Connection = Depends(get_conn),
1105
+ idempotency_key: str | None = Header(default=None),
1106
+ ) -> JSONResponse:
1107
+ settings: Settings = request.app.state.settings
1108
+
1109
+ def compute() -> tuple[int, dict[str, Any]]:
1110
+ issued = rotate_config(conn, settings, principal.tenant_id, config_id)
1111
+ audit(
1112
+ conn,
1113
+ principal.tenant_id,
1114
+ principal.token_id,
1115
+ "webhook_rotated",
1116
+ config_id,
1117
+ details={"key_id": issued.config.key_id},
1118
+ )
1119
+ return 200, {
1120
+ "id": issued.config.id,
1121
+ "key_id": issued.config.key_id,
1122
+ "secret": issued.secret,
1123
+ }
1124
+
1125
+ return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
1126
+
1127
+
1128
+ def _test_webhook(
1129
+ body: dict[str, Any],
1130
+ request: Request,
1131
+ principal: Principal = Depends(CONTROL_READ),
1132
+ conn: sqlite3.Connection = Depends(get_conn),
1133
+ idempotency_key: str | None = Header(default=None),
1134
+ ) -> JSONResponse:
1135
+ settings: Settings = request.app.state.settings
1136
+ config_id = body.get("config_id")
1137
+ if not isinstance(config_id, str) or not config_id:
1138
+ raise AgentevoError(ErrorCode.INVALID_ARGUMENT, "config_id must be a non-empty string", {})
1139
+ config = get_config(conn, principal.tenant_id, config_id)
1140
+
1141
+ def compute() -> tuple[int, dict[str, Any]]:
1142
+ event = build_event(
1143
+ "webhook.test",
1144
+ principal.tenant_id,
1145
+ config.id,
1146
+ payload={"config_id": config.id, "key_id": config.key_id},
1147
+ )
1148
+ delivery = deliver_to_config(
1149
+ conn,
1150
+ settings,
1151
+ principal.tenant_id,
1152
+ config.id,
1153
+ event,
1154
+ sender=_webhook_sender(request),
1155
+ timeout_seconds=settings.webhook_timeout_seconds,
1156
+ )
1157
+ audit(
1158
+ conn,
1159
+ principal.tenant_id,
1160
+ principal.token_id,
1161
+ "webhook_tested",
1162
+ config.id,
1163
+ details={"status": delivery.status, "delivery_id": delivery.id},
1164
+ )
1165
+ return 200, {"status": delivery.status, "delivery": _delivery_json(delivery)}
1166
+
1167
+ return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
1168
+
1169
+
1170
+ def _webhook_deliveries(
1171
+ config_id: str,
1172
+ limit: int = 50,
1173
+ principal: Principal = Depends(CONTROL_READ),
1174
+ conn: sqlite3.Connection = Depends(get_conn),
1175
+ ) -> dict[str, Any]:
1176
+ deliveries = list_deliveries(conn, principal.tenant_id, config_id, limit=limit)
1177
+ return {"deliveries": [_delivery_json(item) for item in deliveries]}
1178
+
1179
+
1180
+ def _replay_webhook( # noqa: PLR0913, PLR0917 - FastAPI handler signature
1181
+ delivery_id: str,
1182
+ body: dict[str, Any],
1183
+ request: Request,
1184
+ principal: Principal = Depends(CONTROL_READ),
1185
+ conn: sqlite3.Connection = Depends(get_conn),
1186
+ idempotency_key: str | None = Header(default=None),
1187
+ ) -> JSONResponse:
1188
+ settings: Settings = request.app.state.settings
1189
+
1190
+ def compute() -> tuple[int, dict[str, Any]]:
1191
+ delivery = replay_delivery(
1192
+ conn,
1193
+ settings,
1194
+ principal.tenant_id,
1195
+ delivery_id,
1196
+ sender=_webhook_sender(request),
1197
+ timeout_seconds=settings.webhook_timeout_seconds,
1198
+ )
1199
+ audit(
1200
+ conn,
1201
+ principal.tenant_id,
1202
+ principal.token_id,
1203
+ "webhook_replayed",
1204
+ delivery.id,
1205
+ details={"event_id": delivery.event_id, "status": delivery.status},
1206
+ )
1207
+ return 200, {"delivery": _delivery_json(delivery)}
1208
+
1209
+ return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
1210
+
1211
+
1212
+ def _audit(
1213
+ since_seq: int = 0,
1214
+ limit: int = 100,
1215
+ principal: Principal = Depends(AUDIT_READ),
1216
+ conn: sqlite3.Connection = Depends(get_conn),
1217
+ ) -> dict[str, Any]:
1218
+ entries = list_audit(conn, principal.tenant_id, since_seq=since_seq, limit=limit)
1219
+ cursor = entries[-1].seq if entries else since_seq
1220
+ return {
1221
+ "entries": [
1222
+ {
1223
+ "seq": entry.seq,
1224
+ "actor": entry.actor,
1225
+ "action": entry.action,
1226
+ "resource": entry.resource,
1227
+ "details": entry.details,
1228
+ "created_at": entry.created_at,
1229
+ }
1230
+ for entry in entries
1231
+ ],
1232
+ "next_cursor": cursor,
1233
+ }
1234
+
1235
+
1236
+ def _require_workspace(principal: Principal, slug: str) -> None:
1237
+ """Scoped tokens see only their workspaces (404, non-enumerating)."""
1238
+ if principal.workspaces and slug not in principal.workspaces:
1239
+ raise AgentevoError(ErrorCode.NOT_FOUND, f"unknown workspace: {slug}", {})
1240
+
1241
+
1242
+ def _require_admin(principal: Principal) -> None:
1243
+ """BR-011: export/delete need an owner/admin membership (403 otherwise)."""
1244
+ if principal.role not in (OWNER_ROLE, ADMIN_ROLE):
1245
+ raise AgentevoError(
1246
+ ErrorCode.FORBIDDEN,
1247
+ "workspace export/delete requires an owner/admin role",
1248
+ {},
1249
+ )
1250
+
1251
+
1252
+ def _export_workspace(
1253
+ slug: str,
1254
+ principal: Principal = Depends(CONTROL_READ),
1255
+ conn: sqlite3.Connection = Depends(get_conn),
1256
+ ) -> dict[str, Any]:
1257
+ _require_workspace(principal, slug)
1258
+ _require_admin(principal)
1259
+ export = export_workspace_data(conn, principal.tenant_id, slug)
1260
+ audit(
1261
+ conn,
1262
+ principal.tenant_id,
1263
+ principal.token_id,
1264
+ "workspace_data_exported",
1265
+ slug,
1266
+ details={
1267
+ "deltas": len(export.deltas),
1268
+ "devices": len(export.devices),
1269
+ "repos": len(export.repos),
1270
+ },
1271
+ )
1272
+ return {
1273
+ "workspace": export.workspace,
1274
+ "exported_at": export.exported_at,
1275
+ "deltas": list(export.deltas),
1276
+ "devices": list(export.devices),
1277
+ "repos": list(export.repos),
1278
+ "sync_mode": export.sync_mode.to_dict(),
1279
+ }
1280
+
1281
+
1282
+ def _delete_workspace_data(
1283
+ slug: str,
1284
+ principal: Principal = Depends(CONTROL_READ),
1285
+ conn: sqlite3.Connection = Depends(get_conn),
1286
+ ) -> dict[str, Any]:
1287
+ _require_workspace(principal, slug)
1288
+ _require_admin(principal)
1289
+ deletion = delete_workspace_data(conn, principal.tenant_id, slug)
1290
+ audit(
1291
+ conn,
1292
+ principal.tenant_id,
1293
+ principal.token_id,
1294
+ "workspace_data_deleted",
1295
+ slug,
1296
+ details={
1297
+ "deltas": deletion.deltas,
1298
+ "managed_objects": deletion.managed_objects,
1299
+ "devices": deletion.devices,
1300
+ "repos": deletion.repos,
1301
+ "backup_expires_at": deletion.backup_expires_at,
1302
+ },
1303
+ )
1304
+ return {
1305
+ "workspace": deletion.workspace,
1306
+ "deleted_at": deletion.deleted_at,
1307
+ "backup_expires_at": deletion.backup_expires_at,
1308
+ "deleted": {
1309
+ "sync_deltas": deletion.deltas,
1310
+ "managed_objects": deletion.managed_objects,
1311
+ "devices": deletion.devices,
1312
+ "repo_links": deletion.repos,
1313
+ },
1314
+ }
1315
+
1316
+
1317
+ def _get_sync_mode(
1318
+ slug: str,
1319
+ principal: Principal = Depends(CONTROL_READ),
1320
+ conn: sqlite3.Connection = Depends(get_conn),
1321
+ ) -> dict[str, Any]:
1322
+ _require_workspace(principal, slug)
1323
+ return get_sync_mode(conn, principal.tenant_id, slug).to_dict()
1324
+
1325
+
1326
+ def _set_sync_mode(
1327
+ slug: str,
1328
+ body: dict[str, Any],
1329
+ principal: Principal = Depends(CONTROL_READ),
1330
+ conn: sqlite3.Connection = Depends(get_conn),
1331
+ ) -> dict[str, Any]:
1332
+ _require_workspace(principal, slug)
1333
+ _require_admin(principal)
1334
+ mode = _body_mode(body)
1335
+ terms = _body_optional_str(body, "terms_version")
1336
+ expected = _body_optional_str(body, "expected_mode")
1337
+ previous = get_sync_mode(conn, principal.tenant_id, slug)
1338
+ consent = set_sync_mode(
1339
+ conn,
1340
+ principal.tenant_id,
1341
+ slug,
1342
+ mode=mode,
1343
+ actor=principal.user_id or principal.token_id,
1344
+ terms_version=terms,
1345
+ expected_mode=expected,
1346
+ )
1347
+ audit(
1348
+ conn,
1349
+ principal.tenant_id,
1350
+ principal.user_id or principal.token_id,
1351
+ "sync_mode_changed",
1352
+ f"workspaces/{slug}/sync-mode",
1353
+ details={
1354
+ "from": previous.sync_mode,
1355
+ "to": consent.sync_mode,
1356
+ "terms_version": consent.terms_version,
1357
+ },
1358
+ )
1359
+ return consent.to_dict()
1360
+
1361
+
1362
+ def _analytics_summary(
1363
+ workspace: str | None = None,
1364
+ principal: Principal = Depends(CONTROL_READ),
1365
+ conn: sqlite3.Connection = Depends(get_conn),
1366
+ ) -> dict[str, Any]:
1367
+ if workspace is not None:
1368
+ _require_workspace(principal, workspace)
1369
+ return summarize(
1370
+ conn, principal.tenant_id, workspace=workspace, workspaces=principal.workspaces
1371
+ ).to_dict()
1372
+
1373
+
1374
+ def _body_mode(body: dict[str, Any]) -> str:
1375
+ """Required sync mode from a mode-change body (400 on anything else)."""
1376
+ raw: Any = body.get("sync_mode", "")
1377
+ if not isinstance(raw, str) or not raw:
1378
+ raise AgentevoError(ErrorCode.INVALID_ARGUMENT, "sync_mode must be a non-empty string", {})
1379
+ return raw
1380
+
1381
+
1382
+ def _body_optional_str(body: dict[str, Any], field: str) -> str | None:
1383
+ """Optional string field from a route body (400 when mistyped)."""
1384
+ raw: Any = body.get(field)
1385
+ if raw is None:
1386
+ return None
1387
+ if not isinstance(raw, str):
1388
+ raise AgentevoError(ErrorCode.INVALID_ARGUMENT, f"{field} must be a string", {})
1389
+ return raw
1390
+
1391
+
1392
+ def _sync_push(
1393
+ body: dict[str, Any],
1394
+ request: Request,
1395
+ principal: Principal = Depends(SYNC_WRITE),
1396
+ conn: sqlite3.Connection = Depends(get_conn),
1397
+ idempotency_key: str | None = Header(default=None),
1398
+ ) -> JSONResponse:
1399
+ envelopes: Any = body.get("envelopes")
1400
+
1401
+ def compute() -> tuple[int, dict[str, Any]]:
1402
+ receipt = push_batch(conn, principal, envelopes, now=datetime.now(UTC))
1403
+ indexed = index_accepted(conn, principal.tenant_id, receipt.accepted)
1404
+ audit(
1405
+ conn,
1406
+ principal.tenant_id,
1407
+ principal.token_id,
1408
+ "sync_push",
1409
+ "sync/push",
1410
+ details={
1411
+ "accepted": [f"{item.object_id}:v{item.version}" for item in receipt.accepted],
1412
+ "duplicates": list(receipt.duplicates),
1413
+ "indexed": indexed,
1414
+ },
1415
+ )
1416
+ return 200, receipt.to_dict()
1417
+
1418
+ return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
1419
+
1420
+
1421
+ def _sync_pull(
1422
+ body: dict[str, Any],
1423
+ request: Request,
1424
+ principal: Principal = Depends(SYNC_READ),
1425
+ conn: sqlite3.Connection = Depends(get_conn),
1426
+ idempotency_key: str | None = Header(default=None),
1427
+ ) -> JSONResponse:
1428
+ cursor = body.get("cursor", "")
1429
+ limit = body.get("limit", 100)
1430
+
1431
+ def compute() -> tuple[int, dict[str, Any]]:
1432
+ page = pull_page(conn, principal, cursor, limit, tombstones_only=False)
1433
+ return 200, page.to_dict()
1434
+
1435
+ return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
1436
+
1437
+
1438
+ def _revocations(
1439
+ after: str = "",
1440
+ limit: int = 100,
1441
+ principal: Principal = Depends(SYNC_READ),
1442
+ conn: sqlite3.Connection = Depends(get_conn),
1443
+ ) -> dict[str, Any]:
1444
+ page = pull_page(conn, principal, after, limit, tombstones_only=True)
1445
+ return page.to_dict()
1446
+
1447
+
1448
+ def _events_batch(
1449
+ body: dict[str, Any],
1450
+ request: Request,
1451
+ principal: Principal = Depends(SYNC_WRITE),
1452
+ conn: sqlite3.Connection = Depends(get_conn),
1453
+ idempotency_key: str | None = Header(default=None),
1454
+ ) -> JSONResponse:
1455
+ events: Any = body.get("events")
1456
+
1457
+ def compute() -> tuple[int, dict[str, Any]]:
1458
+ receipt = ingest_batch(conn, principal.tenant_id, events)
1459
+ audit(
1460
+ conn,
1461
+ principal.tenant_id,
1462
+ principal.token_id,
1463
+ "events_ingested",
1464
+ "events/batch",
1465
+ details={
1466
+ "accepted": len(receipt.accepted),
1467
+ "duplicates": len(receipt.duplicates),
1468
+ },
1469
+ )
1470
+ return 201, {
1471
+ "accepted": list(receipt.accepted),
1472
+ "duplicates": list(receipt.duplicates),
1473
+ }
1474
+
1475
+ return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
1476
+
1477
+
1478
+ def _recall(
1479
+ body: dict[str, Any],
1480
+ principal: Principal = Depends(RECALL_READ),
1481
+ conn: sqlite3.Connection = Depends(get_conn),
1482
+ ) -> dict[str, Any]:
1483
+ query = parse_query(body)
1484
+ result = recall(conn, principal.tenant_id, query, workspaces=principal.workspaces)
1485
+ return {
1486
+ "objects": [
1487
+ {
1488
+ "object_id": hit.object_id,
1489
+ "version": hit.version,
1490
+ "object_type": hit.object_type,
1491
+ "state": hit.state,
1492
+ "advisory": hit.advisory,
1493
+ "score": hit.score,
1494
+ "reason": hit.reason,
1495
+ "confidence": hit.confidence,
1496
+ "risk_class": hit.risk_class,
1497
+ "environment_match": hit.environment_match,
1498
+ "freshness": hit.freshness,
1499
+ "body": hit.body,
1500
+ }
1501
+ for hit in result.objects
1502
+ ],
1503
+ "budgets": {
1504
+ "returned": len(result.objects),
1505
+ "max_objects": result.max_objects,
1506
+ "tokens_used": result.tokens_used,
1507
+ "max_tokens": result.max_tokens,
1508
+ },
1509
+ }
1510
+
1511
+
1512
+ #: Submit/poll/cancel scope per job kind (one scope owns a job's lifecycle).
1513
+ #: Compile is producer work (derived C2 objects), so the sync:write scope
1514
+ #: that pushes source evidence also submits and polls compile jobs.
1515
+ _JOB_TYPE_SCOPES = {
1516
+ JobType.VERIFY.value: "verify:run",
1517
+ JobType.COMPILE_EXPERIENCES.value: "sync:write",
1518
+ JobType.COMPILE_WORKFLOW.value: "sync:write",
1519
+ }
1520
+
1521
+ #: Any scope that participates in async work (listing spans job kinds).
1522
+ _JOB_SCOPES = frozenset(_JOB_TYPE_SCOPES.values())
1523
+
1524
+ #: List filter vocabulary (unknown states 400 instead of silently matching nothing).
1525
+ _JOB_STATES = frozenset(state.value for state in JobState)
1526
+
1527
+
1528
+ def _require_job_scope(principal: Principal, job_type: str) -> None:
1529
+ """Enforce the lifecycle scope of one job kind (400 on unknown kinds)."""
1530
+ scope = _JOB_TYPE_SCOPES.get(job_type)
1531
+ if scope is None:
1532
+ raise AgentevoError(
1533
+ ErrorCode.INVALID_ARGUMENT, f"unknown job type: {job_type}", {"job_type": job_type}
1534
+ )
1535
+ if not principal.allows(scope):
1536
+ raise AgentevoError(
1537
+ ErrorCode.FORBIDDEN,
1538
+ f"token lacks required scope: {scope}",
1539
+ {"scope": scope},
1540
+ )
1541
+
1542
+
1543
+ #: States carrying prior standing (revalidation re-confirms; it never validates fresh work).
1544
+ _REVALIDATABLE_STATES = frozenset(
1545
+ {
1546
+ TrustState.VERIFIED.value,
1547
+ TrustState.TRUSTED.value,
1548
+ TrustState.STALE.value,
1549
+ TrustState.COMPILED.value,
1550
+ }
1551
+ )
1552
+
1553
+
1554
+ def _body_version(body: dict[str, Any], field: str = "version") -> int:
1555
+ """Required exact version from an object-route body (400 on anything else)."""
1556
+ version: Any = body.get(field)
1557
+ if isinstance(version, bool) or not isinstance(version, int):
1558
+ raise AgentevoError(ErrorCode.INVALID_ARGUMENT, f"{field} must be an integer", {})
1559
+ return version
1560
+
1561
+
1562
+ def _body_target(body: dict[str, Any]) -> TrustState:
1563
+ """Required promotion target from a promote body (400 on anything else)."""
1564
+ raw: Any = body.get("target", "")
1565
+ if not isinstance(raw, str):
1566
+ raise AgentevoError(ErrorCode.INVALID_ARGUMENT, "target must be a string", {})
1567
+ try:
1568
+ return TrustState(raw)
1569
+ except ValueError as exc:
1570
+ raise AgentevoError(
1571
+ ErrorCode.INVALID_ARGUMENT, f"unknown promotion target: {raw}", {"target": raw}
1572
+ ) from exc
1573
+
1574
+
1575
+ def _managed_or_404(
1576
+ conn: sqlite3.Connection, tenant_id: str, object_id: str, version: int
1577
+ ) -> ManagedObject:
1578
+ """One indexed version, tenant-scoped (404 reveals nothing across tenants)."""
1579
+ target = get_managed(conn, tenant_id, object_id, version)
1580
+ if target is None:
1581
+ raise AgentevoError(
1582
+ ErrorCode.NOT_FOUND,
1583
+ "unknown object version",
1584
+ {"object_id": object_id, "version": version},
1585
+ )
1586
+ return target
1587
+
1588
+
1589
+ def _verify_object( # noqa: PLR0913, PLR0917 - handler carries route context
1590
+ object_id: str,
1591
+ body: dict[str, Any],
1592
+ request: Request,
1593
+ principal: Principal = Depends(VERIFY_RUN),
1594
+ conn: sqlite3.Connection = Depends(get_conn),
1595
+ idempotency_key: str | None = Header(default=None),
1596
+ ) -> JSONResponse:
1597
+ version = _body_version(body)
1598
+ environment = body.get("environment")
1599
+ parse_environment(environment)
1600
+ _managed_or_404(conn, principal.tenant_id, object_id, version)
1601
+ params = {} if environment is None else {"environment": environment}
1602
+
1603
+ def compute() -> tuple[int, dict[str, Any]]:
1604
+ job = create_job(
1605
+ conn,
1606
+ principal.tenant_id,
1607
+ JobType.VERIFY.value,
1608
+ object_id=object_id,
1609
+ version=version,
1610
+ params=params,
1611
+ )
1612
+ audit(
1613
+ conn,
1614
+ principal.tenant_id,
1615
+ principal.token_id,
1616
+ "job_submitted",
1617
+ f"jobs/{job.job_id}",
1618
+ details={"job_type": JobType.VERIFY.value},
1619
+ )
1620
+ return 202, job.to_dict()
1621
+
1622
+ return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
1623
+
1624
+
1625
+ def _revalidate_object( # noqa: PLR0913, PLR0917 - handler carries route context
1626
+ object_id: str,
1627
+ body: dict[str, Any],
1628
+ request: Request,
1629
+ principal: Principal = Depends(VERIFY_RUN),
1630
+ conn: sqlite3.Connection = Depends(get_conn),
1631
+ idempotency_key: str | None = Header(default=None),
1632
+ ) -> JSONResponse:
1633
+ version = _body_version(body)
1634
+ environment = body.get("environment")
1635
+ parse_environment(environment)
1636
+ target = _managed_or_404(conn, principal.tenant_id, object_id, version)
1637
+ if target.state not in _REVALIDATABLE_STATES:
1638
+ raise AgentevoError(
1639
+ ErrorCode.CONFLICT,
1640
+ f"object has no standing to revalidate: {target.state}",
1641
+ {"object_id": object_id, "version": version, "state": target.state},
1642
+ )
1643
+ params: dict[str, Any] = {"revalidation": True}
1644
+ if environment is not None:
1645
+ params["environment"] = environment
1646
+
1647
+ def compute() -> tuple[int, dict[str, Any]]:
1648
+ job = create_job(
1649
+ conn,
1650
+ principal.tenant_id,
1651
+ JobType.VERIFY.value,
1652
+ object_id=object_id,
1653
+ version=version,
1654
+ params=params,
1655
+ )
1656
+ audit(
1657
+ conn,
1658
+ principal.tenant_id,
1659
+ principal.token_id,
1660
+ "revalidation_scheduled",
1661
+ f"jobs/{job.job_id}",
1662
+ details={"object_id": object_id, "version": version},
1663
+ )
1664
+ return 202, job.to_dict()
1665
+
1666
+ return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
1667
+
1668
+
1669
+ def _get_object_version(
1670
+ object_id: str,
1671
+ version: int,
1672
+ principal: Principal = Depends(RECALL_READ),
1673
+ conn: sqlite3.Connection = Depends(get_conn),
1674
+ ) -> dict[str, Any]:
1675
+ return _managed_or_404(conn, principal.tenant_id, object_id, version).to_dict()
1676
+
1677
+
1678
+ def _body_reason(body: dict[str, Any]) -> RevocationReason:
1679
+ """Closed C1 reason code (400 on anything else; admin-revoke by default)."""
1680
+ raw: Any = body.get("reason", RevocationReason.ADMIN_REVOKE.value)
1681
+ if not isinstance(raw, str):
1682
+ raise AgentevoError(ErrorCode.INVALID_ARGUMENT, "reason must be a string", {})
1683
+ try:
1684
+ return RevocationReason(raw)
1685
+ except ValueError as exc:
1686
+ raise AgentevoError(
1687
+ ErrorCode.INVALID_ARGUMENT, f"unknown revocation reason: {raw}", {"reason": raw}
1688
+ ) from exc
1689
+
1690
+
1691
+ def _revoke_object( # noqa: PLR0913, PLR0917 - handler carries route context
1692
+ object_id: str,
1693
+ body: dict[str, Any],
1694
+ request: Request,
1695
+ principal: Principal = Depends(OBJECT_REVOKE),
1696
+ conn: sqlite3.Connection = Depends(get_conn),
1697
+ idempotency_key: str | None = Header(default=None),
1698
+ ) -> JSONResponse:
1699
+ reason = _body_reason(body)
1700
+
1701
+ def compute() -> tuple[int, dict[str, Any]]:
1702
+ result = revoke_object(conn, principal, object_id, reason=reason)
1703
+ return 200, result.to_dict()
1704
+
1705
+ return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
1706
+
1707
+
1708
+ def _compile_experiences(
1709
+ body: dict[str, Any],
1710
+ request: Request,
1711
+ principal: Principal = Depends(SYNC_WRITE),
1712
+ conn: sqlite3.Connection = Depends(get_conn),
1713
+ idempotency_key: str | None = Header(default=None),
1714
+ ) -> JSONResponse:
1715
+ session_id = parse_required_str(body.get("session_id"), "session_id")
1716
+ workspace_id = parse_required_str(body.get("workspace_id"), "workspace_id")
1717
+ project_id = parse_required_str(body.get("project_id", DEFAULT_COMPILE_PROJECT), "project_id")
1718
+ ensure_workspace(conn, principal.tenant_id, workspace_id)
1719
+ require_searchable(conn, principal.tenant_id, workspace_id, "compile managed experiences")
1720
+ params = {"session_id": session_id, "workspace_id": workspace_id, "project_id": project_id}
1721
+
1722
+ def compute() -> tuple[int, dict[str, Any]]:
1723
+ job = create_job(
1724
+ conn, principal.tenant_id, JobType.COMPILE_EXPERIENCES.value, params=params
1725
+ )
1726
+ audit(
1727
+ conn,
1728
+ principal.tenant_id,
1729
+ principal.token_id,
1730
+ "job_submitted",
1731
+ f"jobs/{job.job_id}",
1732
+ details={"job_type": JobType.COMPILE_EXPERIENCES.value},
1733
+ )
1734
+ return 202, job.to_dict()
1735
+
1736
+ return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
1737
+
1738
+
1739
+ def _compile_workflows(
1740
+ body: dict[str, Any],
1741
+ request: Request,
1742
+ principal: Principal = Depends(SYNC_WRITE),
1743
+ conn: sqlite3.Connection = Depends(get_conn),
1744
+ idempotency_key: str | None = Header(default=None),
1745
+ ) -> JSONResponse:
1746
+ refs = parse_skill_refs(body.get("skills"))
1747
+ parse_bindings(body.get("bindings"))
1748
+ parse_rollback(body.get("rollback", []))
1749
+ parse_fallbacks(body.get("fallbacks", {}))
1750
+ canary = parse_canary_steps(body.get("canary_steps", 1))
1751
+ rows = [
1752
+ _compile_skill_row(conn, principal.tenant_id, object_id, version)
1753
+ for object_id, version in refs
1754
+ ]
1755
+ if len({row.workspace_id for row in rows}) > 1:
1756
+ raise AgentevoError(
1757
+ ErrorCode.INVALID_ARGUMENT,
1758
+ "cross-workspace compile rejected",
1759
+ {"workspaces": sorted({row.workspace_id for row in rows})},
1760
+ )
1761
+ require_searchable(conn, principal.tenant_id, rows[0].workspace_id, "compile managed workflows")
1762
+ params: dict[str, Any] = {
1763
+ "skills": [{"object_id": row.object_id, "version": row.version} for row in rows],
1764
+ "bindings": body.get("bindings"),
1765
+ "rollback": body.get("rollback", []),
1766
+ "fallbacks": body.get("fallbacks", {}),
1767
+ "canary_steps": canary,
1768
+ }
1769
+
1770
+ def compute() -> tuple[int, dict[str, Any]]:
1771
+ job = create_job(conn, principal.tenant_id, JobType.COMPILE_WORKFLOW.value, params=params)
1772
+ audit(
1773
+ conn,
1774
+ principal.tenant_id,
1775
+ principal.token_id,
1776
+ "job_submitted",
1777
+ f"jobs/{job.job_id}",
1778
+ details={"job_type": JobType.COMPILE_WORKFLOW.value},
1779
+ )
1780
+ return 202, job.to_dict()
1781
+
1782
+ return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
1783
+
1784
+
1785
+ def _compile_skill_row(
1786
+ conn: sqlite3.Connection, tenant_id: str, object_id: str, version: int
1787
+ ) -> ManagedObject:
1788
+ """One referenced skill (404 unknown; 409 unless SKILL plus Trusted)."""
1789
+ row = get_managed(conn, tenant_id, object_id, version)
1790
+ if row is None:
1791
+ raise AgentevoError(
1792
+ ErrorCode.NOT_FOUND,
1793
+ "unknown object version",
1794
+ {"object_id": object_id, "version": version},
1795
+ )
1796
+ if row.object_type != LearnedObjectType.SKILL.value:
1797
+ raise AgentevoError(
1798
+ ErrorCode.CONFLICT,
1799
+ f"only skills compile: {object_id}",
1800
+ {"object_id": object_id, "object_type": row.object_type},
1801
+ )
1802
+ if row.state != TrustState.TRUSTED.value:
1803
+ raise AgentevoError(
1804
+ ErrorCode.CONFLICT,
1805
+ f"only Trusted skills compile: {row.state}",
1806
+ {"object_id": object_id, "state": row.state},
1807
+ )
1808
+ return row
1809
+
1810
+
1811
+ def _promote_object( # noqa: PLR0913, PLR0917 - handler carries route context
1812
+ object_id: str,
1813
+ body: dict[str, Any],
1814
+ request: Request,
1815
+ principal: Principal = Depends(OBJECT_PROMOTE),
1816
+ conn: sqlite3.Connection = Depends(get_conn),
1817
+ idempotency_key: str | None = Header(default=None),
1818
+ ) -> JSONResponse:
1819
+ version = _body_version(body, "expected_version")
1820
+ target = _body_target(body)
1821
+
1822
+ def compute() -> tuple[int, dict[str, Any]]:
1823
+ try:
1824
+ promoted = promote_managed(conn, principal, object_id, version, target)
1825
+ except AgentevoError as exc:
1826
+ if exc.code is ErrorCode.FORBIDDEN:
1827
+ # Denials roll the request transaction back (failed attempts
1828
+ # never bill), so release it now and commit the denial audit
1829
+ # out-of-band on a fresh connection.
1830
+ conn.rollback()
1831
+ database: Database = request.app.state.db
1832
+ with database.connect() as audit_conn:
1833
+ audit(
1834
+ audit_conn,
1835
+ principal.tenant_id,
1836
+ principal.user_id or principal.token_id,
1837
+ "promotion_denied",
1838
+ f"objects/{object_id}/versions/{version}",
1839
+ details={
1840
+ "target": target.value,
1841
+ "reason": exc.details.get("reason", ""),
1842
+ },
1843
+ )
1844
+ raise
1845
+ return 200, promoted.to_dict()
1846
+
1847
+ return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
1848
+
1849
+
1850
+ def _get_job(
1851
+ job_id: str,
1852
+ principal: Principal = Depends(metered),
1853
+ conn: sqlite3.Connection = Depends(get_conn),
1854
+ ) -> dict[str, Any]:
1855
+ job = get_job(conn, principal.tenant_id, job_id)
1856
+ if job is None:
1857
+ raise AgentevoError(ErrorCode.NOT_FOUND, "unknown job", {"job_id": job_id})
1858
+ _require_job_scope(principal, job.job_type)
1859
+ return job.to_dict()
1860
+
1861
+
1862
+ def _list_jobs(
1863
+ state: str | None = None,
1864
+ limit: int = 50,
1865
+ principal: Principal = Depends(metered),
1866
+ conn: sqlite3.Connection = Depends(get_conn),
1867
+ ) -> dict[str, Any]:
1868
+ if not (principal.scopes & _JOB_SCOPES):
1869
+ raise AgentevoError(
1870
+ ErrorCode.FORBIDDEN,
1871
+ "token lacks a job scope",
1872
+ {"scopes": sorted(_JOB_SCOPES)},
1873
+ )
1874
+ if state is not None and state not in _JOB_STATES:
1875
+ raise AgentevoError(
1876
+ ErrorCode.INVALID_ARGUMENT, f"unknown job state: {state}", {"state": state}
1877
+ )
1878
+ jobs = list_jobs(conn, principal.tenant_id, state=state, limit=limit)
1879
+ return {"jobs": [job.to_dict() for job in jobs]}
1880
+
1881
+
1882
+ def _cancel_job(
1883
+ job_id: str,
1884
+ request: Request,
1885
+ principal: Principal = Depends(metered),
1886
+ conn: sqlite3.Connection = Depends(get_conn),
1887
+ idempotency_key: str | None = Header(default=None),
1888
+ ) -> JSONResponse:
1889
+ job = get_job(conn, principal.tenant_id, job_id)
1890
+ if job is None:
1891
+ raise AgentevoError(ErrorCode.NOT_FOUND, "unknown job", {"job_id": job_id})
1892
+ _require_job_scope(principal, job.job_type)
1893
+
1894
+ def compute() -> tuple[int, dict[str, Any]]:
1895
+ canceled = cancel_job(conn, principal.tenant_id, job_id)
1896
+ if canceled is None: # pragma: no cover - rows are never deleted; pre-checked above
1897
+ raise AgentevoError(ErrorCode.NOT_FOUND, "unknown job", {"job_id": job_id})
1898
+ audit(
1899
+ conn,
1900
+ principal.tenant_id,
1901
+ principal.token_id,
1902
+ "job_canceled",
1903
+ f"jobs/{job_id}",
1904
+ details={"state": canceled.state},
1905
+ )
1906
+ return 200, canceled.to_dict()
1907
+
1908
+ return _idempotent(request, conn, principal.tenant_id, idempotency_key, {}, compute)
1909
+
1910
+
1911
+ #: Error-detail strings are truncated here so a hostile input is never echoed
1912
+ #: back unbounded (and a non-serializable input never 500s the handler).
1913
+ _ERROR_DETAIL_CHARS = 1024
1914
+
1915
+
1916
+ def _truncate_detail(text: str) -> str:
1917
+ """Cap one detail string (no-op for short text)."""
1918
+ if len(text) > _ERROR_DETAIL_CHARS:
1919
+ return text[:_ERROR_DETAIL_CHARS] + "…(truncated)"
1920
+ return text
1921
+
1922
+
1923
+ def _jsonable_detail(value: Any) -> Any:
1924
+ """Render one error-detail value JSON-safe (validation inputs are hostile)."""
1925
+ if isinstance(value, dict):
1926
+ rendered: Any = {str(key): _jsonable_detail(item) for key, item in value.items()}
1927
+ elif isinstance(value, (list, tuple)):
1928
+ rendered = [_jsonable_detail(item) for item in value]
1929
+ elif isinstance(value, bytes):
1930
+ try:
1931
+ rendered = _truncate_detail(value.decode("utf-8"))
1932
+ except UnicodeDecodeError:
1933
+ rendered = _truncate_detail(repr(value))
1934
+ elif isinstance(value, str):
1935
+ rendered = _truncate_detail(value)
1936
+ elif value is None or isinstance(value, (bool, int, float)):
1937
+ rendered = value
1938
+ else:
1939
+ rendered = _truncate_detail(str(value))
1940
+ return rendered
1941
+
1942
+
1943
+ def _error_response(
1944
+ code: ErrorCode, message: str, details: dict[str, Any], exc: AgentevoError | None
1945
+ ) -> JSONResponse:
1946
+ request_id = uuid4().hex[:16]
1947
+ headers = {REQUEST_ID_HEADER: request_id}
1948
+ if exc is not None:
1949
+ retry_after = retry_after_for(exc)
1950
+ if retry_after is not None:
1951
+ headers[RETRY_AFTER_HEADER] = str(retry_after)
1952
+ safe = {key: _jsonable_detail(value) for key, value in details.items()}
1953
+ return JSONResponse(
1954
+ status_code=HTTP_STATUS[code],
1955
+ content={"code": code.value, "message": message, "details": safe},
1956
+ headers=headers,
1957
+ )
1958
+
1959
+
1960
+ def _bearer_secret(authorization: str | None) -> str:
1961
+ if authorization is None:
1962
+ raise AgentevoError(ErrorCode.UNAUTHENTICATED, "missing credentials", {})
1963
+ scheme, _, secret = authorization.partition(" ")
1964
+ if scheme.lower() != "bearer" or not secret:
1965
+ raise AgentevoError(ErrorCode.UNAUTHENTICATED, "malformed credentials", {})
1966
+ return secret
1967
+
1968
+
1969
+ def _token_body(body: dict[str, Any]) -> tuple[str, list[str], list[str], timedelta]:
1970
+ name = body.get("name")
1971
+ scopes = body.get("scopes")
1972
+ workspaces = body.get("workspaces", [])
1973
+ ttl_seconds = body.get("ttl_seconds", 90 * 24 * 3600)
1974
+ if not isinstance(name, str) or not name:
1975
+ raise AgentevoError(ErrorCode.INVALID_ARGUMENT, "name must be non-empty", {})
1976
+ if (
1977
+ not isinstance(scopes, list)
1978
+ or not scopes
1979
+ or any(not isinstance(item, str) for item in scopes)
1980
+ ):
1981
+ raise AgentevoError(
1982
+ ErrorCode.INVALID_ARGUMENT, "scopes must be a non-empty string list", {}
1983
+ )
1984
+ if not isinstance(workspaces, list) or any(not isinstance(item, str) for item in workspaces):
1985
+ raise AgentevoError(ErrorCode.INVALID_ARGUMENT, "workspaces must be a string list", {})
1986
+ if isinstance(ttl_seconds, bool) or not isinstance(ttl_seconds, int) or ttl_seconds <= 0:
1987
+ raise AgentevoError(
1988
+ ErrorCode.INVALID_ARGUMENT, "ttl_seconds must be a positive integer", {}
1989
+ )
1990
+ return name, scopes, workspaces, timedelta(seconds=ttl_seconds)
1991
+
1992
+
1993
+ def _device_code_body(body: dict[str, Any]) -> tuple[list[str], str | None]:
1994
+ scopes = body.get("scopes")
1995
+ workspace = body.get("workspace")
1996
+ if (
1997
+ not isinstance(scopes, list)
1998
+ or not scopes
1999
+ or any(not isinstance(item, str) for item in scopes)
2000
+ ):
2001
+ raise AgentevoError(
2002
+ ErrorCode.INVALID_ARGUMENT, "scopes must be a non-empty string list", {}
2003
+ )
2004
+ if workspace is not None and (not isinstance(workspace, str) or not workspace):
2005
+ raise AgentevoError(ErrorCode.INVALID_ARGUMENT, "workspace must be a non-empty string", {})
2006
+ return scopes, workspace
2007
+
2008
+
2009
+ def _decision_body(body: dict[str, Any]) -> tuple[str, str | None]:
2010
+ user_code = body.get("user_code")
2011
+ id_token = body.get("id_token")
2012
+ if not isinstance(user_code, str) or not user_code:
2013
+ raise AgentevoError(ErrorCode.INVALID_ARGUMENT, "user_code must be a non-empty string", {})
2014
+ if id_token is not None and (not isinstance(id_token, str) or not id_token):
2015
+ raise AgentevoError(ErrorCode.INVALID_ARGUMENT, "id_token must be a non-empty string", {})
2016
+ return user_code, id_token
2017
+
2018
+
2019
+ def _device_body(body: dict[str, Any]) -> tuple[str, str, str, str, str | None]:
2020
+ device_id = body.get("device_id")
2021
+ name = body.get("name")
2022
+ workspace = body.get("workspace")
2023
+ public_key = body.get("public_key")
2024
+ key_id = body.get("key_id")
2025
+ if not isinstance(device_id, str) or not device_id:
2026
+ raise AgentevoError(ErrorCode.INVALID_ARGUMENT, "device_id must be a non-empty string", {})
2027
+ if not isinstance(name, str) or not name:
2028
+ raise AgentevoError(ErrorCode.INVALID_ARGUMENT, "name must be a non-empty string", {})
2029
+ if not isinstance(workspace, str) or not workspace:
2030
+ raise AgentevoError(ErrorCode.INVALID_ARGUMENT, "workspace must be a non-empty string", {})
2031
+ if not isinstance(public_key, str) or not public_key:
2032
+ raise AgentevoError(ErrorCode.INVALID_ARGUMENT, "public_key must be a non-empty string", {})
2033
+ if key_id is not None and (not isinstance(key_id, str) or not key_id):
2034
+ raise AgentevoError(ErrorCode.INVALID_ARGUMENT, "key_id must be a non-empty string", {})
2035
+ return device_id, name, workspace, public_key, key_id
2036
+
2037
+
2038
+ def _webhook_body(body: dict[str, Any]) -> tuple[str, list[str]]:
2039
+ url = body.get("url")
2040
+ events = body.get("events")
2041
+ if not isinstance(url, str) or not url:
2042
+ raise AgentevoError(ErrorCode.INVALID_ARGUMENT, "url must be a non-empty string", {})
2043
+ if (
2044
+ not isinstance(events, list)
2045
+ or not events
2046
+ or any(not isinstance(item, str) for item in events)
2047
+ ):
2048
+ raise AgentevoError(
2049
+ ErrorCode.INVALID_ARGUMENT, "events must be a non-empty string list", {}
2050
+ )
2051
+ return url, events
2052
+
2053
+
2054
+ def _device_json(record: DeviceRecord) -> dict[str, Any]:
2055
+ return {
2056
+ "key_id": record.key_id,
2057
+ "device_id": record.device_id,
2058
+ "name": record.name,
2059
+ "workspace": record.workspace_slug,
2060
+ "public_key": record.public_key,
2061
+ "key_format": record.key_format,
2062
+ "first_seen": record.first_seen,
2063
+ "revoked_at": record.revoked_at,
2064
+ "compromised": record.compromised,
2065
+ }
2066
+
2067
+
2068
+ def _webhook_json(config: WebhookConfig) -> dict[str, Any]:
2069
+ return {
2070
+ "id": config.id,
2071
+ "url": config.url,
2072
+ "key_id": config.key_id,
2073
+ "events": list(config.events),
2074
+ "created_at": config.created_at,
2075
+ "revoked_at": config.revoked_at,
2076
+ }
2077
+
2078
+
2079
+ def _delivery_json(delivery: Delivery) -> dict[str, Any]:
2080
+ return {
2081
+ "id": delivery.id,
2082
+ "config_id": delivery.config_id,
2083
+ "event_id": delivery.event_id,
2084
+ "event_type": delivery.event_type,
2085
+ "event": delivery.event,
2086
+ "status": delivery.status,
2087
+ "attempts": delivery.attempts,
2088
+ "http_status": delivery.http_status,
2089
+ "last_error": delivery.last_error,
2090
+ "created_at": delivery.created_at,
2091
+ }