diff --git a/README.md b/README.md index 678845b..32e0287 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ Java SDK for the Agent Name Service (ANS) Registry. This SDK provides clients fo ## API Specification Reference -The ANS Registry SDK is based off of the REST API. The spec is documented using the OpenAPI (Swagger) specification: -- [View OpenAPI Spec - Human Readable](https://developer.godaddy.com/doc/endpoint/ans) -- [OpenAPI Spec - AI/Machine Readable](https://developer.godaddy.com/swagger/swagger_ans.json) +The ANS Registry SDK is based off of the REST API. The spec is documented using the OpenAPI specification: +- [ANS OpenAPI Spec (ans repo)](https://github.com/agentnameservice/ans/blob/main/spec/api-spec-v2.yaml) +- Local copy used for code generation: [`ans-sdk-api/spec/api-spec.yaml`](ans-sdk-api/spec/api-spec.yaml) ## Requirements diff --git a/ans-sdk-agent-client/src/main/java/com/godaddy/ans/sdk/agent/connection/AgentConnection.java b/ans-sdk-agent-client/src/main/java/com/godaddy/ans/sdk/agent/connection/AgentConnection.java index b3a8da3..4c71332 100644 --- a/ans-sdk-agent-client/src/main/java/com/godaddy/ans/sdk/agent/connection/AgentConnection.java +++ b/ans-sdk-agent-client/src/main/java/com/godaddy/ans/sdk/agent/connection/AgentConnection.java @@ -5,6 +5,7 @@ import com.godaddy.ans.sdk.agent.protocol.HttpApiClient; import com.godaddy.ans.sdk.model.generated.AgentDetails; import com.godaddy.ans.sdk.model.generated.AgentEndpoint; +import com.godaddy.ans.sdk.model.generated.Protocol; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -178,13 +179,14 @@ public Optional getMetadataUrl(String protocol) { /** * Checks if a Protocol enum matches the protocol string. */ - private boolean matchesProtocol(AgentEndpoint.ProtocolEnum endpointProtocol, String protocolStr) { + private boolean matchesProtocol(Protocol endpointProtocol, String protocolStr) { if (endpointProtocol == null || protocolStr == null) { return false; } - // Handle both enum name (HTTP_API) and value (HTTP-API) - return protocolStr.equals(endpointProtocol.name()) - || protocolStr.equals(endpointProtocol.getValue()); + // Normalize hyphens to underscores so "HTTP-API" form matches HTTP_API + String normalized = protocolStr.replace('-', '_'); + return normalized.equals(endpointProtocol.name()) + || normalized.equals(endpointProtocol.getValue()); } /** diff --git a/ans-sdk-agent-client/src/test/java/com/godaddy/ans/sdk/agent/connection/AgentConnectionTest.java b/ans-sdk-agent-client/src/test/java/com/godaddy/ans/sdk/agent/connection/AgentConnectionTest.java index 52186d0..7c6375a 100644 --- a/ans-sdk-agent-client/src/test/java/com/godaddy/ans/sdk/agent/connection/AgentConnectionTest.java +++ b/ans-sdk-agent-client/src/test/java/com/godaddy/ans/sdk/agent/connection/AgentConnectionTest.java @@ -5,6 +5,7 @@ import com.godaddy.ans.sdk.model.generated.AgentDetails; import com.godaddy.ans.sdk.model.generated.AgentEndpoint; import com.godaddy.ans.sdk.model.generated.AgentLifecycleStatus; +import com.godaddy.ans.sdk.model.generated.Protocol; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -16,6 +17,7 @@ import java.time.Duration; import java.util.List; import java.util.Optional; +import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -36,16 +38,16 @@ class AgentConnectionTest { void setUp() { // Create test agent details with endpoints AgentEndpoint httpApiEndpoint = new AgentEndpoint(); - httpApiEndpoint.setProtocol(AgentEndpoint.ProtocolEnum.HTTP_API); + httpApiEndpoint.setProtocol(Protocol.HTTP_API); httpApiEndpoint.setAgentUrl(URI.create("https://agent.example.com/api")); httpApiEndpoint.setMetaDataUrl(URI.create("https://agent.example.com/metadata")); AgentEndpoint a2aEndpoint = new AgentEndpoint(); - a2aEndpoint.setProtocol(AgentEndpoint.ProtocolEnum.A2_A); + a2aEndpoint.setProtocol(Protocol.A2_A); a2aEndpoint.setAgentUrl(URI.create("wss://agent.example.com/a2a")); agentDetails = new AgentDetails(); - agentDetails.setAgentId("test-agent-id"); + agentDetails.setAgentId(UUID.randomUUID()); agentDetails.setAnsName("ans://v1.0.0.agent.example.com"); agentDetails.setAgentHost("agent.example.com"); agentDetails.setVersion("1.0.0"); @@ -94,7 +96,7 @@ void supportsProtocolWithProtocolValueShouldReturnTrue() { // Given AgentConnection connection = new AgentConnection(agentDetails, ansHttpClient); - // When/Then - using protocol value format (HTTP-API) + // When/Then - legacy hyphenated alias (HTTP-API) should still match HTTP_API assertThat(connection.supportsProtocol("HTTP-API")).isTrue(); } diff --git a/ans-sdk-api/build.gradle.kts b/ans-sdk-api/build.gradle.kts index 8a4199f..24c110b 100644 --- a/ans-sdk-api/build.gradle.kts +++ b/ans-sdk-api/build.gradle.kts @@ -7,8 +7,7 @@ plugins { val jacksonVersion: String by project // Authoritative source for the API spec -val apiSpecUrl = "https://developer.godaddy.com/swagger/swagger_ans.json" -val apiSpecFile = layout.buildDirectory.file("api-spec.json") +val apiSpecFile = layout.projectDirectory.file("spec/api-spec.yaml") dependencies { // Jackson for JSON serialization @@ -20,25 +19,10 @@ dependencies { implementation("jakarta.annotation:jakarta.annotation-api:3.0.0") } -// Task to download the API spec from the authoritative source -val downloadApiSpec by tasks.registering { - val outputFile = apiSpecFile - outputs.file(outputFile) - doLast { - val destFile = outputFile.get().asFile - destFile.parentFile.mkdirs() - URI.create(apiSpecUrl).toURL().openStream().use { input -> - destFile.outputStream().use { output -> - input.copyTo(output) - } - } - logger.lifecycle("Downloaded API spec from $apiSpecUrl") - } -} - openApiGenerate { generatorName.set("java") - inputSpec.set(apiSpecFile.get().asFile.absolutePath) + inputSpec.set(apiSpecFile) + skipValidateSpec.set(true) outputDir.set(layout.buildDirectory.dir("generated").get().asFile.absolutePath) apiPackage.set("com.godaddy.ans.sdk.api.generated") modelPackage.set("com.godaddy.ans.sdk.model.generated") @@ -61,10 +45,6 @@ sourceSets { } } -tasks.openApiGenerate { - dependsOn(downloadApiSpec) -} - tasks.compileJava { dependsOn(tasks.openApiGenerate) } diff --git a/ans-sdk-api/spec/api-spec.yaml b/ans-sdk-api/spec/api-spec.yaml new file mode 100644 index 0000000..770e4ed --- /dev/null +++ b/ans-sdk-api/spec/api-spec.yaml @@ -0,0 +1,3159 @@ +openapi: 3.0.3 +info: + title: Agent Name Service (ANS) Management API + description: | + Management API for the Agent Name Service (ANS) — v2. + + This API provides authenticated, ownership-scoped operations for managing + ANS agent registrations, certificates, and lifecycle. It replaces the + /v1/agents API with the following key changes: + + **Route structure:** All routes moved from `/v1/agents` to `/v2/ans/agents`. + Registration uses `POST /v2/ans/agents` (standard REST collection POST, + no `/register` verb suffix). Action endpoints (`/revoke`, `/verify-acme`, + `/verify-dns`) retain verb suffixes for procedural clarity. + + **Management-scoped listing:** `GET /v2/ans/agents` returns only agents owned by + the authenticated caller. Global search/discovery has moved to a dedicated + search service. + + **Cursor-based pagination:** The list endpoint uses cursor-based pagination + instead of offset-based, eliminating the O(n) performance degradation at + high offsets. + + **Ownership enforcement:** All agent-specific operations (`GET /{agentId}`, + `POST /{agentId}/revoke`, certificate endpoints) enforce that the + authenticated caller owns the requested agent. + + **Resolution removed:** `POST /v1/agents/resolution` is not carried forward + to v2. The `_ans` DNS TXT record already contains agent endpoints, making + this endpoint redundant. Consumers needing discovery should use the + registered agents search service (`/v1/ans/registered-agents`). + + ## Deprecation of v1 + + All `/v1/agents/*` routes are deprecated and will be removed per the + published sunset schedule. v1 responses include `Deprecation: true`, + `Sunset`, and `Link` headers pointing to v2 equivalents. + version: 2.0.0 + contact: + name: ANS Working Group + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html + +servers: + - url: http://localhost:18080/v2 + description: Local dev — quickstart ra-local.yaml + - url: https://api.ans.example.org/v2 + description: Main ANS API server (v2) + - url: https://test.ans.example.org/v2 + description: Test server (v2) + +tags: + - name: Management + description: Agent listing and detail operations (ownership-scoped) + - name: Registration + description: Agent registration and validation operations + - name: Revocation + description: Agent certificate revocation operations + - name: Certificate Management + description: Certificate retrieval, CSR submission, and renewal operations + - name: Events + description: | + Public, unauthenticated agent-lifecycle events feed. Lives on the + `/v1` path (not `/v2`) because it is the production-compatible + ingestion contract the ANS Finder consumes; its shape is pinned to + the production `getAgentEvents` operation. The operation declares a + path-level `servers` override so the documented path resolves at + the host root rather than under this document's `/v2` server prefix. + - name: Verified Identities + description: | + The "who" behind agents — owner-level identities (did:web, + did:key) proven through challenge-bound control proofs, sealed + on their own Transparency Log stream, and linked to the owner's + agents + +# Global security requirement. Every path inherits this and can +# override with `security: []` to declare itself anonymous. The +# handler's actual auth behaviour is documented in CLAUDE.md §Auth: +# static API key (Bearer ) for quickstart, OIDC bearer tokens +# when `auth.type: oidc` is configured. Both providers accept the +# same shape on the wire, which is why a single HTTP-bearer scheme +# covers both in the OpenAPI document. +security: + - bearerAuth: [] + +paths: + # ────────────────────────────────────────────────────────────────── + # Events — public agent-lifecycle feed (the Finder's ingestion source) + # + # Path-level `servers` override: this operation lives at the host + # ROOT (`/v1/agents/events`), not under this document's `/v2` server + # prefix. The override makes the rendered URL correct in Swagger UI. + # The shape is byte-compatible with the production `getAgentEvents` + # operation; the OSS RA owns a conformance test asserting that. + # ────────────────────────────────────────────────────────────────── + /v1/agents/events: + servers: + - url: http://localhost:18080 + description: Local dev — quickstart ra-local.yaml (feed is not under /v2) + - url: https://api.ans.example.org + description: Main ANS API server + get: + tags: + - Events + summary: Retrieve ANS agent lifecycle events + description: | + Returns a paginated, strictly-ordered stream of ANS agent + lifecycle events (registrations and revocations). This is the + ingestion contract the ANS Finder polls to build its discovery + catalog. + + **Anonymous.** This route requires no authentication (`security: []`). + + **Only sealed events appear.** An event is served only after it + has been accepted by the Transparency Log and a `logId` assigned, + so an item in the feed is provably sealed in the log and its + receipt is resolvable from `logId`. + + **Pagination.** Pass the `lastLogId` from a response as the next + request's `lastLogId` query parameter to fetch the following + page. Omit it for the first page. When the response omits + `lastLogId`, there are no more results. + + **Retention.** Events are retained for a bounded window (default + 30 days) and then age out. Because of this, an unknown + `lastLogId` and one that has aged out are indistinguishable: both + restart the stream from the oldest retained event rather than + erroring. + + **providerId.** Accepted for production-shape parity. This + open-source RA has no provider concept, so supplying a non-empty + `providerId` yields an empty page. + operationId: getAgentEvents + security: [] + parameters: + - name: providerId + in: query + description: | + Optional provider identifier. This OSS RA has no provider + concept; a non-empty value returns an empty page. + required: false + schema: + type: string + - name: lastLogId + in: query + description: | + Opaque cursor — the `lastLogId` from a previous response. + Omit for the first page. An unknown or aged-out cursor + restarts from the oldest retained event. + required: false + schema: + type: string + - name: limit + in: query + description: Number of events to return (1-200, default 100) + required: false + schema: + type: integer + minimum: 1 + maximum: 200 + default: 100 + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/EventPageResponse' + '422': + description: Invalid request parameters (out-of-range or non-numeric limit) + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + '500': + description: Internal server error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + + # ────────────────────────────────────────────────────────────────── + # Management — ownership-scoped + # ────────────────────────────────────────────────────────────────── + /ans/agents: + get: + tags: + - Management + summary: List caller's agents + description: | + Returns agents owned by the authenticated caller. This is a management + endpoint, not a discovery/search endpoint. + + **Key differences from v1 `GET /v1/agents`:** + - Results are scoped to the authenticated caller's agents only. + - Uses cursor-based pagination (no `offset`). + - Removed filters: `protocol`, `agentDisplayName`, `version`. + Use the dedicated search service for discovery use cases. + - Removed `totalCount` and `searchCriteria` from response. + + **Pagination:** Use the `nextCursor` value from the response as the + `cursor` query parameter in the next request. When `hasMore` is false, + there are no more results. + operationId: listCallerAgents + parameters: + - name: status + in: query + description: | + Filter by agent lifecycle status. Can specify multiple values. + Defaults to ACTIVE. Use 'ALL' to return agents of any status. + required: false + style: form + explode: true + schema: + type: array + items: + $ref: '#/components/schemas/AgentLifecycleStatusFilter' + - name: agentHost + in: query + description: Filter by agent host domain (exact match) + required: false + schema: + type: string + - name: limit + in: query + description: Maximum number of results to return (default 20, max 100) + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + - name: cursor + in: query + description: | + Opaque pagination cursor from a previous response's `nextCursor`. + Omit for the first page. + required: false + schema: + type: string + responses: + '200': + description: Agent list retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/AgentListResponse' + '401': + description: Authentication failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Authorization failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: Invalid query parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + post: + tags: + - Registration + summary: Register a new agent with the ANS + description: | + Registers a new agent with the Agent Name Service. + + The request MAY include a Certificate Signing Request (CSR) so that + an Identity certificate can be issued. Identity certificates are + issued by the RA. + + The request must include a server CSR (for RA-issued server cert) + or a BYOC server certificate with its chain. + operationId: registerAgent + requestBody: + description: Agent registration request + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AgentRegistrationRequest' + responses: + '202': + description: Registration accepted, pending validation + content: + application/json: + schema: + $ref: '#/components/schemas/RegistrationPending' + '401': + description: Authentication failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Authorization failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: | + Conflict. `code` distinguishes the cause: + `ANS_NAME_TAKEN` (this exact versioned ANS name is already + registered) or `AGENT_HOST_TAKEN` (the FQDN has a live — + ACTIVE or DEPRECATED — registration owned by a different + operator; the FQDN belongs to that owner until no live + registration remains). + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + '422': + description: Invalid registration request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /ans/agents/{agentId}: + get: + tags: + - Management + summary: Get agent details + description: | + Retrieves detailed information about a registered agent. + + **Ownership enforced:** Returns 404 if the authenticated caller does + not own the requested agent. + operationId: getAgent + parameters: + - $ref: '#/components/parameters/AgentIdPath' + responses: + '200': + description: Agent details retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/AgentDetails' + '401': + description: Authentication failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Authorization failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Agent not found or not owned by caller + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + # ────────────────────────────────────────────────────────────────── + # AI Catalog (producer-generated discovery artifact) + # ────────────────────────────────────────────────────────────────── + /ans/agents/{agentId}/catalog-entry: + get: + tags: + - Management + summary: Get the agent's AI Catalog entry + description: | + Returns the agent's AI Catalog `CatalogEntry` — the producer-side + record an external AI Catalog / ARD registry ingests. The entry is + derived entirely from the registration aggregate (display name, + version, endpoints, Transparency-Log badge/receipt URLs); nothing is + sealed or fetched, and it is regenerated on lifecycle events. + + **Owner-scoped, read-only.** Returns 404 if the authenticated caller + does not own the agent (existence is hidden). This route is the + registrant's own view of how their agent appears in a catalog; it is + not the public, crawlable surface. + + **Eligibility:** an entry is produced only for an **ACTIVE** + registration (the entry's SCITT-receipt attestation and TL badge + link to Transparency-Log records that exist only once the agent is + sealed at activation — a pending agent has nothing to link to) that + is versioned and has at least one A2A or MCP endpoint whose + `metaDataUrl` is present and passes the emit-side URL policy + (absolute https, no userinfo/query/fragment, host == agentHost). + Otherwise the route returns 422 `NOT_CATALOG_ELIGIBLE` (the `detail` + names the specific gate: not active, versionless, or no eligible + endpoint). + operationId: getCatalogEntry + parameters: + - $ref: '#/components/parameters/AgentIdPath' + responses: + '200': + description: The agent's CatalogEntry + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogEntry' + '401': + description: Authentication failed + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + '403': + description: Authorization failed + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + '404': + description: Agent not found or not owned by caller + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + '422': + description: | + Registration is not catalog-eligible: not ACTIVE (no + Transparency-Log entry to link to yet), versionless, or no + A2A/MCP endpoint with a policy-passing metaDataUrl. `code` is + `NOT_CATALOG_ELIGIBLE`; `detail` names the specific gate. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + '500': + description: Internal server error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + + /ans/agents/{agentId}/ai-catalog: + get: + tags: + - Management + summary: Get the host-complete AI Catalog document + description: | + Returns the host-complete AI Catalog **document** for the agent's + host — the file an Agent Hosting Platform publishes verbatim at + `/.well-known/ai-catalog.json`. It contains one `CatalogEntry` for + every **ACTIVE**, catalog-eligible agent registered on that host + (deprecated and revoked registrations are pruned from the + well-known file; the population export keeps them, marked). + + **Owner-scoped, read-only.** Returns 404 if the authenticated + caller does not own the agent (existence is hidden). The caller + proves ownership of the keying agent; the document then lists that + owner's ACTIVE catalog-eligible agents on the host (the body is + scoped to the owner, not just the route key, so a shared host never + discloses one owner's agents to another). This is the registrant's + refresh target — not a public, crawlable surface. + + **Caching:** the response carries a strong `ETag` (SHA-256 of the + body). A conditional request with a matching `If-None-Match` + receives `304 Not Modified`. + operationId: getHostCatalog + parameters: + - $ref: '#/components/parameters/AgentIdPath' + responses: + '200': + description: The host-complete AI Catalog document + headers: + ETag: + description: Strong validator (SHA-256 of the document body) + schema: + type: string + Cache-Control: + schema: + type: string + content: + application/ai-catalog+json: + schema: + $ref: '#/components/schemas/CatalogDocument' + '304': + description: Not modified (If-None-Match matched the current ETag) + headers: + ETag: + description: Strong validator (SHA-256 of the document body) + schema: + type: string + Cache-Control: + schema: + type: string + '401': + description: Authentication failed + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + '403': + description: Authorization failed + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + '404': + description: Agent not found or not owned by caller + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + '500': + description: Internal server error + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + + # ────────────────────────────────────────────────────────────────── + # Registration Validation + # ────────────────────────────────────────────────────────────────── + /ans/agents/{agentId}/verify-acme: + post: + tags: + - Registration + summary: Trigger ACME validation + description: | + Initiates domain control validation. The AHP calls this after placing + the ACME challenge token. The RA validates domain control, which is + required before issuing both server and identity certificates. + operationId: validateRegistration + parameters: + - $ref: '#/components/parameters/AgentIdPath' + responses: + '202': + description: Validation successful, certificates pending + content: + application/json: + schema: + $ref: '#/components/schemas/AgentStatus' + '401': + description: Authentication failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Authorization failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Agent not found or not owned by caller + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: | + `AGENT_HOST_TAKEN` — a different operator now holds this FQDN + live (it activated first). This registration lost the host and + was cancelled; it can no longer progress. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + '422': + description: Validation failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /ans/agents/{agentId}/verify-dns: + post: + tags: + - Registration + summary: Verify DNS records configured + description: | + Verifies that all required DNS records have been configured correctly. + This is the final step for external domain registration. + + **Seal-before-success:** activation does not report `ACTIVE` until + the agent's single terminal `AGENT_REGISTERED` event has been + sealed in the Transparency Log. The RA submits the event to the TL + INLINE and only then commits the ACTIVE transition; if the TL is + unavailable the call fails 503 `TL_UNAVAILABLE` (retryable — the + agent stays `PENDING_DNS` and nothing is committed). This is what + guarantees a catalog entry's SCITT-receipt and badge links can only + ever point at a TL record that actually exists. + operationId: verifyDnsRecords + parameters: + - $ref: '#/components/parameters/AgentIdPath' + responses: + '202': + description: DNS verified and AGENT_REGISTERED sealed in the TL; registration active + content: + application/json: + schema: + $ref: '#/components/schemas/AgentStatus' + '401': + description: Authentication failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Authorization failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Agent not found or not owned by caller + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: | + `AGENT_HOST_TAKEN` — a different operator activated a + registration on this FQDN during this registration's pending + window. The FQDN belongs to that owner until no live + registration remains, so this activation is refused. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + '422': + description: DNS records not found or incorrect + content: + application/json: + schema: + $ref: '#/components/schemas/DnsVerificationError' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '503': + description: | + `TL_UNAVAILABLE` — the Transparency Log could not seal the + `AGENT_REGISTERED` event. Retryable: the agent stays + `PENDING_DNS` and nothing is committed; retry verify-dns once + the TL is reachable. + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + + # ────────────────────────────────────────────────────────────────── + # Revocation + # ────────────────────────────────────────────────────────────────── + /ans/agents/{agentId}/revoke: + post: + tags: + - Revocation + summary: Revoke an active agent or cancel a pending registration + description: | + Revokes an active agent or cancels a pending registration. + + **Ownership enforced:** Returns 403 if caller does not own the agent. + + For ACTIVE agents: Revokes due to key compromise, decommissioning, or + other security reasons. Certificate added to CRL, registry entry flagged. + + For PENDING registrations (PENDING_CERTS or PENDING_DNS): Cancels + the registration, revokes any already-issued certificates. + + PENDING_VALIDATION registrations are not cancellable and will + auto-expire. + operationId: revokeAgent + parameters: + - $ref: '#/components/parameters/AgentIdPath' + requestBody: + description: Agent revocation request + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AgentRevocationRequest' + responses: + '200': + description: Agent revoked successfully + content: + application/json: + schema: + $ref: '#/components/schemas/AgentRevocationResponse' + '401': + description: Authentication failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Authorization failed or caller does not own agent + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Agent not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: Invalid revocation request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + # ────────────────────────────────────────────────────────────────── + # Certificate Management — Server + # ────────────────────────────────────────────────────────────────── + /ans/agents/{agentId}/certificates/server: + get: + tags: + - Certificate Management + summary: Get agent's server certificates + description: Retrieves all TLS server certificates for the specified agent. + operationId: getAgentServerCertificates + parameters: + - $ref: '#/components/parameters/AgentIdPath' + responses: + '200': + description: Server certificate(s) retrieved + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/CertificateResponse' + '401': + description: Authentication failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Authorization failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Agent or certificate not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + post: + tags: + - Certificate Management + summary: Submit server certificate CSR + description: | + Submits a CSR for the agent's server certificate. + The response contains a `csrId` matching `CertificateResponse.csrId`. + operationId: submitServerCsr + parameters: + - $ref: '#/components/parameters/AgentIdPath' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CsrSubmissionRequest' + responses: + '202': + description: CSR accepted + content: + application/json: + schema: + $ref: '#/components/schemas/CsrSubmissionResponse' + '401': + description: Authentication failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Authorization failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Agent not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: Invalid CSR + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /ans/agents/{agentId}/certificates/server/renewal: + post: + tags: + - Certificate Management + summary: Submit server certificate renewal + description: | + Initiates server certificate renewal. Supports CSR path (RA issues) + or BYOC path (client provides certificate). Only one pending renewal + allowed per agent. + operationId: submitServerCertRenewal + parameters: + - $ref: '#/components/parameters/AgentIdPath' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ServerCertificateRenewalRequest' + responses: + '202': + description: Renewal accepted + content: + application/json: + schema: + $ref: '#/components/schemas/RenewalSubmissionResponse' + '401': + description: Authentication failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Authorization failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Agent not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: Pending renewal already exists + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: Validation failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + get: + tags: + - Certificate Management + summary: Get pending renewal status + description: Returns current renewal status if one exists. + operationId: getServerCertRenewalStatus + parameters: + - $ref: '#/components/parameters/AgentIdPath' + responses: + '200': + description: Renewal found + content: + application/json: + schema: + $ref: '#/components/schemas/RenewalStatusResponse' + '401': + description: Authentication failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Authorization failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: No renewal exists + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + delete: + tags: + - Certificate Management + summary: Cancel pending renewal + description: | + Cancels the pending server certificate renewal. Marks associated CSR + as REJECTED if applicable. Client can immediately submit a new renewal. + operationId: cancelServerCertRenewal + parameters: + - $ref: '#/components/parameters/AgentIdPath' + responses: + '204': + description: Renewal cancelled + '401': + description: Authentication failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Authorization failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: No pending renewal + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: Renewal already completed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /ans/agents/{agentId}/certificates/server/renewal/verify-acme: + post: + tags: + - Certificate Management + summary: Verify ACME challenges for pending server cert renewal + description: | + Triggers ACME validation for external agents only. + DNS-01 or HTTP-01 challenge verification. + operationId: verifyRenewalAcme + parameters: + - $ref: '#/components/parameters/AgentIdPath' + responses: + '200': + description: BYOC renewal completed synchronously + content: + application/json: + schema: + $ref: '#/components/schemas/RenewalVerificationResponse' + '202': + description: CSR renewal validated, certificate issuance in progress + content: + application/json: + schema: + $ref: '#/components/schemas/RenewalVerificationResponse' + '401': + description: Authentication failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Authorization failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: No pending renewal found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: Validation failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + # ────────────────────────────────────────────────────────────────── + # Certificate Management — Identity + # ────────────────────────────────────────────────────────────────── + /ans/agents/{agentId}/certificates/identity: + get: + tags: + - Certificate Management + summary: Get agent's identity certificates + description: Retrieves all identity certificates for the specified agent. + operationId: getAgentIdentityCertificates + parameters: + - $ref: '#/components/parameters/AgentIdPath' + responses: + '200': + description: Identity certificate(s) retrieved + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/CertificateResponse' + '401': + description: Authentication failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Authorization failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Agent or certificate not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + post: + tags: + - Certificate Management + summary: Submit identity certificate CSR + description: Submits a CSR for the agent's identity certificate. + operationId: submitIdentityCsr + parameters: + - $ref: '#/components/parameters/AgentIdPath' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CsrSubmissionRequest' + responses: + '202': + description: CSR accepted + content: + application/json: + schema: + $ref: '#/components/schemas/CsrSubmissionResponse' + '401': + description: Authentication failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Authorization failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Agent not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: Invalid CSR + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + # ────────────────────────────────────────────────────────────────── + # CSR Status + # ────────────────────────────────────────────────────────────────── + /ans/agents/{agentId}/csrs/{csrId}/status: + get: + tags: + - Certificate Management + summary: Get CSR status + description: Retrieves the current status of a Certificate Signing Request. + operationId: getCsrStatus + parameters: + - $ref: '#/components/parameters/AgentIdPath' + - name: csrId + in: path + description: Unique identifier of the CSR + required: true + schema: + type: string + format: uuid + responses: + '200': + description: CSR status retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/CsrStatusResponse' + '401': + description: Authentication failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Authorization failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: CSR not found or does not belong to this agent + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: Invalid request parameters + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + # ────────────────────────────────────────────────────────────────── + # Verified Identities — the "who" behind agents + # + # An identity is a first-class object owned by the authenticated + # principal (the same owner as the agents), proven through a + # per-kind control proof, sealed onto its own Transparency Log + # stream, and linked to any number of that owner's agents. The + # agent registration surface above is unchanged — agents carry no + # identity fields; the association is the links sub-resource. + # + # The single security invariant: the RA seals an identity + # attestation only after control is PROVEN — a challenge-bound + # signature verified against the identifier's authoritative keys — + # never on resolution alone. + # ────────────────────────────────────────────────────────────────── + /ans/identities: + post: + tags: + - Verified Identities + summary: Register a verified identity + description: | + Registers an identifier (the kind — `did:web`, `did:key` — is + inferred from the value's lexical form, never caller-asserted) + and returns the challenge round to sign: one entry per + eligible key, all over a single anti-replay nonce. The + identity is created in `PENDING_CONTROL`; nothing is sealed + until verify-control passes. + + Re-POSTing the same value while the row is `PENDING_CONTROL` + is the idempotent re-add: the same `identityId` returns with + a fresh nonce (the prior nonce is superseded). A value + already verified — by this owner or any other — returns 409 + `IDENTIFIER_DUPLICATE`. A recognized value whose kind has no + enabled control verifier (e.g. `did:plc`, `did:ion`, until + their verifiers ship) returns 422 + `IDENTIFIER_KIND_UNSUPPORTED`. The `lei` kind additionally + requires a `vleiPresentation` at register time; omitting it + returns 422 `IDENTIFIER_PRESENTATION_REQUIRED`. + operationId: registerIdentity + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/IdentityRegistrationRequest' + responses: + '202': + description: Identity registered — challenges to sign + content: + application/json: + schema: + $ref: '#/components/schemas/IdentityChallengeResponse' + '401': + description: Authentication failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: Identifier already verified (IDENTIFIER_DUPLICATE) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: | + Invalid or unsupported identifier; for the `did:web` kind the + register-time resolution may fail with `DID_RESOLUTION_FAILED` + or `DID_DOCUMENT_ID_MISMATCH`; for the `lei` kind — + `IDENTIFIER_PRESENTATION_REQUIRED`, `LEI_PRESENTATION_INVALID`, + `LEI_MISMATCH`, `LEI_SUBJECT_AID_INVALID`. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '503': + description: | + Upstream unavailable — `LEI_VERIFIER_UNAVAILABLE` (the `lei` + verifier runs synchronously at register; retryable). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + get: + tags: + - Verified Identities + summary: List my identities + description: | + Returns one page of the caller's identities, newest first, in + the same limit + opaque-cursor envelope as + `GET /v2/ans/agents` (`AgentListResponse`): the collection + array is named `items`, alongside `returnedCount`, `limit`, + `nextCursor`, and `hasMore`. Pass `nextCursor` back as + `cursor` for the next page; a null `nextCursor` is the last + page. + operationId: listIdentities + parameters: + - name: limit + in: query + required: false + schema: { type: integer, default: 20, minimum: 1, maximum: 100 } + - name: cursor + in: query + required: false + schema: { type: string } + description: Opaque cursor from the previous page's nextCursor + responses: + '200': + description: One page of the caller's identities + content: + application/json: + schema: + $ref: '#/components/schemas/IdentityListResponse' + '401': + description: Authentication failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /ans/identities/{identityId}: + get: + tags: + - Verified Identities + summary: Get identity detail + description: | + Returns the identity plus its live links. Ownership-scoped: + an identity that doesn't exist or isn't the caller's returns + 404 (existence is hidden). + operationId: getIdentity + parameters: + - $ref: '#/components/parameters/IdentityIdPath' + responses: + '200': + description: Identity detail + content: + application/json: + schema: + $ref: '#/components/schemas/IdentityDetails' + '404': + description: Identity not found or not accessible + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + put: + tags: + - Verified Identities + summary: Rotate / replace the identifier + description: | + Stages a same-kind replacement and returns fresh challenges + over it. Until the new proof lands the previously sealed + state stands; a replacement that never verifies expires with + its nonce. On a clean verify-control the RA swaps the value + and seals ONE `IDENTITY_UPDATED` event — regardless of how + many agents are linked. Cross-kind replacement is rejected + (revoke and register a new identity instead). + operationId: rotateIdentity + parameters: + - $ref: '#/components/parameters/IdentityIdPath' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/IdentityRegistrationRequest' + responses: + '202': + description: Rotation staged — fresh challenges to sign + content: + application/json: + schema: + $ref: '#/components/schemas/IdentityChallengeResponse' + '403': + description: Caller does not own this identity + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Identity not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: Identity is not in a rotatable state + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: | + Invalid replacement value (incl. `IDENTIFIER_KIND_MISMATCH`); + for the `did:web` kind the register-time resolution may fail + with `DID_RESOLUTION_FAILED` or `DID_DOCUMENT_ID_MISMATCH`; + for the `lei` kind — `IDENTIFIER_PRESENTATION_REQUIRED`, + `LEI_PRESENTATION_INVALID`, `LEI_MISMATCH`, + `LEI_SUBJECT_AID_INVALID`. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '503': + description: | + Upstream unavailable — `LEI_VERIFIER_UNAVAILABLE` (the `lei` + verifier runs synchronously at rotate; retryable). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /ans/identities/{identityId}/verify-control: + post: + tags: + - Verified Identities + summary: Prove control of the identifier + description: | + Submits the control proofs: one compact JWS per proven key, + each payload equal — verbatim — to the served `signingInput` + (clients never canonicalize; the RA checks payload equality + before verifying any signature). Every proof must verify + against the identifier's AUTHORITATIVE key for its `kid` + (resolved from the DID document for `did:web`, decoded from + the identifier for `did:key`); one bad proof fails the call + closed. The nonce is consumed exactly once, inside the + success transaction — a failed attempt does not consume it. + + The `lei` kind instead submits a single `cesrSignature` over + the same `signingInput`: the RA forwards it to the + vlei-verifier, which live-rechecks the subject AID's + authorization and verifies the signature against its current + KEL key state. + + JWS bearing unrecognized `crit` header parameters are + rejected (RFC 7515 §4.1.11) — this verifier implements no + critical extensions. + + Seal-before-success: the RA seals `IDENTITY_VERIFIED` / + `IDENTITY_UPDATED` on the identity's own Transparency Log + stream — every proven key sealed self-verifyingly (public + key + signed proof) — and reports success ONLY after the TL + acknowledges the seal; the row transition commits with that + acknowledgment, so anything this API reports as set up is + resolvable in the TL at that moment. If the TL is + unavailable the call fails 503 `TL_UNAVAILABLE`, retryable: + the nonce is NOT consumed and the prior state stands. + operationId: verifyIdentityControl + parameters: + - $ref: '#/components/parameters/IdentityIdPath' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/VerifyControlRequest' + responses: + '200': + description: Control proven — identity VERIFIED, event sealed + content: + application/json: + schema: + $ref: '#/components/schemas/IdentityDetails' + '403': + description: Caller does not own this identity + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Identity not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: | + Challenge state error — `PRICC_TOKEN_EXPIRED`, + `PRICC_TOKEN_ALREADY_USED`, `IDENTIFIER_CHALLENGE_EXPIRED` + — the identity is revoked, a concurrent verify attempt + holds the seal claim (`VERIFICATION_IN_FLIGHT` — retry + shortly; the claim expires within ~30s), or the + identifier is already verified by another owner + (`IDENTIFIER_DUPLICATE`). Recovery from an expired nonce + is the idempotent re-add (re-POST the same value). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: | + Proof rejected — `PRICC_SIGNATURE_INVALID`, + `DID_VERIFICATION_METHOD_INVALID`, `DID_RESOLUTION_FAILED`, + `DID_DOCUMENT_ID_MISMATCH`, `IDENTIFIER_PROOF_INVALID`, `LEI_NOT_AUTHORIZED`, `LEI_MISMATCH`. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + '503': + description: Upstream unavailable (TL_UNAVAILABLE, or LEI_VERIFIER_UNAVAILABLE — retryable; the nonce is NOT consumed) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /ans/identities/{identityId}/revoke: + post: + tags: + - Verified Identities + summary: Revoke the identity + description: | + Transitions a VERIFIED identity to REVOKED and seals ONE + `IDENTITY_REVOKED` event. A POST (state change), never a + DELETE: an identity cannot be deleted — its history is + append-only in the Transparency Log. Propagation to every + linked agent's badge is the TL's read-time join, not a write + fan-out. + + Seal-before-success: the row flips only after the TL + acknowledges the seal. TL unavailable → 503 `TL_UNAVAILABLE`, + retryable, the identity stays VERIFIED. + operationId: revokeIdentity + parameters: + - $ref: '#/components/parameters/IdentityIdPath' + responses: + '200': + description: Identity revoked, event sealed + content: + application/json: + schema: + $ref: '#/components/schemas/IdentityDetails' + '403': + description: Caller does not own this identity + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Identity not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: Identity is not VERIFIED (nothing sealed to revoke) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '503': + description: Transparency Log unavailable (TL_UNAVAILABLE — retryable, nothing changed) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /ans/identities/{identityId}/links: + post: + tags: + - Verified Identities + summary: Link agents to the identity + description: | + Binds a batch of the caller's agents to the identity — a + single owner-gated call with no challenge and no signature: + the caller MUST own the identity AND every named agent (key + possession never authorizes a link). The whole batch seals as + ONE `IDENTITY_LINKED` event on the IDENTITY stream carrying + the agent ids; an agent's own stream and audit history are + never written by identity operations. Already-linked agents + are skipped idempotently; a call that links nothing new seals + nothing. + + Liveness gate: links attach only while the identity is + VERIFIED, and every named agent must be live — ACTIVE or + DEPRECATED; a terminal or pre-activation agent fails the + whole batch with 422 `AGENT_NOT_LINKABLE` (all-or-nothing, + matching the one-event batch semantics). The route is + per-owner rate limited (429-equivalent `RATE_LIMITED`). + + Seal-before-success: success is reported only after the TL + acknowledges the `IDENTITY_LINKED` seal; link rows commit + with the acknowledgment. TL unavailable → 503 + `TL_UNAVAILABLE`, retryable, nothing linked. + operationId: linkIdentityAgents + parameters: + - $ref: '#/components/parameters/IdentityIdPath' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/IdentityLinkRequest' + responses: + '200': + description: Batch linked (count of newly-created links) + content: + application/json: + schema: + $ref: '#/components/schemas/IdentityLinkResponse' + '403': + description: Caller does not own this identity + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Identity, or a named agent, not found / not the caller's + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '409': + description: Identity is not VERIFIED (IDENTITY_NOT_VERIFIED) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '422': + description: Invalid link request (empty or oversized batch — INVALID_LINK_REQUEST), or a named agent is not live (AGENT_NOT_LINKABLE — links require ACTIVE or DEPRECATED; rejected all-or-nothing) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + '503': + description: Transparency Log unavailable (TL_UNAVAILABLE — retryable, nothing linked) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /ans/identities/{identityId}/links/{agentId}: + delete: + tags: + - Verified Identities + summary: Unlink an agent + description: | + Ends one association and seals `IDENTITY_UNLINKED` on the + identity stream. The association's history persists in the + identity's audit chain and the raw log tiles; unlinked pairs + may be re-linked later. Shares the link route's per-owner + rate limit. + + Seal-before-success: the link row flips only after the TL + acknowledges the seal. TL unavailable → 503 `TL_UNAVAILABLE`, + retryable, the link stands. + operationId: unlinkIdentityAgent + parameters: + - $ref: '#/components/parameters/IdentityIdPath' + - $ref: '#/components/parameters/AgentIdPath' + responses: + '204': + description: Link removed, event sealed + '403': + description: Caller does not own this identity + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Identity or live link not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '503': + description: Transparency Log unavailable (TL_UNAVAILABLE — retryable, nothing changed) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + +components: + # ────────────────────────────────────────────────────────────────── + # Security schemes — both providers (static API key + OIDC) accept + # the same `Authorization: Bearer ` shape on the wire, so + # one HTTP-bearer definition covers both. Swagger UI renders an + # "Authorize" button from this definition; paste your API key + # (quickstart default: `ans-dev-key-change-me`) as the bearer value + # and "Try it out" will include it on subsequent requests. + # ────────────────────────────────────────────────────────────────── + securitySchemes: + bearerAuth: + type: http + scheme: bearer + description: | + Static API key or OIDC bearer token. The RA treats the static + key as admin; OIDC tokens inherit admin-ness from configured + admin-groups (see `auth.oidc.admin-groups` in the config + reference). Public-read routes (TL verifier GETs) and + `/v2/admin/{health,ready}` and `/docs` ignore this header. + + # ────────────────────────────────────────────────────────────────── + # Reusable parameters + # ────────────────────────────────────────────────────────────────── + parameters: + AgentIdPath: + name: agentId + in: path + description: Unique identifier of the agent (UUID) + required: true + schema: + type: string + format: uuid + + IdentityIdPath: + name: identityId + in: path + description: Unique identifier of the verified identity (UUIDv7) + required: true + schema: + type: string + + # ────────────────────────────────────────────────────────────────── + # Schemas + # ────────────────────────────────────────────────────────────────── + schemas: + # ── New: Cursor-paginated list response ────────────────────── + AgentListResponse: + type: object + description: | + Ownership-scoped, cursor-paginated list of the caller's agents. + Replaces v1's `AgentSearchResponse`. Collection array is named + `items` per standard REST collection-response conventions. + properties: + items: + type: array + description: List of agents owned by the caller + items: + type: object + properties: + ansName: + type: string + example: ans://v1.0.0.myagent.example.com + agentId: + type: string + format: uuid + agentDisplayName: + type: string + maxLength: 64 + agentDescription: + type: string + version: + type: string + agentHost: + type: string + maxLength: 253 + example: myagent.example.com + status: + $ref: '#/components/schemas/AgentLifecycleStatus' + ttl: + type: integer + default: 300 + registrationTimestamp: + type: string + format: date-time + endpoints: + type: array + items: + $ref: '#/components/schemas/AgentEndpoint' + links: + type: array + items: + $ref: '#/components/schemas/Link' + required: + - ansName + - agentId + - agentDisplayName + - version + - agentHost + - status + - endpoints + - links + returnedCount: + type: integer + description: Number of agents in this response + limit: + type: integer + description: Limit that was applied + nextCursor: + type: string + nullable: true + description: | + Opaque cursor for the next page. Pass as the `cursor` query + parameter to retrieve the next page. Null when there are no + more results. + hasMore: + type: boolean + description: Whether more results exist beyond this page + required: + - items + - returnedCount + - limit + - hasMore + + # ── Unchanged schemas carried forward from v1 ──────────────── + Link: + type: object + properties: + rel: + type: string + example: agent-details + href: + type: string + format: uri + required: + - rel + - href + + Protocol: + type: string + enum: [A2A, MCP, HTTP_API] + + DiscoveryProfile: + type: string + enum: [ANS_DNSAID, ANS_TXT] + description: | + Names one DNS record family the RA can emit for an agent + registration. Used as the element type of discoveryProfiles[]. + Each value provisions a complete record set, not a single + record — the bullets below enumerate exactly what an operator + must publish for that profile. + + - ANS_DNSAID: the DNS-AID-aligned Consolidated Approach (RFC + 9460), and the server default when discoveryProfiles is + omitted. Provisions: + 1. One SVCB row per protocol endpoint at the bare FQDN + (ServiceMode `1 .`), carrying `alpn` (the protocol + token: a2a / mcp / x-http), `port` (the endpoint's TLS + port), `key65402` (bap, the agent protocol, always + present), and — when the endpoint supplies them — + `key65400` (cap, the metadataUrl capability locator), + `key65401` (cap-sha256, the base64url capability digest, + present only when the endpoint declared a metaDataHash), + and `key65409` (the well-known path suffix, emitted only + when metadataUrl sits at + https://{fqdn}/.well-known/). + key65400/key65401/key65402/key65409 are the RFC 9460 + §14.3.1 Private Use presentation of the DNS-AID draft-02 + cap / cap-sha256 / bap / well-known SvcParams, which have + no IANA code point yet; the named forms are unpublishable + (strict RFC 9460 parsers reject them), so the keyNNNNN + forms are what reaches DNS. They switch back to named + forms if/when IANA registers the keys. + 2. One `_ans-badge` TXT at `_ans-badge.{fqdn}` (the + transparency-log discovery hint). + 3. One TLSA per distinct TLS port at `_._tcp.{fqdn}` + binding the server certificate (DANE-EE, full cert, + SHA-256), emitted only when a server certificate exists. + - ANS_TXT: original `_ans` TXT shape (one row per protocol at + `_ans.{fqdn}`), supported indefinitely for operators with + existing zone-edit tooling that targets `_ans.{fqdn}`; + opt-in via an explicit discoveryProfiles value. Emits an + HTTPS RR at the bare FQDN alongside carrying only `alpn=h2` + (an IANA-registered SvcParam — no keyNNNNN form is needed + here; the asymmetry with ANS_DNSAID is intentional), since + `_ans` TXT carries no connection hints. The HTTPS RR is + best-effort: operators on CNAME-fronted apexes cannot publish + a record at that name (RFC 1034 §3.6.2) and verification does + not require it. Provisions the same `_ans-badge` TXT and + per-port TLSA records as ANS_DNSAID. + + RevocationReason: + type: string + enum: + - KEY_COMPROMISE + - CESSATION_OF_OPERATION + - AFFILIATION_CHANGED + - SUPERSEDED + - CERTIFICATE_HOLD + - PRIVILEGE_WITHDRAWN + - AA_COMPROMISE + + AgentRegistrationRequest: + type: object + properties: + agentDisplayName: + type: string + maxLength: 64 + example: sentiment-analyzer + agentDescription: + type: string + maxLength: 150 + version: + type: string + pattern: '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$' + example: 1.2.0 + agentHost: + type: string + maxLength: 253 + example: myagent.example.com + endpoints: + type: array + minItems: 1 + description: >- + One or more protocol endpoints. Each protocol may appear at + most once across the array — a second endpoint with an + already-used protocol is rejected with 422 DUPLICATE_PROTOCOL, + and an exact protocol+agentUrl repeat with 422 + DUPLICATE_ENDPOINT. (By-field uniqueness is not expressible in + OpenAPI/JSON Schema; enforced in + internal/domain/endpoint.go AgentEndpoints.Validate.) + items: + $ref: '#/components/schemas/AgentEndpoint' + serverCsrPEM: + type: string + serverCertificatePEM: + type: string + serverCertificateChainPEM: + type: string + identityCsrPEM: + type: string + description: >- + Optional. When supplied, the RA issues an identity certificate + from this CSR at verify-acme. When omitted, the agent registers + without an identity certificate and cannot add one later — it + must register a new version instead. + discoveryProfiles: + type: array + items: + $ref: '#/components/schemas/DiscoveryProfile' + uniqueItems: true + minItems: 1 + default: ["ANS_DNSAID"] + description: | + Set of DNS record families the RA tells the operator to + publish and emits in the AGENT_REGISTERED TL event's + attestations.dnsRecordsProvisioned[]. The computed records + surface to the client on GET /v2/ans/agents/{agentId} as + registrationPending.dnsRecords[] once the agent reaches + PENDING_DNS — not on the 202 register response, which + returns only the ACME challenge (production records are + deferred until verify-acme proves domain control and issues + the certificates the TLSA binding depends on). + + Each value names one record family; an operator publishing + the union (DNS-AID-aligned SVCB plus the original `_ans` TXT + shape) sends both. Order is not significant. + + Optional. Omitted (or an explicit empty array) normalizes to + the default ["ANS_DNSAID"] server-side; opt into the legacy + ["ANS_TXT"] shape explicitly. The `minItems`/`uniqueItems` + schema constraints are the canonical client contract for a + present array — validate before sending. A non-conformant + request (an explicit empty array, or duplicate values) is + handled defensively rather than rejected: empty is treated + as omitted and duplicates are ignored, but conformant + clients never send either. + example: ["ANS_DNSAID"] + required: + - agentDisplayName + - agentHost + - endpoints + - version + oneOf: + - required: [serverCsrPEM] + - required: [serverCertificatePEM] + + AgentRevocationRequest: + type: object + properties: + reason: + $ref: '#/components/schemas/RevocationReason' + comments: + type: string + maxLength: 200 + required: + - reason + + AgentRevocationResponse: + type: object + properties: + agentId: + type: string + format: uuid + ansName: + type: string + status: + $ref: '#/components/schemas/AgentLifecycleStatus' + revokedAt: + type: string + format: date-time + reason: + $ref: '#/components/schemas/RevocationReason' + dnsRecordsToRemove: + type: array + items: + $ref: '#/components/schemas/DnsRecord' + links: + type: array + items: + $ref: '#/components/schemas/Link' + required: + - agentId + - ansName + - status + - revokedAt + - reason + - links + + AgentDetails: + type: object + properties: + agentId: + type: string + format: uuid + agentDisplayName: + type: string + maxLength: 64 + agentDescription: + type: string + version: + type: string + agentHost: + type: string + maxLength: 253 + endpoints: + type: array + items: + $ref: '#/components/schemas/AgentEndpoint' + ansName: + type: string + example: ans://v1.0.0.myagent.example.com + agentStatus: + $ref: '#/components/schemas/AgentLifecycleStatus' + registrationTimestamp: + type: string + format: date-time + lastRenewalTimestamp: + type: string + format: date-time + registrationPending: + $ref: '#/components/schemas/RegistrationPending' + links: + type: array + items: + $ref: '#/components/schemas/Link' + identities: + type: array + description: | + Additive, optional, COMPUTED — the verified identities + currently linked to this agent, joined from the link rows + at read time. Never stored on the registration; identity + rotation/revocation is visible here immediately with zero + agent-side writes. + items: + $ref: '#/components/schemas/LinkedIdentity' + required: + - agentId + - ansName + - agentDisplayName + - version + - endpoints + - agentHost + - agentStatus + - links + + AgentStatus: + type: object + properties: + status: + $ref: '#/components/schemas/AgentLifecycleStatus' + phase: + type: string + enum: [INITIALIZATION, DOMAIN_VALIDATION, CERTIFICATE_ISSUANCE, DNS_PROVISIONING, COMPLETED] + completedSteps: + type: array + items: + type: string + pendingSteps: + type: array + items: + type: string + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + expiresAt: + type: string + format: date-time + + AgentLifecycleStatus: + type: string + enum: [PENDING_VALIDATION, PENDING_DNS, ACTIVE, FAILED, EXPIRED, DEPRECATED, REVOKED] + + AgentLifecycleStatusFilter: + type: string + enum: [PENDING_DNS, ACTIVE, DEPRECATED, REVOKED, ALL] + + RegistrationPending: + type: object + properties: + agentId: + type: string + format: uuid + description: | + Unique identifier assigned to this agent registration. Required for + all subsequent API calls (verify-acme, verify-dns, certificates, + revoke). New in v2 — previously callers had to parse this from + HATEOAS link hrefs. Recommended for backport to v1. + status: + type: string + enum: [PENDING_VALIDATION, PENDING_CERTS, PENDING_DNS] + ansName: + type: string + example: ans://v1.0.0.external-domain.com + nextSteps: + type: array + items: + $ref: '#/components/schemas/NextStep' + challenges: + type: array + items: + $ref: '#/components/schemas/ChallengeInfo' + dnsRecords: + type: array + items: + $ref: '#/components/schemas/DnsRecord' + expiresAt: + type: string + format: date-time + links: + type: array + items: + $ref: '#/components/schemas/Link' + required: + - agentId + - status + - ansName + - nextSteps + + # ── AI Catalog (producer-generated discovery artifact) ────────── + CatalogEntry: + type: object + description: | + One artifact's AI Catalog record, derived from a registration (AI + Catalog draft 2026-06-11 §4.4). A bare entry is served as + application/json — it is not itself a catalog document and carries no + `specVersion`. Exactly one of `url` or `data` is present: a + single-protocol entry carries `url`; a multi-protocol agent carries + `data` (an inline nested catalog of per-protocol children) with + `mediaType` `application/ai-catalog+json`. + properties: + identifier: + type: string + description: | + Stable, version-spanning lineage handle + `urn:air:{agentHost}:agents:{label}`, where `{label}` is the + agent's display name with whitespace runs collapsed to single + hyphens — the same derivation the ARD discovery service (the + Finder) mints from feed events, so search results and the + published catalog carry one identifier per agent. Never the + per-version agentId. + example: urn:air:ai-agent.acmecorp.com:agents:Support-Assistant + displayName: + type: string + maxLength: 64 + description: + type: string + maxLength: 150 + version: + type: string + example: 2.1.0 + mediaType: + type: string + description: | + Artifact discriminator: `application/a2a-agent-card+json`, + `application/mcp-server-card+json`, or + `application/ai-catalog+json` for a multi-protocol nested entry. + example: application/a2a-agent-card+json + url: + type: string + format: uri + description: | + The endpoint's metaDataUrl (the protocol card location). Present + on a single-protocol or nested-child entry. Mutually exclusive + with `data`. + data: + allOf: + - $ref: '#/components/schemas/CatalogNested' + description: | + Inline nested catalog of per-protocol children, present only on a + multi-protocol outer entry. Mutually exclusive with `url`. + tags: + type: array + items: + type: string + updatedAt: + type: string + format: date-time + publisher: + $ref: '#/components/schemas/CatalogPublisher' + metadata: + $ref: '#/components/schemas/CatalogEntryMetadata' + trustManifest: + $ref: '#/components/schemas/CatalogTrustManifest' + required: + - identifier + - displayName + - mediaType + + CatalogNested: + type: object + description: | + Inline nested catalog held in a multi-protocol entry's `data` (AI + Catalog §6.1 / IMPL §3.5). + properties: + specVersion: + type: string + example: "1.0" + entries: + type: array + items: + $ref: '#/components/schemas/CatalogEntry' + required: + - specVersion + - entries + + CatalogDocument: + type: object + description: | + A host-complete AI Catalog document (AI Catalog §4.2), served as + application/ai-catalog+json. The file an AHP publishes at + `/.well-known/ai-catalog.json`; `entries` carries one CatalogEntry + per ACTIVE, catalog-eligible agent on the host (sorted by + identifier then version for a stable ETag). + properties: + specVersion: + type: string + example: "1.0" + host: + $ref: '#/components/schemas/CatalogHostInfo' + entries: + type: array + items: + $ref: '#/components/schemas/CatalogEntry' + required: + - specVersion + - entries + + CatalogHostInfo: + type: object + description: | + Host Info object (AI Catalog §4.3) identifying the operator of a + catalog document. `displayName` is required within Host Info; for a + per-host document both fields are the agentHost. + properties: + identifier: + type: string + example: ai-agent.acmecorp.com + displayName: + type: string + example: ai-agent.acmecorp.com + required: + - displayName + + CatalogPublisher: + type: object + description: | + Publishing entity (AI Catalog §4.6). DNS-anchored in this RA; + `identifier` and `displayName` are both the agentHost. + properties: + identifier: + type: string + example: ai-agent.acmecorp.com + displayName: + type: string + example: ai-agent.acmecorp.com + identityType: + type: string + example: dns + required: + - identifier + - displayName + + CatalogEntryMetadata: + type: object + description: | + Entry-level identifiers a consumer verifies against (IMPL §5.3). No + `logId` — the TL is keyed by agentId, not logId. + properties: + ansName: + type: string + example: ans://v2.1.0.ai-agent.acmecorp.com + agentHost: + type: string + example: ai-agent.acmecorp.com + badgeUrl: + type: string + format: uri + description: | + TL status / card-integrity surface (`/v1/agents/{agentId}`). + Omitted when no TL base URL is configured. + required: + - ansName + - agentHost + + CatalogTrustManifest: + type: object + description: | + Verifiable identity + trust metadata (AI Catalog §5). `identity` + MUST equal the containing entry's `identifier`. + properties: + identity: + type: string + example: urn:air:ai-agent.acmecorp.com:agents:ai-agent + attestations: + type: array + description: | + Present only when a TL base URL is configured. The slice-1 + attestation is the ANS-Registration SCITT receipt. + items: + $ref: '#/components/schemas/CatalogAttestation' + required: + - identity + + CatalogAttestation: + type: object + description: | + A verifiable claim (AI Catalog §5.4). The ANS-Registration + attestation points at the agent's SCITT receipt on the Transparency + Log; it carries no digest (the receipt proves inclusion, not entry + content). + properties: + type: + type: string + example: ANS-Registration + uri: + type: string + format: uri + example: https://tl.example.org/v1/agents/550e8400-e29b-41d4-a716-446655440000/receipt + mediaType: + type: string + example: application/scitt-receipt+cose + required: + - type + - uri + - mediaType + + NextStep: + type: object + properties: + action: + type: string + enum: [CONFIGURE_DNS, CONFIGURE_HTTP, VERIFY_DNS, VALIDATE_DOMAIN, WAIT, CANCEL] + description: + type: string + endpoint: + type: string + format: uri + estimatedTimeMinutes: + type: integer + + ChallengeInfo: + type: object + properties: + type: + type: string + enum: [DNS_01, HTTP_01] + token: + type: string + keyAuthorization: + type: string + dnsRecord: + type: object + properties: + name: + type: string + type: + type: string + value: + type: string + httpPath: + type: string + expiresAt: + type: string + format: date-time + + DnsRecord: + type: object + properties: + name: + type: string + type: + type: string + enum: [HTTPS, SVCB, TLSA, TXT] + value: + type: string + priority: + type: integer + ttl: + type: integer + default: 3600 + purpose: + type: string + enum: [DISCOVERY, TRUST, CERTIFICATE_BINDING, BADGE] + required: + type: boolean + default: true + required: + - name + - type + - value + + AgentEndpoint: + type: object + properties: + agentUrl: + type: string + format: uri + example: https://myagent.example.com/mcp + metaDataUrl: + type: string + format: uri + maxLength: 2048 + description: >- + Optional. The endpoint's metadata descriptor URL. Must be + https and free of whitespace, quotes, and other characters + that require SVCB presentation escaping — it is emitted + verbatim as the ANS_DNSAID `cap` SvcParam (key65400) and is + the document `cap-sha256` (key65401) digests. When it sits at + https://{agentHost}/.well-known/, the suffix is also + advertised as the `well-known` SvcParam (key65409). NOTE: a + `cap` whose host differs from the agent FQDN is published + without an integrity pin unless metaDataHash is also supplied + — consumers SHOULD prefer a metaDataHash-pinned descriptor for + off-host metadata. + metaDataHash: + type: string + pattern: '^SHA256:[a-f0-9]{64}$' + description: >- + Optional integrity pin over the metadata descriptor. + Meaningless on its own: it MUST be accompanied by metaDataUrl. + A metaDataHash without a metaDataUrl is rejected with 422 INVALID_ENDPOINT. + documentationUrl: + type: string + format: uri + protocol: + $ref: '#/components/schemas/Protocol' + functions: + type: array + items: + $ref: '#/components/schemas/AgentFunction' + transports: + type: array + items: + type: string + enum: [STREAMABLE_HTTP, SSE, JSON_RPC, GRPC, REST, HTTP] + required: + - protocol + - agentUrl + anyOf: + - not: + required: [metaDataHash] + - required: [metaDataUrl] + + AgentFunction: + type: object + properties: + id: + type: string + maxLength: 64 + name: + type: string + maxLength: 64 + tags: + type: array + maxItems: 5 + items: + type: string + maxLength: 20 + required: + - id + - name + + # ── Events feed (GET /v1/agents/events) ────────────────────── + # Byte-compatible with the production `getAgentEvents` response. + # Reuses AgentEndpoint / AgentFunction above (the feed's endpoint + # shape is the same minus metaDataHash, which the feed never emits). + EventPageResponse: + type: object + description: Paginated response containing ANS events. + properties: + items: + type: array + description: Array of event items (always present; `[]` when empty). + items: + $ref: '#/components/schemas/EventItem' + lastLogId: + type: string + description: | + The logId of the last event in this page. Pass it as the next + request's `lastLogId` to fetch the following page. Omitted + when there are no more results. + required: + - items + + EventItem: + type: object + description: One ANS lifecycle event with agent details and event metadata. + properties: + logId: + type: string + description: Unique identifier for this event in the stream. + example: 019a7a52-e5bf-7b5b-b048-d0b78f4b4c5f + eventType: + type: string + description: Type of ANS event. + enum: [AGENT_DEPRECATED, AGENT_REGISTERED, AGENT_REVOKED, AGENT_RENEWED] + example: AGENT_REGISTERED + createdAt: + type: string + format: date-time + description: Timestamp when the event was created (producer time). + example: '2025-01-08T12:30:00Z' + expiresAt: + type: string + format: date-time + description: When the agent's registration expires (if applicable). + example: '2026-01-08T12:30:00Z' + agentId: + type: string + format: uuid + description: Unique identifier of the agent. + example: 550e8400-e29b-41d4-a716-446655440000 + ansName: + type: string + description: Fully qualified ANS name (ans://{version}.{agentHost}). + example: ans://v1.0.0.myagent.example.com + agentHost: + type: string + maxLength: 253 + description: The agent's hosting domain. + example: myagent.example.com + agentDisplayName: + type: string + maxLength: 64 + description: Human-readable display name for the agent. + example: Sentiment Analyzer + agentDescription: + type: string + maxLength: 150 + description: Description of the agent. + example: An agent that analyzes sentiment in text + version: + type: string + description: Semantic version of the agent. + example: 1.0.0 + providerId: + type: string + description: | + Provider identifier (production field). This OSS RA never + emits it — the only principal id is the registrant's owner + id, which is not exposed. + example: PC_1234567890 + endpoints: + type: array + description: Agent endpoints with protocol-specific configuration. + items: + $ref: '#/components/schemas/AgentEndpoint' + required: + - logId + - eventType + - createdAt + - agentId + - ansName + - agentHost + - version + + CertificateResponse: + type: object + properties: + csrId: + type: string + format: uuid + certificateSubject: + type: string + nullable: true + certificateIssuer: + type: string + nullable: true + certificateSerialNumber: + type: string + nullable: true + certificateValidFrom: + type: string + format: date-time + certificateValidTo: + type: string + format: date-time + certificatePEM: + type: string + chainPEM: + type: string + nullable: true + certificatePublicKeyAlgorithm: + type: string + nullable: true + certificateSignatureAlgorithm: + type: string + nullable: true + required: + - csrId + - certificatePEM + - certificateValidFrom + - certificateValidTo + + CsrSubmissionRequest: + type: object + properties: + csrPEM: + type: string + required: + - csrPEM + + CsrSubmissionResponse: + type: object + properties: + csrId: + type: string + format: uuid + message: + type: string + required: + - csrId + + CsrStatusResponse: + type: object + properties: + csrId: + type: string + format: uuid + type: + type: string + enum: [SERVER, IDENTITY] + status: + type: string + enum: [PENDING, SIGNED, REJECTED] + submittedAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + failureReason: + type: string + nullable: true + required: + - csrId + - type + - status + - submittedAt + - updatedAt + + ServerCertificateRenewalRequest: + type: object + properties: + serverCsrPEM: + type: string + serverCertificatePEM: + type: string + serverCertificateChainPEM: + type: string + oneOf: + - required: [serverCsrPEM] + - required: [serverCertificatePEM] + + RenewalSubmissionResponse: + type: object + properties: + renewalType: + type: string + enum: [SERVER_CSR, SERVER_BYOC] + status: + type: string + enum: [PENDING_VALIDATION, ISSUING_CERTIFICATE] + csrId: + type: string + format: uuid + nullable: true + challenges: + type: object + nullable: true + properties: + dns01: + $ref: '#/components/schemas/ChallengeInfo' + http01: + $ref: '#/components/schemas/ChallengeInfo' + expiresAt: + type: string + format: date-time + nextStep: + $ref: '#/components/schemas/NextStep' + links: + type: array + items: + $ref: '#/components/schemas/Link' + required: + - renewalType + - status + - expiresAt + - nextStep + + RenewalStatusResponse: + type: object + properties: + renewalType: + type: string + enum: [SERVER_CSR, SERVER_BYOC] + status: + type: string + enum: [PENDING_VALIDATION, ISSUING_CERTIFICATE, COMPLETED, FAILED, EXPIRED] + csrId: + type: string + format: uuid + nullable: true + challenges: + type: object + nullable: true + properties: + dns01: + $ref: '#/components/schemas/ChallengeInfo' + http01: + $ref: '#/components/schemas/ChallengeInfo' + tlsaDnsRecord: + $ref: '#/components/schemas/DnsRecord' + nullable: true + failureReason: + type: string + nullable: true + expiresAt: + type: string + format: date-time + nextStep: + $ref: '#/components/schemas/NextStep' + required: + - renewalType + - status + - expiresAt + - nextStep + + RenewalVerificationResponse: + type: object + properties: + status: + type: string + enum: [VERIFIED, ISSUING_CERTIFICATE, COMPLETED] + csrId: + type: string + format: uuid + nullable: true + tlsaDnsRecord: + $ref: '#/components/schemas/DnsRecord' + nullable: true + nextStep: + $ref: '#/components/schemas/NextStep' + required: + - status + - nextStep + + Problem: + type: object + description: | + RFC 7807 problem details — the body every RA error response + carries, served as `application/problem+json`. `type`, `title`, + and `status` are always present; `detail` is the human-readable + explanation specific to this occurrence, and `code` is a stable, + machine-readable error identifier (read `code` for programmatic + handling, `detail` for display). + properties: + type: + type: string + description: A URI reference identifying the problem type. + example: about:blank + title: + type: string + description: Short, human-readable summary of the problem type. + example: Validation Failed + status: + type: integer + description: The HTTP status code for this occurrence. + example: 422 + detail: + type: string + description: Human-readable explanation specific to this occurrence. + code: + type: string + description: Stable, machine-readable error code. + example: INVALID_LIMIT + required: + - type + - title + - status + + Problem: + type: object + description: | + RFC 7807 Problem Details — the actual error shape every RA failure + path emits, served as `application/problem+json`. Clients read + `code` for programmatic handling and `detail` for the + human-readable explanation. + + (The legacy `ErrorResponse` schema below is retained only for the + pre-existing routes still declared against it; new routes point + here. Migrating the remaining declarations is tracked separately.) + properties: + type: + type: string + description: URI reference identifying the problem type. + title: + type: string + description: Short, human-readable summary of the problem type. + status: + type: integer + description: HTTP status code. + detail: + type: string + description: Human-readable explanation specific to this occurrence. + code: + type: string + description: Stable, machine-readable error code for programmatic handling. + required: + - title + - status + - code + + ErrorResponse: + type: object + properties: + status: + type: string + enum: [ERROR] + code: + type: string + message: + type: string + details: + type: object + additionalProperties: true + required: + - status + - code + - message + + DnsVerificationError: + type: object + properties: + status: + type: string + enum: [ERROR] + missingRecords: + type: array + items: + $ref: '#/components/schemas/DnsRecord' + incorrectRecords: + type: array + items: + type: object + properties: + record: + $ref: '#/components/schemas/DnsRecord' + found: + type: string + description: | + The live record value observed when a record exists + but does not match the required value. + expected: + type: string + description: | + The required value; equals record.value. + + # ──────────────────────────────────────────────────────────────── + # Verified Identities + # ──────────────────────────────────────────────────────────────── + IdentityRegistrationRequest: + type: object + description: | + Registers (POST) or rotates (PUT) an identifier. The kind is + inferred from the value's lexical form — `did:web:` prefix, + `did:key:` prefix, or a 20-character LEI — never + caller-asserted. + properties: + value: + type: string + description: The identifier to prove control of + example: did:web:identity.acme-corp.com + vleiPresentation: + $ref: '#/components/schemas/VLEIPresentation' + required: + - value + + VLEIPresentation: + type: object + description: | + The lei (vLEI) register-time credential presentation. REQUIRED + for the `lei` kind and omitted for the JWS kinds (`did:web`, + `did:key`). The RA submits the CESR to its configured + vlei-verifier, which derives and pins the subject AID; the + 202's `presentationStatus` reports the verifier's advisory + authorization decision. + properties: + cesr: + type: string + description: | + The full-chain CESR export of the vLEI credential and its + supporting KELs/ACDCs (the `credentials().get(said, true)` + shape). The RA never parses KERI key state itself — the + verifier is the authoritative key-state oracle. + required: + - cesr + + IdentityChallengeResponse: + type: object + description: | + The 202 challenge round. Every entry shares the same + anti-replay nonce and the same signingInput — the input is + key-independent; entries enumerate the keys the resolver + could see in advance (a single unkeyed entry when it could + not — name keys via the JWS `kid` header at verify time). + properties: + identityId: + type: string + description: RA-assigned UUIDv7 — the TL stream key + kind: + type: string + enum: ['did:web', 'did:key', 'lei'] + value: + type: string + description: The canonical identifier this round proves + status: + $ref: '#/components/schemas/IdentityLifecycleStatus' + nonce: + type: string + description: Base64url 32-byte single-use anti-replay nonce + expiresAt: + type: string + format: date-time + challenges: + type: array + items: + $ref: '#/components/schemas/IdentityProofChallenge' + presentationStatus: + type: string + enum: [AUTHORIZED, PENDING] + description: | + The lei register-time advisory authorization status from + the vlei-verifier (`AUTHORIZED` | `PENDING`). Omitted for + kinds with no register-time presentation (`did:web`, + `did:key`). Advisory only — control is finally established + by a LIVE re-authorization at verify-control. + required: + - identityId + - kind + - value + - status + - nonce + - expiresAt + - challenges + + IdentityProofChallenge: + type: object + properties: + kid: + type: string + description: | + Verification-method id eligible to sign this round + (omitted when the resolver could not enumerate keys) + example: did:web:identity.acme-corp.com#key-1 + signingInput: + type: string + description: | + Base64url of the exact RFC 8785 (JCS) canonical + IdentityProofInput bytes — {identifier, identityId, + nonce, purpose:"ans:identity-proof:v1", raId, scheme}. A + compact JWS's payload segment MUST equal this string + verbatim; clients never canonicalize. + required: + - signingInput + + VerifyControlRequest: + type: object + description: | + The control proof. Its members are additive per identifier + kind, and EXACTLY ONE family is set per request: + + - JWS kinds (`did:web`, `did:key`) submit `signedProofs` — + one compact JWS per proven key. + - The `lei` kind submits `cesrSignature` — a single CESR + signature over the served signingInput by the subject + AID's current key. + + Supported JWS algorithms match what the verifier implements: + EdDSA (Ed25519), ES256 (ECDSA P-256), and RS256 (RSA >= + 2048). Key-agreement keys (X25519) and curves without a + verifier (secp256k1, P-384/521) are rejected with a precise + error. + properties: + signedProofs: + type: array + minItems: 1 + maxItems: 16 + items: + type: string + description: | + Compact JWS over the served signingInput. The protected + header carries `kid` (the claimed verification method) + and MAY carry `jwk` (the signer's public key — required + by the quickstart noop resolver, ignored by the web + resolver, which always uses the resolved document). + cesrSignature: + type: string + description: | + The lei proof: a single CESR signature over the served + signingInput by the subject AID's current key. Set only + for the `lei` kind. The RA forwards it to the + vlei-verifier, which resolves the AID's key state from its + KEL and checks the signature over the exact signingInput + bytes (the same payload the JWS kinds sign). + oneOf: + - required: [signedProofs] + - required: [cesrSignature] + + IdentityLifecycleStatus: + type: string + enum: [PENDING_CONTROL, VERIFIED, REVOKED] + description: | + PENDING_CONTROL → VERIFIED → REVOKED. Rotation keeps the row + VERIFIED (the staged replacement proves control before + anything changes). + + IdentityListResponse: + type: object + description: | + Ownership-scoped, cursor-paginated list of the caller's + verified identities. Mirrors `AgentListResponse`: the + collection array is named `items` per the standard REST + collection-response convention shared across the v2 surface. + properties: + items: + type: array + description: List of identities owned by the caller + items: + $ref: '#/components/schemas/IdentityDetails' + returnedCount: + type: integer + description: Number of identities in this response + limit: + type: integer + description: Limit that was applied + nextCursor: + type: string + nullable: true + description: | + Opaque cursor for the next page. Pass as the `cursor` query + parameter to retrieve the next page. Null when there are no + more results. + hasMore: + type: boolean + description: Whether more results exist beyond this page + required: + - items + - returnedCount + - limit + - hasMore + + IdentityDetails: + type: object + properties: + identityId: + type: string + kind: + type: string + enum: ['did:web', 'did:key', 'lei'] + value: + type: string + status: + $ref: '#/components/schemas/IdentityLifecycleStatus' + proofMethod: + type: string + description: Control proof that verified this identity + enum: [did-web-sig, did-key-sig, lei-vlei-acdc] + pendingValue: + type: string + description: Staged rotation replacement (empty unless rotating) + verifiedAt: + type: string + format: date-time + createdAt: + type: string + format: date-time + linkedAgents: + type: array + description: Live links (detail responses only) + items: + type: object + properties: + agentId: + type: string + format: uuid + linkedAt: + type: string + format: date-time + required: + - agentId + required: + - identityId + - kind + - value + - status + - createdAt + + IdentityLinkRequest: + type: object + description: | + The batch of the caller's agents to bind — one owner-gated + call, no challenge, no signature; the whole batch seals as + ONE IDENTITY_LINKED event on the identity stream. + properties: + agentIds: + type: array + minItems: 1 + maxItems: 256 + items: + type: string + format: uuid + required: + - agentIds + + IdentityLinkResponse: + type: object + properties: + linked: + type: integer + description: Newly-created links (already-linked agents are skipped) + required: + - linked + + LinkedIdentity: + type: object + description: One computed identities[] entry on AgentDetails. + properties: + identityId: + type: string + kind: + type: string + enum: ['did:web', 'did:key', 'lei'] + value: + type: string + identityStatus: + type: string + enum: [VERIFIED, REVOKED] + description: The identity's CURRENT status — reflects its stream now + linkedAt: + type: string + format: date-time + required: + - identityId + - kind + - value + - identityStatus diff --git a/ans-sdk-core/src/main/java/com/godaddy/ans/sdk/config/AnsConfiguration.java b/ans-sdk-core/src/main/java/com/godaddy/ans/sdk/config/AnsConfiguration.java index 6764baa..8d88576 100644 --- a/ans-sdk-core/src/main/java/com/godaddy/ans/sdk/config/AnsConfiguration.java +++ b/ans-sdk-core/src/main/java/com/godaddy/ans/sdk/config/AnsConfiguration.java @@ -32,6 +32,7 @@ public final class AnsConfiguration { private final Duration connectTimeout; private final Duration readTimeout; private final int maxRetries; + private final ApiVersion apiVersion; private AnsConfiguration(Builder builder) { this.environment = builder.environment; @@ -41,6 +42,7 @@ private AnsConfiguration(Builder builder) { this.connectTimeout = builder.connectTimeout != null ? builder.connectTimeout : DEFAULT_CONNECT_TIMEOUT; this.readTimeout = builder.readTimeout != null ? builder.readTimeout : DEFAULT_READ_TIMEOUT; this.maxRetries = builder.maxRetries; + this.apiVersion = Objects.requireNonNull(builder.apiVersion, "Api version is required"); } /** @@ -115,6 +117,15 @@ public boolean isRetryEnabled() { return maxRetries > 0; } + /** + * Returns the selected API version lane. + * + * @return the API version (defaults to {@link ApiVersion#V2}) + */ + public ApiVersion getApiVersion() { + return apiVersion; + } + /** * Builder for {@link AnsConfiguration}. */ @@ -126,6 +137,7 @@ public static final class Builder { private Duration connectTimeout; private Duration readTimeout; private int maxRetries = DEFAULT_MAX_RETRIES; + private ApiVersion apiVersion = ApiVersion.V2; private Builder() { } @@ -199,6 +211,17 @@ public Builder enableRetry(int maxRetries) { return this; } + /** + * Sets the API version lane. Defaults to {@link ApiVersion#V2}. + * + * @param apiVersion the API version + * @return this builder + */ + public Builder apiVersion(ApiVersion apiVersion) { + this.apiVersion = Objects.requireNonNull(apiVersion, "API version cannot be null"); + return this; + } + /** * Builds the configuration. * @@ -206,8 +229,8 @@ public Builder enableRetry(int maxRetries) { * @throws NullPointerException if required fields are not set */ public AnsConfiguration build() { - if (this.environment == null) { - throw new IllegalStateException("Environment is required"); + if (this.environment == null && this.baseUrl == null) { + throw new IllegalStateException("Either an environment or a base URL is required"); } return new AnsConfiguration(this); } diff --git a/ans-sdk-core/src/main/java/com/godaddy/ans/sdk/config/ApiVersion.java b/ans-sdk-core/src/main/java/com/godaddy/ans/sdk/config/ApiVersion.java new file mode 100644 index 0000000..00b76a8 --- /dev/null +++ b/ans-sdk-core/src/main/java/com/godaddy/ans/sdk/config/ApiVersion.java @@ -0,0 +1,20 @@ +package com.godaddy.ans.sdk.config; + +/** + * Selects which ANS API lane the SDK targets. + * + *

