ACDP Control Plane — API Reference

Base URL: http://localhost:3001 (dev). Swagger UI is served at /docs (development; opt-in in production via SWAGGER_ENABLED=true, path SWAGGER_PATH).

Authentication

Every non-@Public() route requires a credential in the Authorization header:

  • API keyAuthorization: Bearer <key> where <key>AUTH_API_KEYS (or a tenant-bound key in TENANT_API_KEYS). When AUTH_API_KEYS is empty (dev only, non-production), auth is bypassed.
  • Bearer JWT — a token issued by /auth/token or by a trusted external issuer (TRUSTED_ISSUERS). The guard auto-detects JWT vs opaque key by shape.

Admin-only routes additionally require the key to be in AUTH_ADMIN_API_KEYS. See AUTH.md.

Tenancy headers

All tenant-owned reads/writes are scoped to the caller's resolved tenant. An X-Tenant-Id header may be sent but is rejected if it disagrees with the JWT tenant claim or an API key's bound tenant, or if it asserts the reserved default tenant. With AUTH_REQUIRE_TENANT=true, a request that resolves only to default is denied. See TENANCY.md.

Error responses

All non-2xx responses use a consistent shape (normalized by GlobalExceptionFilter):

{ "statusCode": 404, "errorCode": "RUN_NOT_FOUND", "message": "run X not found" }

errorCode is one of (src/errors/error-codes.ts): RUN_NOT_FOUND, REGISTRY_NOT_FOUND, AGENT_NOT_FOUND, CONTEXT_NOT_FOUND, FEDERATION_UPSTREAM_RATE_LIMITED, INVALID_PAYLOAD, INVALID_SIGNATURE, VALIDATION_ERROR, INTERNAL_ERROR.

Policy denials return 403 with { message, code, reason }; quota exceeded returns 429 with a Retry-After header (see POLICY.md).


Route index

MethodPathAuthNotes
POST/ingest/acdpPublic (HMAC)Registry webhook in
GET/ingest/healthPublicRegistry config liveness
GET/runskey/JWTList runs
GET/runs/:runIdkey/JWTRun detail
GET/runs/:runId/lineagekey/JWTLineage DAG
GET/runs/:runId/eventskey/JWTRun events
GET/runs/:runId/events/streamkey/JWTSSE — per-run
POST/runs/:runId/completekey/JWTMark run terminal
GET/eventskey/JWTCross-run event history
GET/events/streamkey/JWTSSE — global firehose
GET/contexts/*ctxIdkey/JWT (policy)Federation proxy
GET/agentskey/JWTKnown agents
GET/agents/*didkey/JWTAgent detail
POST/capabilitieskey/JWT (policy+quota)Declare a signed capability
GET/capabilities/searchkey/JWTFind agents by capability
GET/capabilities/by-agent/*didkey/JWTOne agent's capabilities
GET/registrieskey/JWTKnown registries
GET/registries/enrollmentskey/JWTEnrolled registries (secrets hidden)
POST/registries/enrolladminEnroll/update a registry
GET/dashboard/overviewkey/JWTKPIs
POST/webhookskey/JWTCreate subscription
GET/webhookskey/JWTList
PATCH/webhooks/:idkey/JWTUpdate
DELETE/webhooks/:idkey/JWTRemove
GET/domain-packskey/JWTList active packs
GET/routing/statsadminBandit router arm state
POST/auth/challengePublicRequest a signing nonce
POST/auth/tokenPublicExchange a signed nonce for a JWT
POST/auth/introspectkey/JWTRFC 7662 token introspection
POST/auth/token/revokeself/adminRFC 7009 token revocation
GET/auth/revocationsadminCross-issuer revocation feed
GET/.well-known/jwks.jsonPublicCP public JWKS
POST/admin/pinned-keys/reloadadminReload pinned keys from env
GET/healthz /readyz /metricsPublicProbes / Prometheus
GET/docsdevSwagger UI

The list is authoritative against the controllers as of this writing, but confirm against src/**/*.controller.ts if in doubt.


Ingest

POST /ingest/acdp — receive a registry webhook

Public (no Bearer token). Authenticated via HMAC-SHA256. Full contract: INGEST.md.

