Initial commit: Elydora Node SDK
TypeScript SDK for Elydora agent operations, cryptographic signing, and audit trail integration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
65910a3ad6
23 files changed
+2663
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.tsbuildinfo
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
src/
|
||||||
|
tsconfig.json
|
||||||
|
.gitignore
|
||||||
|
node_modules/
|
||||||
|
*.tsbuildinfo
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# @elydora/sdk
|
||||||
|
|
||||||
|
Node.js/TypeScript SDK for the Elydora tamper-evident audit platform.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @elydora/sdk
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires Node.js 18+ (uses built-in `crypto` module with Ed25519 support).
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ElydoraClient } from '@elydora/sdk';
|
||||||
|
|
||||||
|
// 1. Authenticate
|
||||||
|
const auth = await ElydoraClient.login('https://api.elydora.com', 'user@example.com', 'password');
|
||||||
|
|
||||||
|
// 2. Create client
|
||||||
|
const client = new ElydoraClient({
|
||||||
|
orgId: auth.user.org_id,
|
||||||
|
agentId: 'my-agent-id',
|
||||||
|
privateKey: '<base64url-encoded-32-byte-ed25519-seed>',
|
||||||
|
baseUrl: 'https://api.elydora.com',
|
||||||
|
});
|
||||||
|
client.setToken(auth.token);
|
||||||
|
|
||||||
|
// 3. Create and submit an operation
|
||||||
|
const eor = client.createOperation({
|
||||||
|
operationType: 'data.access',
|
||||||
|
subject: { user_id: 'u-123', resource: 'patient-record' },
|
||||||
|
action: { type: 'read', scope: 'full' },
|
||||||
|
payload: { record_id: 'rec-456' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { receipt } = await client.submitOperation(eor);
|
||||||
|
console.log('Operation submitted:', receipt.operation_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### Static Methods
|
||||||
|
|
||||||
|
- `ElydoraClient.register(baseUrl, email, password, displayName?, orgName?)` — Register a new user and organization
|
||||||
|
- `ElydoraClient.login(baseUrl, email, password)` — Authenticate and receive a JWT
|
||||||
|
|
||||||
|
### Instance Methods
|
||||||
|
|
||||||
|
#### Agent Management
|
||||||
|
- `registerAgent(request)` — Register a new agent with public keys
|
||||||
|
- `getAgent(agentId)` — Retrieve agent details
|
||||||
|
- `freezeAgent(agentId, reason)` — Freeze an agent
|
||||||
|
- `revokeKey(agentId, kid, reason)` — Revoke an agent key
|
||||||
|
|
||||||
|
#### Operations
|
||||||
|
- `createOperation(params)` — Construct and sign an EOR locally (synchronous)
|
||||||
|
- `submitOperation(eor)` — Submit a signed EOR to the API
|
||||||
|
- `getOperation(operationId)` — Retrieve an operation
|
||||||
|
- `verifyOperation(operationId)` — Verify operation integrity
|
||||||
|
|
||||||
|
#### Audit
|
||||||
|
- `queryAudit(params)` — Query the audit log
|
||||||
|
|
||||||
|
#### Epochs
|
||||||
|
- `listEpochs()` — List all epochs
|
||||||
|
- `getEpoch(epochId)` — Retrieve an epoch
|
||||||
|
|
||||||
|
#### Exports
|
||||||
|
- `createExport(params)` — Create a compliance export
|
||||||
|
- `listExports()` — List all exports
|
||||||
|
- `getExport(exportId)` — Retrieve export status
|
||||||
|
|
||||||
|
#### JWKS
|
||||||
|
- `getJWKS()` — Retrieve platform public keys
|
||||||
|
|
||||||
|
## Cryptographic Details
|
||||||
|
|
||||||
|
- **Ed25519**: Signs operations using Node.js built-in `crypto` module
|
||||||
|
- **Chain hashing**: `SHA-256(prev_chain_hash | payload_hash | operation_id | issued_at)`
|
||||||
|
- **JCS**: RFC 8785 JSON Canonicalization Scheme for deterministic serialization
|
||||||
|
- **Base64url**: RFC 4648 section 5, no padding
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
Generated
+51
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "@elydora/sdk",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "@elydora/sdk",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "22.19.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz",
|
||||||
|
"integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "@elydora/sdk",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Node.js/TypeScript SDK for the Elydora tamper-evident audit platform",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"bin": {
|
||||||
|
"elydora": "./dist/cli.js"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"scripts"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc && node scripts/postbuild.mjs",
|
||||||
|
"prepublishOnly": "npm run build"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"@types/node": "^22.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { resolve, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const cliPath = resolve(__dirname, '..', 'dist', 'cli.js');
|
||||||
|
|
||||||
|
const content = await readFile(cliPath, 'utf-8');
|
||||||
|
if (!content.startsWith('#!/')) {
|
||||||
|
await writeFile(cliPath, '#!/usr/bin/env node\n' + content, 'utf-8');
|
||||||
|
}
|
||||||
+264
@@ -0,0 +1,264 @@
|
|||||||
|
import { parseArgs } from 'node:util';
|
||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
|
import { derivePublicKey } from './crypto.js';
|
||||||
|
import { SUPPORTED_AGENTS } from './plugins/registry.js';
|
||||||
|
import type { AgentPlugin, InstallConfig } from './plugins/base.js';
|
||||||
|
import { generateHookScript, generateGuardScript } from './plugins/hook-template.js';
|
||||||
|
import { claudecodePlugin } from './plugins/claudecode.js';
|
||||||
|
import { cursorPlugin } from './plugins/cursor.js';
|
||||||
|
import { geminiPlugin } from './plugins/gemini.js';
|
||||||
|
import { augmentPlugin } from './plugins/augment.js';
|
||||||
|
import { kiroPlugin } from './plugins/kiro.js';
|
||||||
|
import { opencodePlugin } from './plugins/opencode.js';
|
||||||
|
|
||||||
|
const ELYDORA_DIR = path.join(os.homedir(), '.elydora');
|
||||||
|
|
||||||
|
const PLUGINS: ReadonlyMap<string, AgentPlugin> = new Map([
|
||||||
|
['claudecode', claudecodePlugin],
|
||||||
|
['cursor', cursorPlugin],
|
||||||
|
['gemini', geminiPlugin],
|
||||||
|
['augment', augmentPlugin],
|
||||||
|
['kiro', kiroPlugin],
|
||||||
|
['opencode', opencodePlugin],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function die(message: string): never {
|
||||||
|
console.error(`Error: ${message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function printUsage(): void {
|
||||||
|
console.log(`Elydora CLI — Tamper-evident audit for AI coding agents
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
elydora install --agent <name> --org_id <id> --agent_id <id> --private_key <key> --kid <kid> [--token <jwt>] [--base_url <url>]
|
||||||
|
elydora uninstall --agent <name>
|
||||||
|
elydora status
|
||||||
|
elydora agents
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
install Install Elydora audit hook for a coding agent
|
||||||
|
uninstall Remove Elydora audit hook for a coding agent
|
||||||
|
status Show installation status for all agents
|
||||||
|
agents List supported coding agents
|
||||||
|
|
||||||
|
Supported agents: ${Array.from(SUPPORTED_AGENTS.keys()).join(', ')}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Commands
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function cmdInstall(args: string[]): Promise<void> {
|
||||||
|
const { values } = parseArgs({
|
||||||
|
args,
|
||||||
|
options: {
|
||||||
|
agent: { type: 'string' },
|
||||||
|
org_id: { type: 'string' },
|
||||||
|
agent_id: { type: 'string' },
|
||||||
|
private_key: { type: 'string' },
|
||||||
|
kid: { type: 'string' },
|
||||||
|
token: { type: 'string' },
|
||||||
|
base_url: { type: 'string' },
|
||||||
|
},
|
||||||
|
strict: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const agentName = values.agent;
|
||||||
|
if (!agentName) die('--agent is required');
|
||||||
|
if (!SUPPORTED_AGENTS.has(agentName)) {
|
||||||
|
die(`Unknown agent "${agentName}". Supported: ${Array.from(SUPPORTED_AGENTS.keys()).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgId = values.org_id;
|
||||||
|
if (!orgId) die('--org_id is required');
|
||||||
|
|
||||||
|
const agentId = values.agent_id;
|
||||||
|
if (!agentId) die('--agent_id is required');
|
||||||
|
|
||||||
|
const privateKey = values.private_key;
|
||||||
|
if (!privateKey) die('--private_key is required');
|
||||||
|
|
||||||
|
const kid = values.kid;
|
||||||
|
if (!kid) die('--kid is required');
|
||||||
|
|
||||||
|
const token = values.token;
|
||||||
|
const baseUrl = values.base_url ?? 'https://api.elydora.com';
|
||||||
|
|
||||||
|
// Validate private key by deriving public key
|
||||||
|
let publicKey: string;
|
||||||
|
try {
|
||||||
|
publicKey = derivePublicKey(privateKey);
|
||||||
|
} catch {
|
||||||
|
die('Invalid private key — could not derive public key');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Verifying private key... Public key: ${publicKey.slice(0, 12)}...`);
|
||||||
|
|
||||||
|
// Create ~/.elydora directory structure
|
||||||
|
const agentsDir = path.join(ELYDORA_DIR, 'agents');
|
||||||
|
const hooksDir = path.join(ELYDORA_DIR, 'hooks');
|
||||||
|
await fsp.mkdir(agentsDir, { recursive: true });
|
||||||
|
await fsp.mkdir(hooksDir, { recursive: true });
|
||||||
|
|
||||||
|
// Write agent config
|
||||||
|
const agentConfigPath = path.join(agentsDir, `${agentName}.json`);
|
||||||
|
const agentConfig = {
|
||||||
|
org_id: orgId,
|
||||||
|
agent_id: agentId,
|
||||||
|
kid,
|
||||||
|
base_url: baseUrl,
|
||||||
|
...(token ? { token } : {}),
|
||||||
|
};
|
||||||
|
await fsp.writeFile(agentConfigPath, JSON.stringify(agentConfig, null, 2) + '\n', 'utf-8');
|
||||||
|
console.log(` Agent config: ${agentConfigPath}`);
|
||||||
|
|
||||||
|
// Write private key (chmod 600)
|
||||||
|
const keyPath = path.join(agentsDir, `${agentName}.key`);
|
||||||
|
await fsp.writeFile(keyPath, privateKey, { encoding: 'utf-8', mode: 0o600 });
|
||||||
|
console.log(` Private key: ${keyPath}`);
|
||||||
|
|
||||||
|
// Generate and write hook script (PostToolUse — audit logging)
|
||||||
|
const hookScriptPath = path.join(hooksDir, `${agentName}-hook.js`);
|
||||||
|
const hookScript = generateHookScript(agentName);
|
||||||
|
await fsp.writeFile(hookScriptPath, hookScript, { encoding: 'utf-8', mode: 0o755 });
|
||||||
|
console.log(` Hook script: ${hookScriptPath}`);
|
||||||
|
|
||||||
|
// Generate and write guard script (PreToolUse — freeze enforcement)
|
||||||
|
const guardScriptPath = path.join(hooksDir, `${agentName}-guard.js`);
|
||||||
|
const guardScript = generateGuardScript(agentName);
|
||||||
|
await fsp.writeFile(guardScriptPath, guardScript, { encoding: 'utf-8', mode: 0o755 });
|
||||||
|
console.log(` Guard script: ${guardScriptPath}`);
|
||||||
|
|
||||||
|
// Install agent-specific config hook
|
||||||
|
const plugin = PLUGINS.get(agentName)!;
|
||||||
|
const installConfig: InstallConfig = {
|
||||||
|
agentName,
|
||||||
|
orgId,
|
||||||
|
agentId,
|
||||||
|
privateKey,
|
||||||
|
kid,
|
||||||
|
token,
|
||||||
|
baseUrl,
|
||||||
|
hookScriptPath,
|
||||||
|
guardScriptPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
await plugin.install(installConfig);
|
||||||
|
|
||||||
|
const entry = SUPPORTED_AGENTS.get(agentName)!;
|
||||||
|
console.log(`\nElydora audit hook installed for ${entry.name}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmdUninstall(args: string[]): Promise<void> {
|
||||||
|
const { values } = parseArgs({
|
||||||
|
args,
|
||||||
|
options: {
|
||||||
|
agent: { type: 'string' },
|
||||||
|
},
|
||||||
|
strict: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const agentName = values.agent;
|
||||||
|
if (!agentName) die('--agent is required');
|
||||||
|
if (!SUPPORTED_AGENTS.has(agentName)) {
|
||||||
|
die(`Unknown agent "${agentName}". Supported: ${Array.from(SUPPORTED_AGENTS.keys()).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin = PLUGINS.get(agentName)!;
|
||||||
|
const entry = SUPPORTED_AGENTS.get(agentName)!;
|
||||||
|
|
||||||
|
// Uninstall agent-specific config
|
||||||
|
await plugin.uninstall();
|
||||||
|
|
||||||
|
// Remove hook and guard scripts
|
||||||
|
const hookScriptPath = path.join(ELYDORA_DIR, 'hooks', `${agentName}-hook.js`);
|
||||||
|
const guardScriptPath = path.join(ELYDORA_DIR, 'hooks', `${agentName}-guard.js`);
|
||||||
|
try { await fsp.unlink(hookScriptPath); } catch { /* Already removed */ }
|
||||||
|
try { await fsp.unlink(guardScriptPath); } catch { /* Already removed */ }
|
||||||
|
|
||||||
|
// Remove agent config and key
|
||||||
|
const agentConfigPath = path.join(ELYDORA_DIR, 'agents', `${agentName}.json`);
|
||||||
|
const keyPath = path.join(ELYDORA_DIR, 'agents', `${agentName}.key`);
|
||||||
|
try { await fsp.unlink(agentConfigPath); } catch { /* */ }
|
||||||
|
try { await fsp.unlink(keyPath); } catch { /* */ }
|
||||||
|
|
||||||
|
console.log(`Elydora audit hook uninstalled for ${entry.name}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmdStatus(): Promise<void> {
|
||||||
|
console.log('Elydora Agent Status\n');
|
||||||
|
|
||||||
|
let anyInstalled = false;
|
||||||
|
|
||||||
|
for (const [name, plugin] of PLUGINS) {
|
||||||
|
const st = await plugin.status();
|
||||||
|
const statusIcon = st.installed ? '[installed]' : '[not installed]';
|
||||||
|
|
||||||
|
console.log(` ${st.displayName} (${name}) ${statusIcon}`);
|
||||||
|
if (st.installed || st.hookConfigured || st.hookScriptExists) {
|
||||||
|
console.log(` Hook config: ${st.hookConfigured ? 'yes' : 'no'}`);
|
||||||
|
console.log(` Hook script: ${st.hookScriptExists ? 'yes' : 'no'}`);
|
||||||
|
console.log(` Config path: ${st.configPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (st.installed) anyInstalled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!anyInstalled) {
|
||||||
|
console.log('\nNo agents installed. Run "elydora install --agent <name>" to get started.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmdAgents(): void {
|
||||||
|
console.log('Supported Coding Agents:\n');
|
||||||
|
for (const [key, entry] of SUPPORTED_AGENTS) {
|
||||||
|
console.log(` ${key.padEnd(12)} ${entry.name}`);
|
||||||
|
}
|
||||||
|
console.log('\nUse "elydora install --agent <name>" to install an audit hook.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
||||||
|
printUsage();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = args[0];
|
||||||
|
const commandArgs = args.slice(1);
|
||||||
|
|
||||||
|
switch (command) {
|
||||||
|
case 'install':
|
||||||
|
await cmdInstall(commandArgs);
|
||||||
|
break;
|
||||||
|
case 'uninstall':
|
||||||
|
await cmdUninstall(commandArgs);
|
||||||
|
break;
|
||||||
|
case 'status':
|
||||||
|
await cmdStatus();
|
||||||
|
break;
|
||||||
|
case 'agents':
|
||||||
|
cmdAgents();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
die(`Unknown command "${command}". Run "elydora --help" for usage.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
+368
@@ -0,0 +1,368 @@
|
|||||||
|
import type {
|
||||||
|
ElydoraClientConfig,
|
||||||
|
CreateOperationParams,
|
||||||
|
EOR,
|
||||||
|
RegisterAgentRequest,
|
||||||
|
RegisterAgentResponse,
|
||||||
|
GetAgentResponse,
|
||||||
|
SubmitOperationResponse,
|
||||||
|
GetOperationResponse,
|
||||||
|
VerifyOperationResponse,
|
||||||
|
AuditQueryRequest,
|
||||||
|
AuditQueryResponse,
|
||||||
|
ListEpochsResponse,
|
||||||
|
GetEpochResponse,
|
||||||
|
CreateExportRequest,
|
||||||
|
CreateExportResponse,
|
||||||
|
ListExportsResponse,
|
||||||
|
GetExportResponse,
|
||||||
|
JWKSResponse,
|
||||||
|
AuthRegisterResponse,
|
||||||
|
AuthLoginResponse,
|
||||||
|
ErrorResponse,
|
||||||
|
} from './types.js';
|
||||||
|
import { ElydoraError } from './errors.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 JWT 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)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Agent management
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async registerAgent(request: RegisterAgentRequest): Promise<RegisterAgentResponse> {
|
||||||
|
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 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)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 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));
|
||||||
|
}
|
||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
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
|
||||||
|
*/
|
||||||
|
export function jcsCanonicalise(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return 'null';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'boolean' || typeof value === 'number') {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const elements = value.map((v) => jcsCanonicalise(v));
|
||||||
|
return '[' + elements.join(',') + ']';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
const keys = Object.keys(obj).sort();
|
||||||
|
const pairs: string[] = [];
|
||||||
|
for (const key of keys) {
|
||||||
|
if (obj[key] !== undefined) {
|
||||||
|
pairs.push(JSON.stringify(key) + ':' + jcsCanonicalise(obj[key]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '{' + pairs.join(',') + '}';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 '|'.
|
||||||
|
*/
|
||||||
|
export function computeChainHash(
|
||||||
|
prevChainHash: string,
|
||||||
|
payloadHash: string,
|
||||||
|
operationId: string,
|
||||||
|
issuedAt: number,
|
||||||
|
): string {
|
||||||
|
const input = `${prevChainHash}|${payloadHash}|${operationId}|${issuedAt}`;
|
||||||
|
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 {
|
||||||
|
const canonical = jcsCanonicalise(payload);
|
||||||
|
return sha256Base64url(canonical);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Ed25519 signing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// PKCS8 prefix for wrapping a 32-byte Ed25519 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)
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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]);
|
||||||
|
return crypto.createPrivateKey({
|
||||||
|
key: pkcs8,
|
||||||
|
format: 'der',
|
||||||
|
type: 'pkcs8',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
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: SHA-256 of 32 zero bytes, base64url encoded.
|
||||||
|
*/
|
||||||
|
export const ZERO_CHAIN_HASH: string = base64urlEncode(
|
||||||
|
crypto.createHash('sha256').update(Buffer.alloc(32, 0)).digest(),
|
||||||
|
);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { ErrorCode } from './types.js';
|
||||||
|
|
||||||
|
export class ElydoraError extends Error {
|
||||||
|
public readonly code: ErrorCode;
|
||||||
|
public readonly requestId: string;
|
||||||
|
public readonly statusCode: number;
|
||||||
|
public readonly details?: Record<string, unknown>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
statusCode: number,
|
||||||
|
code: ErrorCode,
|
||||||
|
message: string,
|
||||||
|
requestId: string,
|
||||||
|
details?: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ElydoraError';
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.code = code;
|
||||||
|
this.requestId = requestId;
|
||||||
|
this.details = details;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
export { ElydoraClient } from './client.js';
|
||||||
|
export { ElydoraError } from './errors.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
jcsCanonicalise,
|
||||||
|
sha256Base64url,
|
||||||
|
computeChainHash,
|
||||||
|
computePayloadHash,
|
||||||
|
signEd25519,
|
||||||
|
derivePublicKey,
|
||||||
|
ZERO_CHAIN_HASH,
|
||||||
|
} from './crypto.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
uuidv7,
|
||||||
|
generateNonce,
|
||||||
|
base64urlEncode,
|
||||||
|
base64urlDecode,
|
||||||
|
} from './utils.js';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
// Enums
|
||||||
|
AgentStatus,
|
||||||
|
KeyStatus,
|
||||||
|
ExportStatus,
|
||||||
|
RbacRole,
|
||||||
|
ErrorCode,
|
||||||
|
|
||||||
|
// Entities
|
||||||
|
Agent,
|
||||||
|
AgentKey,
|
||||||
|
Operation,
|
||||||
|
Receipt,
|
||||||
|
Epoch,
|
||||||
|
Organization,
|
||||||
|
User,
|
||||||
|
Export,
|
||||||
|
|
||||||
|
// Protocol
|
||||||
|
EOR,
|
||||||
|
EAR,
|
||||||
|
|
||||||
|
// API request/response
|
||||||
|
ElydoraClientConfig,
|
||||||
|
CreateOperationParams,
|
||||||
|
RegisterAgentRequest,
|
||||||
|
RegisterAgentResponse,
|
||||||
|
GetAgentResponse,
|
||||||
|
SubmitOperationResponse,
|
||||||
|
GetOperationResponse,
|
||||||
|
VerifyOperationResponse,
|
||||||
|
AuditQueryRequest,
|
||||||
|
AuditQueryResponse,
|
||||||
|
GetEpochResponse,
|
||||||
|
ListEpochsResponse,
|
||||||
|
CreateExportRequest,
|
||||||
|
CreateExportResponse,
|
||||||
|
GetExportResponse,
|
||||||
|
ListExportsResponse,
|
||||||
|
JWK,
|
||||||
|
JWKSResponse,
|
||||||
|
AuthRegisterResponse,
|
||||||
|
AuthLoginResponse,
|
||||||
|
ErrorResponse,
|
||||||
|
} from './types.js';
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
|
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||||
|
import { SUPPORTED_AGENTS } from './registry.js';
|
||||||
|
|
||||||
|
const AGENT_KEY = 'augment';
|
||||||
|
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||||
|
|
||||||
|
function resolveConfigDir(): string {
|
||||||
|
return entry.configDir.replace(/^~/, os.homedir());
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConfigPath(): string {
|
||||||
|
return path.join(resolveConfigDir(), entry.configFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHookCommand(scriptPath: string): string {
|
||||||
|
return `node "${scriptPath}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterElydoraEntries(arr: Array<Record<string, unknown>>): Array<Record<string, unknown>> {
|
||||||
|
return arr.filter((entry) => {
|
||||||
|
if (Array.isArray(entry.hooks)) {
|
||||||
|
const cmds = entry.hooks as Array<Record<string, unknown>>;
|
||||||
|
return !cmds.some((h) => typeof h.command === 'string' && h.command.includes('elydora'));
|
||||||
|
}
|
||||||
|
if (typeof entry.command === 'string') return !entry.command.includes('elydora');
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const augmentPlugin: AgentPlugin = {
|
||||||
|
async install(config: InstallConfig): Promise<void> {
|
||||||
|
const configDir = resolveConfigDir();
|
||||||
|
await fsp.mkdir(configDir, { recursive: true });
|
||||||
|
|
||||||
|
const configPath = resolveConfigPath();
|
||||||
|
let settings: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||||
|
settings = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
// Start fresh
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!settings.hooks || typeof settings.hooks !== 'object') {
|
||||||
|
settings.hooks = {};
|
||||||
|
}
|
||||||
|
const hooks = settings.hooks as Record<string, unknown>;
|
||||||
|
|
||||||
|
// --- PreToolUse (guard — freeze enforcement) ---
|
||||||
|
if (!Array.isArray(hooks.PreToolUse)) {
|
||||||
|
hooks.PreToolUse = [];
|
||||||
|
}
|
||||||
|
const preFiltered = filterElydoraEntries(hooks.PreToolUse as Array<Record<string, unknown>>);
|
||||||
|
preFiltered.push({
|
||||||
|
hooks: [{ type: 'command', command: buildHookCommand(config.guardScriptPath) }],
|
||||||
|
});
|
||||||
|
hooks.PreToolUse = preFiltered;
|
||||||
|
|
||||||
|
// --- PostToolUse (audit logging) ---
|
||||||
|
if (!Array.isArray(hooks.PostToolUse)) {
|
||||||
|
hooks.PostToolUse = [];
|
||||||
|
}
|
||||||
|
const postFiltered = filterElydoraEntries(hooks.PostToolUse as Array<Record<string, unknown>>);
|
||||||
|
postFiltered.push({
|
||||||
|
hooks: [{ type: 'command', command: buildHookCommand(config.hookScriptPath) }],
|
||||||
|
});
|
||||||
|
hooks.PostToolUse = postFiltered;
|
||||||
|
|
||||||
|
settings.hooks = hooks;
|
||||||
|
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||||
|
},
|
||||||
|
|
||||||
|
async uninstall(): Promise<void> {
|
||||||
|
const configPath = resolveConfigPath();
|
||||||
|
|
||||||
|
let settings: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||||
|
settings = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||||
|
if (!hooks) return;
|
||||||
|
|
||||||
|
if (Array.isArray(hooks.PreToolUse)) {
|
||||||
|
hooks.PreToolUse = filterElydoraEntries(hooks.PreToolUse as Array<Record<string, unknown>>);
|
||||||
|
}
|
||||||
|
if (Array.isArray(hooks.PostToolUse)) {
|
||||||
|
hooks.PostToolUse = filterElydoraEntries(hooks.PostToolUse as Array<Record<string, unknown>>);
|
||||||
|
}
|
||||||
|
|
||||||
|
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||||
|
},
|
||||||
|
|
||||||
|
async status(): Promise<PluginStatus> {
|
||||||
|
const configPath = resolveConfigPath();
|
||||||
|
const hookScriptPath = path.join(os.homedir(), '.elydora', 'hooks', `${AGENT_KEY}-hook.js`);
|
||||||
|
|
||||||
|
let hookConfigured = false;
|
||||||
|
try {
|
||||||
|
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||||
|
const settings = JSON.parse(raw);
|
||||||
|
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||||
|
if (hooks) {
|
||||||
|
const checkArr = (arr: unknown) => {
|
||||||
|
if (!Array.isArray(arr)) return false;
|
||||||
|
return (arr as Array<Record<string, unknown>>).some((entry) => {
|
||||||
|
if (Array.isArray(entry.hooks)) {
|
||||||
|
return (entry.hooks as Array<Record<string, unknown>>).some(
|
||||||
|
(h) => typeof h.command === 'string' && h.command.includes('elydora'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return typeof entry.command === 'string' && entry.command.includes('elydora');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
hookConfigured = checkArr(hooks.PreToolUse) && checkArr(hooks.PostToolUse);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Config not readable
|
||||||
|
}
|
||||||
|
|
||||||
|
let hookScriptExists = false;
|
||||||
|
try {
|
||||||
|
await fsp.access(hookScriptPath);
|
||||||
|
hookScriptExists = true;
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
installed: hookConfigured && hookScriptExists,
|
||||||
|
agentName: AGENT_KEY,
|
||||||
|
displayName: entry.name,
|
||||||
|
hookConfigured,
|
||||||
|
hookScriptExists,
|
||||||
|
configPath,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export interface InstallConfig {
|
||||||
|
readonly agentName: string;
|
||||||
|
readonly orgId: string;
|
||||||
|
readonly agentId: string;
|
||||||
|
readonly privateKey: string;
|
||||||
|
readonly kid: string;
|
||||||
|
readonly token?: string;
|
||||||
|
readonly baseUrl: string;
|
||||||
|
readonly hookScriptPath: string;
|
||||||
|
readonly guardScriptPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginStatus {
|
||||||
|
readonly installed: boolean;
|
||||||
|
readonly agentName: string;
|
||||||
|
readonly displayName: string;
|
||||||
|
readonly hookConfigured: boolean;
|
||||||
|
readonly hookScriptExists: boolean;
|
||||||
|
readonly configPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentPlugin {
|
||||||
|
install(config: InstallConfig): Promise<void>;
|
||||||
|
uninstall(): Promise<void>;
|
||||||
|
status(): Promise<PluginStatus>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
|
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||||
|
import { SUPPORTED_AGENTS } from './registry.js';
|
||||||
|
|
||||||
|
const AGENT_KEY = 'claudecode';
|
||||||
|
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||||
|
|
||||||
|
function resolveConfigDir(): string {
|
||||||
|
return entry.configDir.replace(/^~/, os.homedir());
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConfigPath(): string {
|
||||||
|
return path.join(resolveConfigDir(), entry.configFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHookCommand(scriptPath: string): string {
|
||||||
|
return `node "${scriptPath}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterElydoraEntries(arr: Array<Record<string, unknown>>): Array<Record<string, unknown>> {
|
||||||
|
return arr.filter((entry) => {
|
||||||
|
if (Array.isArray(entry.hooks)) {
|
||||||
|
const cmds = entry.hooks as Array<Record<string, unknown>>;
|
||||||
|
return !cmds.some((h) => typeof h.command === 'string' && h.command.includes('elydora'));
|
||||||
|
}
|
||||||
|
if (typeof entry.command === 'string') {
|
||||||
|
return !entry.command.includes('elydora');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const claudecodePlugin: AgentPlugin = {
|
||||||
|
async install(config: InstallConfig): Promise<void> {
|
||||||
|
const configDir = resolveConfigDir();
|
||||||
|
await fsp.mkdir(configDir, { recursive: true });
|
||||||
|
|
||||||
|
const configPath = resolveConfigPath();
|
||||||
|
let settings: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||||
|
settings = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist or isn't valid JSON — start fresh
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!settings.hooks || typeof settings.hooks !== 'object') {
|
||||||
|
settings.hooks = {};
|
||||||
|
}
|
||||||
|
const hooks = settings.hooks as Record<string, unknown>;
|
||||||
|
|
||||||
|
// --- PreToolUse (guard — freeze enforcement) ---
|
||||||
|
if (!Array.isArray(hooks.PreToolUse)) {
|
||||||
|
hooks.PreToolUse = [];
|
||||||
|
}
|
||||||
|
const preFiltered = filterElydoraEntries(hooks.PreToolUse as Array<Record<string, unknown>>);
|
||||||
|
preFiltered.push({
|
||||||
|
hooks: [{ type: 'command', command: buildHookCommand(config.guardScriptPath) }],
|
||||||
|
});
|
||||||
|
hooks.PreToolUse = preFiltered;
|
||||||
|
|
||||||
|
// --- PostToolUse (audit logging) ---
|
||||||
|
if (!Array.isArray(hooks.PostToolUse)) {
|
||||||
|
hooks.PostToolUse = [];
|
||||||
|
}
|
||||||
|
const postFiltered = filterElydoraEntries(hooks.PostToolUse as Array<Record<string, unknown>>);
|
||||||
|
postFiltered.push({
|
||||||
|
hooks: [{ type: 'command', command: buildHookCommand(config.hookScriptPath) }],
|
||||||
|
});
|
||||||
|
hooks.PostToolUse = postFiltered;
|
||||||
|
|
||||||
|
settings.hooks = hooks;
|
||||||
|
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||||
|
},
|
||||||
|
|
||||||
|
async uninstall(): Promise<void> {
|
||||||
|
const configPath = resolveConfigPath();
|
||||||
|
|
||||||
|
let settings: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||||
|
settings = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return; // Nothing to uninstall
|
||||||
|
}
|
||||||
|
|
||||||
|
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||||
|
if (!hooks) return;
|
||||||
|
|
||||||
|
if (Array.isArray(hooks.PreToolUse)) {
|
||||||
|
hooks.PreToolUse = filterElydoraEntries(hooks.PreToolUse as Array<Record<string, unknown>>);
|
||||||
|
}
|
||||||
|
if (Array.isArray(hooks.PostToolUse)) {
|
||||||
|
hooks.PostToolUse = filterElydoraEntries(hooks.PostToolUse as Array<Record<string, unknown>>);
|
||||||
|
}
|
||||||
|
|
||||||
|
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||||
|
},
|
||||||
|
|
||||||
|
async status(): Promise<PluginStatus> {
|
||||||
|
const configPath = resolveConfigPath();
|
||||||
|
const hookScriptPath = path.join(os.homedir(), '.elydora', 'hooks', `${AGENT_KEY}-hook.js`);
|
||||||
|
|
||||||
|
let hookConfigured = false;
|
||||||
|
try {
|
||||||
|
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||||
|
const settings = JSON.parse(raw);
|
||||||
|
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||||
|
if (hooks) {
|
||||||
|
const checkArr = (arr: unknown) => {
|
||||||
|
if (!Array.isArray(arr)) return false;
|
||||||
|
return (arr as Array<Record<string, unknown>>).some((entry) => {
|
||||||
|
if (Array.isArray(entry.hooks)) {
|
||||||
|
return (entry.hooks as Array<Record<string, unknown>>).some(
|
||||||
|
(h) => typeof h.command === 'string' && h.command.includes('elydora'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return typeof entry.command === 'string' && entry.command.includes('elydora');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
hookConfigured = checkArr(hooks.PreToolUse) && checkArr(hooks.PostToolUse);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Config not readable
|
||||||
|
}
|
||||||
|
|
||||||
|
let hookScriptExists = false;
|
||||||
|
try {
|
||||||
|
await fsp.access(hookScriptPath);
|
||||||
|
hookScriptExists = true;
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
installed: hookConfigured && hookScriptExists,
|
||||||
|
agentName: AGENT_KEY,
|
||||||
|
displayName: entry.name,
|
||||||
|
hookConfigured,
|
||||||
|
hookScriptExists,
|
||||||
|
configPath,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
|
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||||
|
import { SUPPORTED_AGENTS } from './registry.js';
|
||||||
|
|
||||||
|
const AGENT_KEY = 'cursor';
|
||||||
|
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||||
|
|
||||||
|
function resolveConfigDir(): string {
|
||||||
|
// .cursor is project-relative, but for global install use home dir
|
||||||
|
return path.join(os.homedir(), entry.configDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConfigPath(): string {
|
||||||
|
return path.join(resolveConfigDir(), entry.configFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHookCommand(scriptPath: string): string {
|
||||||
|
return `node "${scriptPath}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterElydoraEntries(arr: Array<Record<string, unknown>>): Array<Record<string, unknown>> {
|
||||||
|
return arr.filter(
|
||||||
|
(h) => typeof h.command === 'string' && !h.command.includes('elydora'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cursorPlugin: AgentPlugin = {
|
||||||
|
async install(config: InstallConfig): Promise<void> {
|
||||||
|
const configDir = resolveConfigDir();
|
||||||
|
await fsp.mkdir(configDir, { recursive: true });
|
||||||
|
|
||||||
|
const configPath = resolveConfigPath();
|
||||||
|
let settings: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||||
|
settings = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
// Start fresh
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!settings.hooks || typeof settings.hooks !== 'object') {
|
||||||
|
settings.hooks = {};
|
||||||
|
}
|
||||||
|
const hooks = settings.hooks as Record<string, unknown>;
|
||||||
|
|
||||||
|
// --- preToolUse (guard — freeze enforcement) ---
|
||||||
|
if (!Array.isArray(hooks.preToolUse)) {
|
||||||
|
hooks.preToolUse = [];
|
||||||
|
}
|
||||||
|
const preFiltered = filterElydoraEntries(hooks.preToolUse as Array<Record<string, unknown>>);
|
||||||
|
preFiltered.push({ command: buildHookCommand(config.guardScriptPath) });
|
||||||
|
hooks.preToolUse = preFiltered;
|
||||||
|
|
||||||
|
// --- postToolUse (audit logging) ---
|
||||||
|
if (!Array.isArray(hooks.postToolUse)) {
|
||||||
|
hooks.postToolUse = [];
|
||||||
|
}
|
||||||
|
const postFiltered = filterElydoraEntries(hooks.postToolUse as Array<Record<string, unknown>>);
|
||||||
|
postFiltered.push({ command: buildHookCommand(config.hookScriptPath) });
|
||||||
|
hooks.postToolUse = postFiltered;
|
||||||
|
|
||||||
|
settings.hooks = hooks;
|
||||||
|
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||||
|
},
|
||||||
|
|
||||||
|
async uninstall(): Promise<void> {
|
||||||
|
const configPath = resolveConfigPath();
|
||||||
|
|
||||||
|
let settings: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||||
|
settings = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||||
|
if (!hooks) return;
|
||||||
|
|
||||||
|
if (Array.isArray(hooks.preToolUse)) {
|
||||||
|
hooks.preToolUse = filterElydoraEntries(hooks.preToolUse as Array<Record<string, unknown>>);
|
||||||
|
}
|
||||||
|
if (Array.isArray(hooks.postToolUse)) {
|
||||||
|
hooks.postToolUse = filterElydoraEntries(hooks.postToolUse as Array<Record<string, unknown>>);
|
||||||
|
}
|
||||||
|
|
||||||
|
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||||
|
},
|
||||||
|
|
||||||
|
async status(): Promise<PluginStatus> {
|
||||||
|
const configPath = resolveConfigPath();
|
||||||
|
const hookScriptPath = path.join(os.homedir(), '.elydora', 'hooks', `${AGENT_KEY}-hook.js`);
|
||||||
|
|
||||||
|
let hookConfigured = false;
|
||||||
|
try {
|
||||||
|
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||||
|
const settings = JSON.parse(raw);
|
||||||
|
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||||
|
if (hooks) {
|
||||||
|
const check = (arr: unknown) =>
|
||||||
|
Array.isArray(arr) && (arr as Array<Record<string, unknown>>).some(
|
||||||
|
(h) => typeof h.command === 'string' && h.command.includes('elydora'),
|
||||||
|
);
|
||||||
|
hookConfigured = check(hooks.preToolUse) && check(hooks.postToolUse);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Config not readable
|
||||||
|
}
|
||||||
|
|
||||||
|
let hookScriptExists = false;
|
||||||
|
try {
|
||||||
|
await fsp.access(hookScriptPath);
|
||||||
|
hookScriptExists = true;
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
installed: hookConfigured && hookScriptExists,
|
||||||
|
agentName: AGENT_KEY,
|
||||||
|
displayName: entry.name,
|
||||||
|
hookConfigured,
|
||||||
|
hookScriptExists,
|
||||||
|
configPath,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
|
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||||
|
import { SUPPORTED_AGENTS } from './registry.js';
|
||||||
|
|
||||||
|
const AGENT_KEY = 'gemini';
|
||||||
|
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||||
|
|
||||||
|
function resolveConfigDir(): string {
|
||||||
|
return entry.configDir.replace(/^~/, os.homedir());
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConfigPath(): string {
|
||||||
|
return path.join(resolveConfigDir(), entry.configFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHookCommand(scriptPath: string): string {
|
||||||
|
return `node "${scriptPath}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterElydoraEntries(arr: Array<Record<string, unknown>>): Array<Record<string, unknown>> {
|
||||||
|
return arr.filter((entry) => {
|
||||||
|
if (Array.isArray(entry.hooks)) {
|
||||||
|
const cmds = entry.hooks as Array<Record<string, unknown>>;
|
||||||
|
return !cmds.some((h) => typeof h.command === 'string' && h.command.includes('elydora'));
|
||||||
|
}
|
||||||
|
if (typeof entry.command === 'string') return !entry.command.includes('elydora');
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const geminiPlugin: AgentPlugin = {
|
||||||
|
async install(config: InstallConfig): Promise<void> {
|
||||||
|
const configDir = resolveConfigDir();
|
||||||
|
await fsp.mkdir(configDir, { recursive: true });
|
||||||
|
|
||||||
|
const configPath = resolveConfigPath();
|
||||||
|
let settings: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||||
|
settings = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
// Start fresh
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!settings.hooks || typeof settings.hooks !== 'object') {
|
||||||
|
settings.hooks = {};
|
||||||
|
}
|
||||||
|
const hooks = settings.hooks as Record<string, unknown>;
|
||||||
|
|
||||||
|
// --- BeforeTool (guard — freeze enforcement) ---
|
||||||
|
if (!Array.isArray(hooks.BeforeTool)) {
|
||||||
|
hooks.BeforeTool = [];
|
||||||
|
}
|
||||||
|
const preFiltered = filterElydoraEntries(hooks.BeforeTool as Array<Record<string, unknown>>);
|
||||||
|
preFiltered.push({
|
||||||
|
hooks: [{ type: 'command', command: buildHookCommand(config.guardScriptPath) }],
|
||||||
|
});
|
||||||
|
hooks.BeforeTool = preFiltered;
|
||||||
|
|
||||||
|
// --- AfterTool (audit logging) ---
|
||||||
|
if (!Array.isArray(hooks.AfterTool)) {
|
||||||
|
hooks.AfterTool = [];
|
||||||
|
}
|
||||||
|
const postFiltered = filterElydoraEntries(hooks.AfterTool as Array<Record<string, unknown>>);
|
||||||
|
postFiltered.push({
|
||||||
|
hooks: [{ type: 'command', command: buildHookCommand(config.hookScriptPath) }],
|
||||||
|
});
|
||||||
|
hooks.AfterTool = postFiltered;
|
||||||
|
|
||||||
|
settings.hooks = hooks;
|
||||||
|
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||||
|
},
|
||||||
|
|
||||||
|
async uninstall(): Promise<void> {
|
||||||
|
const configPath = resolveConfigPath();
|
||||||
|
|
||||||
|
let settings: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||||
|
settings = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||||
|
if (!hooks) return;
|
||||||
|
|
||||||
|
if (Array.isArray(hooks.BeforeTool)) {
|
||||||
|
hooks.BeforeTool = filterElydoraEntries(hooks.BeforeTool as Array<Record<string, unknown>>);
|
||||||
|
}
|
||||||
|
if (Array.isArray(hooks.AfterTool)) {
|
||||||
|
hooks.AfterTool = filterElydoraEntries(hooks.AfterTool as Array<Record<string, unknown>>);
|
||||||
|
}
|
||||||
|
|
||||||
|
await fsp.writeFile(configPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||||
|
},
|
||||||
|
|
||||||
|
async status(): Promise<PluginStatus> {
|
||||||
|
const configPath = resolveConfigPath();
|
||||||
|
const hookScriptPath = path.join(os.homedir(), '.elydora', 'hooks', `${AGENT_KEY}-hook.js`);
|
||||||
|
|
||||||
|
let hookConfigured = false;
|
||||||
|
try {
|
||||||
|
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||||
|
const settings = JSON.parse(raw);
|
||||||
|
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
||||||
|
if (hooks) {
|
||||||
|
const checkArr = (arr: unknown) => {
|
||||||
|
if (!Array.isArray(arr)) return false;
|
||||||
|
return (arr as Array<Record<string, unknown>>).some((entry) => {
|
||||||
|
if (Array.isArray(entry.hooks)) {
|
||||||
|
return (entry.hooks as Array<Record<string, unknown>>).some(
|
||||||
|
(h) => typeof h.command === 'string' && h.command.includes('elydora'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return typeof entry.command === 'string' && entry.command.includes('elydora');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
hookConfigured = checkArr(hooks.BeforeTool) && checkArr(hooks.AfterTool);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Config not readable
|
||||||
|
}
|
||||||
|
|
||||||
|
let hookScriptExists = false;
|
||||||
|
try {
|
||||||
|
await fsp.access(hookScriptPath);
|
||||||
|
hookScriptExists = true;
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
installed: hookConfigured && hookScriptExists,
|
||||||
|
agentName: AGENT_KEY,
|
||||||
|
displayName: entry.name,
|
||||||
|
hookConfigured,
|
||||||
|
hookScriptExists,
|
||||||
|
configPath,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
/**
|
||||||
|
* Generates a self-contained Node.js hook script that embeds all crypto logic
|
||||||
|
* inline so it has zero runtime dependency on the SDK.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function generateHookScript(agentName: string): string {
|
||||||
|
return `#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Self-contained Elydora audit hook for: ${agentName}
|
||||||
|
// Generated by @elydora/sdk — DO NOT EDIT
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const os = require('node:os');
|
||||||
|
const AGENT_NAME = ${JSON.stringify(agentName)};
|
||||||
|
const ELYDORA_DIR = path.join(os.homedir(), '.elydora');
|
||||||
|
const CONFIG_PATH = path.join(ELYDORA_DIR, 'agents', AGENT_NAME + '.json');
|
||||||
|
const KEY_PATH = path.join(ELYDORA_DIR, 'agents', AGENT_NAME + '.key');
|
||||||
|
const CHAIN_STATE_PATH = path.join(ELYDORA_DIR, 'chain-state.json');
|
||||||
|
const ERROR_LOG_PATH = path.join(ELYDORA_DIR, 'error.log');
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Embedded crypto (from @elydora/sdk src/crypto.ts + src/utils.ts)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function base64urlEncode(data) {
|
||||||
|
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||||
|
return buf.toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
function jcsCanonicalise(value) {
|
||||||
|
if (value === null || value === undefined) return 'null';
|
||||||
|
if (typeof value === 'boolean' || typeof value === 'number') return JSON.stringify(value);
|
||||||
|
if (typeof value === 'string') return JSON.stringify(value);
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return '[' + value.map(v => jcsCanonicalise(v)).join(',') + ']';
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
const keys = Object.keys(value).sort();
|
||||||
|
const pairs = [];
|
||||||
|
for (const key of keys) {
|
||||||
|
if (value[key] !== undefined) {
|
||||||
|
pairs.push(JSON.stringify(key) + ':' + jcsCanonicalise(value[key]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '{' + pairs.join(',') + '}';
|
||||||
|
}
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256Base64url(data) {
|
||||||
|
const input = typeof data === 'string' ? Buffer.from(data, 'utf-8') : data;
|
||||||
|
return base64urlEncode(crypto.createHash('sha256').update(input).digest());
|
||||||
|
}
|
||||||
|
|
||||||
|
function computePayloadHash(payload) {
|
||||||
|
return sha256Base64url(jcsCanonicalise(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeChainHash(prevChainHash, payloadHash, operationId, issuedAt) {
|
||||||
|
const input = prevChainHash + '|' + payloadHash + '|' + operationId + '|' + issuedAt;
|
||||||
|
return sha256Base64url(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PKCS8_ED25519_PREFIX = Buffer.from([
|
||||||
|
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05,
|
||||||
|
0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
|
||||||
|
]);
|
||||||
|
|
||||||
|
function signEd25519(privateKeyBase64url, data) {
|
||||||
|
const seed = Buffer.from(privateKeyBase64url, 'base64url');
|
||||||
|
const pkcs8 = Buffer.concat([PKCS8_ED25519_PREFIX, seed]);
|
||||||
|
const keyObject = crypto.createPrivateKey({ key: pkcs8, format: 'der', type: 'pkcs8' });
|
||||||
|
const signature = crypto.sign(null, data, keyObject);
|
||||||
|
return base64urlEncode(signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZERO_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
|
||||||
|
|
||||||
|
function uuidv7() {
|
||||||
|
const now = Date.now();
|
||||||
|
const random = crypto.randomBytes(10);
|
||||||
|
const bytes = Buffer.alloc(16);
|
||||||
|
bytes[0] = (now / 2 ** 40) & 0xff;
|
||||||
|
bytes[1] = (now / 2 ** 32) & 0xff;
|
||||||
|
bytes[2] = (now / 2 ** 24) & 0xff;
|
||||||
|
bytes[3] = (now / 2 ** 16) & 0xff;
|
||||||
|
bytes[4] = (now / 2 ** 8) & 0xff;
|
||||||
|
bytes[5] = now & 0xff;
|
||||||
|
bytes[6] = 0x70 | (random[0] & 0x0f);
|
||||||
|
bytes[7] = random[1];
|
||||||
|
bytes[8] = 0x80 | (random[2] & 0x3f);
|
||||||
|
bytes[9] = random[3];
|
||||||
|
bytes[10] = random[4];
|
||||||
|
bytes[11] = random[5];
|
||||||
|
bytes[12] = random[6];
|
||||||
|
bytes[13] = random[7];
|
||||||
|
bytes[14] = random[8];
|
||||||
|
bytes[15] = random[9];
|
||||||
|
const hex = bytes.toString('hex');
|
||||||
|
return (
|
||||||
|
hex.slice(0, 8) + '-' +
|
||||||
|
hex.slice(8, 12) + '-' +
|
||||||
|
hex.slice(12, 16) + '-' +
|
||||||
|
hex.slice(16, 20) + '-' +
|
||||||
|
hex.slice(20, 32)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateNonce() {
|
||||||
|
return base64urlEncode(crypto.randomBytes(16));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Chain state persistence (atomic writes)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function readChainState() {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(CHAIN_STATE_PATH, 'utf-8');
|
||||||
|
const state = JSON.parse(raw);
|
||||||
|
return state.prev_chain_hash || ZERO_CHAIN_HASH;
|
||||||
|
} catch {
|
||||||
|
return ZERO_CHAIN_HASH;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeChainState(chainHash) {
|
||||||
|
const tmpPath = CHAIN_STATE_PATH + '.tmp';
|
||||||
|
fs.writeFileSync(tmpPath, JSON.stringify({ prev_chain_hash: chainHash }), 'utf-8');
|
||||||
|
try {
|
||||||
|
fs.renameSync(tmpPath, CHAIN_STATE_PATH);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'EPERM' || err.code === 'EEXIST') {
|
||||||
|
try { fs.unlinkSync(CHAIN_STATE_PATH); } catch { /* ignore */ }
|
||||||
|
fs.renameSync(tmpPath, CHAIN_STATE_PATH);
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Error logging
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function logError(err) {
|
||||||
|
try {
|
||||||
|
const ts = new Date().toISOString();
|
||||||
|
const msg = ts + ' [' + AGENT_NAME + '] ' + (err.stack || err.message || String(err)) + '\\n';
|
||||||
|
fs.appendFileSync(ERROR_LOG_PATH, msg, 'utf-8');
|
||||||
|
} catch {
|
||||||
|
// Silently ignore logging failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main hook logic
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
// Read JSON from stdin
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of process.stdin) {
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
const rawInput = Buffer.concat(chunks).toString('utf-8').trim();
|
||||||
|
if (!rawInput) return;
|
||||||
|
|
||||||
|
let hookData;
|
||||||
|
try {
|
||||||
|
hookData = JSON.parse(rawInput);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract tool_name and tool_input from hook data
|
||||||
|
// Different agents may pass data in different shapes
|
||||||
|
const toolName = hookData.tool_name || hookData.toolName || hookData.name || 'unknown';
|
||||||
|
const toolInput = hookData.tool_input || hookData.toolInput || hookData.input || hookData.parameters || {};
|
||||||
|
const sessionId = hookData.session_id || hookData.sessionId || hookData.session || 'unknown';
|
||||||
|
|
||||||
|
// Read agent config and private key
|
||||||
|
let config;
|
||||||
|
let privateKey;
|
||||||
|
try {
|
||||||
|
config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
||||||
|
privateKey = fs.readFileSync(KEY_PATH, 'utf-8').trim();
|
||||||
|
} catch (err) {
|
||||||
|
logError(new Error('Failed to read agent config/key: ' + err.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgId = config.org_id;
|
||||||
|
const agentId = config.agent_id;
|
||||||
|
const kid = config.kid;
|
||||||
|
const baseUrl = (config.base_url || 'https://api.elydora.com').replace(/\\/+$/, '');
|
||||||
|
const token = config.token || '';
|
||||||
|
|
||||||
|
// Read chain state
|
||||||
|
const prevChainHash = readChainState();
|
||||||
|
|
||||||
|
// Build EOR
|
||||||
|
const operationId = uuidv7();
|
||||||
|
const issuedAt = Date.now();
|
||||||
|
const nonce = generateNonce();
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
tool_name: toolName,
|
||||||
|
tool_input: toolInput,
|
||||||
|
session_id: sessionId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const payloadHash = computePayloadHash(payload);
|
||||||
|
const chainHash = computeChainHash(prevChainHash, payloadHash, operationId, issuedAt);
|
||||||
|
|
||||||
|
const eorWithoutSig = {
|
||||||
|
op_version: '1.0',
|
||||||
|
operation_id: operationId,
|
||||||
|
org_id: orgId,
|
||||||
|
agent_id: agentId,
|
||||||
|
issued_at: issuedAt,
|
||||||
|
ttl_ms: 30000,
|
||||||
|
nonce: nonce,
|
||||||
|
operation_type: 'ai.tool_use',
|
||||||
|
subject: { session_id: sessionId },
|
||||||
|
action: { tool: toolName },
|
||||||
|
payload: payload,
|
||||||
|
payload_hash: payloadHash,
|
||||||
|
prev_chain_hash: prevChainHash,
|
||||||
|
agent_pubkey_kid: kid,
|
||||||
|
};
|
||||||
|
|
||||||
|
const canonical = jcsCanonicalise(eorWithoutSig);
|
||||||
|
const signature = signEd25519(privateKey, Buffer.from(canonical, 'utf-8'));
|
||||||
|
|
||||||
|
const eor = Object.assign({}, eorWithoutSig, { chain_hash: chainHash, signature: signature });
|
||||||
|
|
||||||
|
// POST to /v1/operations (5s timeout)
|
||||||
|
// Only update local chain state on success to prevent desync
|
||||||
|
const url = baseUrl + '/v1/operations';
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
};
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = 'Bearer ' + token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers,
|
||||||
|
body: JSON.stringify(eor),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
// Server accepted — safe to advance local chain state
|
||||||
|
writeChainState(chainHash);
|
||||||
|
} else if (res.status === 400) {
|
||||||
|
// Possible chain hash mismatch — try to resync from server response
|
||||||
|
try {
|
||||||
|
const errBody = await res.json();
|
||||||
|
if (errBody.error && errBody.error.code === 'PREV_HASH_MISMATCH') {
|
||||||
|
// Extract the expected hash from the error message
|
||||||
|
const match = (errBody.error.message || '').match(/Expected prev_chain_hash "([^"]+)"/);
|
||||||
|
if (match) {
|
||||||
|
writeChainState(match[1]);
|
||||||
|
logError(new Error('Chain hash resynced to server: ' + match[1]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore parse errors */ }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Network error — don't advance chain state, will retry with same prev_chain_hash
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a lightweight PreToolUse guard script that checks if the agent
|
||||||
|
* is frozen and blocks tool execution if so.
|
||||||
|
*
|
||||||
|
* - Caches the agent status locally (60s TTL) to avoid hitting the API on every tool call
|
||||||
|
* - Fails open: if the API is unreachable, tool execution is allowed
|
||||||
|
* - If frozen: exits with code 1 to block the tool
|
||||||
|
*/
|
||||||
|
export function generateGuardScript(agentName: string): string {
|
||||||
|
return `#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Elydora agent guard (PreToolUse) for: ${agentName}
|
||||||
|
// Generated by @elydora/sdk — DO NOT EDIT
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const os = require('node:os');
|
||||||
|
|
||||||
|
const AGENT_NAME = ${JSON.stringify(agentName)};
|
||||||
|
const ELYDORA_DIR = path.join(os.homedir(), '.elydora');
|
||||||
|
const CONFIG_PATH = path.join(ELYDORA_DIR, 'agents', AGENT_NAME + '.json');
|
||||||
|
const STATUS_CACHE_PATH = path.join(ELYDORA_DIR, 'agents', AGENT_NAME + '.status.json');
|
||||||
|
const CACHE_TTL_MS = 60000; // 60 seconds
|
||||||
|
|
||||||
|
// Discard stdin immediately so the process can exit cleanly
|
||||||
|
process.stdin.destroy();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// Read agent config
|
||||||
|
let config;
|
||||||
|
try {
|
||||||
|
config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
||||||
|
} catch {
|
||||||
|
return; // Can't read config — fail-open (allow)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cached status
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(STATUS_CACHE_PATH, 'utf-8');
|
||||||
|
const cache = JSON.parse(raw);
|
||||||
|
const age = Date.now() - (cache.cached_at || 0);
|
||||||
|
|
||||||
|
if (age < CACHE_TTL_MS) {
|
||||||
|
if (cache.status === 'frozen') {
|
||||||
|
process.stderr.write('Agent "' + AGENT_NAME + '" is frozen by Elydora. Tool execution blocked.\\n');
|
||||||
|
process.exitCode = 2;
|
||||||
|
}
|
||||||
|
return; // Cache is fresh — use cached result
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// No cache or invalid — need to check API
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch agent status from API
|
||||||
|
const baseUrl = (config.base_url || 'https://api.elydora.com').replace(/\\/+$/, '');
|
||||||
|
const token = config.token || '';
|
||||||
|
const agentId = config.agent_id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||||
|
|
||||||
|
const headers = { 'Accept': 'application/json' };
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = 'Bearer ' + token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(baseUrl + '/v1/agents/' + encodeURIComponent(agentId), {
|
||||||
|
headers: headers,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
const agentStatus = (data.agent && data.agent.status) || 'active';
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(STATUS_CACHE_PATH, JSON.stringify({
|
||||||
|
status: agentStatus,
|
||||||
|
cached_at: Date.now(),
|
||||||
|
}), 'utf-8');
|
||||||
|
} catch {
|
||||||
|
// Ignore cache write failures
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agentStatus === 'frozen') {
|
||||||
|
process.stderr.write('Agent "' + AGENT_NAME + '" is frozen by Elydora. Tool execution blocked.\\n');
|
||||||
|
process.exitCode = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// API unreachable — fail-open (allow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
|
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||||
|
import { SUPPORTED_AGENTS } from './registry.js';
|
||||||
|
|
||||||
|
const AGENT_KEY = 'kiro';
|
||||||
|
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||||
|
|
||||||
|
function resolveConfigDir(): string {
|
||||||
|
// .kiro/hooks is project-relative, but for global install use home dir
|
||||||
|
return path.join(os.homedir(), entry.configDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConfigPath(): string {
|
||||||
|
return path.join(resolveConfigDir(), entry.configFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const kiroPlugin: AgentPlugin = {
|
||||||
|
async install(config: InstallConfig): Promise<void> {
|
||||||
|
const configDir = resolveConfigDir();
|
||||||
|
await fsp.mkdir(configDir, { recursive: true });
|
||||||
|
|
||||||
|
const configPath = resolveConfigPath();
|
||||||
|
|
||||||
|
const hookConfig = {
|
||||||
|
name: 'Elydora Audit',
|
||||||
|
description: 'Sends tool-use events to the Elydora tamper-evident audit platform',
|
||||||
|
version: '1.0.0',
|
||||||
|
hooks: {
|
||||||
|
pre_tool_use: {
|
||||||
|
command: `node "${config.guardScriptPath}"`,
|
||||||
|
timeout_ms: 5000,
|
||||||
|
},
|
||||||
|
post_tool_use: {
|
||||||
|
command: `node "${config.hookScriptPath}"`,
|
||||||
|
timeout_ms: 5000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await fsp.writeFile(configPath, JSON.stringify(hookConfig, null, 2) + '\n', 'utf-8');
|
||||||
|
},
|
||||||
|
|
||||||
|
async uninstall(): Promise<void> {
|
||||||
|
const configPath = resolveConfigPath();
|
||||||
|
try {
|
||||||
|
await fsp.unlink(configPath);
|
||||||
|
} catch {
|
||||||
|
// Already removed
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async status(): Promise<PluginStatus> {
|
||||||
|
const configPath = resolveConfigPath();
|
||||||
|
const hookScriptPath = path.join(os.homedir(), '.elydora', 'hooks', `${AGENT_KEY}-hook.js`);
|
||||||
|
|
||||||
|
let hookConfigured = false;
|
||||||
|
try {
|
||||||
|
const raw = await fsp.readFile(configPath, 'utf-8');
|
||||||
|
const config = JSON.parse(raw);
|
||||||
|
hookConfigured = !!(config.hooks && config.hooks.pre_tool_use && config.hooks.post_tool_use);
|
||||||
|
} catch {
|
||||||
|
// Config not readable
|
||||||
|
}
|
||||||
|
|
||||||
|
let hookScriptExists = false;
|
||||||
|
try {
|
||||||
|
await fsp.access(hookScriptPath);
|
||||||
|
hookScriptExists = true;
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
installed: hookConfigured && hookScriptExists,
|
||||||
|
agentName: AGENT_KEY,
|
||||||
|
displayName: entry.name,
|
||||||
|
hookConfigured,
|
||||||
|
hookScriptExists,
|
||||||
|
configPath,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
|
import type { AgentPlugin, InstallConfig, PluginStatus } from './base.js';
|
||||||
|
import { SUPPORTED_AGENTS } from './registry.js';
|
||||||
|
|
||||||
|
const AGENT_KEY = 'opencode';
|
||||||
|
const entry = SUPPORTED_AGENTS.get(AGENT_KEY)!;
|
||||||
|
|
||||||
|
function resolveConfigDir(): string {
|
||||||
|
// .opencode/plugins is project-relative, but for global install use home dir
|
||||||
|
return path.join(os.homedir(), entry.configDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConfigPath(): string {
|
||||||
|
return path.join(resolveConfigDir(), entry.configFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatePluginScript(hookScriptPath: string, guardScriptPath: string): string {
|
||||||
|
return `// Elydora Audit Plugin for OpenCode
|
||||||
|
// Generated by @elydora/sdk — DO NOT EDIT
|
||||||
|
|
||||||
|
const { spawn, spawnSync } = require('node:child_process');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: 'elydora-audit',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'Sends tool-use events to the Elydora tamper-evident audit platform',
|
||||||
|
|
||||||
|
hooks: {
|
||||||
|
preToolUse(context) {
|
||||||
|
// Synchronous guard — blocks tool if agent is frozen
|
||||||
|
try {
|
||||||
|
const result = spawnSync('node', ['__ELYDORA_GUARD_SCRIPT_PATH__'], {
|
||||||
|
timeout: 5000,
|
||||||
|
stdio: ['pipe', 'ignore', 'pipe'],
|
||||||
|
});
|
||||||
|
if (result.status !== 0) {
|
||||||
|
const msg = result.stderr ? result.stderr.toString().trim() : 'Agent is frozen by Elydora.';
|
||||||
|
return { blocked: true, reason: msg };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fail-open — allow if guard can't run
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
postToolUse(context) {
|
||||||
|
try {
|
||||||
|
const input = JSON.stringify({
|
||||||
|
tool_name: context.toolName || context.tool_name || 'unknown',
|
||||||
|
tool_input: context.toolInput || context.tool_input || {},
|
||||||
|
session_id: context.sessionId || context.session_id || 'unknown',
|
||||||
|
});
|
||||||
|
|
||||||
|
const child = spawn('node', ['__ELYDORA_HOOK_SCRIPT_PATH__'], {
|
||||||
|
detached: true,
|
||||||
|
stdio: ['pipe', 'ignore', 'ignore'],
|
||||||
|
});
|
||||||
|
child.stdin.write(input);
|
||||||
|
child.stdin.end();
|
||||||
|
child.unref();
|
||||||
|
} catch {
|
||||||
|
// Fire-and-forget — never block the developer
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const opencodePlugin: AgentPlugin = {
|
||||||
|
async install(config: InstallConfig): Promise<void> {
|
||||||
|
const configDir = resolveConfigDir();
|
||||||
|
await fsp.mkdir(configDir, { recursive: true });
|
||||||
|
|
||||||
|
const configPath = resolveConfigPath();
|
||||||
|
const script = generatePluginScript(config.hookScriptPath, config.guardScriptPath)
|
||||||
|
.replace('__ELYDORA_HOOK_SCRIPT_PATH__', config.hookScriptPath.replace(/\\/g, '\\\\'))
|
||||||
|
.replace('__ELYDORA_GUARD_SCRIPT_PATH__', config.guardScriptPath.replace(/\\/g, '\\\\'));
|
||||||
|
|
||||||
|
await fsp.writeFile(configPath, script, 'utf-8');
|
||||||
|
},
|
||||||
|
|
||||||
|
async uninstall(): Promise<void> {
|
||||||
|
const configPath = resolveConfigPath();
|
||||||
|
try {
|
||||||
|
await fsp.unlink(configPath);
|
||||||
|
} catch {
|
||||||
|
// Already removed
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async status(): Promise<PluginStatus> {
|
||||||
|
const configPath = resolveConfigPath();
|
||||||
|
const hookScriptPath = path.join(os.homedir(), '.elydora', 'hooks', `${AGENT_KEY}-hook.js`);
|
||||||
|
|
||||||
|
let hookConfigured = false;
|
||||||
|
try {
|
||||||
|
await fsp.access(configPath);
|
||||||
|
hookConfigured = true;
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist
|
||||||
|
}
|
||||||
|
|
||||||
|
let hookScriptExists = false;
|
||||||
|
try {
|
||||||
|
await fsp.access(hookScriptPath);
|
||||||
|
hookScriptExists = true;
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
installed: hookConfigured && hookScriptExists,
|
||||||
|
agentName: AGENT_KEY,
|
||||||
|
displayName: entry.name,
|
||||||
|
hookConfigured,
|
||||||
|
hookScriptExists,
|
||||||
|
configPath,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export interface AgentRegistryEntry {
|
||||||
|
readonly name: string;
|
||||||
|
readonly configDir: string;
|
||||||
|
readonly configFile: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SUPPORTED_AGENTS: ReadonlyMap<string, AgentRegistryEntry> = new Map([
|
||||||
|
['claudecode', { name: 'Claude Code', configDir: '~/.claude', configFile: 'settings.json' }],
|
||||||
|
['cursor', { name: 'Cursor', configDir: '.cursor', configFile: 'hooks.json' }],
|
||||||
|
['gemini', { name: 'Gemini CLI', configDir: '~/.gemini', configFile: 'settings.json' }],
|
||||||
|
['augment', { name: 'Augment Code', configDir: '~/.augment', configFile: 'settings.json' }],
|
||||||
|
['kiro', { name: 'Kiro', configDir: '.kiro/hooks', configFile: 'elydora-audit.kiro.hook' }],
|
||||||
|
['opencode', { name: 'OpenCode', configDir: '.opencode/plugins', configFile: 'elydora-audit.js' }],
|
||||||
|
]);
|
||||||
+302
@@ -0,0 +1,302 @@
|
|||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Enums / status types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type AgentStatus = 'active' | 'frozen' | 'revoked';
|
||||||
|
export type KeyStatus = 'active' | 'retired' | 'revoked';
|
||||||
|
export type ExportStatus = 'queued' | 'running' | 'done' | 'failed';
|
||||||
|
export type RbacRole =
|
||||||
|
| 'org_owner'
|
||||||
|
| 'security_admin'
|
||||||
|
| 'compliance_auditor'
|
||||||
|
| 'readonly_investigator'
|
||||||
|
| 'integration_engineer';
|
||||||
|
export type ErrorCode =
|
||||||
|
| 'INVALID_SIGNATURE'
|
||||||
|
| 'UNKNOWN_AGENT'
|
||||||
|
| 'KEY_REVOKED'
|
||||||
|
| 'AGENT_FROZEN'
|
||||||
|
| 'TTL_EXPIRED'
|
||||||
|
| 'REPLAY_DETECTED'
|
||||||
|
| 'PREV_HASH_MISMATCH'
|
||||||
|
| 'PAYLOAD_TOO_LARGE'
|
||||||
|
| 'RATE_LIMITED'
|
||||||
|
| 'INTERNAL_ERROR'
|
||||||
|
| 'UNAUTHORIZED'
|
||||||
|
| 'FORBIDDEN'
|
||||||
|
| 'NOT_FOUND'
|
||||||
|
| 'VALIDATION_ERROR';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Entities
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface Agent {
|
||||||
|
readonly agent_id: string;
|
||||||
|
readonly org_id: string;
|
||||||
|
readonly display_name: string;
|
||||||
|
readonly responsible_entity: string;
|
||||||
|
readonly status: AgentStatus;
|
||||||
|
readonly created_at: number;
|
||||||
|
readonly updated_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentKey {
|
||||||
|
readonly kid: string;
|
||||||
|
readonly agent_id: string;
|
||||||
|
readonly public_key: string;
|
||||||
|
readonly algorithm: 'ed25519';
|
||||||
|
readonly status: KeyStatus;
|
||||||
|
readonly created_at: number;
|
||||||
|
readonly retired_at: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Operation {
|
||||||
|
readonly operation_id: string;
|
||||||
|
readonly org_id: string;
|
||||||
|
readonly agent_id: string;
|
||||||
|
readonly seq_no: number;
|
||||||
|
readonly operation_type: string;
|
||||||
|
readonly issued_at: number;
|
||||||
|
readonly ttl_ms: number;
|
||||||
|
readonly nonce: string;
|
||||||
|
readonly subject: string;
|
||||||
|
readonly action: string;
|
||||||
|
readonly payload_hash: string;
|
||||||
|
readonly prev_chain_hash: string;
|
||||||
|
readonly chain_hash: string;
|
||||||
|
readonly agent_pubkey_kid: string;
|
||||||
|
readonly signature: string;
|
||||||
|
readonly r2_payload_key: string | null;
|
||||||
|
readonly created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Receipt {
|
||||||
|
readonly receipt_id: string;
|
||||||
|
readonly operation_id: string;
|
||||||
|
readonly r2_receipt_key: string;
|
||||||
|
readonly created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Epoch {
|
||||||
|
readonly epoch_id: string;
|
||||||
|
readonly org_id: string;
|
||||||
|
readonly start_time: number;
|
||||||
|
readonly end_time: number;
|
||||||
|
readonly root_hash: string;
|
||||||
|
readonly leaf_count: number;
|
||||||
|
readonly r2_epoch_key: string;
|
||||||
|
readonly created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Organization {
|
||||||
|
readonly org_id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly created_at: number;
|
||||||
|
readonly updated_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
readonly user_id: string;
|
||||||
|
readonly org_id: string;
|
||||||
|
readonly email: string;
|
||||||
|
readonly display_name: string;
|
||||||
|
readonly role: RbacRole;
|
||||||
|
readonly status: 'active' | 'suspended';
|
||||||
|
readonly created_at: number;
|
||||||
|
readonly updated_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Export {
|
||||||
|
readonly export_id: string;
|
||||||
|
readonly org_id: string;
|
||||||
|
readonly status: ExportStatus;
|
||||||
|
readonly query_params: string;
|
||||||
|
readonly r2_export_key: string | null;
|
||||||
|
readonly created_at: number;
|
||||||
|
readonly completed_at: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Protocol types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface EOR {
|
||||||
|
readonly op_version: '1.0';
|
||||||
|
readonly operation_id: string;
|
||||||
|
readonly org_id: string;
|
||||||
|
readonly agent_id: string;
|
||||||
|
readonly issued_at: number;
|
||||||
|
readonly ttl_ms: number;
|
||||||
|
readonly nonce: string;
|
||||||
|
readonly operation_type: string;
|
||||||
|
readonly subject: Record<string, unknown>;
|
||||||
|
readonly action: Record<string, unknown>;
|
||||||
|
readonly payload: Record<string, unknown> | string | null;
|
||||||
|
readonly payload_hash: string;
|
||||||
|
readonly prev_chain_hash: string;
|
||||||
|
readonly agent_pubkey_kid: string;
|
||||||
|
readonly signature: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EAR {
|
||||||
|
readonly receipt_version: string;
|
||||||
|
readonly receipt_id: string;
|
||||||
|
readonly operation_id: string;
|
||||||
|
readonly org_id: string;
|
||||||
|
readonly agent_id: string;
|
||||||
|
readonly server_received_at: number;
|
||||||
|
readonly seq_no: number;
|
||||||
|
readonly chain_hash: string;
|
||||||
|
readonly queue_message_id: string;
|
||||||
|
readonly receipt_hash: string;
|
||||||
|
readonly elydora_kid: string;
|
||||||
|
readonly elydora_signature: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// API request/response types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface RegisterAgentRequest {
|
||||||
|
readonly agent_id: string;
|
||||||
|
readonly display_name?: string;
|
||||||
|
readonly responsible_entity?: string;
|
||||||
|
readonly keys: ReadonlyArray<{
|
||||||
|
readonly kid: string;
|
||||||
|
readonly public_key: string;
|
||||||
|
readonly algorithm: 'ed25519';
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterAgentResponse {
|
||||||
|
readonly agent: Agent;
|
||||||
|
readonly keys: AgentKey[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetAgentResponse {
|
||||||
|
readonly agent: Agent;
|
||||||
|
readonly keys: AgentKey[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmitOperationResponse {
|
||||||
|
readonly receipt: EAR;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetOperationResponse {
|
||||||
|
readonly operation: Operation;
|
||||||
|
readonly receipt?: Receipt;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyOperationResponse {
|
||||||
|
readonly valid: boolean;
|
||||||
|
readonly checks: {
|
||||||
|
readonly signature: boolean;
|
||||||
|
readonly chain: boolean;
|
||||||
|
readonly receipt: boolean;
|
||||||
|
readonly merkle?: boolean;
|
||||||
|
};
|
||||||
|
readonly errors?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditQueryRequest {
|
||||||
|
readonly org_id?: string;
|
||||||
|
readonly agent_id?: string;
|
||||||
|
readonly operation_type?: string;
|
||||||
|
readonly start_time?: number;
|
||||||
|
readonly end_time?: number;
|
||||||
|
readonly cursor?: string;
|
||||||
|
readonly limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditQueryResponse {
|
||||||
|
readonly operations: Operation[];
|
||||||
|
readonly cursor?: string;
|
||||||
|
readonly total_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetEpochResponse {
|
||||||
|
readonly epoch: Epoch;
|
||||||
|
readonly anchor?: {
|
||||||
|
readonly tsa_token?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListEpochsResponse {
|
||||||
|
readonly epochs: Epoch[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateExportRequest {
|
||||||
|
readonly start_time: number;
|
||||||
|
readonly end_time: number;
|
||||||
|
readonly agent_id?: string;
|
||||||
|
readonly operation_type?: string;
|
||||||
|
readonly format: 'json' | 'pdf';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateExportResponse {
|
||||||
|
readonly export: Export;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetExportResponse {
|
||||||
|
readonly export: Export;
|
||||||
|
readonly download_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListExportsResponse {
|
||||||
|
readonly exports: Export[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JWK {
|
||||||
|
readonly kty: string;
|
||||||
|
readonly crv?: string;
|
||||||
|
readonly x?: string;
|
||||||
|
readonly kid: string;
|
||||||
|
readonly use: string;
|
||||||
|
readonly alg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JWKSResponse {
|
||||||
|
readonly keys: JWK[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthRegisterResponse {
|
||||||
|
readonly user: User;
|
||||||
|
readonly organization: Organization;
|
||||||
|
readonly token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthLoginResponse {
|
||||||
|
readonly user: User;
|
||||||
|
readonly token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorResponse {
|
||||||
|
readonly error: {
|
||||||
|
readonly code: ErrorCode;
|
||||||
|
readonly message: string;
|
||||||
|
readonly request_id: string;
|
||||||
|
readonly details?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Client configuration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ElydoraClientConfig {
|
||||||
|
readonly orgId: string;
|
||||||
|
readonly agentId: string;
|
||||||
|
readonly privateKey: string;
|
||||||
|
readonly baseUrl?: string;
|
||||||
|
readonly ttlMs?: number;
|
||||||
|
readonly maxRetries?: number;
|
||||||
|
readonly kid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateOperationParams {
|
||||||
|
readonly operationType: string;
|
||||||
|
readonly subject: Record<string, unknown>;
|
||||||
|
readonly action: Record<string, unknown>;
|
||||||
|
readonly payload?: Record<string, unknown> | string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
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
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
bytes[2] = (now / 2 ** 24) & 0xff;
|
||||||
|
bytes[3] = (now / 2 ** 16) & 0xff;
|
||||||
|
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]!;
|
||||||
|
bytes[11] = random[5]!;
|
||||||
|
bytes[12] = random[6]!;
|
||||||
|
bytes[13] = random[7]!;
|
||||||
|
bytes[14] = random[8]!;
|
||||||
|
bytes[15] = random[9]!;
|
||||||
|
|
||||||
|
const hex = bytes.toString('hex');
|
||||||
|
return (
|
||||||
|
hex.slice(0, 8) + '-' +
|
||||||
|
hex.slice(8, 12) + '-' +
|
||||||
|
hex.slice(12, 16) + '-' +
|
||||||
|
hex.slice(16, 20) + '-' +
|
||||||
|
hex.slice(20, 32)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "Node16",
|
||||||
|
"moduleResolution": "Node16",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user