From 07f32a48d71b82c87a1116e58ffd86d0f47c7c9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 2 Sep 2026 21:19:01 -0400 Subject: [PATCH] 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 --- src/cli.ts | 18 ---------- src/client.ts | 95 +++------------------------------------------------ src/crypto.ts | 76 ++++------------------------------------- src/index.ts | 5 +-- src/types.ts | 64 +++++++++++++++++----------------- src/utils.ts | 20 +---------- 6 files changed, 45 insertions(+), 233 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 2caae9c..2d62c61 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -50,10 +50,6 @@ const PLUGINS: ReadonlyMap = 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 { const { values } = parseArgs({ args, @@ -178,7 +170,6 @@ async function cmdInstall(args: string[]): Promise { }); 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 { 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 { 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 { 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 " to install an audit hook.'); } -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- - async function main(): Promise { const args = process.argv.slice(2); diff --git a/src/client.ts b/src/client.ts index e4283c8..ba5ba5f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -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(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(res); } - // ------------------------------------------------------------------------- - // Auth (instance methods — require token) - // ------------------------------------------------------------------------- - async getMe(): Promise { return this.request('GET', '/v1/auth/me'); } @@ -157,10 +136,6 @@ export class ElydoraClient { return this.request('POST', '/v1/auth/rotate', {}); } - // ------------------------------------------------------------------------- - // Agent management - // ------------------------------------------------------------------------- - async registerAgent(request: RegisterAgentRequest): Promise { assertIntegrationType(request?.integration_type); return this.request('POST', '/v1/agents/register', request); @@ -195,31 +170,15 @@ export class ElydoraClient { await this.request('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 = { 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('POST', `/v1/operations/${encodeURIComponent(operationId)}/verify`, {}); } - // ------------------------------------------------------------------------- - // Audit - // ------------------------------------------------------------------------- - async queryAudit(params: AuditQueryRequest): Promise { return this.request('POST', '/v1/audit/query', params); } - // ------------------------------------------------------------------------- - // Epochs - // ------------------------------------------------------------------------- - async listEpochs(): Promise { return this.request('GET', '/v1/epochs'); } @@ -290,10 +238,6 @@ export class ElydoraClient { return this.request('GET', `/v1/epochs/${encodeURIComponent(epochId)}`); } - // ------------------------------------------------------------------------- - // Exports - // ------------------------------------------------------------------------- - async createExport(params: CreateExportRequest): Promise { return this.request('POST', '/v1/exports', params); } @@ -323,10 +267,6 @@ export class ElydoraClient { return res.arrayBuffer(); } - // ------------------------------------------------------------------------- - // JWKS - // ------------------------------------------------------------------------- - async getJWKS(): Promise { const url = `${this.baseUrl}/.well-known/elydora/jwks.json`; const res = await fetch(url, { @@ -336,10 +276,6 @@ export class ElydoraClient { return handleResponse(res); } - // ------------------------------------------------------------------------- - // Health - // ------------------------------------------------------------------------- - async health(): Promise { const url = `${this.baseUrl}/v1/health`; const res = await fetch(url, { @@ -355,10 +291,6 @@ export class ElydoraClient { return handleResponse(res); } - // ------------------------------------------------------------------------- - // Webhooks - // ------------------------------------------------------------------------- - async listWebhooks(): Promise { return this.request('GET', '/v1/webhooks'); } @@ -371,27 +303,15 @@ export class ElydoraClient { await this.request('DELETE', `/v1/webhooks/${encodeURIComponent(webhookId)}`); } - // ------------------------------------------------------------------------- - // Members - // ------------------------------------------------------------------------- - async listMembers(): Promise { return this.request('GET', '/v1/members'); } - // ------------------------------------------------------------------------- - // Admin events - // ------------------------------------------------------------------------- - async listAdminEvents(limit?: number): Promise { const query = limit ? `?limit=${limit}` : ''; return this.request('GET', `/v1/admin/events${query}`); } - // ------------------------------------------------------------------------- - // Internal HTTP helpers - // ------------------------------------------------------------------------- - private async request( 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(res: Response): Promise { if (res.ok) { - // 204 No Content if (res.status === 204) { return undefined as T; } @@ -467,7 +380,7 @@ async function handleResponse(res: Response): Promise { 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) { diff --git a/src/crypto.ts b/src/crypto.ts index 3fa7422..0882b18 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -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 | 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)); diff --git a/src/index.ts b/src/index.ts index 42e3949..e6c5fe0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, diff --git a/src/types.ts b/src/types.ts index e3fa1da..f849bc5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; } 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; 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 & { + 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; readonly timestamp: number; } @@ -421,10 +427,6 @@ export interface ErrorResponse { }; } -// --------------------------------------------------------------------------- -// Client configuration -// --------------------------------------------------------------------------- - export interface ElydoraClientConfig { readonly orgId: string; readonly agentId: string; diff --git a/src/utils.ts b/src/utils.ts index 4394f3f..49c089a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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'); }