Headers

HeaderDescription
x-acdp-signaturesha256=<hex> of HMAC-SHA256(rawBody, WEBHOOK_SECRET). Skipped when WEBHOOK_SECRET is empty.
x-acdp-event-idOptional. Registry-minted event id, used for idempotency.
x-run-idOptional. Correlates this event into a run. Takes precedence over payload.run_id.
x-tenant-idOptional. Tenant binding (subject to enrollment / strict-tenant rules).
Content-Typeapplication/json

Body — see INGEST.md. Required fields: type, registry_authority, and agent_id (required for context_published).

Responses

StatusMeaning
204Accepted (persisted and broadcast), or silently deduplicated.
400Malformed JSON, missing required fields, oversized body, JSON too deep, or domain-pack-gated context_type.
401Bad or missing HMAC signature.
403Unenrolled authority (when INGEST_REQUIRE_ENROLLMENT=true) or tenant assertion rejected.

GET /ingest/health

Public. Liveness for registry config tests. Returns { ok: true }.


Runs

MethodPathDescription
GET/runsList runs with optional filters and pagination.
GET/runs/:runIdFetch a single run. 404 if not found.
GET/runs/:runId/lineageLineage DAG: { runId, nodes[], edges[] }.
GET/runs/:runId/eventsContext events for the run, ordered by event_ts.
GET/runs/:runId/events/streamSSE — live events for this run.
POST/runs/:runId/completeMark the run terminal. Body: { status, result? }. Returns 204.

GET /runs query parameters

ParamTypeNotes
statusenumrunning | completed | failed | cancelled
scenarioIdstringFilter by scenario_id
limitint1–200, default 50
offsetint≥ 0, default 0

Response:

{ "data": [ /* Run */ ], "total": 42, "limit": 50, "offset": 0 }

GET /runs/:runId/lineage response

{
  "runId": "run-001",
  "nodes": [
    {
      "ctxId": "acdp://registry-a/ctx-001",
      "agentId": "did:web:agent-a.example",
      "contextType": "task",
      "visibility": "public",
      "registryAuthority": "registry-a.example",
      "step": 1
    }
  ],
  "edges": [ { "from": "acdp://registry-a/ctx-001", "to": "acdp://registry-a/ctx-002" } ]
}

SSE: GET /runs/:runId/events/stream

Each event is emitted as event: <event_type>\ndata: <json>\n\n. A heartbeat frame is emitted every STREAM_SSE_HEARTBEAT_MS (default 15 s).

event: context_published
data: {"type":"context_published","ts":"...","runId":"r-1",...}

event: heartbeat
data: {"ts":"2026-05-24T12:00:00Z"}

Events (cross-run)

MethodPathDescription
GET/eventsCross-run event history with filters.
GET/events/streamSSE — global firehose of all events.

GET /events query parameters: runId, eventType, agentId, registryAuthority, afterTs (ISO), beforeTs (ISO), limit (default 500).


Contexts (federation proxy)

