Files
Elydora-Open-Source/packages/server/src/services/agent-service.ts
T
雷电芽衣 bc6983a62e chore(oss): audit the server package and resync the SDK mirrors
Split the 657-line operation service into eor, submit, and verify
modules, add the AGENT_REVOKED and KEY_RETIRED codes the Backend already
emits, and condense every multi-line comment. Mirror Node, Python, and
Go at 2.1.0 with the deep-health 503, non-idempotent retry, rotation
grace, and path-encoding fixes. Document which routes and response
fields the self-hosted server does not provide.

Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
2026-09-03 20:48:20 -04:00

470 lines
13 KiB
TypeScript

/** Agent service: business logic for agent lifecycle management. */
import type {
Agent,
AgentKey,
RegisterAgentRequest,
RegisterAgentResponse,
GetAgentResponse,
ListAgentsResponse,
AdminAction,
} from '../shared/index.js';
import { generateUUIDv7 } from '../utils/uuid.js';
import { base64urlDecode } from '../utils/crypto.js';
import { AppError } from '../middleware/error-handler.js';
import type { Database, PreparedStatement } from '../adapters/interfaces.js';
import { requireIntegrationType } from './agent-integration.js';
export { updateAgentIntegrationType } from './agent-integration.js';
// ---------------------------------------------------------------------------
// Register agent
// ---------------------------------------------------------------------------
export async function registerAgent(
db: Database,
body: RegisterAgentRequest,
orgId: string,
actor: string,
): Promise<RegisterAgentResponse> {
const integrationType = requireIntegrationType(body.integration_type);
const now = Date.now();
// Check if agent already exists
const existing = await db
.prepare('SELECT agent_id FROM agents WHERE agent_id = ?')
.bind(body.agent_id)
.first<{ agent_id: string }>();
if (existing) {
throw new AppError(409, 'VALIDATION_ERROR', { key: 'agent.alreadyExists', params: { id: body.agent_id } });
}
// Validate at least one key is provided
if (!body.keys || body.keys.length === 0) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'agent.atLeastOneKey' });
}
// Validate each public key is a valid 32-byte Ed25519 key
for (const k of body.keys) {
if (k.algorithm !== 'ed25519') {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'agent.unsupportedAlgorithm', params: { algorithm: k.algorithm } });
}
try {
const keyBytes = base64urlDecode(k.public_key);
if (keyBytes.length !== 32) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'agent.invalidPublicKeyLength', params: { kid: k.kid, expected: 32, actual: keyBytes.length } });
}
} catch (e) {
if (e instanceof AppError) throw e;
throw new AppError(400, 'VALIDATION_ERROR', { key: 'agent.invalidPublicKeyEncoding', params: { kid: k.kid } });
}
}
// Build the agent record
const agent: Agent = {
agent_id: body.agent_id,
org_id: orgId,
display_name: body.display_name ?? body.agent_id,
responsible_entity: body.responsible_entity ?? '',
integration_type: integrationType,
status: 'active',
created_at: now,
updated_at: now,
};
// Build key records
const keys: AgentKey[] = body.keys.map((k) => ({
kid: k.kid,
agent_id: body.agent_id,
public_key: k.public_key,
algorithm: k.algorithm,
status: 'active' as const,
created_at: now,
retired_at: null,
}));
// Use a batch to insert atomically
const statements: PreparedStatement[] = [];
// Insert agent
statements.push(
db
.prepare(
`INSERT INTO agents (agent_id, org_id, display_name, responsible_entity, integration_type, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
agent.agent_id,
agent.org_id,
agent.display_name,
agent.responsible_entity,
agent.integration_type,
agent.status,
agent.created_at,
agent.updated_at,
),
);
// Insert keys
for (const key of keys) {
statements.push(
db
.prepare(
`INSERT INTO agent_keys (kid, agent_id, public_key, algorithm, status, created_at, retired_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
key.kid,
key.agent_id,
key.public_key,
key.algorithm,
key.status,
key.created_at,
key.retired_at,
),
);
}
// Insert admin event
const eventId = generateUUIDv7();
const action: AdminAction = 'agent.register';
statements.push(
db
.prepare(
`INSERT INTO admin_events (event_id, org_id, actor, action, target_type, target_id, details, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
eventId,
orgId,
actor,
action,
'agent',
body.agent_id,
JSON.stringify({ display_name: agent.display_name, integration_type: agent.integration_type, key_count: keys.length }),
now,
),
);
await db.batch(statements);
return { agent, keys };
}
// ---------------------------------------------------------------------------
// List agents
// ---------------------------------------------------------------------------
export async function listAgents(
db: Database,
orgId: string,
): Promise<ListAgentsResponse> {
const result = await db
.prepare('SELECT * FROM agents WHERE org_id = ? ORDER BY created_at DESC')
.bind(orgId)
.all<Agent>();
return { agents: result.results ?? [] };
}
// ---------------------------------------------------------------------------
// Get agent
// ---------------------------------------------------------------------------
export async function getAgent(
db: Database,
agentId: string,
orgId: string,
): Promise<GetAgentResponse> {
const agentRow = await db
.prepare('SELECT * FROM agents WHERE agent_id = ? AND org_id = ?')
.bind(agentId, orgId)
.first<Agent>();
if (!agentRow) {
throw new AppError(404, 'NOT_FOUND', { key: 'agent.notFound', params: { id: agentId } });
}
const keysResult = await db
.prepare('SELECT * FROM agent_keys WHERE agent_id = ?')
.bind(agentId)
.all<AgentKey>();
const keys = keysResult.results ?? [];
return { agent: agentRow, keys };
}
// ---------------------------------------------------------------------------
// Freeze agent
// ---------------------------------------------------------------------------
export async function freezeAgent(
db: Database,
agentId: string,
reason: string,
orgId: string,
actor: string,
): Promise<{ agent: Agent }> {
const now = Date.now();
// Look up the agent
const agentRow = await db
.prepare('SELECT * FROM agents WHERE agent_id = ? AND org_id = ?')
.bind(agentId, orgId)
.first<Agent>();
if (!agentRow) {
throw new AppError(404, 'NOT_FOUND', { key: 'agent.notFound', params: { id: agentId } });
}
if (agentRow.status === 'frozen') {
throw new AppError(409, 'VALIDATION_ERROR', { key: 'agent.alreadyFrozen', params: { id: agentId } });
}
if (agentRow.status === 'revoked') {
throw new AppError(409, 'VALIDATION_ERROR', { key: 'agent.permanentlyRevoked', params: { id: agentId } });
}
const statements: PreparedStatement[] = [];
// Update agent status
statements.push(
db
.prepare('UPDATE agents SET status = ?, updated_at = ? WHERE agent_id = ?')
.bind('frozen', now, agentId),
);
// Log admin event
const eventId = generateUUIDv7();
const action: AdminAction = 'agent.freeze';
statements.push(
db
.prepare(
`INSERT INTO admin_events (event_id, org_id, actor, action, target_type, target_id, details, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(eventId, orgId, actor, action, 'agent', agentId, JSON.stringify({ reason }), now),
);
await db.batch(statements);
const updatedAgent: Agent = {
...agentRow,
status: 'frozen',
updated_at: now,
};
return { agent: updatedAgent };
}
// ---------------------------------------------------------------------------
// Unfreeze agent
// ---------------------------------------------------------------------------
export async function unfreezeAgent(
db: Database,
agentId: string,
reason: string,
orgId: string,
actor: string,
): Promise<{ agent: Agent }> {
const now = Date.now();
const agentRow = await db
.prepare('SELECT * FROM agents WHERE agent_id = ? AND org_id = ?')
.bind(agentId, orgId)
.first<Agent>();
if (!agentRow) {
throw new AppError(404, 'NOT_FOUND', { key: 'agent.notFound', params: { id: agentId } });
}
if (agentRow.status === 'active') {
throw new AppError(409, 'VALIDATION_ERROR', { key: 'agent.notFrozen', params: { id: agentId } });
}
if (agentRow.status === 'revoked') {
throw new AppError(409, 'VALIDATION_ERROR', { key: 'agent.permanentlyRevokedCannotUnfreeze', params: { id: agentId } });
}
const statements: PreparedStatement[] = [];
statements.push(
db
.prepare('UPDATE agents SET status = ?, updated_at = ? WHERE agent_id = ?')
.bind('active', now, agentId),
);
const eventId = generateUUIDv7();
const action: AdminAction = 'agent.unfreeze';
statements.push(
db
.prepare(
`INSERT INTO admin_events (event_id, org_id, actor, action, target_type, target_id, details, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(eventId, orgId, actor, action, 'agent', agentId, JSON.stringify({ reason }), now),
);
await db.batch(statements);
const updatedAgent: Agent = {
...agentRow,
status: 'active',
updated_at: now,
};
return { agent: updatedAgent };
}
// ---------------------------------------------------------------------------
// Delete agent
// ---------------------------------------------------------------------------
export async function deleteAgent(
db: Database,
agentId: string,
orgId: string,
actor: string,
): Promise<void> {
const now = Date.now();
// Verify the agent belongs to this org
const agentRow = await db
.prepare('SELECT * FROM agents WHERE agent_id = ? AND org_id = ?')
.bind(agentId, orgId)
.first<Agent>();
if (!agentRow) {
throw new AppError(404, 'NOT_FOUND', { key: 'agent.notFound', params: { id: agentId } });
}
const statements: PreparedStatement[] = [];
// Delete receipts for this agent's operations (receipts FK → operations)
statements.push(
db.prepare(
'DELETE FROM receipts WHERE operation_id IN (SELECT operation_id FROM operations WHERE agent_id = ?)',
).bind(agentId),
);
// Delete operations (operations FK → agents)
statements.push(
db.prepare('DELETE FROM operations WHERE agent_id = ?').bind(agentId),
);
// Delete associated keys (agent_keys FK → agents)
statements.push(
db.prepare('DELETE FROM agent_keys WHERE agent_id = ?').bind(agentId),
);
// Delete the agent
statements.push(
db.prepare('DELETE FROM agents WHERE agent_id = ? AND org_id = ?').bind(agentId, orgId),
);
// Log admin event
const eventId = generateUUIDv7();
const action: AdminAction = 'agent.delete';
statements.push(
db
.prepare(
`INSERT INTO admin_events (event_id, org_id, actor, action, target_type, target_id, details, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
eventId,
orgId,
actor,
action,
'agent',
agentId,
JSON.stringify({ display_name: agentRow.display_name }),
now,
),
);
await db.batch(statements);
}
// ---------------------------------------------------------------------------
// Revoke key
// ---------------------------------------------------------------------------
export async function revokeKey(
db: Database,
agentId: string,
kid: string,
reason: string,
orgId: string,
actor: string,
): Promise<{ key: AgentKey }> {
const now = Date.now();
// Verify the agent belongs to this org
const agentRow = await db
.prepare('SELECT * FROM agents WHERE agent_id = ? AND org_id = ?')
.bind(agentId, orgId)
.first<Agent>();
if (!agentRow) {
throw new AppError(404, 'NOT_FOUND', { key: 'agent.notFound', params: { id: agentId } });
}
// Look up the key
const keyRow = await db
.prepare('SELECT * FROM agent_keys WHERE kid = ? AND agent_id = ?')
.bind(kid, agentId)
.first<AgentKey>();
if (!keyRow) {
throw new AppError(404, 'NOT_FOUND', { key: 'agent.keyNotFound', params: { kid, id: agentId } });
}
if (keyRow.status === 'revoked') {
throw new AppError(409, 'VALIDATION_ERROR', { key: 'agent.keyAlreadyRevoked', params: { kid } });
}
const statements: PreparedStatement[] = [];
// Revoke the key
statements.push(
db
.prepare('UPDATE agent_keys SET status = ?, retired_at = ? WHERE kid = ?')
.bind('revoked', now, kid),
);
// Log admin event
const eventId = generateUUIDv7();
const action: AdminAction = 'key.revoke';
statements.push(
db
.prepare(
`INSERT INTO admin_events (event_id, org_id, actor, action, target_type, target_id, details, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
eventId,
orgId,
actor,
action,
'agent_key',
kid,
JSON.stringify({ agent_id: agentId, reason }),
now,
),
);
await db.batch(statements);
const updatedKey: AgentKey = {
...keyRow,
status: 'revoked',
retired_at: now,
};
return { key: updatedKey };
}