/** * Agent Registry Service for SentryAgent.ai AgentIdP. * Business logic for agent lifecycle management. */ import { AgentRepository } from '../repositories/AgentRepository.js'; import { CredentialRepository } from '../repositories/CredentialRepository.js'; import { AuditService } from './AuditService.js'; import { DIDService } from './DIDService.js'; import { EventPublisher } from './EventPublisher.js'; import { AnalyticsService } from './AnalyticsService.js'; import { IAgent, ICreateAgentRequest, IUpdateAgentRequest, IAgentListFilters, IPaginatedAgentsResponse, } from '../types/index.js'; import { AgentNotFoundError, AgentAlreadyExistsError, AgentAlreadyDecommissionedError, FreeTierLimitError, AuthorizationError, } from '../utils/errors.js'; import { agentsRegisteredTotal } from '../metrics/registry.js'; import { TierService } from './TierService.js'; const FREE_TIER_MAX_AGENTS = 100; /** * Service for agent registration and lifecycle management. * Enforces free-tier limits and coordinates with AuditService. */ export class AgentService { /** * @param agentRepository - The agent data repository. * @param credentialRepository - The credential repository (for decommission cleanup). * @param auditService - The audit log service. * @param didService - Optional DIDService. When provided, a W3C DID is generated for each * newly registered agent. When null/undefined, DID generation is skipped * (backward-compatible default). * @param eventPublisher - Optional EventPublisher. When provided, lifecycle events are * published as webhooks and Kafka messages (fire-and-forget). * @param analyticsService - Optional AnalyticsService. When provided, agent_registered * and agent_deactivated events are recorded fire-and-forget. * @param tierService - Optional TierService. When provided, per-tier agent count limits * are enforced at agent creation time (Phase 6 WS4). */ constructor( private readonly agentRepository: AgentRepository, private readonly credentialRepository: CredentialRepository, private readonly auditService: AuditService, private readonly didService: DIDService | null = null, private readonly eventPublisher: EventPublisher | null = null, private readonly analyticsService: AnalyticsService | null = null, private readonly tierService: TierService | null = null, ) {} /** * Registers a new AI agent identity. * Enforces the free-tier 100-agent limit and unique email constraint. * * @param data - The agent registration data. * @param ipAddress - Client IP for audit logging. * @param userAgent - Client User-Agent for audit logging. * @returns The newly created agent record. * @throws FreeTierLimitError if the 100-agent limit is reached. * @throws AgentAlreadyExistsError if the email is already registered. */ async registerAgent( data: ICreateAgentRequest, ipAddress: string, userAgent: string, ): Promise { const orgId = data.organizationId ?? 'org_system'; // ── Tier-based agent count enforcement (Phase 6 WS4) ──────────────────── // When TierService is available and TIER_ENFORCEMENT is enabled, validate // the per-tier agent limit for the requesting organization. if (this.tierService !== null && process.env['TIER_ENFORCEMENT'] !== 'false') { const tier = await this.tierService.fetchTier(orgId); await this.tierService.enforceAgentLimit(orgId, tier); } // Enforce legacy free-tier agent count limit (global across all orgs) const currentCount = await this.agentRepository.countActive(); if (currentCount >= FREE_TIER_MAX_AGENTS) { throw new FreeTierLimitError( 'Free tier limit of 100 registered agents has been reached.', { limit: FREE_TIER_MAX_AGENTS, current: currentCount }, ); } // Check email uniqueness const existing = await this.agentRepository.findByEmail(data.email); if (existing !== null) { throw new AgentAlreadyExistsError(data.email); } const agent = await this.agentRepository.create(data); // Generate a W3C DID for the new agent when DIDService is available if (this.didService !== null) { await this.didService.generateDIDForAgent(agent.agentId, orgId); } // Synchronous audit insert await this.auditService.logEvent( agent.agentId, 'agent.created', 'success', ipAddress, userAgent, { agentType: agent.agentType, owner: agent.owner }, ); // Instrument: count successful agent registrations agentsRegisteredTotal.inc({ deployment_env: data.deploymentEnv }); // Analytics: record agent_registered event (fire-and-forget) if (this.analyticsService !== null) { void this.analyticsService.recordEvent( agent.organizationId ?? 'org_system', 'agent_registered', ).catch((err: unknown) => { // eslint-disable-next-line no-console console.error('[AgentService] analytics record (agent_registered) failed', err); }); } // Publish event (fire-and-forget) void this.eventPublisher?.publishEvent( agent.organizationId, 'agent.created', { agentId: agent.agentId, email: agent.email, name: agent.owner }, ); return agent; } /** * Retrieves a single agent by its UUID. * When `organizationId` is provided the agent's organization is verified — callers * from a different organization receive an AuthorizationError (403). * * @param agentId - The agent UUID. * @param organizationId - Optional. When present, the agent must belong to this org. * @returns The agent record. * @throws AgentNotFoundError if the agent does not exist. * @throws AuthorizationError if the agent belongs to a different organization. */ async getAgentById(agentId: string, organizationId?: string): Promise { const agent = await this.agentRepository.findById(agentId); if (!agent) { throw new AgentNotFoundError(agentId); } if (organizationId !== undefined && agent.organizationId !== organizationId) { throw new AuthorizationError(); } return agent; } /** * Returns a paginated, optionally filtered list of agents. * * @param filters - Pagination and filter criteria. * @returns Paginated agents response. */ async listAgents(filters: IAgentListFilters): Promise { const { agents, total } = await this.agentRepository.findAll(filters); return { data: agents, total, page: filters.page, limit: filters.limit, }; } /** * Partially updates an agent's metadata. * Immutable fields (agentId, email, createdAt) cannot be changed. * Decommissioned agents cannot be updated. * When `organizationId` is provided the agent's organization is verified — callers * from a different organization receive an AuthorizationError (403). * * @param agentId - The agent UUID to update. * @param data - The fields to update. * @param ipAddress - Client IP for audit logging. * @param userAgent - Client User-Agent for audit logging. * @param organizationId - Optional. When present, the agent must belong to this org. * @returns The updated agent record. * @throws AgentNotFoundError if the agent does not exist. * @throws AgentAlreadyDecommissionedError if the agent is decommissioned. * @throws AuthorizationError if the agent belongs to a different organization. * @throws ValidationError if immutable fields are included. */ async updateAgent( agentId: string, data: IUpdateAgentRequest, ipAddress: string, userAgent: string, organizationId?: string, ): Promise { const agent = await this.agentRepository.findById(agentId); if (!agent) { throw new AgentNotFoundError(agentId); } if (organizationId !== undefined && agent.organizationId !== organizationId) { throw new AuthorizationError(); } if (agent.status === 'decommissioned') { throw new AgentAlreadyDecommissionedError(agentId); } // Detect if status changes const oldStatus = agent.status; const updated = await this.agentRepository.update(agentId, data); if (!updated) { throw new AgentNotFoundError(agentId); } // Determine which audit action to log let auditAction: 'agent.updated' | 'agent.suspended' | 'agent.reactivated' | 'agent.decommissioned' = 'agent.updated'; if (data.status !== undefined && data.status !== oldStatus) { if (data.status === 'suspended') auditAction = 'agent.suspended'; else if (data.status === 'active') auditAction = 'agent.reactivated'; else if (data.status === 'decommissioned') auditAction = 'agent.decommissioned'; } await this.auditService.logEvent( agentId, auditAction, 'success', ipAddress, userAgent, { updatedFields: Object.keys(data) }, ); // Publish lifecycle event (fire-and-forget) if (auditAction === 'agent.updated') { void this.eventPublisher?.publishEvent( updated.organizationId, 'agent.updated', { agentId, changes: data }, ); } else if (auditAction === 'agent.suspended') { void this.eventPublisher?.publishEvent( updated.organizationId, 'agent.suspended', { agentId }, ); } else if (auditAction === 'agent.reactivated') { void this.eventPublisher?.publishEvent( updated.organizationId, 'agent.reactivated', { agentId }, ); } else if (auditAction === 'agent.decommissioned') { void this.eventPublisher?.publishEvent( updated.organizationId, 'agent.decommissioned', { agentId }, ); } return updated; } /** * Permanently decommissions an agent (soft delete). * Revokes all active credentials for the agent. * When `organizationId` is provided the agent's organization is verified — callers * from a different organization receive an AuthorizationError (403). * * @param agentId - The agent UUID to decommission. * @param ipAddress - Client IP for audit logging. * @param userAgent - Client User-Agent for audit logging. * @param organizationId - Optional. When present, the agent must belong to this org. * @throws AgentNotFoundError if the agent does not exist. * @throws AgentAlreadyDecommissionedError if already decommissioned. * @throws AuthorizationError if the agent belongs to a different organization. */ async decommissionAgent( agentId: string, ipAddress: string, userAgent: string, organizationId?: string, ): Promise { const agent = await this.agentRepository.findById(agentId); if (!agent) { throw new AgentNotFoundError(agentId); } if (organizationId !== undefined && agent.organizationId !== organizationId) { throw new AuthorizationError(); } if (agent.status === 'decommissioned') { throw new AgentAlreadyDecommissionedError(agentId); } // Revoke all active credentials await this.credentialRepository.revokeAllForAgent(agentId); await this.agentRepository.decommission(agentId); await this.auditService.logEvent( agentId, 'agent.decommissioned', 'success', ipAddress, userAgent, {}, ); // Analytics: record agent_deactivated event (fire-and-forget) if (this.analyticsService !== null) { void this.analyticsService.recordEvent( agent.organizationId ?? 'org_system', 'agent_deactivated', ).catch((err: unknown) => { // eslint-disable-next-line no-console console.error('[AgentService] analytics record (agent_deactivated) failed', err); }); } // Publish event (fire-and-forget) void this.eventPublisher?.publishEvent( agent.organizationId, 'agent.decommissioned', { agentId }, ); } }