GET /contexts/*ctxId

Gated by @CheckPolicy('context.retrieve'). Proxies the request to the registry that owns the context. ctxId format: acdp://<authority>/<id> (authority matches ^[a-zA-Z0-9.:-]+$ — no path, no scheme, no IP literal). The authority is looked up in the caller's tenant enrollments, and the request is forwarded to <base_url>/contexts/<ctxId> through the SSRF-safe SafeFederationClient (the same defense model as the SDK — acdp-rs · Security, RFC-ACDP-0006 §7 / RFC-ACDP-0008):

  • HTTPS-only; DNS-resolved IPs must not be private/loopback/link-local/IMDS.
  • Redirects followed manually, max 3, same-authority only (else 502).
  • Response body capped at 1 MiB; 10 s deadline.

Status mapping:

ConditionResponse
Upstream 2xx/4xxRelayed verbatim (status, content-type, body).
Upstream 429503 FEDERATION_UPSTREAM_RATE_LIMITED (upstream Retry-After logged).
Unknown / unenrolled authority404
Malformed ctxId400
SSRF / transport / oversized / cross-authority redirect502

Agents

MethodPathDescription
GET/agentsList known agents (tenant-scoped, ordered by last_seen).
GET/agents/*didAgent detail by DID. 404 if not seen.

Capabilities

Agents self-declare capabilities by signing a canonical assertion. See ARCHITECTURE.md.

POST /capabilities — declare a capability

Gated by @CheckPolicy('capability.declare') + @CheckQuota('capability.declare').

Body (DeclareCapabilityRequestDto):

{
  "agent_did": "did:web:cp.example.com:agents:alice",
  "capability_uri": "urn:acdp:cap:publish:data_snapshot:finance",
  "declared_at": "2026-05-25T18:00:00Z",
  "key_id": "did:web:cp.example.com:agents:alice#key-1",
  "algorithm": "ed25519",
  "signature": "<base64>"
}

The agent signs acdp-cap:v1:<agent_did>:<capability_uri>:<declared_at> with its pinned key. The server validates the URN form (urn:acdp:cap:<verb>:<type>:<domain>, each segment [a-z0-9_]+), a ±300 s clock-skew window, algorithm-match (downgrade defense), and the signature, then persists idempotently on (tenant_id, agent_did, capability_uri).

Response (CapabilityResponseDto):

{
  "agent_did": "did:web:cp.example.com:agents:alice",
  "capability_uri": "urn:acdp:cap:publish:data_snapshot:finance",
  "declared_at": "2026-05-25T18:00:00Z",
  "signed_by": "did:web:cp.example.com:agents:alice#key-1"
}

Re-declaring the same pair returns the original server-pinned declared_at.

GET /capabilities/search?capability=<uri>

Returns agents declaring the given capability: { "data": [ CapabilityResponse… ], "total": N }.

GET /capabilities/by-agent/*did

Returns one agent's capabilities: { "data": [ … ], "total": N }.


Registries

MethodPathDescription
GET/registriesKnown registries (observed via events), tenant-scoped, with eventCount.
GET/registries/enrollmentsEnrolled registries. webhookSecret is always omitted.
POST/registries/enrollAdmin-only. Upsert an enrollment.

POST /registries/enroll

Body (EnrollRegistryDto):

{
  "authority": "registry-a.example",
  "tenantId": "tenant-a",
  "baseUrl": "https://registry-a.example",
  "registryDid": "did:web:registry-a.example",
  "webhookSecret": "per-registry-secret-min-16-chars",
  "enabled": true
}
  • authority (required) — ACDP authority / hostname.
  • tenantId (optional) — defaults to the caller's tenant. Explicitly passing "default" is rejected (403).
  • baseUrl (optional) — used by the federation proxy.
  • webhookSecret (optional, ≥16 chars) — per-registry HMAC secret; omit to use the global WEBHOOK_SECRET.
  • enabled (optional, default true) — whether ingest from this authority is accepted.

Response echoes the enrollment without webhookSecret.


Dashboard

GET /dashboard/overview?window=1h|6h|24h|7d|30d

KPIs over the window (default 24h), tenant-scoped:

{
  "window": "24h",
  "totalRuns": 12,
  "totalContexts": 87,
  "totalAgents": 5,
  "recentRuns": [ /* last 10 runs */ ],
  "byScenario": [ { "scenario_id": "...", "run_count": 4 } ],
  "byRegistry": [ { "registry_authority": "...", "event_count": 31 } ]
}

Webhooks (outbound subscriptions)

MethodPathDescription
POST/webhooksCreate. Body: { url, events?, secret }.
GET/webhooksList.
PATCH/webhooks/:idUpdate any of { url, events, secret, active }.
DELETE/webhooks/:idRemove. Returns 204.

When the control plane ingests an event, every active webhook whose events list is empty (= all events) or contains the event type is dispatched. The body is HMAC-SHA256 signed with the subscription's secret (X-ACDP-Signature: sha256=<hex>, event type in X-ACDP-Event). Delivery is outbox-tracked with a background retry sweep; see ARCHITECTURE.md.


Domain packs

GET /domain-packs — list active packs

{
  "packs": [
    {
      "id": "finance",
      "version": "0.1.0",
      "label": "Finance (reference)",
      "contextTypes": [
        { "contextType": "earnings_report", "requiredFields": ["fiscal_quarter","ticker","currency"], "defaultVisibility": "restricted" }
      ]
    }
  ]
}

