diff --git a/src/cli-entry.ts b/src/cli-entry.ts index 35cbf42..d3788f5 100644 --- a/src/cli-entry.ts +++ b/src/cli-entry.ts @@ -24,6 +24,7 @@ import { NodeLiveLayer } from "./effect/runtime"; import { setVerbosityLevel } from "./services/logger"; import { actionsCommand } from "./cli/commands/actions"; +import { ansCommand } from "./cli/commands/ans"; import { apiCommand } from "./cli/commands/api"; import { applicationCommand } from "./cli/commands/application"; import { authCommand } from "./cli/commands/auth"; @@ -42,6 +43,10 @@ const rootNextActions: NextAction[] = [ }, { command: "godaddy env get", description: "Get current active environment" }, { command: "godaddy application list", description: "List all applications" }, + { + command: "godaddy ans register", + description: "Register an agent with the ANS", + }, ]; // --------------------------------------------------------------------------- @@ -112,6 +117,86 @@ const COMMAND_TREE: CommandNode = { command: "godaddy webhook", description: "Manage webhook integrations", }, + { + id: "ans.group", + command: "godaddy ans", + description: + "Manage agent registrations with the GoDaddy Agent Name Service", + children: [ + { + id: "ans.register", + command: "godaddy ans register", + description: + "Register an agent (requires pre-generated RSA-2048 CSR files)", + }, + { + id: "ans.status", + command: "godaddy ans status ", + description: "Get current registration status", + }, + { + id: "ans.verify-acme", + command: "godaddy ans verify-acme ", + description: "Trigger ACME domain validation", + }, + { + id: "ans.verify-dns", + command: "godaddy ans verify-dns ", + description: "Trigger DNS record verification", + }, + { + id: "ans.submit-server-csr", + command: "godaddy ans submit-server-csr --csr-file ", + description: "Submit a server CSR", + }, + { + id: "ans.submit-identity-csr", + command: + "godaddy ans submit-identity-csr --csr-file ", + description: "Submit an identity CSR", + }, + { + id: "ans.csr-status", + command: "godaddy ans csr-status --csr-id ", + description: "Get CSR submission status", + }, + { + id: "ans.revoke", + command: "godaddy ans revoke --reason ", + description: "Revoke an agent registration", + }, + { + id: "ans.search", + command: "godaddy ans search", + description: "Search for registered agents", + }, + { + id: "ans.resolve", + command: "godaddy ans resolve --host ", + description: "Resolve agents by hostname", + }, + { + id: "ans.events", + command: "godaddy ans events", + description: "List ANS audit events", + }, + { + id: "ans.get-server-certs", + command: "godaddy ans get-server-certs ", + description: "Retrieve issued server certificates", + }, + { + id: "ans.get-identity-certs", + command: "godaddy ans get-identity-certs ", + description: "Retrieve issued identity certificates", + }, + { + id: "ans.badge", + command: "godaddy ans badge ", + description: "Get transparency log entry and Merkle proof", + }, + ], + }, { id: "application.group", command: "godaddy application", @@ -358,6 +443,7 @@ const rootCommand = Command.make( authCommand, apiCommand, actionsCommand, + ansCommand, webhookCommand, applicationCommand, ]), diff --git a/src/cli/commands/ans.ts b/src/cli/commands/ans.ts new file mode 100644 index 0000000..bf15b13 --- /dev/null +++ b/src/cli/commands/ans.ts @@ -0,0 +1,640 @@ +/** + * ANS (Agent Name Service) commands. + * + * Provides lifecycle management for agents registered with the GoDaddy Agent + * Name Service: registration, verification, certificate management, search, + * resolution, auditing, and revocation. + * + * Authentication: GODADDY_KEY + GODADDY_SECRET environment variables. + * + * NOTE (CSR gap): The `register` command requires pre-generated CSR files + * (--server-csr-file, --identity-csr-file). Auto-generation is not implemented + * because there is no built-in ASN.1 CSR builder in Node.js, and adding + * @peculiar/x509 as a new dependency needs discussion. See the PR description. + */ + +import { readFile } from "node:fs/promises"; +import * as Args from "@effect/cli/Args"; +import * as Command from "@effect/cli/Command"; +import * as Options from "@effect/cli/Options"; +import * as Effect from "effect/Effect"; +import { + REVOCATION_REASONS, + type RevocationReason, + getAgentStatusEffect, + getBadgeEffect, + getCsrStatusEffect, + getEventsEffect, + getIdentityCertsEffect, + getServerCertsEffect, + registerAgentEffect, + resolveAgentEffect, + revokeAgentEffect, + searchAgentsEffect, + submitIdentityCsrEffect, + submitServerCsrEffect, + verifyAcmeEffect, + verifyDnsEffect, +} from "../../core/ans"; +import { ConfigurationError, ValidationError } from "../../effect/errors"; +import type { NextAction } from "../agent/types"; +import { EnvelopeWriter } from "../services/envelope-writer"; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +function readCsrFile( + path: string, + label: string, +): Effect.Effect { + return Effect.tryPromise({ + try: () => readFile(path, "utf8"), + catch: (e) => + new ConfigurationError({ + message: `Failed to read ${label} file at '${path}': ${String(e)}`, + userMessage: `Could not read ${label} file. Check the path and file permissions.`, + }), + }); +} + +// --------------------------------------------------------------------------- +// register +// --------------------------------------------------------------------------- + +const ansRegisterActions: NextAction[] = [ + { + command: "godaddy ans status ", + description: "Check registration status and retrieve DNS records", + }, + { + command: "godaddy ans verify-acme ", + description: + "Trigger ACME domain validation after adding the _acme-challenge TXT record", + }, +]; + +const ansRegister = Command.make( + "register", + { + host: Options.text("host").pipe( + Options.withDescription( + "Fully-qualified domain name where the agent is hosted (e.g. agent.example.com)", + ), + ), + a2aUrl: Options.text("a2a-url").pipe( + Options.withDescription( + "Full A2A endpoint URL (e.g. https://agent.example.com/a2a)", + ), + ), + version: Options.text("version").pipe( + Options.withDescription("Agent version for the ANS name"), + Options.withDefault("0.1.0"), + ), + displayName: Options.text("display-name").pipe( + Options.withDescription("Human-readable display name for the agent"), + Options.optional, + ), + serverCsrFile: Options.text("server-csr-file").pipe( + Options.withDescription( + "Path to PEM-encoded server CSR file (RSA-2048 required). Generate with the Rust or Go ANS SDK CLI.", + ), + ), + identityCsrFile: Options.text("identity-csr-file").pipe( + Options.withDescription( + "Path to PEM-encoded identity CSR file (RSA-2048 required). Generate with the Rust or Go ANS SDK CLI.", + ), + ), + }, + ({ host, a2aUrl, version, displayName, serverCsrFile, identityCsrFile }) => + Effect.gen(function* () { + const writer = yield* EnvelopeWriter; + + const serverCsrPem = yield* readCsrFile(serverCsrFile, "server CSR"); + const identityCsrPem = yield* readCsrFile( + identityCsrFile, + "identity CSR", + ); + + const pending = yield* registerAgentEffect({ + agentDisplayName: + displayName._tag === "Some" ? displayName.value : host, + agentHost: host, + version, + identityCsrPem, + serverCsrPem, + endpoints: [ + { + url: a2aUrl, + protocol: "A2A", + transports: ["STREAMABLE-HTTP"], + }, + ], + }); + + yield* writer.emitSuccess( + "godaddy ans register", + { + ...pending, + note: "Configure the DNS records listed in dns_records, then run verify-acme.", + }, + ansRegisterActions, + ); + }), +).pipe( + Command.withDescription( + "Register an agent with the ANS. Requires pre-generated RSA-2048 CSR files.", + ), +); + +// --------------------------------------------------------------------------- +// status +// --------------------------------------------------------------------------- + +const ansStatus = Command.make( + "status", + { + agentId: Args.text({ name: "agent-id" }).pipe( + Args.withDescription("ANS agent ID returned by register"), + ), + }, + ({ agentId }) => + Effect.gen(function* () { + const writer = yield* EnvelopeWriter; + const status = yield* getAgentStatusEffect(agentId); + + const nextActions: NextAction[] = []; + if (status.status === "PENDING_VALIDATION") { + nextActions.push({ + command: `godaddy ans verify-acme ${agentId}`, + description: + "Trigger ACME verification after adding _acme-challenge TXT record", + }); + } + if (status.status === "PENDING_CERTS") { + nextActions.push({ + command: `godaddy ans verify-dns ${agentId}`, + description: + "Trigger DNS record verification after adding all DNS records", + }); + } + if (status.status !== "REVOKED") { + nextActions.push({ + command: `godaddy ans revoke ${agentId} --reason CESSATION_OF_OPERATION`, + description: "Revoke this agent registration", + }); + } + + yield* writer.emitSuccess("godaddy ans status", status, nextActions); + }), +).pipe(Command.withDescription("Get current registration status for an agent")); + +// --------------------------------------------------------------------------- +// verify-acme +// --------------------------------------------------------------------------- + +const ansVerifyAcme = Command.make( + "verify-acme", + { + agentId: Args.text({ name: "agent-id" }).pipe( + Args.withDescription("ANS agent ID"), + ), + }, + ({ agentId }) => + Effect.gen(function* () { + const writer = yield* EnvelopeWriter; + const result = yield* verifyAcmeEffect(agentId); + yield* writer.emitSuccess("godaddy ans verify-acme", result, [ + { + command: `godaddy ans status ${agentId}`, + description: "Poll for status after ACME verification", + }, + ]); + }), +).pipe( + Command.withDescription( + "Trigger ACME domain validation. Add the _acme-challenge TXT record shown in status first.", + ), +); + +// --------------------------------------------------------------------------- +// verify-dns +// --------------------------------------------------------------------------- + +const ansVerifyDns = Command.make( + "verify-dns", + { + agentId: Args.text({ name: "agent-id" }).pipe( + Args.withDescription("ANS agent ID"), + ), + }, + ({ agentId }) => + Effect.gen(function* () { + const writer = yield* EnvelopeWriter; + const result = yield* verifyDnsEffect(agentId); + yield* writer.emitSuccess("godaddy ans verify-dns", result, [ + { + command: `godaddy ans status ${agentId}`, + description: "Check status after DNS verification", + }, + ]); + }), +).pipe( + Command.withDescription( + "Trigger DNS record verification after all required DNS records have been configured.", + ), +); + +// --------------------------------------------------------------------------- +// submit-server-csr +// --------------------------------------------------------------------------- + +const ansSubmitServerCsr = Command.make( + "submit-server-csr", + { + agentId: Args.text({ name: "agent-id" }).pipe( + Args.withDescription("ANS agent ID"), + ), + csrFile: Options.text("csr-file").pipe( + Options.withDescription("Path to PEM-encoded server CSR file"), + ), + }, + ({ agentId, csrFile }) => + Effect.gen(function* () { + const writer = yield* EnvelopeWriter; + const csrPem = yield* readCsrFile(csrFile, "server CSR"); + const result = yield* submitServerCsrEffect(agentId, csrPem); + yield* writer.emitSuccess("godaddy ans submit-server-csr", result, [ + { + command: `godaddy ans csr-status ${agentId} --csr-id ${result.csrId}`, + description: "Poll CSR status", + }, + ]); + }), +).pipe( + Command.withDescription( + "Submit a server CSR for an already-registered agent.", + ), +); + +// --------------------------------------------------------------------------- +// submit-identity-csr +// --------------------------------------------------------------------------- + +const ansSubmitIdentityCsr = Command.make( + "submit-identity-csr", + { + agentId: Args.text({ name: "agent-id" }).pipe( + Args.withDescription("ANS agent ID"), + ), + csrFile: Options.text("csr-file").pipe( + Options.withDescription("Path to PEM-encoded identity CSR file"), + ), + }, + ({ agentId, csrFile }) => + Effect.gen(function* () { + const writer = yield* EnvelopeWriter; + const csrPem = yield* readCsrFile(csrFile, "identity CSR"); + const result = yield* submitIdentityCsrEffect(agentId, csrPem); + yield* writer.emitSuccess("godaddy ans submit-identity-csr", result, [ + { + command: `godaddy ans csr-status ${agentId} --csr-id ${result.csrId}`, + description: "Poll CSR status", + }, + ]); + }), +).pipe( + Command.withDescription( + "Submit an identity CSR for an already-registered agent.", + ), +); + +// --------------------------------------------------------------------------- +// csr-status +// --------------------------------------------------------------------------- + +const ansCsrStatus = Command.make( + "csr-status", + { + agentId: Args.text({ name: "agent-id" }).pipe( + Args.withDescription("ANS agent ID"), + ), + csrId: Options.text("csr-id").pipe( + Options.withDescription( + "CSR ID returned by submit-server-csr or submit-identity-csr", + ), + ), + }, + ({ agentId, csrId }) => + Effect.gen(function* () { + const writer = yield* EnvelopeWriter; + const result = yield* getCsrStatusEffect(agentId, csrId); + yield* writer.emitSuccess("godaddy ans csr-status", result, [ + { + command: `godaddy ans status ${agentId}`, + description: "Check overall agent registration status", + }, + ]); + }), +).pipe(Command.withDescription("Get the status of a pending CSR submission.")); + +// --------------------------------------------------------------------------- +// revoke +// --------------------------------------------------------------------------- + +const ansRevoke = Command.make( + "revoke", + { + agentId: Args.text({ name: "agent-id" }).pipe( + Args.withDescription("ANS agent ID to revoke"), + ), + reason: Options.text("reason").pipe( + Options.withDescription( + `Revocation reason. One of: ${REVOCATION_REASONS.join(", ")}`, + ), + ), + }, + ({ agentId, reason }) => + Effect.gen(function* () { + const writer = yield* EnvelopeWriter; + + if (!REVOCATION_REASONS.includes(reason as RevocationReason)) { + return yield* Effect.fail( + new ValidationError({ + message: `Invalid revocation reason: '${reason}'`, + userMessage: `Revocation reason must be one of: ${REVOCATION_REASONS.join(", ")}`, + }), + ); + } + + yield* revokeAgentEffect(agentId, reason as RevocationReason); + yield* writer.emitSuccess( + "godaddy ans revoke", + { + agentId, + revoked: true, + reason, + note: "The ANS name lock is released by a background job. Re-registration may not be available immediately.", + }, + [ + { + command: "godaddy ans register", + description: "Register a new agent", + }, + ], + ); + }), +).pipe( + Command.withDescription( + "Revoke an agent registration. Valid reasons: CESSATION_OF_OPERATION, KEY_COMPROMISE, AFFILIATION_CHANGED, SUPERSEDED, EXPIRED_CERT, UNSPECIFIED.", + ), +); + +// --------------------------------------------------------------------------- +// search +// --------------------------------------------------------------------------- + +const ansSearch = Command.make( + "search", + { + host: Options.text("host").pipe( + Options.withDescription("Filter by agent hostname"), + Options.optional, + ), + name: Options.text("name").pipe( + Options.withDescription("Filter by agent display name"), + Options.optional, + ), + version: Options.text("version").pipe( + Options.withDescription("Filter by agent version"), + Options.optional, + ), + }, + ({ host, name, version }) => + Effect.gen(function* () { + const writer = yield* EnvelopeWriter; + const result = yield* searchAgentsEffect({ + host: host._tag === "Some" ? host.value : undefined, + name: name._tag === "Some" ? name.value : undefined, + version: version._tag === "Some" ? version.value : undefined, + }); + yield* writer.emitSuccess("godaddy ans search", result, [ + { + command: "godaddy ans resolve --host ", + description: "Resolve agent endpoints by hostname", + }, + ]); + }), +).pipe( + Command.withDescription( + "Search for registered ANS agents by host, name, or version.", + ), +); + +// --------------------------------------------------------------------------- +// resolve +// --------------------------------------------------------------------------- + +const ansResolve = Command.make( + "resolve", + { + host: Options.text("host").pipe( + Options.withDescription("Hostname to resolve agents for"), + ), + version: Options.text("version").pipe( + Options.withDescription( + "Version pattern (semver range) to match, e.g. ^1.0.0", + ), + Options.optional, + ), + }, + ({ host, version }) => + Effect.gen(function* () { + const writer = yield* EnvelopeWriter; + const result = yield* resolveAgentEffect( + host, + version._tag === "Some" ? version.value : undefined, + ); + yield* writer.emitSuccess("godaddy ans resolve", result, [ + { + command: "godaddy ans status ", + description: "Get full status for a specific agent", + }, + ]); + }), +).pipe( + Command.withDescription( + "Resolve ANS agents by hostname and optional version pattern.", + ), +); + +// --------------------------------------------------------------------------- +// events +// --------------------------------------------------------------------------- + +const ansEvents = Command.make( + "events", + { + limit: Options.integer("limit").pipe( + Options.withDescription("Maximum number of events to return"), + Options.withDefault(50), + ), + lastLogId: Options.text("last-log-id").pipe( + Options.withDescription( + "Pagination cursor: return events after this log ID", + ), + Options.optional, + ), + }, + ({ limit, lastLogId }) => + Effect.gen(function* () { + const writer = yield* EnvelopeWriter; + const result = yield* getEventsEffect({ + limit, + lastLogId: lastLogId._tag === "Some" ? lastLogId.value : undefined, + }); + const nextActions: NextAction[] = []; + if (result.nextLogId) { + nextActions.push({ + command: `godaddy ans events --last-log-id ${result.nextLogId}`, + description: "Fetch the next page of events", + params: { "last-log-id": { required: true } }, + }); + } + yield* writer.emitSuccess("godaddy ans events", result, nextActions); + }), +).pipe( + Command.withDescription("List ANS audit events with optional pagination."), +); + +// --------------------------------------------------------------------------- +// get-server-certs +// --------------------------------------------------------------------------- + +const ansGetServerCerts = Command.make( + "get-server-certs", + { + agentId: Args.text({ name: "agent-id" }).pipe( + Args.withDescription("ANS agent ID"), + ), + }, + ({ agentId }) => + Effect.gen(function* () { + const writer = yield* EnvelopeWriter; + const result = yield* getServerCertsEffect(agentId); + yield* writer.emitSuccess("godaddy ans get-server-certs", result, []); + }), +).pipe( + Command.withDescription("Retrieve issued server certificates for an agent."), +); + +// --------------------------------------------------------------------------- +// get-identity-certs +// --------------------------------------------------------------------------- + +const ansGetIdentityCerts = Command.make( + "get-identity-certs", + { + agentId: Args.text({ name: "agent-id" }).pipe( + Args.withDescription("ANS agent ID"), + ), + }, + ({ agentId }) => + Effect.gen(function* () { + const writer = yield* EnvelopeWriter; + const result = yield* getIdentityCertsEffect(agentId); + yield* writer.emitSuccess("godaddy ans get-identity-certs", result, []); + }), +).pipe( + Command.withDescription( + "Retrieve issued identity certificates for an agent.", + ), +); + +// --------------------------------------------------------------------------- +// badge +// --------------------------------------------------------------------------- + +const ansBadge = Command.make( + "badge", + { + agentId: Args.text({ name: "agent-id" }).pipe( + Args.withDescription("ANS agent ID"), + ), + }, + ({ agentId }) => + Effect.gen(function* () { + const writer = yield* EnvelopeWriter; + const result = yield* getBadgeEffect(agentId); + yield* writer.emitSuccess("godaddy ans badge", result, []); + }), +).pipe( + Command.withDescription( + "Get the transparency log entry and Merkle proof for an agent.", + ), +); + +// --------------------------------------------------------------------------- +// Parent command +// --------------------------------------------------------------------------- + +const ansParent = Command.make("ans", {}, () => + Effect.gen(function* () { + const writer = yield* EnvelopeWriter; + yield* writer.emitSuccess( + "godaddy ans", + { + description: + "Manage agent registrations with the GoDaddy Agent Name Service", + commands: [ + "register", + "status", + "verify-acme", + "verify-dns", + "submit-server-csr", + "submit-identity-csr", + "csr-status", + "revoke", + "search", + "resolve", + "events", + "get-server-certs", + "get-identity-certs", + "badge", + ], + }, + [ + { + command: "godaddy ans register", + description: "Register a new agent", + }, + { + command: "godaddy ans search", + description: "Search for registered agents", + }, + ], + ); + }), +).pipe( + Command.withDescription( + "Manage agent registrations with the GoDaddy Agent Name Service (ANS)", + ), + Command.withSubcommands([ + ansRegister, + ansStatus, + ansVerifyAcme, + ansVerifyDns, + ansSubmitServerCsr, + ansSubmitIdentityCsr, + ansCsrStatus, + ansRevoke, + ansSearch, + ansResolve, + ansEvents, + ansGetServerCerts, + ansGetIdentityCerts, + ansBadge, + ]), +); + +export const ansCommand = ansParent; diff --git a/src/core/ans.ts b/src/core/ans.ts new file mode 100644 index 0000000..d8233da --- /dev/null +++ b/src/core/ans.ts @@ -0,0 +1,397 @@ +/** + * ANS (Agent Name Service) REST API client. + * + * Authentication: API key via GODADDY_KEY + GODADDY_SECRET env vars. + * + * NOTE (auth gap): The existing CLI uses OAuth Bearer tokens via `godaddy auth + * login`. ANS endpoints also accept Bearer tokens, but the current OAuth flow + * does not request ANS-specific scopes. Until the required scope is known and + * added to the CLI's auth flow, ANS commands authenticate via API key only. + * See the PR description for discussion. + */ + +import type { FileSystem } from "@effect/platform/FileSystem"; +import * as Effect from "effect/Effect"; +import { + ConfigurationError, + NetworkError, + ServerError, +} from "../effect/errors"; +import { type Environment, envGetEffect, getApiUrl } from "./environment"; + +// --------------------------------------------------------------------------- +// ANS response types +// --------------------------------------------------------------------------- + +export interface DnsRecord { + type: string; + name: string; + value: string; + ttl?: number; +} + +export interface NextStep { + action: string; + description: string; + endpoint?: string; + estimatedTimeMinutes?: number; +} + +export interface RegistrationPending { + agentId?: string; + status: string; + expiresAt?: string; + dnsRecords?: DnsRecord[]; + nextSteps?: NextStep; +} + +export interface AgentStatus { + agentId: string; + ansName: string; + status: string; + failureReason?: string; + nextSteps?: NextStep; + dnsRecords?: DnsRecord[]; + createdAt?: string; + updatedAt?: string; + expiresAt?: string; +} + +export interface CsrResponse { + csrId: string; + status: string; +} + +export interface CsrStatus { + csrId: string; + status: string; + failureReason?: string; + submittedAt?: string; + updatedAt?: string; +} + +export interface AgentSummary { + agentId: string; + ansName: string; + agentDisplayName: string; + agentDescription?: string; + status: string; +} + +export interface AgentSearchResult { + agents: AgentSummary[]; + total?: number; +} + +export interface EventPage { + events: AnsEvent[]; + nextLogId?: string; +} + +export interface AnsEvent { + logId: string; + type: string; + agentId?: string; + timestamp: string; + details?: unknown; +} + +export interface BadgeEntry { + agentId: string; + certificate?: string; + merkleProof?: unknown; + timestamp?: string; +} + +export interface CertList { + certificates: string[]; +} + +export interface AgentEndpoint { + url: string; + protocol: string; + transports?: string[]; +} + +export interface AgentRegistrationRequest { + agentDisplayName: string; + agentHost: string; + version: string; + identityCsrPem: string; + serverCsrPem?: string; + endpoints: AgentEndpoint[]; +} + +export type RevocationReason = + | "CESSATION_OF_OPERATION" + | "KEY_COMPROMISE" + | "AFFILIATION_CHANGED" + | "SUPERSEDED" + | "EXPIRED_CERT" + | "UNSPECIFIED"; + +export const REVOCATION_REASONS: RevocationReason[] = [ + "CESSATION_OF_OPERATION", + "KEY_COMPROMISE", + "AFFILIATION_CHANGED", + "SUPERSEDED", + "EXPIRED_CERT", + "UNSPECIFIED", +]; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +type AnsService = "registry" | "transparency"; + +function getAnsBaseUrl(env: Environment, service: AnsService): string { + if (service === "transparency") { + return env === "prod" + ? "https://transparency.ans.godaddy.com" + : "https://transparency.ans.ote-godaddy.com"; + } + return getApiUrl(env); +} + +function getAnsAuthHeader(): Effect.Effect { + const key = process.env.GODADDY_KEY; + const secret = process.env.GODADDY_SECRET; + if (!key || !secret) { + return Effect.fail( + new ConfigurationError({ + message: "GODADDY_KEY and GODADDY_SECRET are required for ANS commands", + userMessage: + "ANS commands require API key credentials. Set GODADDY_KEY and GODADDY_SECRET environment variables. Obtain credentials at https://developer.godaddy.com/keys", + }), + ); + } + return Effect.succeed(`sso-key ${key}:${secret}`); +} + +type AnsError = ConfigurationError | NetworkError | ServerError; + +function makeAnsRequest( + method: "GET" | "POST" | "DELETE", + path: string, + body?: unknown, + service: AnsService = "registry", +): Effect.Effect { + return Effect.gen(function* () { + const authHeader = yield* getAnsAuthHeader(); + const env = yield* envGetEffect().pipe( + Effect.mapError( + (e) => + new ConfigurationError({ + message: `Failed to determine environment: ${e.message}`, + userMessage: "Could not determine target environment", + }), + ), + ); + + const baseUrl = getAnsBaseUrl(env, service); + const url = `${baseUrl}${path}`; + + const headers: Record = { + Authorization: authHeader, + Accept: "application/json", + }; + + if (body !== undefined) { + headers["Content-Type"] = "application/json"; + } else if (method === "POST") { + // Akamai edge layer requires Content-Length: 0 on bodyless POST requests. + // Without it the request is rejected with HTTP 411. + headers["Content-Length"] = "0"; + } + + const response = yield* Effect.tryPromise({ + try: () => + globalThis.fetch(url, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }), + catch: (e) => + new NetworkError({ + message: `ANS request failed: ${String(e)}`, + userMessage: "Could not reach ANS API", + endpoint: path, + method, + }), + }); + + if (!response.ok) { + const errorBody = yield* Effect.tryPromise({ + try: () => response.json() as Promise<{ message?: string }>, + catch: (e) => + new NetworkError({ + message: `Failed to parse error response: ${String(e)}`, + userMessage: "Unexpected error response from ANS API", + endpoint: path, + method, + }), + }).pipe(Effect.orElse(() => Effect.succeed({} as { message?: string }))); + const message = + errorBody.message ?? + `ANS API error: ${response.status} ${response.statusText}`; + return yield* Effect.fail( + new ServerError({ + kind: + response.status === 404 + ? "NOT_FOUND" + : response.status === 409 + ? "CONFLICT" + : response.status === 403 + ? "FORBIDDEN" + : response.status === 429 + ? "RATE_LIMITED" + : "VALIDATION", + message, + userMessage: message, + status: response.status, + statusText: response.statusText, + endpoint: path, + method, + }), + ); + } + + // 204 No Content or empty body + const contentLength = response.headers.get("content-length"); + if (response.status === 204 || contentLength === "0") { + return undefined as T; + } + + return yield* Effect.tryPromise({ + try: () => response.json() as Promise, + catch: (e) => + new NetworkError({ + message: `Failed to parse ANS response: ${String(e)}`, + userMessage: "Unexpected response format from ANS API", + endpoint: path, + method, + }), + }); + }); +} + +// --------------------------------------------------------------------------- +// Registry API effects +// --------------------------------------------------------------------------- + +export function registerAgentEffect( + req: AgentRegistrationRequest, +): Effect.Effect { + return makeAnsRequest("POST", "/v1/agents/register", req); +} + +export function getAgentStatusEffect( + agentId: string, +): Effect.Effect { + return makeAnsRequest("GET", `/v1/agents/${agentId}`); +} + +export function verifyAcmeEffect( + agentId: string, +): Effect.Effect { + return makeAnsRequest("POST", `/v1/agents/${agentId}/verify-acme`); +} + +export function verifyDnsEffect( + agentId: string, +): Effect.Effect { + return makeAnsRequest("POST", `/v1/agents/${agentId}/verify-dns`); +} + +export function submitServerCsrEffect( + agentId: string, + csrPem: string, +): Effect.Effect { + return makeAnsRequest("POST", `/v1/agents/${agentId}/certificates/server`, { + csrPem, + }); +} + +export function submitIdentityCsrEffect( + agentId: string, + csrPem: string, +): Effect.Effect { + return makeAnsRequest("POST", `/v1/agents/${agentId}/certificates/identity`, { + csrPem, + }); +} + +export function getCsrStatusEffect( + agentId: string, + csrId: string, +): Effect.Effect { + return makeAnsRequest("GET", `/v1/agents/${agentId}/csrs/${csrId}/status`); +} + +export function revokeAgentEffect( + agentId: string, + reason: RevocationReason, +): Effect.Effect { + return makeAnsRequest("POST", `/v1/agents/${agentId}/revoke`, { reason }); +} + +export function searchAgentsEffect(criteria: { + host?: string; + name?: string; + version?: string; +}): Effect.Effect { + const params = new URLSearchParams(); + if (criteria.host) params.set("agentHost", criteria.host); + if (criteria.name) params.set("agentDisplayName", criteria.name); + if (criteria.version) params.set("version", criteria.version); + const qs = params.toString(); + return makeAnsRequest("GET", `/v1/agents${qs ? `?${qs}` : ""}`); +} + +export function resolveAgentEffect( + host: string, + version?: string, +): Effect.Effect { + const params = new URLSearchParams({ agentHost: host }); + if (version) params.set("version", version); + return makeAnsRequest("GET", `/v1/agents/resolve?${params.toString()}`); +} + +export function getEventsEffect(opts: { + limit?: number; + lastLogId?: string; +}): Effect.Effect { + const params = new URLSearchParams(); + if (opts.limit !== undefined) params.set("limit", String(opts.limit)); + if (opts.lastLogId) params.set("lastLogId", opts.lastLogId); + const qs = params.toString(); + return makeAnsRequest("GET", `/v1/agents/events${qs ? `?${qs}` : ""}`); +} + +export function getServerCertsEffect( + agentId: string, +): Effect.Effect { + return makeAnsRequest("GET", `/v1/agents/${agentId}/certificates/server`); +} + +export function getIdentityCertsEffect( + agentId: string, +): Effect.Effect { + return makeAnsRequest("GET", `/v1/agents/${agentId}/certificates/identity`); +} + +// --------------------------------------------------------------------------- +// Transparency log API effects +// --------------------------------------------------------------------------- + +export function getBadgeEffect( + agentId: string, +): Effect.Effect { + return makeAnsRequest( + "GET", + `/v1/agents/${agentId}/badge`, + undefined, + "transparency", + ); +} diff --git a/tests/integration/ans-smoke.test.ts b/tests/integration/ans-smoke.test.ts new file mode 100644 index 0000000..9294bdb --- /dev/null +++ b/tests/integration/ans-smoke.test.ts @@ -0,0 +1,133 @@ +import { execSync, spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { beforeAll, describe, expect, it } from "vitest"; + +const CLI_PATH = join(process.cwd(), "dist", "cli.js"); + +function runCli(args: string[]) { + const result = spawnSync("node", [CLI_PATH, ...args], { + encoding: "utf-8", + }); + return { + stdout: result.stdout.trim(), + stderr: result.stderr.trim(), + status: result.status ?? 0, + }; +} + +describe("ANS command smoke tests", () => { + beforeAll(() => { + if (!existsSync(CLI_PATH)) { + execSync("pnpm run build", { stdio: "inherit" }); + } + }); + + it("discovery tree includes ans group", () => { + const result = runCli([]); + expect(result.status).toBe(0); + + const payload = JSON.parse(result.stdout); + const tree = payload.result.command_tree; + const ansGroup = tree.children.find( + (c: { id: string }) => c.id === "ans.group", + ); + expect(ansGroup).toBeDefined(); + expect(ansGroup.command).toBe("godaddy ans"); + }); + + it("ans command tree includes all 14 subcommands", () => { + const result = runCli([]); + const payload = JSON.parse(result.stdout); + const tree = payload.result.command_tree; + const ansGroup = tree.children.find( + (c: { id: string }) => c.id === "ans.group", + ); + + const ids = (ansGroup.children as Array<{ id: string }>).map((c) => c.id); + const expected = [ + "ans.register", + "ans.status", + "ans.verify-acme", + "ans.verify-dns", + "ans.submit-server-csr", + "ans.submit-identity-csr", + "ans.csr-status", + "ans.revoke", + "ans.search", + "ans.resolve", + "ans.events", + "ans.get-server-certs", + "ans.get-identity-certs", + "ans.badge", + ]; + for (const id of expected) { + expect(ids).toContain(id); + } + }); + + it("ans bare command returns discovery envelope", () => { + const result = runCli(["ans"]); + expect(result.status).toBe(0); + + const payload = JSON.parse(result.stdout); + expect(payload.ok).toBe(true); + expect(payload.command).toBe("godaddy ans"); + expect(Array.isArray(payload.result.commands)).toBe(true); + expect(payload.result.commands).toContain("register"); + }); + + it("ans register without credentials returns auth error envelope", () => { + const result = spawnSync( + "node", + [ + CLI_PATH, + "ans", + "register", + "--host", + "example.ai", + "--a2a-url", + "https://example.ai/a2a", + "--server-csr-file", + "/nonexistent.pem", + "--identity-csr-file", + "/nonexistent.pem", + ], + { + encoding: "utf-8", + env: { + ...process.env, + GODADDY_KEY: undefined, + GODADDY_SECRET: undefined, + }, + }, + ); + + // Either a config error (missing creds) or a file error (csr file not found) + // — either way the CLI exits with a non-zero code and emits an error envelope + const payload = JSON.parse(result.stdout); + expect(payload.ok).toBe(false); + expect(typeof payload.error.message).toBe("string"); + expect(typeof payload.error.code).toBe("string"); + }); + + it("ans revoke rejects unknown reason with validation error", () => { + const result = spawnSync( + "node", + [CLI_PATH, "ans", "revoke", "fake-agent-id", "--reason", "NOPE"], + { + encoding: "utf-8", + env: { + ...process.env, + GODADDY_KEY: "test", + GODADDY_SECRET: "test", + }, + }, + ); + + const payload = JSON.parse(result.stdout); + expect(payload.ok).toBe(false); + expect(payload.error.code).toBe("VALIDATION_ERROR"); + expect(payload.error.message).toContain("NOPE"); + }); +});