chore(node-sdk): audit src core

Align response types with the Backend (webhooks, me, health, operation payload, error codes, admin actions), shorten comments, drop section banners.

Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
This commit is contained in:
雷电芽衣 committed 2026-09-02 21:19:01 -04:00
1 parent 2101cb77e3
commit 07f32a48d7
6 files changed
+45 -233

No files matched your search

-18
View File
@@ -50,10 +50,6 @@ const PLUGINS: ReadonlyMap<string, AgentPlugin> = new Map([
['qwen', qwenPlugin],
]);
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function die(message: string): never {
console.error(`Error: ${message}`);
process.exit(1);
@@ -137,10 +133,6 @@ Supported agents: ${Array.from(SUPPORTED_AGENTS.keys()).join(', ')}
`);
}
// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------
async function cmdInstall(args: string[]): Promise<void> {
const { values } = parseArgs({
args,
@@ -178,7 +170,6 @@ async function cmdInstall(args: string[]): Promise<void> {
});
const baseUrl = values.base_url ?? 'https://api.elydora.com';
// Validate private key by deriving public key
let publicKey: string;
try {
publicKey = derivePublicKey(privateKey);
@@ -255,7 +246,6 @@ async function cmdUninstall(args: string[]): Promise<void> {
let agentDir: string;
let agentDirectoryExists: boolean;
// If --agent_id not provided, scan ~/.elydora/*/config.json for matching agent_name
if (agentId) {
agentDir = resolvePrivateChildDirectory(ELYDORA_DIR, agentId);
const runtimeRootExists = await requirePhysicalDirectory(ELYDORA_DIR);
@@ -287,10 +277,8 @@ async function cmdUninstall(args: string[]): Promise<void> {
const plugin = PLUGINS.get(agentName)!;
const registryEntry = SUPPORTED_AGENTS.get(agentName)!;
// Uninstall agent-specific config
await plugin.uninstall(agentId);
// Remove entire agent directory
if (agentDirectoryExists) {
await fsp.rm(agentDir, { recursive: true });
}
@@ -303,14 +291,12 @@ async function cmdStatus(): Promise<void> {
let anyInstalled = false;
// Scan ~/.elydora/*/config.json to discover installed agents
const installedAgents = await discoverInstalledAgents();
for (const [name, plugin] of PLUGINS) {
const st = await plugin.status();
const statusIcon = st.installed ? '[installed]' : '[not installed]';
// Find matching installed agent(s) for this plugin
const matching = installedAgents.filter((a) => a.agentName === name);
console.log(` ${st.displayName} (${name}) ${statusIcon}`);
@@ -339,10 +325,6 @@ function cmdAgents(): void {
console.log('\nUse "elydora install --agent <name>" to install an audit hook.');
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main(): Promise<void> {
const args = process.argv.slice(2);
+4 -91
View File
@@ -74,34 +74,19 @@ export class ElydoraClient {
this.prevChainHash = ZERO_CHAIN_HASH;
}
/**
* Set the API 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.
*/
/** @deprecated Password auth for the SDK CLI path; Console users sign in through Better Auth. */
static async register(
baseUrl: string,
email: string,
@@ -123,9 +108,7 @@ export class ElydoraClient {
return handleResponse<AuthRegisterResponse>(res);
}
/**
* @deprecated Use Better Auth endpoints directly. See docs.
*/
/** @deprecated Password auth for the SDK CLI path; Console users sign in through Better Auth. */
static async login(
baseUrl: string,
email: string,
@@ -141,10 +124,6 @@ export class ElydoraClient {
return handleResponse<AuthLoginResponse>(res);
}
// -------------------------------------------------------------------------
// Auth (instance methods — require token)
// -------------------------------------------------------------------------
async getMe(): Promise<GetMeResponse> {
return this.request<GetMeResponse>('GET', '/v1/auth/me');
}
@@ -157,10 +136,6 @@ export class ElydoraClient {
return this.request<RotateApiTokenResponse>('POST', '/v1/auth/rotate', {});
}
// -------------------------------------------------------------------------
// Agent management
// -------------------------------------------------------------------------
async registerAgent(request: RegisterAgentRequest): Promise<RegisterAgentResponse> {
assertIntegrationType(request?.integration_type);
return this.request<RegisterAgentResponse>('POST', '/v1/agents/register', request);
@@ -195,31 +170,15 @@ export class ElydoraClient {
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
*/
// Builds, hashes, chains, and signs an EOR locally, then advances 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,
@@ -227,7 +186,6 @@ export class ElydoraClient {
issuedAt,
);
// Construct the EOR without signature for signing
const eorWithoutSig: Omit<EOR, 'signature'> = {
op_version: '1.0',
operation_id: operationId,
@@ -245,11 +203,9 @@ export class ElydoraClient {
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 {
@@ -270,18 +226,10 @@ export class ElydoraClient {
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');
}
@@ -290,10 +238,6 @@ export class ElydoraClient {
return this.request<GetEpochResponse>('GET', `/v1/epochs/${encodeURIComponent(epochId)}`);
}
// -------------------------------------------------------------------------
// Exports
// -------------------------------------------------------------------------
async createExport(params: CreateExportRequest): Promise<CreateExportResponse> {
return this.request<CreateExportResponse>('POST', '/v1/exports', params);
}
@@ -323,10 +267,6 @@ export class ElydoraClient {
return res.arrayBuffer();
}
// -------------------------------------------------------------------------
// JWKS
// -------------------------------------------------------------------------
async getJWKS(): Promise<JWKSResponse> {
const url = `${this.baseUrl}/.well-known/elydora/jwks.json`;
const res = await fetch(url, {
@@ -336,10 +276,6 @@ export class ElydoraClient {
return handleResponse<JWKSResponse>(res);
}
// -------------------------------------------------------------------------
// Health
// -------------------------------------------------------------------------
async health(): Promise<HealthResponse> {
const url = `${this.baseUrl}/v1/health`;
const res = await fetch(url, {
@@ -355,10 +291,6 @@ export class ElydoraClient {
return handleResponse<DeepHealthResponse>(res);
}
// -------------------------------------------------------------------------
// Webhooks
// -------------------------------------------------------------------------
async listWebhooks(): Promise<ListWebhooksResponse> {
return this.request<ListWebhooksResponse>('GET', '/v1/webhooks');
}
@@ -371,27 +303,15 @@ export class ElydoraClient {
await this.request<unknown>('DELETE', `/v1/webhooks/${encodeURIComponent(webhookId)}`);
}
// -------------------------------------------------------------------------
// Members
// -------------------------------------------------------------------------
async listMembers(): Promise<ListMembersResponse> {
return this.request<ListMembersResponse>('GET', '/v1/members');
}
// -------------------------------------------------------------------------
// Admin events
// -------------------------------------------------------------------------
async listAdminEvents(limit?: number): Promise<ListAdminEventsResponse> {
const query = limit ? `?limit=${limit}` : '';
return this.request<ListAdminEventsResponse>('GET', `/v1/admin/events${query}`);
}
// -------------------------------------------------------------------------
// Internal HTTP helpers
// -------------------------------------------------------------------------
private async request<T>(
method: string,
path: string,
@@ -420,7 +340,6 @@ export class ElydoraClient {
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
@@ -434,7 +353,6 @@ export class ElydoraClient {
} 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;
}
@@ -450,13 +368,8 @@ export class ElydoraClient {
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function handleResponse<T>(res: Response): Promise<T> {
if (res.ok) {
// 204 No Content
if (res.status === 204) {
return undefined as T;
}
@@ -467,7 +380,7 @@ async function handleResponse<T>(res: Response): Promise<T> {
try {
errorBody = (await res.json()) as ErrorResponse;
} catch {
// Response body was not valid JSON
// non-JSON error body: fall through to the status-only error
}
if (errorBody?.error) {
+6 -70
View File
@@ -1,18 +1,7 @@
import crypto from 'node:crypto';
import { base64urlEncode } from './utils.js';
// ---------------------------------------------------------------------------
// JCS Canonicalization (RFC 8785)
// ---------------------------------------------------------------------------
/**
* Canonicalize a JSON value according to JCS (RFC 8785).
*
* - Object keys sorted lexicographically by UTF-16 code units
* - No whitespace
* - Numbers serialized using ES2015 Number serialization
* - Strings serialized with minimal escaping per JSON spec
*/
// RFC 8785 JSON Canonicalization Scheme
export function jcsCanonicalise(value: unknown): string {
if (value === null || value === undefined) {
return 'null';
@@ -46,30 +35,13 @@ export function jcsCanonicalise(value: unknown): string {
return JSON.stringify(value);
}
// ---------------------------------------------------------------------------
// SHA-256
// ---------------------------------------------------------------------------
/**
* Compute SHA-256 of a UTF-8 string or buffer and return base64url.
*/
export function sha256Base64url(data: string | Buffer): string {
const input = typeof data === 'string' ? Buffer.from(data, 'utf-8') : data;
const hash = crypto.createHash('sha256').update(input).digest();
return base64urlEncode(hash);
}
// ---------------------------------------------------------------------------
// Chain hash computation
// ---------------------------------------------------------------------------
/**
* Compute the chain hash for an operation.
*
* chain_hash = SHA-256(prev_chain_hash + "|" + payload_hash + "|" + operation_id + "|" + issued_at)
*
* All inputs concatenated as UTF-8 strings separated by '|'.
*/
// chain_hash = SHA-256("prev|payload_hash|operation_id|issued_at")
export function computeChainHash(
prevChainHash: string,
payloadHash: string,
@@ -80,13 +52,6 @@ export function computeChainHash(
return sha256Base64url(input);
}
// ---------------------------------------------------------------------------
// Payload hash
// ---------------------------------------------------------------------------
/**
* Compute SHA-256 hash of JCS-canonicalized payload, returned as base64url.
*/
export function computePayloadHash(
payload: Record<string, unknown> | string | null,
): string {
@@ -94,23 +59,11 @@ export function computePayloadHash(
return sha256Base64url(canonical);
}
// ---------------------------------------------------------------------------
// Ed25519 signing
// ---------------------------------------------------------------------------
// PKCS8 prefix for wrapping a 32-byte Ed25519 seed
// PKCS8 envelope: SEQUENCE { INTEGER 0, SEQUENCE { OID 1.3.101.112 }, OCTET STRING { OCTET STRING seed } }
const PKCS8_ED25519_PREFIX = Buffer.from([
0x30, 0x2e, // SEQUENCE (46 bytes)
0x02, 0x01, 0x00, // INTEGER 0
0x30, 0x05, // SEQUENCE (5 bytes)
0x06, 0x03, 0x2b, 0x65, 0x70, // OID 1.3.101.112 (Ed25519)
0x04, 0x22, // OCTET STRING (34 bytes)
0x04, 0x20, // OCTET STRING (32 bytes)
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
]);
/**
* Import a base64url-encoded 32-byte Ed25519 seed as a Node.js KeyObject.
*/
function importPrivateKey(privateKeyBase64url: string): crypto.KeyObject {
const seed = Buffer.from(privateKeyBase64url, 'base64url');
const pkcs8 = Buffer.concat([PKCS8_ED25519_PREFIX, seed]);
@@ -121,37 +74,20 @@ function importPrivateKey(privateKeyBase64url: string): crypto.KeyObject {
});
}
/**
* Sign data with Ed25519 using a base64url-encoded 32-byte seed.
*
* @returns base64url-encoded 64-byte signature
*/
export function signEd25519(privateKeyBase64url: string, data: Buffer): string {
const keyObject = importPrivateKey(privateKeyBase64url);
const signature = crypto.sign(null, data, keyObject);
return base64urlEncode(signature);
}
/**
* Derive the Ed25519 public key from a base64url-encoded 32-byte seed.
*
* @returns base64url-encoded 32-byte public key
*/
export function derivePublicKey(privateKeyBase64url: string): string {
const keyObject = importPrivateKey(privateKeyBase64url);
const publicKeyObject = crypto.createPublicKey(keyObject);
const rawPublicKey = publicKeyObject.export({ type: 'spki', format: 'der' });
// The raw 32-byte public key is the last 32 bytes of the SPKI DER encoding
// The raw key is the last 32 bytes of the SPKI DER encoding.
const publicKeyBytes = rawPublicKey.subarray(rawPublicKey.length - 32);
return base64urlEncode(publicKeyBytes);
}
// ---------------------------------------------------------------------------
// Zero chain hash (initial prev_chain_hash for the first operation)
// ---------------------------------------------------------------------------
/**
* The initial chain hash value: base64url encoding of 32 zero bytes.
* Must match the backend's GENESIS_CHAIN_HASH constant exactly.
*/
// 32 zero bytes; equals the Backend GENESIS_CHAIN_HASH.
export const ZERO_CHAIN_HASH: string = base64urlEncode(Buffer.alloc(32, 0));
+1 -4
View File
@@ -20,7 +20,6 @@ export {
} from './utils.js';
export type {
// Enums
AgentStatus,
KeyStatus,
ExportStatus,
@@ -29,7 +28,6 @@ export type {
AdminAction,
ErrorCode,
// Entities
Agent,
AgentKey,
Operation,
@@ -41,11 +39,9 @@ export type {
User,
Export,
// Protocol
EOR,
EAR,
// API request/response
ElydoraClientConfig,
CreateOperationParams,
RegisterAgentRequest,
@@ -75,6 +71,7 @@ export type {
JWKSResponse,
HealthResponse,
DeepHealthResponse,
Webhook,
ListWebhooksResponse,
RegisterWebhookResponse,
ListMembersResponse,
+33 -31
View File
@@ -1,7 +1,3 @@
// ---------------------------------------------------------------------------
// Enums / status types
// ---------------------------------------------------------------------------
export type AgentStatus = 'active' | 'frozen' | 'revoked';
export type KeyStatus = 'active' | 'retired' | 'revoked';
export type ExportStatus = 'queued' | 'running' | 'done' | 'failed';
@@ -37,13 +33,16 @@ export type AdminAction =
| 'agent.register' | 'agent.update' | 'agent.freeze' | 'agent.unfreeze'
| 'agent.revoke' | 'agent.delete' | 'key.revoke' | 'export.create'
| 'org.create' | 'org.update'
| 'member.invite' | 'member.remove' | 'member.role_change'
| 'agent.assign' | 'agent.unassign';
| 'member.invite' | 'invitation.cancel' | 'member.remove' | 'member.role_change'
| 'agent.assign' | 'agent.unassign'
| 'webhook.register' | 'webhook.delete';
export type ErrorCode =
| 'INVALID_SIGNATURE'
| 'UNKNOWN_AGENT'
| 'KEY_REVOKED'
| 'KEY_RETIRED'
| 'AGENT_FROZEN'
| 'AGENT_REVOKED'
| 'TTL_EXPIRED'
| 'REPLAY_DETECTED'
| 'PREV_HASH_MISMATCH'
@@ -55,10 +54,6 @@ export type ErrorCode =
| 'NOT_FOUND'
| 'VALIDATION_ERROR';
// ---------------------------------------------------------------------------
// Entities
// ---------------------------------------------------------------------------
export interface Agent {
readonly agent_id: string;
readonly org_id: string;
@@ -168,10 +163,6 @@ export interface Export {
readonly completed_at: number | null;
}
// ---------------------------------------------------------------------------
// Protocol types
// ---------------------------------------------------------------------------
export interface EOR {
readonly op_version: '1.0';
readonly operation_id: string;
@@ -205,10 +196,6 @@ export interface EAR {
readonly elydora_signature: string;
}
// ---------------------------------------------------------------------------
// API request/response types
// ---------------------------------------------------------------------------
export interface RegisterAgentRequest {
readonly agent_id: string;
readonly integration_type: IntegrationType;
@@ -238,6 +225,7 @@ export interface SubmitOperationResponse {
export interface GetOperationResponse {
readonly operation: Operation;
readonly receipt?: Receipt;
readonly payload?: Record<string, unknown> | string;
}
export interface VerifyOperationResponse {
@@ -327,15 +315,22 @@ export interface DeleteAgentResponse {
readonly deleted: boolean;
}
export interface Webhook {
readonly webhook_id: string;
readonly org_id: string;
readonly endpoint_url: string;
readonly events: string[];
readonly status: 'active' | 'disabled';
readonly created_at: number;
readonly updated_at: number;
}
export interface ListWebhooksResponse {
readonly webhooks: unknown[];
readonly webhooks: Webhook[];
}
export interface RegisterWebhookResponse {
readonly webhook_id: string;
readonly endpoint_url: string;
readonly events: string[];
readonly created_at: number;
readonly webhook: Webhook;
}
export interface ListMembersResponse {
@@ -357,16 +352,26 @@ export interface DeepHealthResponse {
readonly status: 'healthy' | 'degraded';
readonly version: string;
readonly protocol_version: string;
readonly capabilities: Record<string, string>;
readonly timestamp: number;
readonly dependencies: {
readonly d1: { status: 'ok' | 'failed'; latency_ms: number; error?: string };
readonly r2: { status: 'ok' | 'failed'; latency_ms: number; error?: string };
readonly kv: { status: 'ok' | 'failed'; latency_ms: number; error?: string };
readonly d1: { status: 'ok' | 'failed'; latency_ms: number };
readonly auth_storage: { status: 'ok' | 'failed'; latency_ms: number; contract_phase?: 'expand' | 'contract' };
readonly r2: { status: 'ok' | 'failed'; latency_ms: number };
readonly kv: { status: 'ok' | 'failed'; latency_ms: number };
};
}
export interface GetMeResponse {
readonly user: User;
readonly user: Omit<User, 'org_id'> & {
readonly org_id: string | null;
readonly onboarding_completed: boolean;
};
readonly current_organization: {
readonly org_id: string | null;
readonly ba_org_id: string | null;
readonly role: RbacRole;
};
}
export interface IssueApiTokenResponse {
@@ -385,6 +390,7 @@ export interface HealthResponse {
readonly status: string;
readonly version: string;
readonly protocol_version: string;
readonly capabilities: Record<string, string>;
readonly timestamp: number;
}
@@ -421,10 +427,6 @@ export interface ErrorResponse {
};
}
// ---------------------------------------------------------------------------
// Client configuration
// ---------------------------------------------------------------------------
export interface ElydoraClientConfig {
readonly orgId: string;
readonly agentId: string;
+1 -19
View File
@@ -1,17 +1,10 @@
import crypto from 'node:crypto';
/**
* Generate a UUIDv7 (time-ordered UUID per RFC 9562).
*
* Layout (128 bits):
* 48-bit Unix timestamp (ms) | 4-bit version (7) | 12-bit rand_a
* 2-bit variant (10) | 62-bit rand_b
*/
// UUIDv7 (RFC 9562): 48-bit ms timestamp, version 7, variant 10, 74 random bits
export function uuidv7(): string {
const now = Date.now();
const random = crypto.randomBytes(10);
// Bytes 0-5: 48-bit timestamp (ms)
const bytes = Buffer.alloc(16);
bytes[0] = (now / 2 ** 40) & 0xff;
bytes[1] = (now / 2 ** 32) & 0xff;
@@ -20,11 +13,9 @@ export function uuidv7(): string {
bytes[4] = (now / 2 ** 8) & 0xff;
bytes[5] = now & 0xff;
// Bytes 6-7: version (0111) + 12 bits random
bytes[6] = 0x70 | (random[0]! & 0x0f);
bytes[7] = random[1]!;
// Bytes 8-15: variant (10) + 62 bits random
bytes[8] = 0x80 | (random[2]! & 0x3f);
bytes[9] = random[3]!;
bytes[10] = random[4]!;
@@ -44,25 +35,16 @@ export function uuidv7(): string {
);
}
/**
* Generate a random nonce: 16 random bytes, base64url encoded.
*/
export function generateNonce(): string {
const bytes = crypto.randomBytes(16);
return base64urlEncode(bytes);
}
/**
* Encode a Buffer or Uint8Array to base64url (no padding, RFC 4648 section 5).
*/
export function base64urlEncode(data: Buffer | Uint8Array): string {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
return buf.toString('base64url');
}
/**
* Decode a base64url string (no padding) to a Buffer.
*/
export function base64urlDecode(input: string): Buffer {
return Buffer.from(input, 'base64url');
}