Packs are registered at boot from DOMAIN_PACKS. Their declared contextTypes extend the ingest allowlist; the base RFC-ACDP-0001 types are always accepted. See INGEST.md.


Routing

GET /routing/stats — bandit router arm state (admin-only)

{
  "arms": [
    { "taskClass": "finance_summary", "agentDid": "did:web:agent-a.example",
      "alpha": 12, "beta": 3, "mean": 0.8, "observations": 13 }
  ],
  "total": 1
}

alpha/beta are the Beta-posterior parameters (successes+1 / failures+1); mean = alpha/(alpha+beta). Selection uses Thompson sampling over arms that pass the capability-match gate, with an exploration fraction (BANDIT_EXPLORATION_FRACTION, default 5%). State is per-instance in V1.


Auth

Full flows in AUTH.md. Endpoints:

POST /auth/challenge — request a signing nonce (Public)

Body: { "agent_id": "did:web:…" }. Returns:

{
  "nonce": "<base64url>",
  "registry_authority": "control-plane.local",
  "expires_at": 1716661234,
  "signing_input": "acdp-registry-auth:v1:<nonce>:<agent_did>:<authority>:<expires_at>"
}

POST /auth/token — exchange a signed nonce for a JWT (Public)

Body:

{
  "agent_id": "did:web:…",
  "key_id": "key-1",
  "nonce": "<from challenge>",
  "expires_at": 1716661234,
  "algorithm": "ed25519",
  "signature": "<base64 over signing_input>"
}

Returns { "token": "<jwt>", "token_type": "Bearer", "expires_at": <unix> }. 401 on unknown/expired nonce, agent mismatch, missing pinned key, or bad signature; 400 on unsupported algorithm. /auth/challenge and /auth/token carry a tighter per-IP throttle than the global limit.

POST /auth/introspect — RFC 7662 introspection

Body: { "token": "<jwt>" }. Active tokens (local or trusted-issuer) return canonical claims; anything that fails verification collapses to { "active": false } (no oracle).

POST /auth/token/revoke — RFC 7009 revocation

Body: { "token": "<jwt>", "reason"?: "user_logout" | "admin_revoke" | "key_rotation" | "security_incident" | "unspecified" }. Allowed for an admin key or the token's own subject (self-revoke); else 403. Always returns 200 { "revoked": <bool> } (no oracle).

GET /auth/revocations — cross-issuer revocation feed (admin-only)

Query: since (unix-ms cursor, default 0), limit (default 200, max 500). Returns { "entries": [ { jti, sub, iss, exp, revoked_at_ms } ], "next_cursor": <ms|null> }. Peers poll this; this CP polls their feeds via REVOCATION_FEEDS.

GET /.well-known/jwks.json (Public)

Publishes the CP's public verification key(s). For HS256 returns { "keys": [] }; for EdDSA returns the active OKP/Ed25519 JWK. Cache-Control: public, max-age=300.

POST /admin/pinned-keys/reload (admin-only)

Reloads CONTROL_PLANE_PINNED_KEYS from the environment and atomically swaps the in-memory directory. Returns { "ok": true, "count": <n> }.


Observability

MethodPathDescription
GET/healthzLiveness ({ ok, service }); pings DB. Public.
GET/readyzReadiness ({ ok, database }). Public.
GET/metricsPrometheus text-format metrics. Public.
GET/docsSwagger UI (dev / opt-in).

Key metrics (all constructed in InstrumentationService):

MetricTypeLabelsMeasures
http_request_duration_secondshistogrammethod, path, status_codeRequest latency (buckets 0.01–10 s)
http_requests_totalcountermethod, path, status_codeRequest count
active_sse_connectionsgaugeLive SSE connections
acdp_events_ingested_totalcounterevent_typeIngested events
acdp_webhook_deliveries_totalcounterstatusOutbound deliveries by status
acdp_ingest_rejected_totalcounterreasonIngest rejections (e.g. pack_gate)

Plus Node.js default metrics (process_cpu, gc, memory, event-loop lag, etc.) via collectDefaultMetrics().