feat: Phase 1 MVP — complete AgentIdP implementation

Implements all P0 features per OpenSpec change phase-1-mvp-implementation:
- Agent Registry Service (CRUD) — full lifecycle management
- OAuth 2.0 Token Service (Client Credentials flow)
- Credential Management (generate, rotate, revoke)
- Immutable Audit Log Service

Tech: Node.js 18+, TypeScript 5.3+ strict, Express 4.18+, PostgreSQL 14+, Redis 7+
Standards: OpenAPI 3.0 specs, DRY/SOLID, zero `any` types
Quality: 18 unit test suites, 244 tests passing, 97%+ coverage
OpenAPI: 4 complete specs (14 endpoints total)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SentryAgent.ai Developer
2026-03-28 09:14:41 +00:00
parent 245f8df427
commit d3530285b9
78 changed files with 20590 additions and 1 deletions

View File

@@ -0,0 +1,213 @@
/**
* 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 {
IAgent,
ICreateAgentRequest,
IUpdateAgentRequest,
IAgentListFilters,
IPaginatedAgentsResponse,
} from '../types/index.js';
import {
AgentNotFoundError,
AgentAlreadyExistsError,
AgentAlreadyDecommissionedError,
FreeTierLimitError,
} from '../utils/errors.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.
*/
constructor(
private readonly agentRepository: AgentRepository,
private readonly credentialRepository: CredentialRepository,
private readonly auditService: AuditService,
) {}
/**
* 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<IAgent> {
// Enforce free-tier agent count limit
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);
// Synchronous audit insert
await this.auditService.logEvent(
agent.agentId,
'agent.created',
'success',
ipAddress,
userAgent,
{ agentType: agent.agentType, owner: agent.owner },
);
return agent;
}
/**
* Retrieves a single agent by its UUID.
*
* @param agentId - The agent UUID.
* @returns The agent record.
* @throws AgentNotFoundError if the agent does not exist.
*/
async getAgentById(agentId: string): Promise<IAgent> {
const agent = await this.agentRepository.findById(agentId);
if (!agent) {
throw new AgentNotFoundError(agentId);
}
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<IPaginatedAgentsResponse> {
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.
*
* @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.
* @returns The updated agent record.
* @throws AgentNotFoundError if the agent does not exist.
* @throws AgentAlreadyDecommissionedError if the agent is decommissioned.
* @throws ValidationError if immutable fields are included.
*/
async updateAgent(
agentId: string,
data: IUpdateAgentRequest,
ipAddress: string,
userAgent: string,
): Promise<IAgent> {
const agent = await this.agentRepository.findById(agentId);
if (!agent) {
throw new AgentNotFoundError(agentId);
}
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) },
);
return updated;
}
/**
* Permanently decommissions an agent (soft delete).
* Revokes all active credentials for the agent.
*
* @param agentId - The agent UUID to decommission.
* @param ipAddress - Client IP for audit logging.
* @param userAgent - Client User-Agent for audit logging.
* @throws AgentNotFoundError if the agent does not exist.
* @throws AgentAlreadyDecommissionedError if already decommissioned.
*/
async decommissionAgent(
agentId: string,
ipAddress: string,
userAgent: string,
): Promise<void> {
const agent = await this.agentRepository.findById(agentId);
if (!agent) {
throw new AgentNotFoundError(agentId);
}
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,
{},
);
}
}