437 lines
13 KiB
TypeScript
437 lines
13 KiB
TypeScript
import type {
|
|
ElydoraClientConfig,
|
|
CreateOperationParams,
|
|
EOR,
|
|
RegisterAgentRequest,
|
|
RegisterAgentResponse,
|
|
GetAgentResponse,
|
|
ListAgentsResponse,
|
|
UnfreezeAgentResponse,
|
|
DeleteAgentResponse,
|
|
SubmitOperationResponse,
|
|
GetOperationResponse,
|
|
VerifyOperationResponse,
|
|
AuditQueryRequest,
|
|
AuditQueryResponse,
|
|
ListEpochsResponse,
|
|
GetEpochResponse,
|
|
CreateExportRequest,
|
|
CreateExportResponse,
|
|
ListExportsResponse,
|
|
GetExportResponse,
|
|
GetMeResponse,
|
|
IssueApiTokenResponse,
|
|
JWKSResponse,
|
|
HealthResponse,
|
|
AuthRegisterResponse,
|
|
AuthLoginResponse,
|
|
ErrorResponse,
|
|
} from './types.js';
|
|
import { ElydoraError } from './errors.js';
|
|
import { assertIntegrationType } from './integration-types.js';
|
|
import {
|
|
jcsCanonicalise,
|
|
computePayloadHash,
|
|
computeChainHash,
|
|
signEd25519,
|
|
derivePublicKey,
|
|
ZERO_CHAIN_HASH,
|
|
} from './crypto.js';
|
|
import { uuidv7, generateNonce } from './utils.js';
|
|
|
|
const DEFAULT_BASE_URL = 'https://api.elydora.com';
|
|
const DEFAULT_TTL_MS = 30_000;
|
|
const DEFAULT_MAX_RETRIES = 3;
|
|
|
|
export class ElydoraClient {
|
|
private readonly orgId: string;
|
|
private readonly agentId: string;
|
|
private readonly privateKey: string;
|
|
private readonly baseUrl: string;
|
|
private readonly ttlMs: number;
|
|
private readonly maxRetries: number;
|
|
private readonly kid: string;
|
|
private prevChainHash: string;
|
|
private token: string | undefined;
|
|
|
|
constructor(config: ElydoraClientConfig) {
|
|
this.orgId = config.orgId;
|
|
this.agentId = config.agentId;
|
|
this.privateKey = config.privateKey;
|
|
this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
this.ttlMs = config.ttlMs ?? DEFAULT_TTL_MS;
|
|
this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
this.kid = config.kid ?? this.agentId + '-key-v1';
|
|
this.prevChainHash = ZERO_CHAIN_HASH;
|
|
}
|
|
|
|
/**
|
|
* Set the session token used for authenticated API calls.
|
|
*/
|
|
setToken(token: string): void {
|
|
this.token = token;
|
|
}
|
|
|
|
/**
|
|
* Get the current chain hash (useful for debugging/inspection).
|
|
*/
|
|
getChainHash(): string {
|
|
return this.prevChainHash;
|
|
}
|
|
|
|
/**
|
|
* Get the public key derived from the configured private key.
|
|
*/
|
|
getPublicKey(): string {
|
|
return derivePublicKey(this.privateKey);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Auth (static methods — no instance needed)
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* @deprecated Use Better Auth endpoints directly. See docs.
|
|
*/
|
|
static async register(
|
|
baseUrl: string,
|
|
email: string,
|
|
password: string,
|
|
displayName?: string,
|
|
orgName?: string,
|
|
): Promise<AuthRegisterResponse> {
|
|
const url = `${baseUrl.replace(/\/+$/, '')}/v1/auth/register`;
|
|
const body: Record<string, unknown> = { email, password };
|
|
if (displayName !== undefined) body.display_name = displayName;
|
|
if (orgName !== undefined) body.org_name = orgName;
|
|
|
|
const res = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
return handleResponse<AuthRegisterResponse>(res);
|
|
}
|
|
|
|
/**
|
|
* @deprecated Use Better Auth endpoints directly. See docs.
|
|
*/
|
|
static async login(
|
|
baseUrl: string,
|
|
email: string,
|
|
password: string,
|
|
): Promise<AuthLoginResponse> {
|
|
const url = `${baseUrl.replace(/\/+$/, '')}/v1/auth/login`;
|
|
const res = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email, password }),
|
|
});
|
|
|
|
return handleResponse<AuthLoginResponse>(res);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Auth (instance methods — require token)
|
|
// -------------------------------------------------------------------------
|
|
|
|
async getMe(): Promise<GetMeResponse> {
|
|
return this.request<GetMeResponse>('GET', '/v1/auth/me');
|
|
}
|
|
|
|
async issueApiToken(ttlSeconds?: number | null): Promise<IssueApiTokenResponse> {
|
|
return this.request<IssueApiTokenResponse>('POST', '/v1/auth/token', { ttl_seconds: ttlSeconds ?? null });
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Agent management
|
|
// -------------------------------------------------------------------------
|
|
|
|
async registerAgent(request: RegisterAgentRequest): Promise<RegisterAgentResponse> {
|
|
assertIntegrationType(request?.integration_type);
|
|
return this.request<RegisterAgentResponse>('POST', '/v1/agents/register', request);
|
|
}
|
|
|
|
async getAgent(agentId: string): Promise<GetAgentResponse> {
|
|
return this.request<GetAgentResponse>('GET', `/v1/agents/${encodeURIComponent(agentId)}`);
|
|
}
|
|
|
|
async freezeAgent(agentId: string, reason: string): Promise<void> {
|
|
await this.request<unknown>('POST', `/v1/agents/${encodeURIComponent(agentId)}/freeze`, { reason });
|
|
}
|
|
|
|
async listAgents(): Promise<ListAgentsResponse> {
|
|
return this.request<ListAgentsResponse>('GET', '/v1/agents');
|
|
}
|
|
|
|
async unfreezeAgent(agentId: string, reason: string): Promise<UnfreezeAgentResponse> {
|
|
return this.request<UnfreezeAgentResponse>('POST', `/v1/agents/${encodeURIComponent(agentId)}/unfreeze`, { reason });
|
|
}
|
|
|
|
async deleteAgent(agentId: string): Promise<DeleteAgentResponse> {
|
|
return this.request<DeleteAgentResponse>('DELETE', `/v1/agents/${encodeURIComponent(agentId)}`);
|
|
}
|
|
|
|
async revokeKey(agentId: string, kid: string, reason: string): Promise<void> {
|
|
await this.request<unknown>('POST', `/v1/agents/${encodeURIComponent(agentId)}/revoke`, { kid, reason });
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Operations (core)
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Construct and sign an Elydora Operation Record (EOR) locally.
|
|
*
|
|
* 1. Generate UUIDv7 for operation_id
|
|
* 2. Generate random nonce (16 bytes, base64url)
|
|
* 3. Compute payload_hash (SHA-256 of JCS-canonicalized payload)
|
|
* 4. Compute chain_hash from prev_chain_hash
|
|
* 5. Construct canonical EOR (minus signature)
|
|
* 6. Sign JCS-canonicalized EOR with Ed25519
|
|
* 7. Update internal prev_chain_hash
|
|
*/
|
|
createOperation(params: CreateOperationParams): EOR {
|
|
const operationId = uuidv7();
|
|
const issuedAt = Date.now();
|
|
const nonce = generateNonce();
|
|
const payload = params.payload ?? null;
|
|
|
|
// Compute payload hash
|
|
const payloadHash = computePayloadHash(payload);
|
|
|
|
// Compute chain hash
|
|
const chainHash = computeChainHash(
|
|
this.prevChainHash,
|
|
payloadHash,
|
|
operationId,
|
|
issuedAt,
|
|
);
|
|
|
|
// Construct the EOR without signature for signing
|
|
const eorWithoutSig: Omit<EOR, 'signature'> = {
|
|
op_version: '1.0',
|
|
operation_id: operationId,
|
|
org_id: this.orgId,
|
|
agent_id: this.agentId,
|
|
issued_at: issuedAt,
|
|
ttl_ms: this.ttlMs,
|
|
nonce,
|
|
operation_type: params.operationType,
|
|
subject: params.subject,
|
|
action: params.action,
|
|
payload,
|
|
payload_hash: payloadHash,
|
|
prev_chain_hash: this.prevChainHash,
|
|
agent_pubkey_kid: this.kid,
|
|
};
|
|
|
|
// Sign the JCS-canonicalized EOR (without signature field)
|
|
const canonical = jcsCanonicalise(eorWithoutSig);
|
|
const signature = signEd25519(this.privateKey, Buffer.from(canonical, 'utf-8'));
|
|
|
|
// Update internal chain state
|
|
this.prevChainHash = chainHash;
|
|
|
|
return {
|
|
...eorWithoutSig,
|
|
signature,
|
|
};
|
|
}
|
|
|
|
async submitOperation(eor: EOR): Promise<SubmitOperationResponse> {
|
|
return this.request<SubmitOperationResponse>('POST', '/v1/operations', eor);
|
|
}
|
|
|
|
async getOperation(operationId: string): Promise<GetOperationResponse> {
|
|
return this.request<GetOperationResponse>('GET', `/v1/operations/${encodeURIComponent(operationId)}`);
|
|
}
|
|
|
|
async verifyOperation(operationId: string): Promise<VerifyOperationResponse> {
|
|
return this.request<VerifyOperationResponse>('POST', `/v1/operations/${encodeURIComponent(operationId)}/verify`);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Audit
|
|
// -------------------------------------------------------------------------
|
|
|
|
async queryAudit(params: AuditQueryRequest): Promise<AuditQueryResponse> {
|
|
return this.request<AuditQueryResponse>('POST', '/v1/audit/query', params);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Epochs
|
|
// -------------------------------------------------------------------------
|
|
|
|
async listEpochs(): Promise<ListEpochsResponse> {
|
|
return this.request<ListEpochsResponse>('GET', '/v1/epochs');
|
|
}
|
|
|
|
async getEpoch(epochId: string): Promise<GetEpochResponse> {
|
|
return this.request<GetEpochResponse>('GET', `/v1/epochs/${encodeURIComponent(epochId)}`);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Exports
|
|
// -------------------------------------------------------------------------
|
|
|
|
async createExport(params: CreateExportRequest): Promise<CreateExportResponse> {
|
|
return this.request<CreateExportResponse>('POST', '/v1/exports', params);
|
|
}
|
|
|
|
async listExports(): Promise<ListExportsResponse> {
|
|
return this.request<ListExportsResponse>('GET', '/v1/exports');
|
|
}
|
|
|
|
async getExport(exportId: string): Promise<GetExportResponse> {
|
|
return this.request<GetExportResponse>('GET', `/v1/exports/${encodeURIComponent(exportId)}`);
|
|
}
|
|
|
|
async downloadExport(exportId: string): Promise<ArrayBuffer> {
|
|
const url = `${this.baseUrl}/v1/exports/${encodeURIComponent(exportId)}/download`;
|
|
const headers: Record<string, string> = {};
|
|
|
|
if (this.token) {
|
|
headers['Authorization'] = `Bearer ${this.token}`;
|
|
}
|
|
|
|
const res = await fetch(url, { method: 'GET', headers });
|
|
|
|
if (!res.ok) {
|
|
return handleResponse<never>(res);
|
|
}
|
|
|
|
return res.arrayBuffer();
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// JWKS
|
|
// -------------------------------------------------------------------------
|
|
|
|
async getJWKS(): Promise<JWKSResponse> {
|
|
const url = `${this.baseUrl}/.well-known/elydora/jwks.json`;
|
|
const res = await fetch(url, {
|
|
method: 'GET',
|
|
headers: { 'Accept': 'application/json' },
|
|
});
|
|
return handleResponse<JWKSResponse>(res);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Health
|
|
// -------------------------------------------------------------------------
|
|
|
|
async health(): Promise<HealthResponse> {
|
|
const url = `${this.baseUrl}/v1/health`;
|
|
const res = await fetch(url, {
|
|
method: 'GET',
|
|
headers: { 'Accept': 'application/json' },
|
|
});
|
|
return handleResponse<HealthResponse>(res);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Internal HTTP helpers
|
|
// -------------------------------------------------------------------------
|
|
|
|
private async request<T>(
|
|
method: string,
|
|
path: string,
|
|
body?: unknown,
|
|
): Promise<T> {
|
|
const url = `${this.baseUrl}${path}`;
|
|
const headers: Record<string, string> = {
|
|
'Accept': 'application/json',
|
|
};
|
|
|
|
if (this.token) {
|
|
headers['Authorization'] = `Bearer ${this.token}`;
|
|
}
|
|
|
|
if (body !== undefined) {
|
|
headers['Content-Type'] = 'application/json';
|
|
}
|
|
|
|
let lastError: Error | undefined;
|
|
|
|
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
try {
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers,
|
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
});
|
|
|
|
// Only retry on 429 or 5xx
|
|
if (attempt < this.maxRetries && (res.status === 429 || res.status >= 500)) {
|
|
const retryAfter = res.headers.get('Retry-After');
|
|
const delayMs = retryAfter
|
|
? parseInt(retryAfter, 10) * 1000
|
|
: Math.min(1000 * 2 ** attempt, 10_000);
|
|
await sleep(delayMs);
|
|
continue;
|
|
}
|
|
|
|
return await handleResponse<T>(res);
|
|
} catch (err) {
|
|
lastError = err instanceof Error ? err : new Error(String(err));
|
|
|
|
// Only retry on network errors, not API errors
|
|
if (err instanceof ElydoraError) {
|
|
throw err;
|
|
}
|
|
|
|
if (attempt < this.maxRetries) {
|
|
await sleep(Math.min(1000 * 2 ** attempt, 10_000));
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
throw lastError ?? new Error('Request failed after retries');
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function handleResponse<T>(res: Response): Promise<T> {
|
|
if (res.ok) {
|
|
// 204 No Content
|
|
if (res.status === 204) {
|
|
return undefined as T;
|
|
}
|
|
return (await res.json()) as T;
|
|
}
|
|
|
|
let errorBody: ErrorResponse | undefined;
|
|
try {
|
|
errorBody = (await res.json()) as ErrorResponse;
|
|
} catch {
|
|
// Response body was not valid JSON
|
|
}
|
|
|
|
if (errorBody?.error) {
|
|
throw new ElydoraError(
|
|
res.status,
|
|
errorBody.error.code,
|
|
errorBody.error.message,
|
|
errorBody.error.request_id,
|
|
errorBody.error.details,
|
|
);
|
|
}
|
|
|
|
throw new ElydoraError(
|
|
res.status,
|
|
'INTERNAL_ERROR',
|
|
`HTTP ${res.status}: ${res.statusText}`,
|
|
'unknown',
|
|
);
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|