New clients default to {@link #V2}. {@link #V1} remains available as an + * explicit opt-in for existing integrators, since both API lanes stay live.

+ */ +public enum ApiVersion { + + /** + * Legacy {@code /v1/agents} API lane. + */ + V1, + + /** + * Current {@code /v2/ans/agents} API lane (default for new clients). + */ + V2 +} diff --git a/ans-sdk-core/src/test/java/com/godaddy/ans/sdk/config/AnsConfigurationTest.java b/ans-sdk-core/src/test/java/com/godaddy/ans/sdk/config/AnsConfigurationTest.java index ff14821..018673e 100644 --- a/ans-sdk-core/src/test/java/com/godaddy/ans/sdk/config/AnsConfigurationTest.java +++ b/ans-sdk-core/src/test/java/com/godaddy/ans/sdk/config/AnsConfigurationTest.java @@ -115,13 +115,13 @@ void shouldThrowExceptionWhenCredentialsProviderIsNull() { } @Test - @DisplayName("Should throw when environment is not set") + @DisplayName("Should throw when neither environment nor base URL is set") void shouldThrowWhenEnvironmentNotSet() { assertThatThrownBy(() -> AnsConfiguration.builder() .credentialsProvider(testProvider) .build()) .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("Environment is required"); + .hasMessageContaining("Either an environment or a base URL is required"); } @Test @@ -147,4 +147,39 @@ void shouldReturnCredentialsProvider() { assertThat(config.getCredentialsProvider()).isSameAs(testProvider); } + + @Test + @DisplayName("Should default API version to V2") + void shouldDefaultApiVersionToV2() { + AnsConfiguration config = AnsConfiguration.builder() + .environment(Environment.OTE) + .credentialsProvider(testProvider) + .build(); + + assertThat(config.getApiVersion()).isEqualTo(ApiVersion.V2); + } + + @Test + @DisplayName("Should override API version to V1") + void shouldOverrideApiVersionToV1() { + AnsConfiguration config = AnsConfiguration.builder() + .environment(Environment.OTE) + .credentialsProvider(testProvider) + .apiVersion(ApiVersion.V1) + .build(); + + assertThat(config.getApiVersion()).isEqualTo(ApiVersion.V1); + } + + @Test + @DisplayName("Should throw when API version is null") + void shouldThrowWhenApiVersionNull() { + assertThatThrownBy(() -> AnsConfiguration.builder() + .environment(Environment.OTE) + .credentialsProvider(testProvider) + .apiVersion(null) + .build()) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("API version"); + } } \ No newline at end of file diff --git a/ans-sdk-discovery/src/main/java/com/godaddy/ans/sdk/discovery/AgentCapabilityRequest.java b/ans-sdk-discovery/src/main/java/com/godaddy/ans/sdk/discovery/AgentCapabilityRequest.java new file mode 100644 index 0000000..d685b5a --- /dev/null +++ b/ans-sdk-discovery/src/main/java/com/godaddy/ans/sdk/discovery/AgentCapabilityRequest.java @@ -0,0 +1,11 @@ +package com.godaddy.ans.sdk.discovery; + +/** + * Request body for the deprecated {@code POST /v1/agents/resolution} endpoint. + * + *

Not generated from the OpenAPI spec: the v2 spec no longer documents this + * v1-only endpoint, so this local DTO fills in for the removed + * {@code AgentCapabilityRequest} model.

+ */ +record AgentCapabilityRequest(String agentHost, String version) { +} \ No newline at end of file diff --git a/ans-sdk-discovery/src/main/java/com/godaddy/ans/sdk/discovery/ResolutionService.java b/ans-sdk-discovery/src/main/java/com/godaddy/ans/sdk/discovery/ResolutionService.java index 633e38b..8682a5d 100644 --- a/ans-sdk-discovery/src/main/java/com/godaddy/ans/sdk/discovery/ResolutionService.java +++ b/ans-sdk-discovery/src/main/java/com/godaddy/ans/sdk/discovery/ResolutionService.java @@ -10,7 +10,6 @@ import com.godaddy.ans.sdk.exception.AnsServerException; import com.godaddy.ans.sdk.exception.AnsValidationException; import com.godaddy.ans.sdk.http.HttpClientFactory; -import com.godaddy.ans.sdk.model.generated.AgentCapabilityRequest; import com.godaddy.ans.sdk.model.generated.AgentDetails; import java.io.IOException; @@ -21,6 +20,13 @@ /** * Internal service for handling agent resolution API calls. + * + *

Resolution is intentionally pinned to the legacy-live v1 service + * ({@code /v1/agents/resolution} and {@code /v1/agents/{agentId}}) regardless of + * the configured {@link com.godaddy.ans.sdk.config.ApiVersion}. Resolution has no + * v2 equivalent: {@code POST /v1/agents/resolution} was deliberately not carried + * forward to v2 (the {@code _ans} DNS TXT record already carries agent endpoints). + * This pinning is by design, not an oversight.

*/ class ResolutionService { @@ -48,9 +54,7 @@ class ResolutionService { AgentDetails resolve(String agentHost, String version) { // Step 1: POST to /v1/agents/resolution to get the agent-details link String resolveVersion = (version != null && !version.isEmpty()) ? version : "*"; - AgentCapabilityRequest request = new AgentCapabilityRequest() - .agentHost(agentHost) - .version(resolveVersion); + AgentCapabilityRequest request = new AgentCapabilityRequest(agentHost, resolveVersion); String requestBody = serializeToJson(request); HttpRequest resolutionRequest = createRequestBuilder("/v1/agents/resolution") diff --git a/ans-sdk-discovery/src/test/java/com/godaddy/ans/sdk/discovery/DiscoveryClientTest.java b/ans-sdk-discovery/src/test/java/com/godaddy/ans/sdk/discovery/DiscoveryClientTest.java index 41d8733..9cffaa5 100644 --- a/ans-sdk-discovery/src/test/java/com/godaddy/ans/sdk/discovery/DiscoveryClientTest.java +++ b/ans-sdk-discovery/src/test/java/com/godaddy/ans/sdk/discovery/DiscoveryClientTest.java @@ -165,7 +165,7 @@ void shouldResolveAgentSuccessfully(WireMockRuntimeInfo wmRuntimeInfo) { AgentDetails result = client.resolve(TEST_AGENT_HOST, "^1.0.0"); assertThat(result).isNotNull(); - assertThat(result.getAgentId()).isEqualTo(TEST_AGENT_ID); + assertThat(result.getAgentId()).hasToString(TEST_AGENT_ID); assertThat(result.getAgentHost()).isEqualTo(TEST_AGENT_HOST); assertThat(result.getAgentDisplayName()).isEqualTo("Booking Agent"); assertThat(result.getAgentDescription()).isEqualTo("A booking agent for scheduling appointments"); @@ -225,7 +225,7 @@ void shouldResolveAgentWithoutVersion(WireMockRuntimeInfo wmRuntimeInfo) { AgentDetails result = client.resolve(TEST_AGENT_HOST); assertThat(result).isNotNull(); - assertThat(result.getAgentId()).isEqualTo(TEST_AGENT_ID); + assertThat(result.getAgentId()).hasToString(TEST_AGENT_ID); // Verify wildcard version was used verify(postRequestedFor(urlEqualTo("/v1/agents/resolution")) @@ -259,7 +259,7 @@ void shouldResolveAgentAsync(WireMockRuntimeInfo wmRuntimeInfo) throws Exception AgentDetails result = future.get(); assertThat(result).isNotNull(); - assertThat(result.getAgentId()).isEqualTo(TEST_AGENT_ID); + assertThat(result.getAgentId()).hasToString(TEST_AGENT_ID); } // ==================== Resolution Error Tests ==================== @@ -444,7 +444,7 @@ void shouldGetAgentByIdSuccessfully(WireMockRuntimeInfo wmRuntimeInfo) { AgentDetails result = client.getAgent(TEST_AGENT_ID); assertThat(result).isNotNull(); - assertThat(result.getAgentId()).isEqualTo(TEST_AGENT_ID); + assertThat(result.getAgentId()).hasToString(TEST_AGENT_ID); assertThat(result.getAgentHost()).isEqualTo(TEST_AGENT_HOST); assertThat(result.getVersion()).isEqualTo("1.0.0"); assertThat(result.getAgentStatus()).isEqualTo(AgentLifecycleStatus.ACTIVE); @@ -517,7 +517,7 @@ void shouldGetAgentAsync(WireMockRuntimeInfo wmRuntimeInfo) throws Exception { AgentDetails result = future.get(); assertThat(result).isNotNull(); - assertThat(result.getAgentId()).isEqualTo(TEST_AGENT_ID); + assertThat(result.getAgentId()).hasToString(TEST_AGENT_ID); } @Test @@ -583,7 +583,7 @@ void shouldHandleRelativeHrefInResolutionResponse(WireMockRuntimeInfo wmRuntimeI AgentDetails result = client.resolve(TEST_AGENT_HOST); assertThat(result).isNotNull(); - assertThat(result.getAgentId()).isEqualTo(TEST_AGENT_ID); + assertThat(result.getAgentId()).hasToString(TEST_AGENT_ID); } @Test diff --git a/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/AgentPaths.java b/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/AgentPaths.java new file mode 100644 index 0000000..2b5ede0 --- /dev/null +++ b/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/AgentPaths.java @@ -0,0 +1,59 @@ +package com.godaddy.ans.sdk.registration; + +import com.godaddy.ans.sdk.config.ApiVersion; + +/** + * Builds ANS agent API paths for the selected {@link ApiVersion} lane. + * + *

All version-dependent path branching lives here so registration and + * certificate services never string-concatenate lane-specific paths inline.

+ */ +final class AgentPaths { + + private static final String V2_COLLECTION = "/v2/ans/agents"; + private static final String V1_COLLECTION = "/v1/agents"; + + private AgentPaths() { + } + + /** + * Returns the agents collection path for the given lane. + * + * @param apiVersion the selected API version + * @return {@code /v2/ans/agents} for v2, {@code /v1/agents} for v1 + */ + static String agentsCollectionPath(ApiVersion apiVersion) { + return apiVersion == ApiVersion.V1 ? V1_COLLECTION : V2_COLLECTION; + } + + /** + * Returns the registration path for the given lane. + * + *

v2 registers via a standard REST collection POST; v1 uses the legacy + * {@code /register} verb suffix.

+ * + * @param apiVersion the selected API version + * @return {@code /v2/ans/agents} for v2, {@code /v1/agents/register} for v1 + */ + static String registerPath(ApiVersion apiVersion) { + return apiVersion == ApiVersion.V1 ? V1_COLLECTION + "/register" : V2_COLLECTION; + } + + /** + * Builds an agent-scoped path: collection + agentId + any trailing segments. + * + * @param apiVersion the selected API version + * @param agentId the agent ID + * @param segments optional trailing path segments (e.g. {@code "certificates", "identity"}) + * @return the joined path, e.g. {@code /v2/ans/agents/{agentId}/certificates/identity} + */ + static String agentPath(ApiVersion apiVersion, String agentId, String... segments) { + StringBuilder path = new StringBuilder(agentsCollectionPath(apiVersion)) + .append('/') + .append(agentId); + for (String segment : segments) { + path.append('/').append(segment); + } + return path.toString(); + } +} diff --git a/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/AnsApiClient.java b/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/AnsApiClient.java index 2eb6f91..9bea8c8 100644 --- a/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/AnsApiClient.java +++ b/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/AnsApiClient.java @@ -2,10 +2,13 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.godaddy.ans.sdk.auth.AnsCredentials; import com.godaddy.ans.sdk.config.AnsConfiguration; +import com.godaddy.ans.sdk.config.ApiVersion; import com.godaddy.ans.sdk.exception.AnsAuthenticationException; import com.godaddy.ans.sdk.exception.AnsConflictException; import com.godaddy.ans.sdk.exception.AnsNotFoundException; @@ -164,4 +167,33 @@ String serializeToJson(Object object) { throw new AnsServerException("Failed to serialize request: " + e.getMessage(), 0, e, null); } } + + /** + * Serializes an object to JSON with the named top-level field removed. + * + *

Used to strip v2-only fields (e.g. {@code discoveryProfiles}) from the + * wire body when targeting the v1 lane, without mutating the caller's object. + * Operates on the JSON tree and reuses this client's {@link ObjectMapper}.

+ * + * @param object the object to serialize + * @param fieldName the top-level field to remove + * @return the JSON string without the named field + * @throws AnsServerException if serialization fails + */ + String serializeToJsonWithoutField(Object object, String fieldName) { + try { + JsonNode tree = objectMapper.valueToTree(object); + if (tree instanceof ObjectNode objectNode) { + objectNode.remove(fieldName); + } + + return objectMapper.writeValueAsString(tree); + } catch (IOException e) { + throw new AnsServerException("Failed to serialize request: " + e.getMessage(), 0, e, null); + } + } + + ApiVersion getApiVersion() { + return configuration.getApiVersion(); + } } diff --git a/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/CertificateService.java b/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/CertificateService.java index 71316d1..ca317c3 100644 --- a/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/CertificateService.java +++ b/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/CertificateService.java @@ -1,7 +1,7 @@ package com.godaddy.ans.sdk.registration; import com.fasterxml.jackson.core.type.TypeReference; -import com.godaddy.ans.sdk.config.AnsConfiguration; +import com.godaddy.ans.sdk.config.ApiVersion; import com.godaddy.ans.sdk.exception.AnsAuthenticationException; import com.godaddy.ans.sdk.exception.AnsNotFoundException; import com.godaddy.ans.sdk.exception.AnsServerException; @@ -34,9 +34,11 @@ public class CertificateService { private final AnsApiClient httpClient; + private final ApiVersion apiVersion; - CertificateService(AnsConfiguration configuration) { - this.httpClient = new AnsApiClient(configuration); + CertificateService(AnsApiClient ansApiClient) { + this.httpClient = ansApiClient; + this.apiVersion = ansApiClient.getApiVersion(); } /** @@ -55,7 +57,8 @@ public class CertificateService { public List getIdentityCertificates(String agentId) { validateAgentId(agentId); - HttpRequest request = httpClient.createRequestBuilder("/v1/agents/" + agentId + "/certificates/identity") + HttpRequest request = httpClient.createRequestBuilder( + AgentPaths.agentPath(apiVersion, agentId, "certificates", "identity")) .GET() .build(); @@ -79,7 +82,8 @@ public List getIdentityCertificates(String agentId) { public List getServerCertificates(String agentId) { validateAgentId(agentId); - HttpRequest request = httpClient.createRequestBuilder("/v1/agents/" + agentId + "/certificates/server") + HttpRequest request = httpClient.createRequestBuilder( + AgentPaths.agentPath(apiVersion, agentId, "certificates", "server")) .GET() .build(); diff --git a/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/RegistrationClient.java b/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/RegistrationClient.java index 6dc6afa..23da29b 100644 --- a/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/RegistrationClient.java +++ b/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/RegistrationClient.java @@ -3,12 +3,14 @@ import com.godaddy.ans.sdk.auth.AnsCredentialsProvider; import com.godaddy.ans.sdk.concurrent.AnsExecutors; import com.godaddy.ans.sdk.config.AnsConfiguration; +import com.godaddy.ans.sdk.config.ApiVersion; import com.godaddy.ans.sdk.config.Environment; import com.godaddy.ans.sdk.model.generated.AgentDetails; import com.godaddy.ans.sdk.model.generated.AgentRegistrationRequest; import com.godaddy.ans.sdk.model.generated.AgentRevocationRequest; import com.godaddy.ans.sdk.model.generated.AgentRevocationResponse; import com.godaddy.ans.sdk.model.generated.AgentStatus; +import com.godaddy.ans.sdk.model.generated.RevocationReason; import java.time.Duration; import java.util.concurrent.CompletableFuture; @@ -54,11 +56,10 @@ public final class RegistrationClient { private final RegistrationService registrationService; private final CertificateService certificateService; - private RegistrationClient(AnsConfiguration configuration) { + private RegistrationClient(AnsConfiguration configuration, AnsApiClient ansApiClient) { this.configuration = configuration; - var ansClient = new AnsApiClient(configuration); - this.registrationService = new RegistrationService(ansClient); - this.certificateService = new CertificateService(configuration); + this.registrationService = new RegistrationService(ansApiClient); + this.certificateService = new CertificateService(ansApiClient); } /** @@ -166,7 +167,7 @@ public AgentRevocationResponse revokeAgent(String agentId, AgentRevocationReques * @return the revocation response * @see #revokeAgent(String, AgentRevocationRequest) */ - public AgentRevocationResponse revokeAgent(String agentId, AgentRevocationRequest.ReasonEnum reason) { + public AgentRevocationResponse revokeAgent(String agentId, RevocationReason reason) { AgentRevocationRequest request = new AgentRevocationRequest().reason(reason); return revokeAgent(agentId, request); } @@ -222,7 +223,7 @@ public CompletableFuture revokeAgentAsync(String agentI * @return a CompletableFuture with the revocation response */ public CompletableFuture revokeAgentAsync(String agentId, - AgentRevocationRequest.ReasonEnum reason) { + RevocationReason reason) { return CompletableFuture.supplyAsync(() -> revokeAgent(agentId, reason), AnsExecutors.sharedIoExecutor()); } @@ -337,6 +338,17 @@ public Builder enableRetry(int maxRetries) { return this; } + /** + * Sets the API version lane. Defaults to {@link ApiVersion#V2}. + * + * @param apiVersion the API version + * @return this builder + */ + public Builder apiVersion(ApiVersion apiVersion) { + configBuilder.apiVersion(apiVersion); + return this; + } + /** * Builds the RegistrationClient. * @@ -346,7 +358,7 @@ public RegistrationClient build() { AnsConfiguration config = (prebuiltConfiguration != null) ? prebuiltConfiguration : configBuilder.build(); - return new RegistrationClient(config); + return new RegistrationClient(config, new AnsApiClient(config)); } } } \ No newline at end of file diff --git a/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/RegistrationService.java b/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/RegistrationService.java index a360491..538bd03 100644 --- a/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/RegistrationService.java +++ b/ans-sdk-registration/src/main/java/com/godaddy/ans/sdk/registration/RegistrationService.java @@ -1,5 +1,6 @@ package com.godaddy.ans.sdk.registration; +import com.godaddy.ans.sdk.config.ApiVersion; import com.godaddy.ans.sdk.exception.AnsServerException; import com.godaddy.ans.sdk.model.generated.AgentDetails; import com.godaddy.ans.sdk.model.generated.AgentRegistrationRequest; @@ -11,6 +12,7 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.util.UUID; /** * Internal service for handling registration API calls. @@ -18,33 +20,55 @@ class RegistrationService { private final AnsApiClient httpClient; + private final ApiVersion apiVersion; RegistrationService(final AnsApiClient ansApiClient) { this.httpClient = ansApiClient; + this.apiVersion = ansApiClient.getApiVersion(); } + /** * Registers a new agent and returns full agent details. * - *

This method registers the agent and then follows the 'self' link - * to retrieve the complete AgentDetails including the agentId.

+ *

On the v2 lane the {@code agentId} returned in the registration response + * is used directly to fetch the complete {@link AgentDetails}. On the v1 lane + * the 'self' link is followed instead (HATEOAS).

*/ AgentDetails register(AgentRegistrationRequest request) { - String requestBody = httpClient.serializeToJson(request); + // discoveryProfiles is a v2-only field; strip it from the wire body on v1. + String requestBody = (apiVersion == ApiVersion.V1) + ? httpClient.serializeToJsonWithoutField(request, "discoveryProfiles") + : httpClient.serializeToJson(request); - HttpRequest httpRequest = httpClient.createRequestBuilder("/v1/agents/register") + HttpRequest httpRequest = httpClient.createRequestBuilder(AgentPaths.registerPath(apiVersion)) .POST(HttpRequest.BodyPublishers.ofString(requestBody)) .build(); HttpResponse response = httpClient.sendRequest(httpRequest); RegistrationPending pending = httpClient.parseResponse(response.body(), RegistrationPending.class); - // Follow the 'self' link to get full AgentDetails with agentId - String selfPath = extractSelfLink(pending); - if (selfPath == null) { - throw new AnsServerException("Registration response missing 'self' link", 0, null); + return getAgentDetails(resolveAgentDetailsPath(pending)); + } + + /** + * Resolves the path to fetch full agent details after registration. + * + *

v2 uses {@code pending.getAgentId()} directly; v1 follows the 'self' link.

+ */ + private String resolveAgentDetailsPath(RegistrationPending pending) { + if (apiVersion == ApiVersion.V1) { + String selfPath = extractSelfLink(pending); + if (selfPath == null) { + throw new AnsServerException("Registration response missing 'self' link", 0, null); + } + return selfPath; } - return getAgentDetails(selfPath); + UUID agentId = pending.getAgentId(); + if (agentId == null) { + throw new AnsServerException("Registration response missing 'agentId'", 0, null); + } + return AgentPaths.agentPath(apiVersion, agentId.toString()); } /** @@ -63,14 +87,15 @@ AgentDetails getAgentDetails(String path) { * Gets agent details by agent ID. */ AgentDetails getAgent(String agentId) { - return getAgentDetails("/v1/agents/" + agentId); + return getAgentDetails(AgentPaths.agentPath(apiVersion, agentId)); } /** * Triggers ACME verification. */ AgentStatus verifyAcme(String agentId) { - HttpRequest request = httpClient.createRequestBuilder("/v1/agents/" + agentId + "/verify-acme") + HttpRequest request = httpClient.createRequestBuilder( + AgentPaths.agentPath(apiVersion, agentId, "verify-acme")) .POST(HttpRequest.BodyPublishers.noBody()) .build(); @@ -82,7 +107,8 @@ AgentStatus verifyAcme(String agentId) { * Triggers DNS verification. */ AgentStatus verifyDns(String agentId) { - HttpRequest request = httpClient.createRequestBuilder("/v1/agents/" + agentId + "/verify-dns") + HttpRequest request = httpClient.createRequestBuilder( + AgentPaths.agentPath(apiVersion, agentId, "verify-dns")) .POST(HttpRequest.BodyPublishers.noBody()) .build(); @@ -104,7 +130,8 @@ AgentStatus verifyDns(String agentId) { AgentRevocationResponse revoke(String agentId, AgentRevocationRequest request) { String requestBody = httpClient.serializeToJson(request); - HttpRequest httpRequest = httpClient.createRequestBuilder("/v1/agents/" + agentId + "/revoke") + HttpRequest httpRequest = httpClient.createRequestBuilder( + AgentPaths.agentPath(apiVersion, agentId, "revoke")) .POST(HttpRequest.BodyPublishers.ofString(requestBody)) .build(); diff --git a/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/AgentPathsTest.java b/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/AgentPathsTest.java new file mode 100644 index 0000000..1f56d11 --- /dev/null +++ b/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/AgentPathsTest.java @@ -0,0 +1,77 @@ +package com.godaddy.ans.sdk.registration; + +import com.godaddy.ans.sdk.config.ApiVersion; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Direct unit tests for {@link AgentPaths}, the single source of truth for + * V1/V2 path branching. + * + *

Path logic was previously exercised only indirectly through WireMock + * integration tests, where a typo in a path constant surfaced as an opaque + * stub miss. These tests pin each method/version combination to an exact + * string so a regression fails here, at the source.

+ */ +class AgentPathsTest { + + private static final String AGENT_ID = "550e8400-e29b-41d4-a716-446655440000"; + + @ParameterizedTest + @CsvSource({ + "V1, /v1/agents", + "V2, /v2/ans/agents", + }) + @DisplayName("agentsCollectionPath returns the lane collection root") + void agentsCollectionPath(ApiVersion version, String expected) { + assertThat(AgentPaths.agentsCollectionPath(version)).isEqualTo(expected); + } + + @ParameterizedTest + @CsvSource({ + "V1, /v1/agents/register", + "V2, /v2/ans/agents", + }) + @DisplayName("registerPath uses the /register verb on v1 and the collection POST on v2") + void registerPath(ApiVersion version, String expected) { + assertThat(AgentPaths.registerPath(version)).isEqualTo(expected); + } + + @ParameterizedTest + @CsvSource({ + "V1, /v1/agents/" + AGENT_ID, + "V2, /v2/ans/agents/" + AGENT_ID, + }) + @DisplayName("agentPath with no trailing segments returns collection/{agentId}") + void agentPathNoSegments(ApiVersion version, String expected) { + assertThat(AgentPaths.agentPath(version, AGENT_ID)).isEqualTo(expected); + } + + @ParameterizedTest + @CsvSource({ + "V1, /v1/agents/" + AGENT_ID + "/verify-acme", + "V2, /v2/ans/agents/" + AGENT_ID + "/verify-acme", + }) + @DisplayName("agentPath appends a single trailing segment") + void agentPathSingleSegment(ApiVersion version, String expected) { + assertThat(AgentPaths.agentPath(version, AGENT_ID, "verify-acme")).isEqualTo(expected); + } + + @Test + @DisplayName("agentPath joins multiple trailing segments in order") + void agentPathMultipleSegments() { + assertThat(AgentPaths.agentPath(ApiVersion.V2, AGENT_ID, "certificates", "identity")) + .isEqualTo("/v2/ans/agents/" + AGENT_ID + "/certificates/identity"); + } + + @Test + @DisplayName("agentPath preserves the v1 lane prefix with multiple segments") + void agentPathMultipleSegmentsV1() { + assertThat(AgentPaths.agentPath(ApiVersion.V1, AGENT_ID, "certificates", "server", "renewal")) + .isEqualTo("/v1/agents/" + AGENT_ID + "/certificates/server/renewal"); + } +} diff --git a/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/AnsApiClientTest.java b/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/AnsApiClientTest.java new file mode 100644 index 0000000..508d5aa --- /dev/null +++ b/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/AnsApiClientTest.java @@ -0,0 +1,83 @@ +package com.godaddy.ans.sdk.registration; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.godaddy.ans.sdk.auth.ApiKeyCredentialsProvider; +import com.godaddy.ans.sdk.config.AnsConfiguration; +import com.godaddy.ans.sdk.config.Environment; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link AnsApiClient} JSON serialization helpers. + */ +class AnsApiClientTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private AnsApiClient client() { + AnsConfiguration config = AnsConfiguration.builder() + .environment(Environment.OTE) + .credentialsProvider(new ApiKeyCredentialsProvider("test-api-key", "test-api-secret")) + .build(); + return new AnsApiClient(config); + } + + @Test + @DisplayName("serializeToJsonWithoutField removes the named top-level field") + void removesNamedTopLevelField() throws Exception { + Map object = new LinkedHashMap<>(); + object.put("agentDisplayName", "Test Agent"); + object.put("discoveryProfiles", "v2-only"); + + String json = client().serializeToJsonWithoutField(object, "discoveryProfiles"); + + JsonNode tree = MAPPER.readTree(json); + assertThat(tree.has("discoveryProfiles")).isFalse(); + assertThat(tree.get("agentDisplayName").asText()).isEqualTo("Test Agent"); + } + + @Test + @DisplayName("serializeToJsonWithoutField is a no-op when the named field is absent") + void noOpWhenFieldAbsent() throws Exception { + Map object = new LinkedHashMap<>(); + object.put("agentDisplayName", "Test Agent"); + object.put("version", "1.0.0"); + + String json = client().serializeToJsonWithoutField(object, "discoveryProfiles"); + + JsonNode tree = MAPPER.readTree(json); + assertThat(tree.get("agentDisplayName").asText()).isEqualTo("Test Agent"); + assertThat(tree.get("version").asText()).isEqualTo("1.0.0"); + } + + @Test + @DisplayName("serializeToJsonWithoutField only removes the top-level field, leaving nested fields intact") + void leavesNestedFieldIntact() throws Exception { + Map nested = new LinkedHashMap<>(); + nested.put("discoveryProfiles", "keep-me"); + + Map object = new LinkedHashMap<>(); + object.put("discoveryProfiles", "remove-me"); + object.put("nested", nested); + + String json = client().serializeToJsonWithoutField(object, "discoveryProfiles"); + + JsonNode tree = MAPPER.readTree(json); + assertThat(tree.has("discoveryProfiles")).isFalse(); + assertThat(tree.get("nested").get("discoveryProfiles").asText()).isEqualTo("keep-me"); + } + + @Test + @DisplayName("serializeToJsonWithoutField returns \"null\" for null input without throwing") + void handlesNullInput() { + String json = client().serializeToJsonWithoutField(null, "discoveryProfiles"); + + assertThat(json).isEqualTo("null"); + } +} diff --git a/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/ApiVersionRoutingTest.java b/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/ApiVersionRoutingTest.java new file mode 100644 index 0000000..d56281c --- /dev/null +++ b/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/ApiVersionRoutingTest.java @@ -0,0 +1,407 @@ +package com.godaddy.ans.sdk.registration; + +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import com.godaddy.ans.sdk.auth.JwtCredentialsProvider; +import com.godaddy.ans.sdk.config.ApiVersion; +import com.godaddy.ans.sdk.config.Environment; +import com.godaddy.ans.sdk.exception.AnsAuthenticationException; +import com.godaddy.ans.sdk.exception.AnsConflictException; +import com.godaddy.ans.sdk.exception.AnsNotFoundException; +import com.godaddy.ans.sdk.exception.AnsServerException; +import com.godaddy.ans.sdk.exception.AnsValidationException; +import com.godaddy.ans.sdk.model.generated.AgentEndpoint; +import com.godaddy.ans.sdk.model.generated.AgentRegistrationRequest; +import com.godaddy.ans.sdk.model.generated.AgentRevocationRequest; +import com.godaddy.ans.sdk.model.generated.Protocol; +import com.godaddy.ans.sdk.model.generated.RevocationReason; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.net.URI; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.absent; +import static com.github.tomakehurst.wiremock.client.WireMock.containing; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.matchingJsonPath; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Verifies API-version path routing across the v1 and v2 lanes. + * + *

The v2 default lane is exercised by {@link RegistrationClientTest}; this + * class pins the client to {@link ApiVersion#V1} and asserts the legacy + * {@code /v1/agents/...} paths plus v1-specific wire-body behavior.

+ */ +@WireMockTest +class ApiVersionRoutingTest { + + private static final String TEST_JWT_TOKEN = "test-jwt-token"; + private static final String TEST_AGENT_ID = "550e8400-e29b-41d4-a716-446655440000"; + + private RegistrationClient v1Client(WireMockRuntimeInfo wm) { + return RegistrationClient.builder() + .environment(Environment.OTE) + .baseUrl(wm.getHttpBaseUrl()) + .apiVersion(ApiVersion.V1) + .credentialsProvider(new JwtCredentialsProvider(TEST_JWT_TOKEN)) + .build(); + } + + private AgentRegistrationRequest sampleRequest() { + return new AgentRegistrationRequest() + .agentDisplayName("Test Agent") + .version("1.0.0") + .agentHost("test-agent.example.com") + .addEndpointsItem(new AgentEndpoint() + .protocol(Protocol.A2_A) + .agentUrl(URI.create("https://test-agent.example.com/a2a"))) + .identityCsrPEM("test-csr") + .serverCsrPEM("test-csr"); + } + + @Test + @DisplayName("v1 register posts to /v1/agents/register and follows the self link") + void v1RegisterFollowsSelfLink(WireMockRuntimeInfo wm) { + stubFor(post(urlEqualTo("/v1/agents/register")) + .willReturn(aResponse() + .withStatus(202) + .withHeader("Content-Type", "application/json") + .withBody(v1PendingWithSelfLink()))); + + stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(agentDetails()))); + + assertThat(v1Client(wm).registerAgent(sampleRequest())).isNotNull(); + + verify(postRequestedFor(urlEqualTo("/v1/agents/register"))); + } + + @Test + @DisplayName("v1 register omits the v2-only discoveryProfiles field from the wire body") + void v1RegisterStripsDiscoveryProfiles(WireMockRuntimeInfo wm) { + stubFor(post(urlEqualTo("/v1/agents/register")) + .willReturn(aResponse() + .withStatus(202) + .withHeader("Content-Type", "application/json") + .withBody(v1PendingWithSelfLink()))); + + stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(agentDetails()))); + + v1Client(wm).registerAgent(sampleRequest()); + + verify(postRequestedFor(urlEqualTo("/v1/agents/register")) + .withRequestBody(containing("\"agentDisplayName\":\"Test Agent\"")) + .withRequestBody(matchingJsonPath("$.discoveryProfiles", absent()))); + } + + @Test + @DisplayName("v1 register throws when self link href is null") + void shouldThrowWhenSelfLinkHrefIsNull(WireMockRuntimeInfo wm) { + stubFor(post(urlEqualTo("/v1/agents/register")) + .willReturn(aResponse().withStatus(202).withBody(""" + {"status":"PENDING_VALIDATION","links":[{"rel":"self","href":null}]} + """))); + + assertThatThrownBy(() -> v1Client(wm).registerAgent(sampleRequest())) + .isInstanceOf(AnsServerException.class); + } + + @Test + @DisplayName("v1 getAgent targets /v1/agents/{agentId}") + void v1GetAgentPath(WireMockRuntimeInfo wm) { + stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(agentDetails()))); + + assertThat(v1Client(wm).getAgent(TEST_AGENT_ID)).isNotNull(); + } + + @Test + @DisplayName("v1 verifyAcme targets /v1/agents/{agentId}/verify-acme") + void v1VerifyAcmePath(WireMockRuntimeInfo wm) { + stubFor(post(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/verify-acme")) + .willReturn(aResponse() + .withStatus(202) + .withHeader("Content-Type", "application/json") + .withBody(agentStatus()))); + + assertThat(v1Client(wm).verifyAcme(TEST_AGENT_ID)).isNotNull(); + } + + @Test + @DisplayName("v1 verifyDns targets /v1/agents/{agentId}/verify-dns") + void v1VerifyDnsPath(WireMockRuntimeInfo wm) { + stubFor(post(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/verify-dns")) + .willReturn(aResponse() + .withStatus(202) + .withHeader("Content-Type", "application/json") + .withBody(agentStatus()))); + + assertThat(v1Client(wm).verifyDns(TEST_AGENT_ID)).isNotNull(); + } + + @Test + @DisplayName("v1 revoke targets /v1/agents/{agentId}/revoke") + void v1RevokePath(WireMockRuntimeInfo wm) { + stubFor(post(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/revoke")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(revocation()))); + + AgentRevocationRequest request = new AgentRevocationRequest() + .reason(RevocationReason.CESSATION_OF_OPERATION); + + assertThat(v1Client(wm).revokeAgent(TEST_AGENT_ID, request)).isNotNull(); + } + + // ==================== V1 error paths ==================== + // + // V1 and V2 share AnsApiClient.handleErrorResponse, but nothing pins the + // V1 lane to the correct exception type per status code. These tests do: + // 401/403 -> AnsAuthenticationException, 404 -> AnsNotFoundException, + // 409 -> AnsConflictException, 422 -> AnsValidationException, + // >=500 -> AnsServerException. + + @Test + @DisplayName("v1 register maps 409 to AnsConflictException") + void v1RegisterConflict(WireMockRuntimeInfo wm) { + stubFor(post(urlEqualTo("/v1/agents/register")) + .willReturn(errorResponse(409, "ALREADY_EXISTS", "Agent already registered"))); + + assertThatThrownBy(() -> v1Client(wm).registerAgent(sampleRequest())) + .isInstanceOf(AnsConflictException.class) + .hasMessageContaining("Conflict"); + } + + @Test + @DisplayName("v1 register maps 422 to AnsValidationException") + void v1RegisterValidation(WireMockRuntimeInfo wm) { + stubFor(post(urlEqualTo("/v1/agents/register")) + .willReturn(errorResponse(422, "INVALID_ARGUMENT", "Invalid version format"))); + + assertThatThrownBy(() -> v1Client(wm).registerAgent(sampleRequest())) + .isInstanceOf(AnsValidationException.class) + .hasMessageContaining("Validation error"); + } + + @Test + @DisplayName("v1 register maps 401 to AnsAuthenticationException") + void v1RegisterAuth(WireMockRuntimeInfo wm) { + stubFor(post(urlEqualTo("/v1/agents/register")) + .willReturn(errorResponse(401, "UNAUTHORIZED", "Invalid token"))); + + assertThatThrownBy(() -> v1Client(wm).registerAgent(sampleRequest())) + .isInstanceOf(AnsAuthenticationException.class) + .hasMessageContaining("Authentication failed"); + } + + @Test + @DisplayName("v1 register maps 500 to AnsServerException carrying the status code") + void v1RegisterServerError(WireMockRuntimeInfo wm) { + stubFor(post(urlEqualTo("/v1/agents/register")) + .willReturn(errorResponse(500, "INTERNAL", "Boom"))); + + assertThatThrownBy(() -> v1Client(wm).registerAgent(sampleRequest())) + .isInstanceOfSatisfying(AnsServerException.class, + ex -> assertThat(ex.getStatusCode()).isEqualTo(500)) + .hasMessageContaining("Server error"); + } + + @Test + @DisplayName("v1 getAgent maps 404 to AnsNotFoundException") + void v1GetAgentNotFound(WireMockRuntimeInfo wm) { + stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID)) + .willReturn(errorResponse(404, "NOT_FOUND", "Agent not found"))); + + assertThatThrownBy(() -> v1Client(wm).getAgent(TEST_AGENT_ID)) + .isInstanceOf(AnsNotFoundException.class) + .hasMessageContaining("not found"); + } + + @Test + @DisplayName("v1 getAgent maps 401 to AnsAuthenticationException") + void v1GetAgentAuth(WireMockRuntimeInfo wm) { + stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID)) + .willReturn(errorResponse(401, "UNAUTHORIZED", "Invalid token"))); + + assertThatThrownBy(() -> v1Client(wm).getAgent(TEST_AGENT_ID)) + .isInstanceOf(AnsAuthenticationException.class) + .hasMessageContaining("Authentication failed"); + } + + @Test + @DisplayName("v1 verifyAcme maps 404 to AnsNotFoundException") + void v1VerifyAcmeNotFound(WireMockRuntimeInfo wm) { + stubFor(post(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/verify-acme")) + .willReturn(errorResponse(404, "NOT_FOUND", "Agent not found"))); + + assertThatThrownBy(() -> v1Client(wm).verifyAcme(TEST_AGENT_ID)) + .isInstanceOf(AnsNotFoundException.class) + .hasMessageContaining("not found"); + } + + @Test + @DisplayName("v1 verifyAcme maps 401 to AnsAuthenticationException") + void v1VerifyAcmeAuth(WireMockRuntimeInfo wm) { + stubFor(post(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/verify-acme")) + .willReturn(errorResponse(401, "UNAUTHORIZED", "Invalid token"))); + + assertThatThrownBy(() -> v1Client(wm).verifyAcme(TEST_AGENT_ID)) + .isInstanceOf(AnsAuthenticationException.class) + .hasMessageContaining("Authentication failed"); + } + + @Test + @DisplayName("v1 verifyAcme maps 422 to AnsValidationException") + void v1VerifyAcmeValidation(WireMockRuntimeInfo wm) { + stubFor(post(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/verify-acme")) + .willReturn(errorResponse(422, "INVALID_STATE", "Order not ready"))); + + assertThatThrownBy(() -> v1Client(wm).verifyAcme(TEST_AGENT_ID)) + .isInstanceOf(AnsValidationException.class) + .hasMessageContaining("Validation error"); + } + + @Test + @DisplayName("v1 verifyDns maps 404 to AnsNotFoundException") + void v1VerifyDnsNotFound(WireMockRuntimeInfo wm) { + stubFor(post(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/verify-dns")) + .willReturn(errorResponse(404, "NOT_FOUND", "Agent not found"))); + + assertThatThrownBy(() -> v1Client(wm).verifyDns(TEST_AGENT_ID)) + .isInstanceOf(AnsNotFoundException.class) + .hasMessageContaining("not found"); + } + + @Test + @DisplayName("v1 verifyDns maps 401 to AnsAuthenticationException") + void v1VerifyDnsAuth(WireMockRuntimeInfo wm) { + stubFor(post(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/verify-dns")) + .willReturn(errorResponse(401, "UNAUTHORIZED", "Invalid token"))); + + assertThatThrownBy(() -> v1Client(wm).verifyDns(TEST_AGENT_ID)) + .isInstanceOf(AnsAuthenticationException.class) + .hasMessageContaining("Authentication failed"); + } + + @Test + @DisplayName("v1 verifyDns maps 422 to AnsValidationException") + void v1VerifyDnsValidation(WireMockRuntimeInfo wm) { + stubFor(post(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/verify-dns")) + .willReturn(errorResponse(422, "DNS_NOT_PROVISIONED", "TXT record missing"))); + + assertThatThrownBy(() -> v1Client(wm).verifyDns(TEST_AGENT_ID)) + .isInstanceOf(AnsValidationException.class) + .hasMessageContaining("Validation error"); + } + + @Test + @DisplayName("v1 revoke maps 404 to AnsNotFoundException") + void v1RevokeNotFound(WireMockRuntimeInfo wm) { + stubFor(post(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/revoke")) + .willReturn(errorResponse(404, "NOT_FOUND", "Agent not found"))); + + AgentRevocationRequest request = new AgentRevocationRequest() + .reason(RevocationReason.CESSATION_OF_OPERATION); + + assertThatThrownBy(() -> v1Client(wm).revokeAgent(TEST_AGENT_ID, request)) + .isInstanceOf(AnsNotFoundException.class) + .hasMessageContaining("not found"); + } + + @Test + @DisplayName("v1 revoke maps 422 to AnsValidationException") + void v1RevokeValidation(WireMockRuntimeInfo wm) { + stubFor(post(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/revoke")) + .willReturn(errorResponse(422, "INVALID_ARGUMENT", "Unknown revocation reason"))); + + AgentRevocationRequest request = new AgentRevocationRequest() + .reason(RevocationReason.CESSATION_OF_OPERATION); + + assertThatThrownBy(() -> v1Client(wm).revokeAgent(TEST_AGENT_ID, request)) + .isInstanceOf(AnsValidationException.class) + .hasMessageContaining("Validation error"); + } + + // ==================== Response bodies ==================== + + private com.github.tomakehurst.wiremock.client.ResponseDefinitionBuilder errorResponse( + int status, String code, String message) { + return aResponse() + .withStatus(status) + .withHeader("Content-Type", "application/json") + .withBody("{\"status\":\"error\",\"code\":\"" + code + "\",\"message\":\"" + message + "\"}"); + } + + private String v1PendingWithSelfLink() { + return """ + { + "status": "PENDING_VALIDATION", + "ansName": "ans://v1.0.0.test-agent.example.com", + "links": [ + { "rel": "self", "href": "/v1/agents/550e8400-e29b-41d4-a716-446655440000" } + ] + } + """; + } + + private String agentDetails() { + return """ + { + "agentId": "550e8400-e29b-41d4-a716-446655440000", + "agentDisplayName": "Test Agent", + "version": "1.0.0", + "agentHost": "test-agent.example.com", + "ansName": "ans://v1.0.0.test-agent.example.com", + "agentStatus": "PENDING_VALIDATION", + "endpoints": [ + { "protocol": "A2A", "agentUrl": "https://test-agent.example.com/a2a" } + ] + } + """; + } + + private String agentStatus() { + return """ + { + "status": "PENDING_DNS", + "phase": "DOMAIN_VALIDATION", + "completedSteps": ["REGISTRATION_SUBMITTED"], + "pendingSteps": ["DNS_VERIFICATION"], + "createdAt": "2024-01-15T10:00:00Z", + "updatedAt": "2024-01-15T10:30:00Z" + } + """; + } + + private String revocation() { + return """ + { + "agentId": "550e8400-e29b-41d4-a716-446655440000", + "ansName": "ans://v1.0.0.test-agent.example.com", + "status": "REVOKED", + "revokedAt": "2024-01-15T12:00:00Z", + "reason": "CESSATION_OF_OPERATION", + "dnsRecordsToRemove": [] + } + """; + } +} diff --git a/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/CertificateServiceTest.java b/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/CertificateServiceTest.java index f566b3d..175e686 100644 --- a/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/CertificateServiceTest.java +++ b/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/CertificateServiceTest.java @@ -4,6 +4,7 @@ import com.github.tomakehurst.wiremock.junit5.WireMockTest; import com.godaddy.ans.sdk.auth.ApiKeyCredentialsProvider; import com.godaddy.ans.sdk.config.AnsConfiguration; +import com.godaddy.ans.sdk.config.ApiVersion; import com.godaddy.ans.sdk.config.Environment; import com.godaddy.ans.sdk.exception.AnsAuthenticationException; import com.godaddy.ans.sdk.exception.AnsNotFoundException; @@ -41,7 +42,51 @@ private CertificateService createCertificateService(WireMockRuntimeInfo wmRuntim .baseUrl(wmRuntimeInfo.getHttpBaseUrl()) .credentialsProvider(new ApiKeyCredentialsProvider(API_KEY, API_SECRET)) .build(); - return new CertificateService(config); + return new CertificateService(new AnsApiClient(config)); + } + + private CertificateService createV1CertificateService(WireMockRuntimeInfo wmRuntimeInfo) { + AnsConfiguration config = AnsConfiguration.builder() + .environment(Environment.OTE) + .baseUrl(wmRuntimeInfo.getHttpBaseUrl()) + .apiVersion(ApiVersion.V1) + .credentialsProvider(new ApiKeyCredentialsProvider(API_KEY, API_SECRET)) + .build(); + return new CertificateService(new AnsApiClient(config)); + } + + // ==================== API version routing ==================== + + @Test + @DisplayName("v1 lane targets /v1/agents/{id}/certificates/server") + void v1ServerCertificatesPath(WireMockRuntimeInfo wmRuntimeInfo) { + stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/certificates/server")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("[]"))); + + List certs = createV1CertificateService(wmRuntimeInfo) + .getServerCertificates(TEST_AGENT_ID); + + assertThat(certs).isEmpty(); + verify(getRequestedFor(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/certificates/server"))); + } + + @Test + @DisplayName("v1 lane targets /v1/agents/{id}/certificates/identity") + void v1IdentityCertificatesPath(WireMockRuntimeInfo wmRuntimeInfo) { + stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/certificates/identity")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("[]"))); + + List certs = createV1CertificateService(wmRuntimeInfo) + .getIdentityCertificates(TEST_AGENT_ID); + + assertThat(certs).isEmpty(); + verify(getRequestedFor(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/certificates/identity"))); } // ==================== getServerCertificates Tests ==================== @@ -67,7 +112,7 @@ void getServerCertificatesShouldReturnListOnSuccess(WireMockRuntimeInfo wmRuntim ] """; - stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/certificates/server")) + stubFor(get(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/certificates/server")) .willReturn(aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") @@ -85,7 +130,7 @@ void getServerCertificatesShouldReturnListOnSuccess(WireMockRuntimeInfo wmRuntim assertThat(cert.getCertificateIssuer()).isEqualTo("CN=ANS CA"); assertThat(cert.getCertificatePEM()).startsWith("-----BEGIN CERTIFICATE-----"); - verify(getRequestedFor(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/certificates/server")) + verify(getRequestedFor(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/certificates/server")) .withHeader("Authorization", containing("sso-key"))); } @@ -93,7 +138,7 @@ void getServerCertificatesShouldReturnListOnSuccess(WireMockRuntimeInfo wmRuntim @DisplayName("getServerCertificates should return empty list when no certificates exist") void getServerCertificatesShouldReturnEmptyListWhenNoCertificates(WireMockRuntimeInfo wmRuntimeInfo) { // Given - stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/certificates/server")) + stubFor(get(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/certificates/server")) .willReturn(aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") @@ -112,7 +157,7 @@ void getServerCertificatesShouldReturnEmptyListWhenNoCertificates(WireMockRuntim @DisplayName("getServerCertificates should throw AnsNotFoundException when agent not found") void getServerCertificatesShouldThrowNotFoundWhenAgentNotFound(WireMockRuntimeInfo wmRuntimeInfo) { // Given - stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/certificates/server")) + stubFor(get(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/certificates/server")) .willReturn(aResponse() .withStatus(404) .withHeader("Content-Type", "application/json") @@ -148,7 +193,7 @@ void getServerCertificatesShouldReturnMultipleCertificates(WireMockRuntimeInfo w ] """; - stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/certificates/server")) + stubFor(get(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/certificates/server")) .willReturn(aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") @@ -185,7 +230,7 @@ void getIdentityCertificatesShouldReturnListOnSuccess(WireMockRuntimeInfo wmRunt ] """; - stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/certificates/identity")) + stubFor(get(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/certificates/identity")) .willReturn(aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") @@ -202,7 +247,7 @@ void getIdentityCertificatesShouldReturnListOnSuccess(WireMockRuntimeInfo wmRunt assertThat(cert.getCertificateSubject()).isEqualTo("CN=test-agent.example.com"); assertThat(cert.getCertificateIssuer()).isEqualTo("CN=ANS Identity CA"); - verify(getRequestedFor(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/certificates/identity")) + verify(getRequestedFor(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/certificates/identity")) .withHeader("Authorization", containing("sso-key"))); } @@ -210,7 +255,7 @@ void getIdentityCertificatesShouldReturnListOnSuccess(WireMockRuntimeInfo wmRunt @DisplayName("getIdentityCertificates should return empty list when no certificates exist") void getIdentityCertificatesShouldReturnEmptyListWhenNoCertificates(WireMockRuntimeInfo wmRuntimeInfo) { // Given - stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/certificates/identity")) + stubFor(get(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/certificates/identity")) .willReturn(aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") @@ -229,7 +274,7 @@ void getIdentityCertificatesShouldReturnEmptyListWhenNoCertificates(WireMockRunt @DisplayName("getIdentityCertificates should throw AnsNotFoundException when agent not found") void getIdentityCertificatesShouldThrowNotFoundWhenAgentNotFound(WireMockRuntimeInfo wmRuntimeInfo) { // Given - stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/certificates/identity")) + stubFor(get(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/certificates/identity")) .willReturn(aResponse() .withStatus(404) .withHeader("Content-Type", "application/json") @@ -290,7 +335,7 @@ void getIdentityCertificatesShouldRejectBlankAgentId(WireMockRuntimeInfo wmRunti @DisplayName("getServerCertificates should throw AnsAuthenticationException on 401") void getServerCertificatesShouldThrowAuthExceptionOn401(WireMockRuntimeInfo wmRuntimeInfo) { // Given - stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/certificates/server")) + stubFor(get(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/certificates/server")) .willReturn(aResponse() .withStatus(401) .withHeader("Content-Type", "application/json") @@ -309,7 +354,7 @@ void getServerCertificatesShouldThrowAuthExceptionOn401(WireMockRuntimeInfo wmRu @DisplayName("getServerCertificates should throw AnsAuthenticationException on 403") void getServerCertificatesShouldThrowAuthExceptionOn403(WireMockRuntimeInfo wmRuntimeInfo) { // Given - stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/certificates/server")) + stubFor(get(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/certificates/server")) .willReturn(aResponse() .withStatus(403) .withHeader("Content-Type", "application/json") @@ -327,7 +372,7 @@ void getServerCertificatesShouldThrowAuthExceptionOn403(WireMockRuntimeInfo wmRu @DisplayName("getIdentityCertificates should throw AnsAuthenticationException on 401") void getIdentityCertificatesShouldThrowAuthExceptionOn401(WireMockRuntimeInfo wmRuntimeInfo) { // Given - stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/certificates/identity")) + stubFor(get(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/certificates/identity")) .willReturn(aResponse() .withStatus(401) .withHeader("Content-Type", "application/json") @@ -346,7 +391,7 @@ void getIdentityCertificatesShouldThrowAuthExceptionOn401(WireMockRuntimeInfo wm @DisplayName("getServerCertificates should throw AnsValidationException on 422") void getServerCertificatesShouldThrowValidationExceptionOn422(WireMockRuntimeInfo wmRuntimeInfo) { // Given - stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/certificates/server")) + stubFor(get(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/certificates/server")) .willReturn(aResponse() .withStatus(422) .withHeader("Content-Type", "application/json") @@ -364,7 +409,7 @@ void getServerCertificatesShouldThrowValidationExceptionOn422(WireMockRuntimeInf @DisplayName("getIdentityCertificates should throw AnsValidationException on 422") void getIdentityCertificatesShouldThrowValidationExceptionOn422(WireMockRuntimeInfo wmRuntimeInfo) { // Given - stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/certificates/identity")) + stubFor(get(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/certificates/identity")) .willReturn(aResponse() .withStatus(422) .withHeader("Content-Type", "application/json") @@ -383,7 +428,7 @@ void getIdentityCertificatesShouldThrowValidationExceptionOn422(WireMockRuntimeI @DisplayName("getServerCertificates should throw AnsServerException on 500") void getServerCertificatesShouldThrowServerExceptionOn500(WireMockRuntimeInfo wmRuntimeInfo) { // Given - stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/certificates/server")) + stubFor(get(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/certificates/server")) .willReturn(aResponse() .withStatus(500) .withHeader("Content-Type", "application/json") @@ -402,7 +447,7 @@ void getServerCertificatesShouldThrowServerExceptionOn500(WireMockRuntimeInfo wm @DisplayName("getServerCertificates should throw AnsServerException on 502") void getServerCertificatesShouldThrowServerExceptionOn502(WireMockRuntimeInfo wmRuntimeInfo) { // Given - stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/certificates/server")) + stubFor(get(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/certificates/server")) .willReturn(aResponse() .withStatus(502) .withHeader("Content-Type", "application/json") @@ -419,7 +464,7 @@ void getServerCertificatesShouldThrowServerExceptionOn502(WireMockRuntimeInfo wm @DisplayName("getServerCertificates should throw AnsServerException on 503") void getServerCertificatesShouldThrowServerExceptionOn503(WireMockRuntimeInfo wmRuntimeInfo) { // Given - stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/certificates/server")) + stubFor(get(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/certificates/server")) .willReturn(aResponse() .withStatus(503) .withHeader("Content-Type", "application/json") @@ -436,7 +481,7 @@ void getServerCertificatesShouldThrowServerExceptionOn503(WireMockRuntimeInfo wm @DisplayName("getIdentityCertificates should throw AnsServerException on 500") void getIdentityCertificatesShouldThrowServerExceptionOn500(WireMockRuntimeInfo wmRuntimeInfo) { // Given - stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/certificates/identity")) + stubFor(get(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/certificates/identity")) .willReturn(aResponse() .withStatus(500) .withHeader("Content-Type", "application/json") @@ -453,7 +498,7 @@ void getIdentityCertificatesShouldThrowServerExceptionOn500(WireMockRuntimeInfo @DisplayName("getServerCertificates should throw AnsServerException on unexpected 4xx error") void getServerCertificatesShouldThrowServerExceptionOnUnexpected4xx(WireMockRuntimeInfo wmRuntimeInfo) { // Given - 418 I'm a teapot (unexpected client error) - stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/certificates/server")) + stubFor(get(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/certificates/server")) .willReturn(aResponse() .withStatus(418) .withHeader("Content-Type", "application/json") @@ -471,7 +516,7 @@ void getServerCertificatesShouldThrowServerExceptionOnUnexpected4xx(WireMockRunt @DisplayName("getServerCertificates should throw AnsServerException on malformed JSON response") void getServerCertificatesShouldThrowServerExceptionOnMalformedJson(WireMockRuntimeInfo wmRuntimeInfo) { // Given - stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/certificates/server")) + stubFor(get(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/certificates/server")) .willReturn(aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") @@ -484,4 +529,91 @@ void getServerCertificatesShouldThrowServerExceptionOnMalformedJson(WireMockRunt .isInstanceOf(AnsServerException.class) .hasMessageContaining("Failed to parse"); } + + // ==================== V1 error paths ==================== + + @Test + @DisplayName("getServerCertificates on V1 should throw AnsAuthenticationException on 401") + void v1GetServerCertificatesShouldThrowAuthExceptionOn401(WireMockRuntimeInfo wmRuntimeInfo) { + // Given + stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/certificates/server")) + .willReturn(aResponse() + .withStatus(401) + .withHeader("Content-Type", "application/json") + .withBody("{\"message\": \"Invalid API key\"}"))); + + CertificateService certificateService = createV1CertificateService(wmRuntimeInfo); + + // When/Then + assertThatThrownBy(() -> certificateService.getServerCertificates(TEST_AGENT_ID)) + .isInstanceOf(AnsAuthenticationException.class); + } + + @Test + @DisplayName("getServerCertificates on V1 should throw AnsNotFoundException on 404") + void v1GetServerCertificatesShouldThrowNotFoundOn404(WireMockRuntimeInfo wmRuntimeInfo) { + // Given + stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/certificates/server")) + .willReturn(aResponse() + .withStatus(404) + .withHeader("Content-Type", "application/json") + .withBody("{\"message\": \"Agent not found\"}"))); + + CertificateService certificateService = createV1CertificateService(wmRuntimeInfo); + + // When/Then + assertThatThrownBy(() -> certificateService.getServerCertificates(TEST_AGENT_ID)) + .isInstanceOf(AnsNotFoundException.class); + } + + @Test + @DisplayName("getServerCertificates on V1 should throw AnsValidationException on 422") + void v1GetServerCertificatesShouldThrowValidationExceptionOn422(WireMockRuntimeInfo wmRuntimeInfo) { + // Given + stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/certificates/server")) + .willReturn(aResponse() + .withStatus(422) + .withHeader("Content-Type", "application/json") + .withBody("{\"message\": \"Invalid agent ID format\"}"))); + + CertificateService certificateService = createV1CertificateService(wmRuntimeInfo); + + // When/Then + assertThatThrownBy(() -> certificateService.getServerCertificates(TEST_AGENT_ID)) + .isInstanceOf(AnsValidationException.class); + } + + @Test + @DisplayName("getServerCertificates on V1 should throw AnsServerException on 500") + void v1GetServerCertificatesShouldThrowServerExceptionOn500(WireMockRuntimeInfo wmRuntimeInfo) { + // Given + stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/certificates/server")) + .willReturn(aResponse() + .withStatus(500) + .withHeader("Content-Type", "application/json") + .withBody("{\"message\": \"Internal server error\"}"))); + + CertificateService certificateService = createV1CertificateService(wmRuntimeInfo); + + // When/Then + assertThatThrownBy(() -> certificateService.getServerCertificates(TEST_AGENT_ID)) + .isInstanceOf(AnsServerException.class); + } + + @Test + @DisplayName("getIdentityCertificates on V1 should throw AnsAuthenticationException on 401") + void v1GetIdentityCertificatesShouldThrowAuthExceptionOn401(WireMockRuntimeInfo wmRuntimeInfo) { + // Given + stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/certificates/identity")) + .willReturn(aResponse() + .withStatus(401) + .withHeader("Content-Type", "application/json") + .withBody("{\"message\": \"Unauthorized\"}"))); + + CertificateService certificateService = createV1CertificateService(wmRuntimeInfo); + + // When/Then + assertThatThrownBy(() -> certificateService.getIdentityCertificates(TEST_AGENT_ID)) + .isInstanceOf(AnsAuthenticationException.class); + } } diff --git a/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/RegistrationClientTest.java b/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/RegistrationClientTest.java index be20716..a68f396 100644 --- a/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/RegistrationClientTest.java +++ b/ans-sdk-registration/src/test/java/com/godaddy/ans/sdk/registration/RegistrationClientTest.java @@ -3,6 +3,7 @@ import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; import com.github.tomakehurst.wiremock.junit5.WireMockTest; import com.godaddy.ans.sdk.auth.JwtCredentialsProvider; +import com.godaddy.ans.sdk.config.ApiVersion; import com.godaddy.ans.sdk.config.Environment; import com.godaddy.ans.sdk.exception.AnsAuthenticationException; import com.godaddy.ans.sdk.exception.AnsConflictException; @@ -16,7 +17,9 @@ import com.godaddy.ans.sdk.model.generated.AgentRevocationRequest; import com.godaddy.ans.sdk.model.generated.AgentRevocationResponse; import com.godaddy.ans.sdk.model.generated.AgentStatus; +import com.godaddy.ans.sdk.model.generated.Protocol; import com.godaddy.ans.sdk.model.generated.RegistrationPending; +import com.godaddy.ans.sdk.model.generated.RevocationReason; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.DisplayName; @@ -129,14 +132,14 @@ void shouldRegisterAgentSuccessfully(WireMockRuntimeInfo wmRuntimeInfo) { String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); // Stub the initial registration POST - stubFor(post(urlEqualTo("/v1/agents/register")) + stubFor(post(urlEqualTo("/v2/ans/agents")) .willReturn(aResponse() .withStatus(202) .withHeader("Content-Type", "application/json") .withBody(registrationPendingResponse()))); // Stub the follow-up GET for AgentDetails - stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID)) + stubFor(get(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID)) .willReturn(aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") @@ -153,7 +156,7 @@ void shouldRegisterAgentSuccessfully(WireMockRuntimeInfo wmRuntimeInfo) { .version("1.0.0") .agentHost("test-agent.example.com") .addEndpointsItem(new AgentEndpoint() - .protocol(AgentEndpoint.ProtocolEnum.A2_A) + .protocol(Protocol.A2_A) .agentUrl(URI.create("https://test-agent.example.com/a2a"))) .identityCsrPEM("-----BEGIN CERTIFICATE REQUEST-----\ntest\n-----END CERTIFICATE REQUEST-----") .serverCsrPEM("-----BEGIN CERTIFICATE REQUEST-----\ntest\n-----END CERTIFICATE REQUEST-----"); @@ -161,7 +164,7 @@ void shouldRegisterAgentSuccessfully(WireMockRuntimeInfo wmRuntimeInfo) { AgentDetails result = client.registerAgent(request); assertThat(result).isNotNull(); - assertThat(result.getAgentId()).isEqualTo(TEST_AGENT_ID); + assertThat(result.getAgentId().toString()).isEqualTo(TEST_AGENT_ID); assertThat(result.getAnsName()).isEqualTo("ans://v1.0.0.test-agent.example.com"); assertThat(result.getRegistrationPending()).isNotNull(); assertThat(result.getRegistrationPending().getStatus()) @@ -169,8 +172,11 @@ void shouldRegisterAgentSuccessfully(WireMockRuntimeInfo wmRuntimeInfo) { assertThat(result.getRegistrationPending().getChallenges()).hasSize(1); assertThat(result.getRegistrationPending().getNextSteps()).hasSize(1); - verify(postRequestedFor(urlEqualTo("/v1/agents/register")) + verify(postRequestedFor(urlEqualTo("/v2/ans/agents")) .withRequestBody(containing("\"agentDisplayName\":\"Test Agent\"")) + // discoveryProfiles is v2-only and defaults to [ANS_DNSAID] on the wire + .withRequestBody(containing("\"discoveryProfiles\"")) + .withRequestBody(containing("ANS_DNSAID")) .withHeader("Authorization", equalTo("sso-jwt " + TEST_JWT_TOKEN))); } @@ -179,7 +185,7 @@ void shouldRegisterAgentSuccessfully(WireMockRuntimeInfo wmRuntimeInfo) { void shouldThrowValidationExceptionOn422(WireMockRuntimeInfo wmRuntimeInfo) { String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); - stubFor(post(urlEqualTo("/v1/agents/register")) + stubFor(post(urlEqualTo("/v2/ans/agents")) .willReturn(aResponse() .withStatus(422) .withHeader("Content-Type", "application/json") @@ -197,7 +203,7 @@ void shouldThrowValidationExceptionOn422(WireMockRuntimeInfo wmRuntimeInfo) { .version("invalid") .agentHost("test-agent.example.com") .addEndpointsItem(new AgentEndpoint() - .protocol(AgentEndpoint.ProtocolEnum.A2_A) + .protocol(Protocol.A2_A) .agentUrl(URI.create("https://test-agent.example.com/a2a"))) .identityCsrPEM("test-csr") .serverCsrPEM("test-csr"); @@ -214,7 +220,7 @@ void shouldThrowValidationExceptionOn422(WireMockRuntimeInfo wmRuntimeInfo) { void shouldVerifyAcmeSuccessfully(WireMockRuntimeInfo wmRuntimeInfo) { String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); - stubFor(post(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/verify-acme")) + stubFor(post(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/verify-acme")) .willReturn(aResponse() .withStatus(202) .withHeader("Content-Type", "application/json") @@ -237,7 +243,7 @@ void shouldVerifyAcmeSuccessfully(WireMockRuntimeInfo wmRuntimeInfo) { void shouldThrowNotFoundExceptionForVerifyAcme(WireMockRuntimeInfo wmRuntimeInfo) { String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); - stubFor(post(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/verify-acme")) + stubFor(post(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/verify-acme")) .willReturn(aResponse() .withStatus(404) .withHeader("Content-Type", "application/json") @@ -261,7 +267,7 @@ void shouldThrowNotFoundExceptionForVerifyAcme(WireMockRuntimeInfo wmRuntimeInfo void shouldVerifyDnsSuccessfully(WireMockRuntimeInfo wmRuntimeInfo) { String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); - stubFor(post(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/verify-dns")) + stubFor(post(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/verify-dns")) .willReturn(aResponse() .withStatus(202) .withHeader("Content-Type", "application/json") @@ -284,7 +290,7 @@ void shouldVerifyDnsSuccessfully(WireMockRuntimeInfo wmRuntimeInfo) { void shouldThrowAuthExceptionForVerifyDns(WireMockRuntimeInfo wmRuntimeInfo) { String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); - stubFor(post(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/verify-dns")) + stubFor(post(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/verify-dns")) .willReturn(aResponse() .withStatus(401) .withHeader("Content-Type", "application/json") @@ -308,7 +314,7 @@ void shouldThrowAuthExceptionForVerifyDns(WireMockRuntimeInfo wmRuntimeInfo) { void shouldRevokeAgentSuccessfully(WireMockRuntimeInfo wmRuntimeInfo) { String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); - stubFor(post(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/revoke")) + stubFor(post(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/revoke")) .willReturn(aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") @@ -321,7 +327,7 @@ void shouldRevokeAgentSuccessfully(WireMockRuntimeInfo wmRuntimeInfo) { .build(); AgentRevocationRequest request = new AgentRevocationRequest() - .reason(AgentRevocationRequest.ReasonEnum.CESSATION_OF_OPERATION) + .reason(RevocationReason.CESSATION_OF_OPERATION) .comments("Agent being decommissioned"); AgentRevocationResponse result = client.revokeAgent(TEST_AGENT_ID, request); @@ -329,10 +335,10 @@ void shouldRevokeAgentSuccessfully(WireMockRuntimeInfo wmRuntimeInfo) { assertThat(result).isNotNull(); assertThat(result.getAgentId()).hasToString(TEST_AGENT_ID); assertThat(result.getStatus()).isEqualTo(AgentLifecycleStatus.REVOKED); - assertThat(result.getReason()).isEqualTo(AgentRevocationResponse.ReasonEnum.CESSATION_OF_OPERATION); + assertThat(result.getReason()).isEqualTo(RevocationReason.CESSATION_OF_OPERATION); assertThat(result.getDnsRecordsToRemove()).hasSize(3); - verify(postRequestedFor(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/revoke")) + verify(postRequestedFor(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/revoke")) .withRequestBody(containing("\"reason\":\"CESSATION_OF_OPERATION\"")) .withRequestBody(containing("\"comments\":\"Agent being decommissioned\"")) .withHeader("Authorization", equalTo("sso-jwt " + TEST_JWT_TOKEN))); @@ -343,7 +349,7 @@ void shouldRevokeAgentSuccessfully(WireMockRuntimeInfo wmRuntimeInfo) { void shouldRevokeAgentWithJustReason(WireMockRuntimeInfo wmRuntimeInfo) { String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); - stubFor(post(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/revoke")) + stubFor(post(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/revoke")) .willReturn(aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") @@ -356,12 +362,12 @@ void shouldRevokeAgentWithJustReason(WireMockRuntimeInfo wmRuntimeInfo) { .build(); AgentRevocationResponse result = client.revokeAgent(TEST_AGENT_ID, - AgentRevocationRequest.ReasonEnum.KEY_COMPROMISE); + RevocationReason.KEY_COMPROMISE); assertThat(result).isNotNull(); assertThat(result.getStatus()).isEqualTo(AgentLifecycleStatus.REVOKED); - verify(postRequestedFor(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/revoke")) + verify(postRequestedFor(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/revoke")) .withRequestBody(containing("\"reason\":\"KEY_COMPROMISE\""))); } @@ -370,7 +376,7 @@ void shouldRevokeAgentWithJustReason(WireMockRuntimeInfo wmRuntimeInfo) { void shouldThrowNotFoundExceptionForRevoke(WireMockRuntimeInfo wmRuntimeInfo) { String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); - stubFor(post(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/revoke")) + stubFor(post(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/revoke")) .willReturn(aResponse() .withStatus(404) .withHeader("Content-Type", "application/json") @@ -383,7 +389,7 @@ void shouldThrowNotFoundExceptionForRevoke(WireMockRuntimeInfo wmRuntimeInfo) { .build(); assertThatThrownBy(() -> client.revokeAgent(TEST_AGENT_ID, - AgentRevocationRequest.ReasonEnum.CESSATION_OF_OPERATION)) + RevocationReason.CESSATION_OF_OPERATION)) .isInstanceOf(AnsNotFoundException.class) .hasMessageContaining("not found"); } @@ -393,7 +399,7 @@ void shouldThrowNotFoundExceptionForRevoke(WireMockRuntimeInfo wmRuntimeInfo) { void shouldThrowValidationExceptionWhenAlreadyRevoked(WireMockRuntimeInfo wmRuntimeInfo) { String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); - stubFor(post(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/revoke")) + stubFor(post(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/revoke")) .willReturn(aResponse() .withStatus(422) .withHeader("Content-Type", "application/json") @@ -407,7 +413,7 @@ void shouldThrowValidationExceptionWhenAlreadyRevoked(WireMockRuntimeInfo wmRunt .build(); assertThatThrownBy(() -> client.revokeAgent(TEST_AGENT_ID, - AgentRevocationRequest.ReasonEnum.CESSATION_OF_OPERATION)) + RevocationReason.CESSATION_OF_OPERATION)) .isInstanceOf(AnsValidationException.class) .hasMessageContaining("Validation error"); } @@ -417,7 +423,7 @@ void shouldThrowValidationExceptionWhenAlreadyRevoked(WireMockRuntimeInfo wmRunt void shouldThrowValidationExceptionForPendingValidation(WireMockRuntimeInfo wmRuntimeInfo) { String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); - stubFor(post(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/revoke")) + stubFor(post(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/revoke")) .willReturn(aResponse() .withStatus(422) .withHeader("Content-Type", "application/json") @@ -431,7 +437,7 @@ void shouldThrowValidationExceptionForPendingValidation(WireMockRuntimeInfo wmRu .build(); assertThatThrownBy(() -> client.revokeAgent(TEST_AGENT_ID, - AgentRevocationRequest.ReasonEnum.CESSATION_OF_OPERATION)) + RevocationReason.CESSATION_OF_OPERATION)) .isInstanceOf(AnsValidationException.class) .hasMessageContaining("Validation error"); } @@ -443,7 +449,7 @@ void shouldThrowValidationExceptionForPendingValidation(WireMockRuntimeInfo wmRu void shouldGetAgentByIdSuccessfully(WireMockRuntimeInfo wmRuntimeInfo) { String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); - stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID)) + stubFor(get(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID)) .willReturn(aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") @@ -458,7 +464,7 @@ void shouldGetAgentByIdSuccessfully(WireMockRuntimeInfo wmRuntimeInfo) { AgentDetails result = client.getAgent(TEST_AGENT_ID); assertThat(result).isNotNull(); - assertThat(result.getAgentId()).isEqualTo(TEST_AGENT_ID); + assertThat(result.getAgentId().toString()).isEqualTo(TEST_AGENT_ID); assertThat(result.getAnsName()).isEqualTo("ans://v1.0.0.test-agent.example.com"); } @@ -467,7 +473,7 @@ void shouldGetAgentByIdSuccessfully(WireMockRuntimeInfo wmRuntimeInfo) { void shouldThrowNotFoundExceptionWhenAgentNotFound(WireMockRuntimeInfo wmRuntimeInfo) { String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); - stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID)) + stubFor(get(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID)) .willReturn(aResponse() .withStatus(404) .withHeader("Content-Type", "application/json") @@ -491,13 +497,13 @@ void shouldThrowNotFoundExceptionWhenAgentNotFound(WireMockRuntimeInfo wmRuntime void shouldRegisterAgentAsync(WireMockRuntimeInfo wmRuntimeInfo) throws Exception { String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); - stubFor(post(urlEqualTo("/v1/agents/register")) + stubFor(post(urlEqualTo("/v2/ans/agents")) .willReturn(aResponse() .withStatus(202) .withHeader("Content-Type", "application/json") .withBody(registrationPendingResponse()))); - stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID)) + stubFor(get(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID)) .willReturn(aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") @@ -514,7 +520,7 @@ void shouldRegisterAgentAsync(WireMockRuntimeInfo wmRuntimeInfo) throws Exceptio .version("1.0.0") .agentHost("test-agent.example.com") .addEndpointsItem(new AgentEndpoint() - .protocol(AgentEndpoint.ProtocolEnum.A2_A) + .protocol(Protocol.A2_A) .agentUrl(URI.create("https://test-agent.example.com/a2a"))) .identityCsrPEM("test-csr") .serverCsrPEM("test-csr"); @@ -522,7 +528,7 @@ void shouldRegisterAgentAsync(WireMockRuntimeInfo wmRuntimeInfo) throws Exceptio AgentDetails result = client.registerAgentAsync(request).get(); assertThat(result).isNotNull(); - assertThat(result.getAgentId()).isEqualTo(TEST_AGENT_ID); + assertThat(result.getAgentId().toString()).isEqualTo(TEST_AGENT_ID); } @Test @@ -530,7 +536,7 @@ void shouldRegisterAgentAsync(WireMockRuntimeInfo wmRuntimeInfo) throws Exceptio void shouldVerifyAcmeAsync(WireMockRuntimeInfo wmRuntimeInfo) throws Exception { String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); - stubFor(post(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/verify-acme")) + stubFor(post(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/verify-acme")) .willReturn(aResponse() .withStatus(202) .withHeader("Content-Type", "application/json") @@ -553,7 +559,7 @@ void shouldVerifyAcmeAsync(WireMockRuntimeInfo wmRuntimeInfo) throws Exception { void shouldVerifyDnsAsync(WireMockRuntimeInfo wmRuntimeInfo) throws Exception { String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); - stubFor(post(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/verify-dns")) + stubFor(post(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/verify-dns")) .willReturn(aResponse() .withStatus(202) .withHeader("Content-Type", "application/json") @@ -576,7 +582,7 @@ void shouldVerifyDnsAsync(WireMockRuntimeInfo wmRuntimeInfo) throws Exception { void shouldRevokeAgentAsyncWithRequest(WireMockRuntimeInfo wmRuntimeInfo) throws Exception { String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); - stubFor(post(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/revoke")) + stubFor(post(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/revoke")) .willReturn(aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") @@ -589,7 +595,7 @@ void shouldRevokeAgentAsyncWithRequest(WireMockRuntimeInfo wmRuntimeInfo) throws .build(); AgentRevocationRequest request = new AgentRevocationRequest() - .reason(AgentRevocationRequest.ReasonEnum.CESSATION_OF_OPERATION); + .reason(RevocationReason.CESSATION_OF_OPERATION); AgentRevocationResponse result = client.revokeAgentAsync(TEST_AGENT_ID, request).get(); @@ -602,7 +608,7 @@ void shouldRevokeAgentAsyncWithRequest(WireMockRuntimeInfo wmRuntimeInfo) throws void shouldRevokeAgentAsyncWithReason(WireMockRuntimeInfo wmRuntimeInfo) throws Exception { String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); - stubFor(post(urlEqualTo("/v1/agents/" + TEST_AGENT_ID + "/revoke")) + stubFor(post(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID + "/revoke")) .willReturn(aResponse() .withStatus(200) .withHeader("Content-Type", "application/json") @@ -615,7 +621,7 @@ void shouldRevokeAgentAsyncWithReason(WireMockRuntimeInfo wmRuntimeInfo) throws .build(); AgentRevocationResponse result = client.revokeAgentAsync(TEST_AGENT_ID, - AgentRevocationRequest.ReasonEnum.KEY_COMPROMISE).get(); + RevocationReason.KEY_COMPROMISE).get(); assertThat(result).isNotNull(); assertThat(result.getStatus()).isEqualTo(AgentLifecycleStatus.REVOKED); @@ -628,7 +634,7 @@ void shouldRevokeAgentAsyncWithReason(WireMockRuntimeInfo wmRuntimeInfo) throws void shouldThrowConflictExceptionOn409(WireMockRuntimeInfo wmRuntimeInfo) { String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); - stubFor(post(urlEqualTo("/v1/agents/register")) + stubFor(post(urlEqualTo("/v2/ans/agents")) .willReturn(aResponse() .withStatus(409) .withHeader("Content-Type", "application/json") @@ -645,7 +651,7 @@ void shouldThrowConflictExceptionOn409(WireMockRuntimeInfo wmRuntimeInfo) { .version("1.0.0") .agentHost("test-agent.example.com") .addEndpointsItem(new AgentEndpoint() - .protocol(AgentEndpoint.ProtocolEnum.A2_A) + .protocol(Protocol.A2_A) .agentUrl(URI.create("https://test-agent.example.com/a2a"))) .identityCsrPEM("test-csr") .serverCsrPEM("test-csr"); @@ -660,7 +666,7 @@ void shouldThrowConflictExceptionOn409(WireMockRuntimeInfo wmRuntimeInfo) { void shouldThrowServerExceptionOnUnexpectedStatusCode(WireMockRuntimeInfo wmRuntimeInfo) { String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); - stubFor(get(urlEqualTo("/v1/agents/" + TEST_AGENT_ID)) + stubFor(get(urlEqualTo("/v2/ans/agents/" + TEST_AGENT_ID)) .willReturn(aResponse() .withStatus(418) .withHeader("Content-Type", "application/json") @@ -691,6 +697,7 @@ void shouldThrowWhenRegistrationMissingSelfLink(WireMockRuntimeInfo wmRuntimeInf RegistrationClient client = RegistrationClient.builder() .environment(Environment.OTE) .baseUrl(baseUrl) + .apiVersion(ApiVersion.V1) .credentialsProvider(new JwtCredentialsProvider(TEST_JWT_TOKEN)) .build(); @@ -699,7 +706,7 @@ void shouldThrowWhenRegistrationMissingSelfLink(WireMockRuntimeInfo wmRuntimeInf .version("1.0.0") .agentHost("test-agent.example.com") .addEndpointsItem(new AgentEndpoint() - .protocol(AgentEndpoint.ProtocolEnum.A2_A) + .protocol(Protocol.A2_A) .agentUrl(URI.create("https://test-agent.example.com/a2a"))) .identityCsrPEM("test-csr") .serverCsrPEM("test-csr"); @@ -723,6 +730,7 @@ void shouldThrowWhenRegistrationHasNullLinks(WireMockRuntimeInfo wmRuntimeInfo) RegistrationClient client = RegistrationClient.builder() .environment(Environment.OTE) .baseUrl(baseUrl) + .apiVersion(ApiVersion.V1) .credentialsProvider(new JwtCredentialsProvider(TEST_JWT_TOKEN)) .build(); @@ -731,7 +739,7 @@ void shouldThrowWhenRegistrationHasNullLinks(WireMockRuntimeInfo wmRuntimeInfo) .version("1.0.0") .agentHost("test-agent.example.com") .addEndpointsItem(new AgentEndpoint() - .protocol(AgentEndpoint.ProtocolEnum.A2_A) + .protocol(Protocol.A2_A) .agentUrl(URI.create("https://test-agent.example.com/a2a"))) .identityCsrPEM("test-csr") .serverCsrPEM("test-csr"); @@ -741,11 +749,45 @@ void shouldThrowWhenRegistrationHasNullLinks(WireMockRuntimeInfo wmRuntimeInfo) .hasMessageContaining("missing 'self' link"); } + @Test + @DisplayName("Should throw AnsServerException when v2 registration response has no agentId") + void shouldThrowWhenV2RegistrationMissingAgentId(WireMockRuntimeInfo wmRuntimeInfo) { + String baseUrl = wmRuntimeInfo.getHttpBaseUrl(); + + // v2 resolves via agentId, not the self link; a response without it is a server error. + stubFor(post(urlEqualTo("/v2/ans/agents")) + .willReturn(aResponse() + .withStatus(202) + .withHeader("Content-Type", "application/json") + .withBody("{\"status\":\"PENDING_VALIDATION\"}"))); + + RegistrationClient client = RegistrationClient.builder() + .environment(Environment.OTE) + .baseUrl(baseUrl) + .credentialsProvider(new JwtCredentialsProvider(TEST_JWT_TOKEN)) + .build(); + + AgentRegistrationRequest request = new AgentRegistrationRequest() + .agentDisplayName("Test Agent") + .version("1.0.0") + .agentHost("test-agent.example.com") + .addEndpointsItem(new AgentEndpoint() + .protocol(Protocol.A2_A) + .agentUrl(URI.create("https://test-agent.example.com/a2a"))) + .identityCsrPEM("test-csr") + .serverCsrPEM("test-csr"); + + assertThatThrownBy(() -> client.registerAgent(request)) + .isInstanceOf(AnsServerException.class) + .hasMessageContaining("missing 'agentId'"); + } + // ==================== Helper Methods ==================== private String registrationPendingResponse() { return """ { + "agentId": "550e8400-e29b-41d4-a716-446655440000", "status": "PENDING_VALIDATION", "ansName": "ans://v1.0.0.test-agent.example.com", "nextSteps": [ diff --git a/ans-sdk-spring-boot-starter/examples/spring-boot-app/src/main/java/com/godaddy/ans/examples/springboot/AgentController.java b/ans-sdk-spring-boot-starter/examples/spring-boot-app/src/main/java/com/godaddy/ans/examples/springboot/AgentController.java index 762d092..9962e63 100644 --- a/ans-sdk-spring-boot-starter/examples/spring-boot-app/src/main/java/com/godaddy/ans/examples/springboot/AgentController.java +++ b/ans-sdk-spring-boot-starter/examples/spring-boot-app/src/main/java/com/godaddy/ans/examples/springboot/AgentController.java @@ -1,5 +1,6 @@ package com.godaddy.ans.examples.springboot; +import com.godaddy.ans.sdk.config.Environment; import com.godaddy.ans.sdk.discovery.DiscoveryClient; import com.godaddy.ans.sdk.model.generated.AgentDetails; import com.godaddy.ans.sdk.model.generated.AgentRegistrationRequest; @@ -68,9 +69,10 @@ public ResponseEntity resolve( */ @GetMapping("/health") public ResponseEntity> health() { + Environment environment = registrationClient.getConfiguration().getEnvironment(); return ResponseEntity.ok(Map.of( "status", "UP", - "environment", registrationClient.getConfiguration().getEnvironment().name() + "environment", environment != null ? environment.name() : "custom" )); } }