import type { Config } from '../config/index.js';
import { sign, createSignaturePayload } from '../crypto/signing.js';
import {
AgentWithKeysSchema,
DiscoveryResultSchema,
InboxResultSchema,
SendMessageResponseSchema,
VerificationResultSchema,
WhoamiResponseSchema,
type AgentWithKeys,
type DiscoveryResult,
type InboxResult,
type SendMessageRequest,
type SendMessageResponse,
type VerificationResult,
type WhoamiResponse,
} from './types.js';
export class ApiError extends Error {
constructor(
public readonly statusCode: number,
public readonly code: string,
message: string,
public readonly details?: Record<string, unknown>
) {
super(message);
this.name = 'ApiError';
}
}
export class RegistryApiClient {
private readonly baseUrl: string;
private readonly config: Config;
constructor(config: Config) {
this.config = config;
this.baseUrl = config.registryUrl.replace(/\/$/, '');
}
/**
* Make an authenticated request to the registry API.
* Adds Ed25519 signature headers for authentication.
*/
private async request<T>(
method: string,
path: string,
body?: unknown,
schema?: { parse: (data: unknown) => T }
): Promise<T> {
const timestamp = Date.now();
const payload = createSignaturePayload(method, path, body, timestamp);
const signature = await sign(payload, this.config.privateKey);
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Agent-Origin': this.config.origin,
'X-Agent-Key-Id': this.config.pubkeyId,
'X-Signature': signature,
'X-Timestamp': timestamp.toString(),
};
const url = `${this.baseUrl}${path}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.config.requestTimeout);
try {
const response = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
const data = await response.json();
if (!response.ok) {
const error = data as { error?: { code?: string; message?: string; details?: Record<string, unknown> } };
throw new ApiError(
response.status,
error.error?.code ?? 'UNKNOWN_ERROR',
error.error?.message ?? 'An unknown error occurred',
error.error?.details
);
}
if (schema) {
return schema.parse(data);
}
return data as T;
} catch (error) {
if (error instanceof ApiError) {
throw error;
}
if (error instanceof Error && error.name === 'AbortError') {
throw new ApiError(408, 'TIMEOUT', `Request timed out after ${this.config.requestTimeout}ms`);
}
throw new ApiError(0, 'NETWORK_ERROR', `Network error: ${String(error)}`);
} finally {
clearTimeout(timeoutId);
}
}
/**
* Get information about the current agent identity.
*/
async whoami(): Promise<WhoamiResponse> {
return this.request('GET', '/api/v1/whoami', undefined, WhoamiResponseSchema);
}
/**
* Lookup an agent by ID.
*/
async lookupById(agentId: string): Promise<AgentWithKeys> {
return this.request('GET', `/api/v1/agents/${agentId}`, undefined, AgentWithKeysSchema);
}
/**
* Lookup an agent by domain/origin.
*/
async lookupByDomain(domain: string): Promise<AgentWithKeys> {
const encoded = encodeURIComponent(domain);
return this.request('GET', `/api/v1/agents/by-domain/${encoded}`, undefined, AgentWithKeysSchema);
}
/**
* Discover agents matching search criteria.
*/
async discover(params: {
query?: string;
capabilities?: string[];
page?: number;
pageSize?: number;
}): Promise<DiscoveryResult> {
const searchParams = new URLSearchParams();
if (params.query) searchParams.set('q', params.query);
if (params.capabilities?.length) searchParams.set('capabilities', params.capabilities.join(','));
if (params.page) searchParams.set('page', params.page.toString());
if (params.pageSize) searchParams.set('pageSize', params.pageSize.toString());
const queryString = searchParams.toString();
const path = `/api/v1/discover${queryString ? `?${queryString}` : ''}`;
return this.request('GET', path, undefined, DiscoveryResultSchema);
}
/**
* Verify a signature from another agent.
*/
async verify(params: {
message: string;
signature: string;
origin: string;
keyId?: string;
}): Promise<VerificationResult> {
return this.request('POST', '/api/v1/gateway/verify', params, VerificationResultSchema);
}
/**
* Send a message to another agent.
*/
async sendMessage(request: SendMessageRequest): Promise<SendMessageResponse> {
return this.request('POST', '/api/v1/messages/send', request, SendMessageResponseSchema);
}
/**
* Fetch inbox messages.
*/
async getInbox(params?: {
unreadOnly?: boolean;
threadId?: string;
limit?: number;
offset?: number;
}): Promise<InboxResult> {
const searchParams = new URLSearchParams();
if (params?.unreadOnly) searchParams.set('unread', 'true');
if (params?.threadId) searchParams.set('thread', params.threadId);
if (params?.limit) searchParams.set('limit', params.limit.toString());
if (params?.offset) searchParams.set('offset', params.offset.toString());
const queryString = searchParams.toString();
const path = `/api/v1/messages/inbox${queryString ? `?${queryString}` : ''}`;
return this.request('GET', path, undefined, InboxResultSchema);
}
/**
* Reply to a message thread.
*/
async replyToThread(threadId: string, body: string, metadata?: Record<string, unknown>): Promise<SendMessageResponse> {
return this.request('POST', `/api/v1/messages/threads/${threadId}/reply`, { body, metadata }, SendMessageResponseSchema);
}
/**
* Mark a message as read.
*/
async markAsRead(messageId: string): Promise<void> {
await this.request('POST', `/api/v1/messages/${messageId}/read`);
}
}
/**
* Create an API client instance.
*/
export function createApiClient(config: Config): RegistryApiClient {
return new RegistryApiClient(config);
}