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

138
src/app.ts Normal file
View File

@@ -0,0 +1,138 @@
/**
* Express application factory for SentryAgent.ai AgentIdP.
* Creates and configures the Express app with all middleware and routes.
* Exported as a factory function — does NOT call listen (testable).
*/
import express, { Application } from 'express';
import helmet from 'helmet';
import cors from 'cors';
import morgan from 'morgan';
import { getPool } from './db/pool.js';
import { getRedisClient } from './cache/redis.js';
import { AgentRepository } from './repositories/AgentRepository.js';
import { CredentialRepository } from './repositories/CredentialRepository.js';
import { TokenRepository } from './repositories/TokenRepository.js';
import { AuditRepository } from './repositories/AuditRepository.js';
import { AuditService } from './services/AuditService.js';
import { AgentService } from './services/AgentService.js';
import { CredentialService } from './services/CredentialService.js';
import { OAuth2Service } from './services/OAuth2Service.js';
import { AgentController } from './controllers/AgentController.js';
import { TokenController } from './controllers/TokenController.js';
import { CredentialController } from './controllers/CredentialController.js';
import { AuditController } from './controllers/AuditController.js';
import { createAgentsRouter } from './routes/agents.js';
import { createTokenRouter } from './routes/token.js';
import { createCredentialsRouter } from './routes/credentials.js';
import { createAuditRouter } from './routes/audit.js';
import { errorHandler } from './middleware/errorHandler.js';
import { RedisClientType } from 'redis';
/**
* Creates and returns a configured Express application.
* All infrastructure dependencies (DB pool, Redis) are initialised here.
*
* @returns Promise resolving to the configured Express Application.
* @throws Error if required environment variables are missing.
*/
export async function createApp(): Promise<Application> {
const app = express();
// ────────────────────────────────────────────────────────────────
// Security headers
// ────────────────────────────────────────────────────────────────
app.use(helmet());
// ────────────────────────────────────────────────────────────────
// CORS
// ────────────────────────────────────────────────────────────────
const corsOrigin = process.env['CORS_ORIGIN'] ?? '*';
app.use(cors({ origin: corsOrigin }));
// ────────────────────────────────────────────────────────────────
// Request logging
// ────────────────────────────────────────────────────────────────
if (process.env['NODE_ENV'] !== 'test') {
app.use(morgan('combined'));
}
// ────────────────────────────────────────────────────────────────
// Body parsers
// JSON body parser for most routes
// urlencoded parser for token endpoint (application/x-www-form-urlencoded)
// ────────────────────────────────────────────────────────────────
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
// ────────────────────────────────────────────────────────────────
// Infrastructure singletons
// ────────────────────────────────────────────────────────────────
const pool = getPool();
const redis = await getRedisClient();
// ────────────────────────────────────────────────────────────────
// Repository layer
// ────────────────────────────────────────────────────────────────
const agentRepo = new AgentRepository(pool);
const credentialRepo = new CredentialRepository(pool);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
const tokenRepo = new TokenRepository(pool, redis as RedisClientType);
const auditRepo = new AuditRepository(pool);
// ────────────────────────────────────────────────────────────────
// Service layer
// ────────────────────────────────────────────────────────────────
const auditService = new AuditService(auditRepo);
const agentService = new AgentService(agentRepo, credentialRepo, auditService);
const credentialService = new CredentialService(credentialRepo, agentRepo, auditService);
const privateKey = process.env['JWT_PRIVATE_KEY'];
const publicKey = process.env['JWT_PUBLIC_KEY'];
if (!privateKey || !publicKey) {
throw new Error('JWT_PRIVATE_KEY and JWT_PUBLIC_KEY environment variables are required');
}
const oauth2Service = new OAuth2Service(
tokenRepo,
credentialRepo,
agentRepo,
auditService,
privateKey,
publicKey,
);
// ────────────────────────────────────────────────────────────────
// Controller layer
// ────────────────────────────────────────────────────────────────
const agentController = new AgentController(agentService);
const tokenController = new TokenController(oauth2Service);
const credentialController = new CredentialController(credentialService);
const auditController = new AuditController(auditService);
// ────────────────────────────────────────────────────────────────
// Routes
// ────────────────────────────────────────────────────────────────
const API_BASE = '/api/v1';
app.use(`${API_BASE}/agents`, createAgentsRouter(agentController));
app.use(
`${API_BASE}/agents/:agentId/credentials`,
createCredentialsRouter(credentialController),
);
app.use(`${API_BASE}/token`, createTokenRouter(tokenController));
app.use(`${API_BASE}/audit`, createAuditRouter(auditController));
// ────────────────────────────────────────────────────────────────
// Global error handler (must be last)
// ────────────────────────────────────────────────────────────────
app.use(errorHandler);
return app;
}

47
src/cache/redis.ts vendored Normal file
View File

@@ -0,0 +1,47 @@
/**
* Redis client singleton for SentryAgent.ai AgentIdP.
* Used for token revocation tracking, rate limiting, and monthly token counts.
*/
import { createClient, RedisClientType } from 'redis';
let redisClient: RedisClientType | null = null;
/**
* Returns the singleton Redis client instance.
* Initialises and connects the client on first call using REDIS_URL from env.
*
* @returns Promise resolving to the connected Redis client.
* @throws Error if REDIS_URL is not set or connection fails.
*/
export async function getRedisClient(): Promise<RedisClientType> {
if (!redisClient) {
const url = process.env['REDIS_URL'];
if (!url) {
throw new Error('REDIS_URL environment variable is required');
}
redisClient = createClient({ url }) as RedisClientType;
redisClient.on('error', (err: Error) => {
// eslint-disable-next-line no-console
console.error('Redis client error', err);
});
await redisClient.connect();
}
return redisClient;
}
/**
* Disconnects the Redis client and resets the singleton.
* Used for graceful shutdown and tests.
*
* @returns Promise that resolves when the client is disconnected.
*/
export async function closeRedisClient(): Promise<void> {
if (redisClient) {
await redisClient.quit();
redisClient = null;
}
}

View File

@@ -0,0 +1,186 @@
/**
* Agent Controller for SentryAgent.ai AgentIdP.
* HTTP handlers for all 5 agent endpoints. No business logic — delegates to AgentService.
*/
import { Request, Response, NextFunction } from 'express';
import { AgentService } from '../services/AgentService.js';
import {
createAgentSchema,
updateAgentSchema,
listAgentsQuerySchema,
} from '../utils/validators.js';
import { ValidationError, AuthorizationError } from '../utils/errors.js';
import {
ICreateAgentRequest,
IUpdateAgentRequest,
IAgentListFilters,
} from '../types/index.js';
/**
* Controller for the Agent Registry endpoints.
* Receives AgentService via constructor injection.
*/
export class AgentController {
/**
* @param agentService - The agent registry service.
*/
constructor(private readonly agentService: AgentService) {}
/**
* Handles POST /agents — registers a new agent.
*
* @param req - Express request with CreateAgentRequest body.
* @param res - Express response.
* @param next - Express next function.
*/
registerAgent = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.user) {
throw new AuthorizationError();
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { error, value } = createAgentSchema.validate(req.body, { abortEarly: false });
if (error) {
throw new ValidationError('Request validation failed.', {
details: error.details.map((d) => ({ field: d.path.join('.'), reason: d.message })),
});
}
const data = value as ICreateAgentRequest;
const ipAddress = req.ip ?? '0.0.0.0';
const userAgent = req.headers['user-agent'] ?? 'unknown';
const agent = await this.agentService.registerAgent(data, ipAddress, userAgent);
res.status(201).json(agent);
} catch (err) {
next(err);
}
};
/**
* Handles GET /agents — returns a paginated list of agents.
*
* @param req - Express request with optional query filters.
* @param res - Express response.
* @param next - Express next function.
*/
listAgents = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.user) {
throw new AuthorizationError();
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { error, value } = listAgentsQuerySchema.validate(req.query, { abortEarly: false });
if (error) {
throw new ValidationError('Invalid query parameter value.', {
details: error.details.map((d) => ({ field: d.path.join('.'), reason: d.message })),
});
}
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
const filters: IAgentListFilters = {
page: value.page as number,
limit: value.limit as number,
owner: value.owner as string | undefined,
agentType: value.agentType as IAgentListFilters['agentType'],
status: value.status as IAgentListFilters['status'],
};
/* eslint-enable @typescript-eslint/no-unsafe-member-access */
const result = await this.agentService.listAgents(filters);
res.status(200).json(result);
} catch (err) {
next(err);
}
};
/**
* Handles GET /agents/:agentId — retrieves a single agent.
*
* @param req - Express request with agentId path param.
* @param res - Express response.
* @param next - Express next function.
*/
getAgentById = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.user) {
throw new AuthorizationError();
}
const { agentId } = req.params;
const agent = await this.agentService.getAgentById(agentId);
res.status(200).json(agent);
} catch (err) {
next(err);
}
};
/**
* Handles PATCH /agents/:agentId — partially updates an agent.
*
* @param req - Express request with agentId path param and UpdateAgentRequest body.
* @param res - Express response.
* @param next - Express next function.
*/
updateAgent = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.user) {
throw new AuthorizationError();
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { error, value } = updateAgentSchema.validate(req.body, { abortEarly: false });
if (error) {
const immutableFields = ['agentId', 'email', 'createdAt'];
const firstImmutable = error.details.find((d) =>
immutableFields.includes(d.path[0] as string),
);
if (firstImmutable) {
throw new ValidationError(`The field '${String(firstImmutable.path[0])}' cannot be modified after registration.`, {
field: firstImmutable.path[0],
});
}
throw new ValidationError('Request validation failed.', {
details: error.details.map((d) => ({ field: d.path.join('.'), reason: d.message })),
});
}
const { agentId } = req.params;
const data = value as IUpdateAgentRequest;
const ipAddress = req.ip ?? '0.0.0.0';
const userAgent = req.headers['user-agent'] ?? 'unknown';
const updated = await this.agentService.updateAgent(agentId, data, ipAddress, userAgent);
res.status(200).json(updated);
} catch (err) {
next(err);
}
};
/**
* Handles DELETE /agents/:agentId — decommissions an agent.
*
* @param req - Express request with agentId path param.
* @param res - Express response (204 No Content).
* @param next - Express next function.
*/
decommissionAgent = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.user) {
throw new AuthorizationError();
}
const { agentId } = req.params;
const ipAddress = req.ip ?? '0.0.0.0';
const userAgent = req.headers['user-agent'] ?? 'unknown';
await this.agentService.decommissionAgent(agentId, ipAddress, userAgent);
res.status(204).send();
} catch (err) {
next(err);
}
};
}

View File

@@ -0,0 +1,100 @@
/**
* Audit Controller for SentryAgent.ai AgentIdP.
* HTTP handlers for GET /audit and GET /audit/:eventId.
*/
import { Request, Response, NextFunction } from 'express';
import { AuditService } from '../services/AuditService.js';
import { auditQuerySchema } from '../utils/validators.js';
import {
ValidationError,
AuthenticationError,
InsufficientScopeError,
} from '../utils/errors.js';
import { IAuditListFilters } from '../types/index.js';
/**
* Controller for the Audit Log endpoints.
* Enforces `audit:read` scope on all handlers.
*/
export class AuditController {
/**
* @param auditService - The audit log service.
*/
constructor(private readonly auditService: AuditService) {}
/**
* Handles GET /audit — queries the audit log with optional filters.
* Requires Bearer token with `audit:read` scope.
*
* @param req - Express request with optional query filters.
* @param res - Express response.
* @param next - Express next function.
*/
queryAuditLog = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.user) {
throw new AuthenticationError();
}
// Enforce audit:read scope
const scopes = req.user.scope.split(' ');
if (!scopes.includes('audit:read')) {
throw new InsufficientScopeError('audit:read');
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { error, value } = auditQuerySchema.validate(req.query, { abortEarly: false });
if (error) {
throw new ValidationError('Invalid query parameter value.', {
details: error.details.map((d) => ({ field: d.path.join('.'), reason: d.message })),
});
}
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
const filters: IAuditListFilters = {
page: value.page as number,
limit: value.limit as number,
agentId: value.agentId as string | undefined,
action: value.action as IAuditListFilters['action'],
outcome: value.outcome as IAuditListFilters['outcome'],
fromDate: value.fromDate as string | undefined,
toDate: value.toDate as string | undefined,
};
/* eslint-enable @typescript-eslint/no-unsafe-member-access */
const result = await this.auditService.queryEvents(filters);
res.status(200).json(result);
} catch (err) {
next(err);
}
};
/**
* Handles GET /audit/:eventId — retrieves a single audit event.
* Requires Bearer token with `audit:read` scope.
*
* @param req - Express request with eventId path param.
* @param res - Express response.
* @param next - Express next function.
*/
getAuditEventById = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.user) {
throw new AuthenticationError();
}
// Enforce audit:read scope
const scopes = req.user.scope.split(' ');
if (!scopes.includes('audit:read')) {
throw new InsufficientScopeError('audit:read');
}
const { eventId } = req.params;
const event = await this.auditService.getEventById(eventId);
res.status(200).json(event);
} catch (err) {
next(err);
}
};
}

View File

@@ -0,0 +1,196 @@
/**
* Credential Controller for SentryAgent.ai AgentIdP.
* HTTP handlers for all 4 credential management endpoints.
*/
import { Request, Response, NextFunction } from 'express';
import { CredentialService } from '../services/CredentialService.js';
import {
generateCredentialSchema,
listCredentialsQuerySchema,
} from '../utils/validators.js';
import { ValidationError, AuthorizationError, AuthenticationError } from '../utils/errors.js';
import {
IGenerateCredentialRequest,
ICredentialListFilters,
} from '../types/index.js';
/**
* Controller for the Credential Management endpoints.
*/
export class CredentialController {
/**
* @param credentialService - The credential management service.
*/
constructor(private readonly credentialService: CredentialService) {}
/**
* Handles POST /agents/:agentId/credentials — generates new credentials.
* Returns 201 with CredentialWithSecret (secret shown once only).
*
* @param req - Express request.
* @param res - Express response.
* @param next - Express next function.
*/
generateCredential = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.user) {
throw new AuthenticationError();
}
const { agentId } = req.params;
// An agent may only manage its own credentials (Phase 1 — no admin scope)
if (req.user.sub !== agentId) {
throw new AuthorizationError('You do not have permission to manage credentials for this agent.');
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { error, value } = generateCredentialSchema.validate(req.body ?? {}, {
abortEarly: false,
});
if (error) {
throw new ValidationError('Request validation failed.', {
details: error.details.map((d) => ({ field: d.path.join('.'), reason: d.message })),
});
}
const data = value as IGenerateCredentialRequest;
const ipAddress = req.ip ?? '0.0.0.0';
const userAgent = req.headers['user-agent'] ?? 'unknown';
const result = await this.credentialService.generateCredential(
agentId,
data,
ipAddress,
userAgent,
);
res.status(201).json(result);
} catch (err) {
next(err);
}
};
/**
* Handles GET /agents/:agentId/credentials — lists credentials for an agent.
* clientSecret is never returned in list responses.
*
* @param req - Express request.
* @param res - Express response.
* @param next - Express next function.
*/
listCredentials = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.user) {
throw new AuthenticationError();
}
const { agentId } = req.params;
if (req.user.sub !== agentId) {
throw new AuthorizationError('You do not have permission to manage credentials for this agent.');
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { error, value } = listCredentialsQuerySchema.validate(req.query, {
abortEarly: false,
});
if (error) {
throw new ValidationError('Invalid query parameter value.', {
details: error.details.map((d) => ({ field: d.path.join('.'), reason: d.message })),
});
}
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
const filters: ICredentialListFilters = {
page: value.page as number,
limit: value.limit as number,
status: value.status as ICredentialListFilters['status'],
};
/* eslint-enable @typescript-eslint/no-unsafe-member-access */
const result = await this.credentialService.listCredentials(agentId, filters);
res.status(200).json(result);
} catch (err) {
next(err);
}
};
/**
* Handles POST /agents/:agentId/credentials/:credentialId/rotate — rotates a credential.
* Returns 200 with CredentialWithSecret (new secret shown once only).
*
* @param req - Express request.
* @param res - Express response.
* @param next - Express next function.
*/
rotateCredential = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.user) {
throw new AuthenticationError();
}
const { agentId, credentialId } = req.params;
if (req.user.sub !== agentId) {
throw new AuthorizationError('You do not have permission to manage credentials for this agent.');
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { error, value } = generateCredentialSchema.validate(req.body ?? {}, {
abortEarly: false,
});
if (error) {
throw new ValidationError('Request validation failed.', {
details: error.details.map((d) => ({ field: d.path.join('.'), reason: d.message })),
});
}
const data = value as IGenerateCredentialRequest;
const ipAddress = req.ip ?? '0.0.0.0';
const userAgent = req.headers['user-agent'] ?? 'unknown';
const result = await this.credentialService.rotateCredential(
agentId,
credentialId,
data,
ipAddress,
userAgent,
);
res.status(200).json(result);
} catch (err) {
next(err);
}
};
/**
* Handles DELETE /agents/:agentId/credentials/:credentialId — revokes a credential.
* Returns 204 No Content.
*
* @param req - Express request.
* @param res - Express response.
* @param next - Express next function.
*/
revokeCredential = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.user) {
throw new AuthenticationError();
}
const { agentId, credentialId } = req.params;
if (req.user.sub !== agentId) {
throw new AuthorizationError('You do not have permission to manage credentials for this agent.');
}
const ipAddress = req.ip ?? '0.0.0.0';
const userAgent = req.headers['user-agent'] ?? 'unknown';
await this.credentialService.revokeCredential(agentId, credentialId, ipAddress, userAgent);
res.status(204).send();
} catch (err) {
next(err);
}
};
}

View File

@@ -0,0 +1,243 @@
/**
* Token Controller for SentryAgent.ai AgentIdP.
* HTTP handlers for POST /token, POST /token/introspect, POST /token/revoke.
* Parses application/x-www-form-urlencoded bodies.
* Returns OAuth2ErrorResponse for /token errors, ErrorResponse for introspect/revoke.
*/
import { Request, Response, NextFunction } from 'express';
import { OAuth2Service } from '../services/OAuth2Service.js';
import { tokenRequestSchema, introspectRequestSchema, revokeRequestSchema } from '../utils/validators.js';
import {
AuthenticationError,
AuthorizationError,
FreeTierLimitError,
} from '../utils/errors.js';
import { ITokenRequest, IIntrospectRequest, IRevokeRequest, IOAuth2ErrorResponse } from '../types/index.js';
/**
* Maps an error from the token issuance flow to an OAuth2ErrorResponse.
*
* @param err - The error to map.
* @returns Object with error, error_description, and httpStatus.
*/
function mapToOAuth2Error(err: unknown): {
body: IOAuth2ErrorResponse;
httpStatus: number;
} {
if (err instanceof FreeTierLimitError) {
return {
body: {
error: 'unauthorized_client',
error_description: err.message,
},
httpStatus: 403,
};
}
if (err instanceof AuthorizationError) {
return {
body: {
error: 'unauthorized_client',
error_description: err.message,
},
httpStatus: 403,
};
}
if (err instanceof AuthenticationError) {
return {
body: {
error: 'invalid_client',
error_description: 'Client authentication failed. Invalid client_id or client_secret.',
},
httpStatus: 401,
};
}
// Default: internal server error
return {
body: {
error: 'invalid_request',
error_description: 'An unexpected error occurred.',
},
httpStatus: 500,
};
}
/**
* Controller for the OAuth 2.0 Token endpoints.
*/
export class TokenController {
/**
* @param oauth2Service - The OAuth2 token service.
*/
constructor(private readonly oauth2Service: OAuth2Service) {}
/**
* Handles POST /token — issues an access token via Client Credentials grant.
* Accepts application/x-www-form-urlencoded body.
* Returns OAuth2ErrorResponse on failure.
*
* @param req - Express request with form-encoded body.
* @param res - Express response.
* @param next - Express next function.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
issueToken = async (req: Request, res: Response, _next: NextFunction): Promise<void> => {
try {
const body = req.body as ITokenRequest;
// Validate grant_type first
if (!body.grant_type) {
res.status(400).json({
error: 'invalid_request',
error_description: "The 'grant_type' parameter is required.",
} as IOAuth2ErrorResponse);
return;
}
if (body.grant_type !== 'client_credentials') {
res.status(400).json({
error: 'unsupported_grant_type',
error_description: "Only 'client_credentials' grant type is supported.",
} as IOAuth2ErrorResponse);
return;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { error, value } = tokenRequestSchema.validate(body, { abortEarly: false });
if (error) {
res.status(400).json({
error: 'invalid_request',
error_description: error.details.map((d) => d.message).join('; '),
} as IOAuth2ErrorResponse);
return;
}
const tokenBody = value as ITokenRequest;
// Support HTTP Basic auth fallback
let clientId = tokenBody.client_id;
let clientSecret = tokenBody.client_secret;
const authHeader = req.headers['authorization'];
if (authHeader?.startsWith('Basic ')) {
const base64 = authHeader.slice(6);
const decoded = Buffer.from(base64, 'base64').toString('utf-8');
const colonIndex = decoded.indexOf(':');
if (colonIndex !== -1) {
clientId = decoded.slice(0, colonIndex);
clientSecret = decoded.slice(colonIndex + 1);
}
}
if (!clientId || !clientSecret) {
res.status(400).json({
error: 'invalid_request',
error_description: "The 'client_id' and 'client_secret' parameters are required.",
} as IOAuth2ErrorResponse);
return;
}
// Validate requested scope
const requestedScope = tokenBody.scope ?? 'agents:read';
const validScopes = ['agents:read', 'agents:write', 'tokens:read', 'audit:read'];
const scopeList = requestedScope.split(' ');
const invalidScope = scopeList.find((s) => !validScopes.includes(s));
if (invalidScope) {
res.status(400).json({
error: 'invalid_scope',
error_description: `Requested scope '${invalidScope}' is not available.`,
} as IOAuth2ErrorResponse);
return;
}
const ipAddress = req.ip ?? '0.0.0.0';
const userAgent = req.headers['user-agent'] ?? 'unknown';
const tokenResponse = await this.oauth2Service.issueToken(
clientId,
clientSecret,
requestedScope,
ipAddress,
userAgent,
);
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Pragma', 'no-cache');
res.status(200).json(tokenResponse);
} catch (err) {
// Token endpoint uses OAuth2ErrorResponse format
const mapped = mapToOAuth2Error(err);
res.status(mapped.httpStatus).json(mapped.body);
}
};
/**
* Handles POST /token/introspect — introspects a token per RFC 7662.
* Requires Bearer auth with tokens:read scope.
*
* @param req - Express request.
* @param res - Express response.
* @param next - Express next function.
*/
introspectToken = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.user) {
throw new AuthenticationError();
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { error, value } = introspectRequestSchema.validate(req.body, { abortEarly: false });
if (error) {
const messages = error.details.map((d) => d.message).join('; ');
throw new Error(messages);
}
const body = value as IIntrospectRequest;
const ipAddress = req.ip ?? '0.0.0.0';
const userAgent = req.headers['user-agent'] ?? 'unknown';
const result = await this.oauth2Service.introspectToken(
body.token,
req.user,
ipAddress,
userAgent,
);
res.status(200).json(result);
} catch (err) {
next(err);
}
};
/**
* Handles POST /token/revoke — revokes a token per RFC 7009.
* Requires Bearer auth.
*
* @param req - Express request.
* @param res - Express response.
* @param next - Express next function.
*/
revokeToken = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.user) {
throw new AuthenticationError();
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { error, value } = revokeRequestSchema.validate(req.body, { abortEarly: false });
if (error) {
const messages = error.details.map((d) => d.message).join('; ');
throw new Error(messages);
}
const body = value as IRevokeRequest;
const ipAddress = req.ip ?? '0.0.0.0';
const userAgent = req.headers['user-agent'] ?? 'unknown';
await this.oauth2Service.revokeToken(body.token, req.user, ipAddress, userAgent);
res.status(200).json({});
} catch (err) {
next(err);
}
};
}

View File

@@ -0,0 +1,28 @@
-- Migration: 001_create_agents
-- Creates the agents table for the Agent Registry service.
CREATE TABLE IF NOT EXISTS agents (
agent_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
agent_type VARCHAR(32) NOT NULL,
version VARCHAR(64) NOT NULL,
capabilities TEXT[] NOT NULL DEFAULT '{}',
owner VARCHAR(128) NOT NULL,
deployment_env VARCHAR(16) NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT agents_agent_type_check
CHECK (agent_type IN ('screener','classifier','orchestrator','extractor','summarizer','router','monitor','custom')),
CONSTRAINT agents_deployment_env_check
CHECK (deployment_env IN ('development','staging','production')),
CONSTRAINT agents_status_check
CHECK (status IN ('active','suspended','decommissioned'))
);
CREATE INDEX IF NOT EXISTS idx_agents_email ON agents (email);
CREATE INDEX IF NOT EXISTS idx_agents_status ON agents (status);
CREATE INDEX IF NOT EXISTS idx_agents_owner ON agents (owner);
CREATE INDEX IF NOT EXISTS idx_agents_agent_type ON agents (agent_type);
CREATE INDEX IF NOT EXISTS idx_agents_created_at ON agents (created_at DESC);

View File

@@ -0,0 +1,19 @@
-- Migration: 002_create_credentials
-- Creates the credentials table for the Credential Management service.
CREATE TABLE IF NOT EXISTS credentials (
credential_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_id UUID NOT NULL REFERENCES agents(agent_id) ON DELETE CASCADE,
secret_hash VARCHAR(255) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
CONSTRAINT credentials_status_check
CHECK (status IN ('active','revoked'))
);
CREATE INDEX IF NOT EXISTS idx_credentials_client_id ON credentials (client_id);
CREATE INDEX IF NOT EXISTS idx_credentials_status ON credentials (status);
CREATE INDEX IF NOT EXISTS idx_credentials_created_at ON credentials (created_at DESC);

View File

@@ -0,0 +1,28 @@
-- Migration: 003_create_audit_events
-- Creates the audit_events table for the Audit Log service.
-- Append-only by design — no UPDATE or DELETE operations are permitted.
CREATE TABLE IF NOT EXISTS audit_events (
event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agent_id UUID NOT NULL,
action VARCHAR(32) NOT NULL,
outcome VARCHAR(16) NOT NULL,
ip_address VARCHAR(64) NOT NULL,
user_agent TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}',
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT audit_events_action_check
CHECK (action IN (
'agent.created','agent.updated','agent.decommissioned','agent.suspended',
'agent.reactivated','token.issued','token.revoked','token.introspected',
'credential.generated','credential.rotated','credential.revoked','auth.failed'
)),
CONSTRAINT audit_events_outcome_check
CHECK (outcome IN ('success','failure'))
);
CREATE INDEX IF NOT EXISTS idx_audit_events_agent_id ON audit_events (agent_id);
CREATE INDEX IF NOT EXISTS idx_audit_events_action ON audit_events (action);
CREATE INDEX IF NOT EXISTS idx_audit_events_outcome ON audit_events (outcome);
CREATE INDEX IF NOT EXISTS idx_audit_events_timestamp ON audit_events (timestamp DESC);

View File

@@ -0,0 +1,11 @@
-- Migration: 004_create_tokens
-- Creates the token_revocations table for soft revocation tracking.
-- Supplementary to Redis — provides durability across Redis restarts.
CREATE TABLE IF NOT EXISTS token_revocations (
jti UUID PRIMARY KEY,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_token_revocations_expires_at ON token_revocations (expires_at);

44
src/db/pool.ts Normal file
View File

@@ -0,0 +1,44 @@
/**
* PostgreSQL connection pool singleton.
* All database access flows through this pool.
*/
import { Pool } from 'pg';
let pool: Pool | null = null;
/**
* Returns the singleton pg Pool instance.
* Initialises the pool on first call using DATABASE_URL from the environment.
*
* @returns The PostgreSQL connection pool.
* @throws Error if DATABASE_URL is not set.
*/
export function getPool(): Pool {
if (!pool) {
const connectionString = process.env['DATABASE_URL'];
if (!connectionString) {
throw new Error('DATABASE_URL environment variable is required');
}
pool = new Pool({ connectionString });
pool.on('error', (err: Error) => {
// eslint-disable-next-line no-console
console.error('Unexpected pg pool error', err);
});
}
return pool;
}
/**
* Closes the pool and resets the singleton.
* Used for graceful shutdown and tests.
*
* @returns Promise that resolves when the pool is closed.
*/
export async function closePool(): Promise<void> {
if (pool) {
await pool.end();
pool = null;
}
}

77
src/middleware/auth.ts Normal file
View File

@@ -0,0 +1,77 @@
/**
* Authentication middleware for SentryAgent.ai AgentIdP.
* Extracts and verifies Bearer tokens from the Authorization header.
* Checks Redis for token revocation before attaching the payload to req.user.
*/
import { Request, Response, NextFunction } from 'express';
import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
import { verifyToken } from '../utils/jwt.js';
import { getRedisClient } from '../cache/redis.js';
import { AuthenticationError } from '../utils/errors.js';
import { ITokenPayload } from '../types/index.js';
/**
* Express middleware that validates a Bearer JWT token on every protected request.
*
* Behaviour:
* 1. Extracts the Bearer token from the Authorization header.
* 2. Verifies the RS256 signature and expiry using the public key.
* 3. Checks Redis whether the JTI has been explicitly revoked.
* 4. Attaches the decoded payload to `req.user`.
* 5. Throws `AuthenticationError` on any failure.
*
* @param req - Express request.
* @param _res - Express response (unused).
* @param next - Express next function.
*/
export async function authMiddleware(
req: Request,
_res: Response,
next: NextFunction,
): Promise<void> {
try {
const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new AuthenticationError('A valid Bearer token is required to access this resource.');
}
const token = authHeader.slice(7).trim();
if (!token) {
throw new AuthenticationError('A valid Bearer token is required to access this resource.');
}
const publicKey = process.env['JWT_PUBLIC_KEY'];
if (!publicKey) {
throw new Error('JWT_PUBLIC_KEY environment variable is required');
}
let payload: ITokenPayload;
try {
payload = verifyToken(token, publicKey);
} catch (err) {
if (err instanceof TokenExpiredError) {
throw new AuthenticationError('Token has expired.');
}
if (err instanceof JsonWebTokenError) {
throw new AuthenticationError('Token signature is invalid.');
}
throw new AuthenticationError();
}
// Check Redis revocation list
const redis = await getRedisClient();
const revocationKey = `revoked:${payload.jti}`;
const isRevoked = await redis.get(revocationKey);
if (isRevoked !== null) {
throw new AuthenticationError('Token has been revoked.');
}
req.user = payload;
next();
} catch (err) {
next(err);
}
}

View File

@@ -0,0 +1,48 @@
/**
* Global Express error-handling middleware for SentryAgent.ai AgentIdP.
* Maps SentryAgentError subclasses to their HTTP status codes and error shapes.
* Unknown errors are mapped to 500 Internal Server Error.
*/
import { Request, Response, NextFunction } from 'express';
import { SentryAgentError } from '../utils/errors.js';
import { IErrorResponse } from '../types/index.js';
/**
* Express error-handling middleware.
* Must have exactly 4 parameters to be recognised as an error handler.
*
* @param err - The error thrown by a route handler or upstream middleware.
* @param _req - Express request (unused).
* @param res - Express response.
* @param _next - Express next function (unused but required by Express signature).
*/
export function errorHandler(
err: unknown,
_req: Request,
res: Response,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_next: NextFunction,
): void {
if (err instanceof SentryAgentError) {
const body: IErrorResponse = {
code: err.code,
message: err.message,
};
if (err.details !== undefined) {
body.details = err.details;
}
res.status(err.httpStatus).json(body);
return;
}
// Unexpected error — log and return generic 500
// eslint-disable-next-line no-console
console.error('Unhandled error:', err);
const body: IErrorResponse = {
code: 'INTERNAL_SERVER_ERROR',
message: 'An unexpected error occurred. Please try again later.',
};
res.status(500).json(body);
}

View File

@@ -0,0 +1,69 @@
/**
* Redis-backed rate limiting middleware for SentryAgent.ai AgentIdP.
* Enforces 100 requests per minute per client_id using a sliding window counter.
*/
import { Request, Response, NextFunction } from 'express';
import { getRedisClient } from '../cache/redis.js';
import { RateLimitError } from '../utils/errors.js';
const RATE_LIMIT_MAX = 100;
const WINDOW_MS = 60000; // 60 seconds
/**
* Computes the current rate-limit window key and next reset timestamp.
*
* @returns Object with `windowKey` (minute index) and `resetAt` (Unix seconds).
*/
function getWindowInfo(): { windowKey: number; resetAt: number } {
const windowKey = Math.floor(Date.now() / WINDOW_MS);
const resetAt = (windowKey + 1) * (WINDOW_MS / 1000);
return { windowKey, resetAt };
}
/**
* Express middleware that applies Redis-based rate limiting per client_id.
*
* The client_id is sourced from `req.user.client_id` (set by authMiddleware).
* For unauthenticated requests (token endpoint), the client IP is used instead.
*
* Sets `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset`
* headers on every response. Throws `RateLimitError` when the limit is exceeded.
*
* @param req - Express request.
* @param res - Express response.
* @param next - Express next function.
*/
export async function rateLimitMiddleware(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
try {
const clientId = req.user?.client_id ?? req.ip ?? 'unknown';
const { windowKey, resetAt } = getWindowInfo();
const redisKey = `rate:${clientId}:${windowKey}`;
const redis = await getRedisClient();
// Atomically increment and set TTL
const count = await redis.incr(redisKey);
if (count === 1) {
await redis.expire(redisKey, 60);
}
const remaining = Math.max(0, RATE_LIMIT_MAX - count);
res.setHeader('X-RateLimit-Limit', RATE_LIMIT_MAX);
res.setHeader('X-RateLimit-Remaining', remaining);
res.setHeader('X-RateLimit-Reset', resetAt);
if (count > RATE_LIMIT_MAX) {
throw new RateLimitError();
}
next();
} catch (err) {
next(err);
}
}

View File

@@ -0,0 +1,247 @@
/**
* Agent Repository for SentryAgent.ai AgentIdP.
* All SQL queries for the agents table live exclusively here.
*/
import { Pool, QueryResult } from 'pg';
import { v4 as uuidv4 } from 'uuid';
import {
IAgent,
ICreateAgentRequest,
IUpdateAgentRequest,
IAgentListFilters,
AgentStatus,
} from '../types/index.js';
/** Raw database row for an agent. */
interface AgentRow {
agent_id: string;
email: string;
agent_type: string;
version: string;
capabilities: string[];
owner: string;
deployment_env: string;
status: string;
created_at: Date;
updated_at: Date;
}
/**
* Maps a raw database row to the IAgent domain model.
*
* @param row - Raw row from the agents table.
* @returns Typed IAgent object.
*/
function mapRowToAgent(row: AgentRow): IAgent {
return {
agentId: row.agent_id,
email: row.email,
agentType: row.agent_type as IAgent['agentType'],
version: row.version,
capabilities: row.capabilities,
owner: row.owner,
deploymentEnv: row.deployment_env as IAgent['deploymentEnv'],
status: row.status as AgentStatus,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
/**
* Repository for all agent database operations.
* Receives a pg.Pool via constructor injection.
*/
export class AgentRepository {
/**
* @param pool - The PostgreSQL connection pool.
*/
constructor(private readonly pool: Pool) {}
/**
* Creates a new agent record in the database.
*
* @param data - The fields for the new agent.
* @returns The created agent record.
*/
async create(data: ICreateAgentRequest): Promise<IAgent> {
const agentId = uuidv4();
const result: QueryResult<AgentRow> = await this.pool.query(
`INSERT INTO agents
(agent_id, email, agent_type, version, capabilities, owner, deployment_env, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'active', NOW(), NOW())
RETURNING *`,
[
agentId,
data.email,
data.agentType,
data.version,
data.capabilities,
data.owner,
data.deploymentEnv,
],
);
return mapRowToAgent(result.rows[0]);
}
/**
* Finds an agent by its UUID.
*
* @param agentId - The agent UUID.
* @returns The agent record, or null if not found.
*/
async findById(agentId: string): Promise<IAgent | null> {
const result: QueryResult<AgentRow> = await this.pool.query(
'SELECT * FROM agents WHERE agent_id = $1',
[agentId],
);
if (result.rows.length === 0) return null;
return mapRowToAgent(result.rows[0]);
}
/**
* Finds an agent by its email address.
*
* @param email - The agent email.
* @returns The agent record, or null if not found.
*/
async findByEmail(email: string): Promise<IAgent | null> {
const result: QueryResult<AgentRow> = await this.pool.query(
'SELECT * FROM agents WHERE email = $1',
[email],
);
if (result.rows.length === 0) return null;
return mapRowToAgent(result.rows[0]);
}
/**
* Returns a paginated list of agents with optional filters.
*
* @param filters - Pagination and filter criteria.
* @returns Object containing the agent list and total count.
*/
async findAll(filters: IAgentListFilters): Promise<{ agents: IAgent[]; total: number }> {
const conditions: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
if (filters.owner !== undefined) {
conditions.push(`owner = $${paramIndex++}`);
params.push(filters.owner);
}
if (filters.agentType !== undefined) {
conditions.push(`agent_type = $${paramIndex++}`);
params.push(filters.agentType);
}
if (filters.status !== undefined) {
conditions.push(`status = $${paramIndex++}`);
params.push(filters.status);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const countResult: QueryResult<{ count: string }> = await this.pool.query(
`SELECT COUNT(*) as count FROM agents ${whereClause}`,
params,
);
const total = parseInt(countResult.rows[0].count, 10);
const offset = (filters.page - 1) * filters.limit;
const dataParams = [...params, filters.limit, offset];
const dataResult: QueryResult<AgentRow> = await this.pool.query(
`SELECT * FROM agents ${whereClause}
ORDER BY created_at DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
dataParams,
);
return {
agents: dataResult.rows.map(mapRowToAgent),
total,
};
}
/**
* Partially updates an agent record.
*
* @param agentId - The agent UUID to update.
* @param data - The fields to update (only provided fields are changed).
* @returns The updated agent record, or null if not found.
*/
async update(agentId: string, data: IUpdateAgentRequest): Promise<IAgent | null> {
const setClauses: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
if (data.agentType !== undefined) {
setClauses.push(`agent_type = $${paramIndex++}`);
params.push(data.agentType);
}
if (data.version !== undefined) {
setClauses.push(`version = $${paramIndex++}`);
params.push(data.version);
}
if (data.capabilities !== undefined) {
setClauses.push(`capabilities = $${paramIndex++}`);
params.push(data.capabilities);
}
if (data.owner !== undefined) {
setClauses.push(`owner = $${paramIndex++}`);
params.push(data.owner);
}
if (data.deploymentEnv !== undefined) {
setClauses.push(`deployment_env = $${paramIndex++}`);
params.push(data.deploymentEnv);
}
if (data.status !== undefined) {
setClauses.push(`status = $${paramIndex++}`);
params.push(data.status);
}
if (setClauses.length === 0) return null;
setClauses.push(`updated_at = NOW()`);
params.push(agentId);
const result: QueryResult<AgentRow> = await this.pool.query(
`UPDATE agents SET ${setClauses.join(', ')}
WHERE agent_id = $${paramIndex}
RETURNING *`,
params,
);
if (result.rows.length === 0) return null;
return mapRowToAgent(result.rows[0]);
}
/**
* Sets an agent's status to 'decommissioned'.
*
* @param agentId - The agent UUID to decommission.
* @returns The updated agent record, or null if not found.
*/
async decommission(agentId: string): Promise<IAgent | null> {
const result: QueryResult<AgentRow> = await this.pool.query(
`UPDATE agents
SET status = 'decommissioned', updated_at = NOW()
WHERE agent_id = $1
RETURNING *`,
[agentId],
);
if (result.rows.length === 0) return null;
return mapRowToAgent(result.rows[0]);
}
/**
* Counts all agents excluding decommissioned ones (for free-tier limit checks).
*
* @returns Total count of active and suspended agents.
*/
async countActive(): Promise<number> {
const result: QueryResult<{ count: string }> = await this.pool.query(
`SELECT COUNT(*) as count FROM agents WHERE status != 'decommissioned'`,
);
return parseInt(result.rows[0].count, 10);
}
}

View File

@@ -0,0 +1,152 @@
/**
* Audit Repository for SentryAgent.ai AgentIdP.
* All SQL queries for the audit_events table live exclusively here.
*/
import { Pool, QueryResult } from 'pg';
import { v4 as uuidv4 } from 'uuid';
import { IAuditEvent, ICreateAuditEventInput, IAuditListFilters } from '../types/index.js';
/** Raw database row for an audit event. */
interface AuditEventRow {
event_id: string;
agent_id: string;
action: string;
outcome: string;
ip_address: string;
user_agent: string;
metadata: Record<string, unknown>;
timestamp: Date;
}
/**
* Maps a raw database row to the IAuditEvent domain model.
*
* @param row - Raw row from the audit_events table.
* @returns Typed IAuditEvent object.
*/
function mapRowToAuditEvent(row: AuditEventRow): IAuditEvent {
return {
eventId: row.event_id,
agentId: row.agent_id,
action: row.action as IAuditEvent['action'],
outcome: row.outcome as IAuditEvent['outcome'],
ipAddress: row.ip_address,
userAgent: row.user_agent,
metadata: row.metadata,
timestamp: row.timestamp,
};
}
/**
* Repository for all audit event database operations.
* Receives a pg.Pool via constructor injection.
*/
export class AuditRepository {
/**
* @param pool - The PostgreSQL connection pool.
*/
constructor(private readonly pool: Pool) {}
/**
* Creates a new audit event record.
*
* @param event - The audit event input data.
* @returns The created audit event.
*/
async create(event: ICreateAuditEventInput): Promise<IAuditEvent> {
const eventId = uuidv4();
const result: QueryResult<AuditEventRow> = await this.pool.query(
`INSERT INTO audit_events
(event_id, agent_id, action, outcome, ip_address, user_agent, metadata, timestamp)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
RETURNING *`,
[
eventId,
event.agentId,
event.action,
event.outcome,
event.ipAddress,
event.userAgent,
JSON.stringify(event.metadata),
],
);
return mapRowToAuditEvent(result.rows[0]);
}
/**
* Finds a single audit event by its UUID.
*
* @param eventId - The audit event UUID.
* @returns The audit event, or null if not found.
*/
async findById(eventId: string): Promise<IAuditEvent | null> {
const result: QueryResult<AuditEventRow> = await this.pool.query(
'SELECT * FROM audit_events WHERE event_id = $1',
[eventId],
);
if (result.rows.length === 0) return null;
return mapRowToAuditEvent(result.rows[0]);
}
/**
* Returns a paginated, filtered list of audit events.
* Automatically enforces the 90-day retention window on query results.
*
* @param filters - Query filters and pagination parameters.
* @param retentionCutoff - The earliest date to include (retention window).
* @returns Object containing the audit events list and total count.
*/
async findAll(
filters: IAuditListFilters,
retentionCutoff: Date,
): Promise<{ events: IAuditEvent[]; total: number }> {
const conditions: string[] = ['timestamp >= $1'];
const params: unknown[] = [retentionCutoff];
let paramIndex = 2;
if (filters.agentId !== undefined) {
conditions.push(`agent_id = $${paramIndex++}`);
params.push(filters.agentId);
}
if (filters.action !== undefined) {
conditions.push(`action = $${paramIndex++}`);
params.push(filters.action);
}
if (filters.outcome !== undefined) {
conditions.push(`outcome = $${paramIndex++}`);
params.push(filters.outcome);
}
if (filters.fromDate !== undefined) {
conditions.push(`timestamp >= $${paramIndex++}`);
params.push(new Date(filters.fromDate));
}
if (filters.toDate !== undefined) {
conditions.push(`timestamp <= $${paramIndex++}`);
params.push(new Date(filters.toDate));
}
const whereClause = `WHERE ${conditions.join(' AND ')}`;
const countResult: QueryResult<{ count: string }> = await this.pool.query(
`SELECT COUNT(*) as count FROM audit_events ${whereClause}`,
params,
);
const total = parseInt(countResult.rows[0].count, 10);
const offset = (filters.page - 1) * filters.limit;
const dataParams = [...params, filters.limit, offset];
const dataResult: QueryResult<AuditEventRow> = await this.pool.query(
`SELECT * FROM audit_events ${whereClause}
ORDER BY timestamp DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
dataParams,
);
return {
events: dataResult.rows.map(mapRowToAuditEvent),
total,
};
}
}

View File

@@ -0,0 +1,201 @@
/**
* Credential Repository for SentryAgent.ai AgentIdP.
* All SQL queries for the credentials table live exclusively here.
*/
import { Pool, QueryResult } from 'pg';
import { v4 as uuidv4 } from 'uuid';
import { ICredential, ICredentialRow, ICredentialListFilters } from '../types/index.js';
/** Raw database row for a credential. */
interface CredentialDbRow {
credential_id: string;
client_id: string;
secret_hash: string;
status: string;
created_at: Date;
expires_at: Date | null;
revoked_at: Date | null;
}
/**
* Maps a raw database row to the ICredentialRow domain model.
*
* @param row - Raw row from the credentials table.
* @returns Typed ICredentialRow including the secret hash.
*/
function mapRowToCredentialRow(row: CredentialDbRow): ICredentialRow {
return {
credentialId: row.credential_id,
clientId: row.client_id,
secretHash: row.secret_hash,
status: row.status as ICredential['status'],
createdAt: row.created_at,
expiresAt: row.expires_at,
revokedAt: row.revoked_at,
};
}
/**
* Maps a raw database row to the ICredential domain model (no secret hash).
*
* @param row - Raw row from the credentials table.
* @returns Typed ICredential without the secret hash.
*/
function mapRowToCredential(row: CredentialDbRow): ICredential {
const { secretHash: _secretHash, ...credential } = mapRowToCredentialRow(row);
void _secretHash;
return credential;
}
/**
* Repository for all credential database operations.
* Receives a pg.Pool via constructor injection.
*/
export class CredentialRepository {
/**
* @param pool - The PostgreSQL connection pool.
*/
constructor(private readonly pool: Pool) {}
/**
* Creates a new credential record.
*
* @param clientId - The agent ID this credential belongs to.
* @param secretHash - The bcrypt hash of the plain-text secret.
* @param expiresAt - Optional expiry date.
* @returns The created credential record (without secret hash).
*/
async create(
clientId: string,
secretHash: string,
expiresAt: Date | null,
): Promise<ICredential> {
const credentialId = uuidv4();
const result: QueryResult<CredentialDbRow> = await this.pool.query(
`INSERT INTO credentials
(credential_id, client_id, secret_hash, status, created_at, expires_at)
VALUES ($1, $2, $3, 'active', NOW(), $4)
RETURNING *`,
[credentialId, clientId, secretHash, expiresAt],
);
return mapRowToCredential(result.rows[0]);
}
/**
* Finds a credential by its UUID, including the secret hash.
*
* @param credentialId - The credential UUID.
* @returns The credential row including secret hash, or null if not found.
*/
async findById(credentialId: string): Promise<ICredentialRow | null> {
const result: QueryResult<CredentialDbRow> = await this.pool.query(
'SELECT * FROM credentials WHERE credential_id = $1',
[credentialId],
);
if (result.rows.length === 0) return null;
return mapRowToCredentialRow(result.rows[0]);
}
/**
* Returns a paginated list of credentials for an agent.
*
* @param agentId - The agent UUID.
* @param filters - Pagination and optional status filter.
* @returns Object with credential list and total count.
*/
async findByAgentId(
agentId: string,
filters: ICredentialListFilters,
): Promise<{ credentials: ICredential[]; total: number }> {
const conditions: string[] = ['client_id = $1'];
const params: unknown[] = [agentId];
let paramIndex = 2;
if (filters.status !== undefined) {
conditions.push(`status = $${paramIndex++}`);
params.push(filters.status);
}
const whereClause = `WHERE ${conditions.join(' AND ')}`;
const countResult: QueryResult<{ count: string }> = await this.pool.query(
`SELECT COUNT(*) as count FROM credentials ${whereClause}`,
params,
);
const total = parseInt(countResult.rows[0].count, 10);
const offset = (filters.page - 1) * filters.limit;
const dataParams = [...params, filters.limit, offset];
const dataResult: QueryResult<CredentialDbRow> = await this.pool.query(
`SELECT * FROM credentials ${whereClause}
ORDER BY created_at DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
dataParams,
);
return {
credentials: dataResult.rows.map(mapRowToCredential),
total,
};
}
/**
* Updates the bcrypt hash for an existing credential (rotation).
*
* @param credentialId - The credential UUID.
* @param newSecretHash - The new bcrypt hash.
* @param newExpiresAt - Optional new expiry date.
* @returns The updated credential record, or null if not found.
*/
async updateHash(
credentialId: string,
newSecretHash: string,
newExpiresAt: Date | null,
): Promise<ICredential | null> {
const result: QueryResult<CredentialDbRow> = await this.pool.query(
`UPDATE credentials
SET secret_hash = $1, expires_at = $2, status = 'active', revoked_at = NULL
WHERE credential_id = $3
RETURNING *`,
[newSecretHash, newExpiresAt, credentialId],
);
if (result.rows.length === 0) return null;
return mapRowToCredential(result.rows[0]);
}
/**
* Sets a credential's status to 'revoked'.
*
* @param credentialId - The credential UUID.
* @returns The updated credential record, or null if not found.
*/
async revoke(credentialId: string): Promise<ICredential | null> {
const result: QueryResult<CredentialDbRow> = await this.pool.query(
`UPDATE credentials
SET status = 'revoked', revoked_at = NOW()
WHERE credential_id = $1
RETURNING *`,
[credentialId],
);
if (result.rows.length === 0) return null;
return mapRowToCredential(result.rows[0]);
}
/**
* Revokes all active credentials for an agent (used on decommission).
*
* @param agentId - The agent UUID.
* @returns The number of credentials revoked.
*/
async revokeAllForAgent(agentId: string): Promise<number> {
const result = await this.pool.query(
`UPDATE credentials
SET status = 'revoked', revoked_at = NOW()
WHERE client_id = $1 AND status = 'active'`,
[agentId],
);
return result.rowCount ?? 0;
}
}

View File

@@ -0,0 +1,113 @@
/**
* Token Repository for SentryAgent.ai AgentIdP.
* Manages token revocation tracking (Redis primary, PostgreSQL fallback)
* and monthly token count tracking (Redis with monthly TTL).
*/
import { Pool, QueryResult } from 'pg';
import { RedisClientType } from 'redis';
/** Raw database row for a token revocation record. */
interface TokenRevocationRow {
jti: string;
expires_at: Date;
revoked_at: Date;
}
/**
* Repository for token revocation and monthly usage tracking.
* Receives a pg.Pool and RedisClientType via constructor injection.
*/
export class TokenRepository {
/**
* @param pool - The PostgreSQL connection pool.
* @param redis - The Redis client.
*/
constructor(
private readonly pool: Pool,
private readonly redis: RedisClientType,
) {}
/**
* Adds a token JTI to both the Redis revocation list and the PostgreSQL
* token_revocations table. Redis TTL is set to the token's remaining lifetime.
*
* @param jti - The JWT ID to revoke.
* @param expiresAt - The token expiry date (used to calculate Redis TTL).
*/
async addToRevocationList(jti: string, expiresAt: Date): Promise<void> {
const nowSeconds = Math.floor(Date.now() / 1000);
const expirySeconds = Math.floor(expiresAt.getTime() / 1000);
const ttl = Math.max(1, expirySeconds - nowSeconds);
const redisKey = `revoked:${jti}`;
await this.redis.set(redisKey, '1', { EX: ttl });
await this.pool.query(
`INSERT INTO token_revocations (jti, expires_at)
VALUES ($1, $2)
ON CONFLICT (jti) DO NOTHING`,
[jti, expiresAt],
);
}
/**
* Checks whether a JTI has been revoked.
* Checks Redis first for performance; falls back to PostgreSQL.
*
* @param jti - The JWT ID to check.
* @returns True if the token has been revoked, false otherwise.
*/
async isRevoked(jti: string): Promise<boolean> {
const redisKey = `revoked:${jti}`;
const cached = await this.redis.get(redisKey);
if (cached !== null) return true;
const result: QueryResult<TokenRevocationRow> = await this.pool.query(
'SELECT jti FROM token_revocations WHERE jti = $1',
[jti],
);
return result.rows.length > 0;
}
/**
* Increments the monthly token count for a client in Redis.
* Sets a TTL to the end of the current month if the key is new.
*
* @param clientId - The agent/client ID.
* @returns The new count after incrementing.
*/
async incrementMonthlyCount(clientId: string): Promise<number> {
const now = new Date();
const year = now.getUTCFullYear();
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
const key = `monthly:tokens:${clientId}:${year}-${month}`;
const count = await this.redis.incr(key);
if (count === 1) {
// Set TTL to end of month
const endOfMonth = new Date(Date.UTC(year, now.getUTCMonth() + 1, 1, 0, 0, 0));
const ttlSeconds = Math.floor((endOfMonth.getTime() - Date.now()) / 1000);
await this.redis.expire(key, Math.max(1, ttlSeconds));
}
return count;
}
/**
* Gets the current monthly token count for a client.
*
* @param clientId - The agent/client ID.
* @returns The current count, or 0 if no tokens have been issued this month.
*/
async getMonthlyCount(clientId: string): Promise<number> {
const now = new Date();
const year = now.getUTCFullYear();
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
const key = `monthly:tokens:${clientId}:${year}-${month}`;
const value = await this.redis.get(key);
return value !== null ? parseInt(value, 10) : 0;
}
}

40
src/routes/agents.ts Normal file
View File

@@ -0,0 +1,40 @@
/**
* Agent Registry routes for SentryAgent.ai AgentIdP.
* Wires AgentController handlers to Express paths with auth and rateLimit middleware.
*/
import { Router } from 'express';
import { AgentController } from '../controllers/AgentController.js';
import { authMiddleware } from '../middleware/auth.js';
import { rateLimitMiddleware } from '../middleware/rateLimit.js';
import { asyncHandler } from '../utils/asyncHandler.js';
/**
* Creates and returns the Express router for agent registry endpoints.
*
* @param agentController - The agent controller instance.
* @returns Configured Express router.
*/
export function createAgentsRouter(agentController: AgentController): Router {
const router = Router();
router.use(asyncHandler(authMiddleware));
router.use(asyncHandler(rateLimitMiddleware));
// POST /agents — Register a new agent
router.post('/', asyncHandler(agentController.registerAgent.bind(agentController)));
// GET /agents — List agents with optional filters
router.get('/', asyncHandler(agentController.listAgents.bind(agentController)));
// GET /agents/:agentId — Get a single agent
router.get('/:agentId', asyncHandler(agentController.getAgentById.bind(agentController)));
// PATCH /agents/:agentId — Update agent metadata
router.patch('/:agentId', asyncHandler(agentController.updateAgent.bind(agentController)));
// DELETE /agents/:agentId — Decommission an agent
router.delete('/:agentId', asyncHandler(agentController.decommissionAgent.bind(agentController)));
return router;
}

31
src/routes/audit.ts Normal file
View File

@@ -0,0 +1,31 @@
/**
* Audit Log routes for SentryAgent.ai AgentIdP.
* All routes require Bearer auth and are rate-limited.
*/
import { Router } from 'express';
import { AuditController } from '../controllers/AuditController.js';
import { authMiddleware } from '../middleware/auth.js';
import { rateLimitMiddleware } from '../middleware/rateLimit.js';
import { asyncHandler } from '../utils/asyncHandler.js';
/**
* Creates and returns the Express router for audit log endpoints.
*
* @param auditController - The audit controller instance.
* @returns Configured Express router.
*/
export function createAuditRouter(auditController: AuditController): Router {
const router = Router();
router.use(asyncHandler(authMiddleware));
router.use(asyncHandler(rateLimitMiddleware));
// GET /audit — Query audit log
router.get('/', asyncHandler(auditController.queryAuditLog.bind(auditController)));
// GET /audit/:eventId — Get a single audit event
router.get('/:eventId', asyncHandler(auditController.getAuditEventById.bind(auditController)));
return router;
}

38
src/routes/credentials.ts Normal file
View File

@@ -0,0 +1,38 @@
/**
* Credential Management routes for SentryAgent.ai AgentIdP.
* All routes are under /agents/:agentId/credentials with auth and rateLimit middleware.
*/
import { Router } from 'express';
import { CredentialController } from '../controllers/CredentialController.js';
import { authMiddleware } from '../middleware/auth.js';
import { rateLimitMiddleware } from '../middleware/rateLimit.js';
import { asyncHandler } from '../utils/asyncHandler.js';
/**
* Creates and returns the Express router for credential management endpoints.
* This router is mounted at /agents — the :agentId param is part of the path.
*
* @param credentialController - The credential controller instance.
* @returns Configured Express router.
*/
export function createCredentialsRouter(credentialController: CredentialController): Router {
const router = Router({ mergeParams: true });
router.use(asyncHandler(authMiddleware));
router.use(asyncHandler(rateLimitMiddleware));
// POST /agents/:agentId/credentials — Generate new credentials
router.post('/', asyncHandler(credentialController.generateCredential.bind(credentialController)));
// GET /agents/:agentId/credentials — List credentials
router.get('/', asyncHandler(credentialController.listCredentials.bind(credentialController)));
// POST /agents/:agentId/credentials/:credentialId/rotate — Rotate a credential
router.post('/:credentialId/rotate', asyncHandler(credentialController.rotateCredential.bind(credentialController)));
// DELETE /agents/:agentId/credentials/:credentialId — Revoke a credential
router.delete('/:credentialId', asyncHandler(credentialController.revokeCredential.bind(credentialController)));
return router;
}

42
src/routes/token.ts Normal file
View File

@@ -0,0 +1,42 @@
/**
* OAuth 2.0 Token routes for SentryAgent.ai AgentIdP.
* POST /token uses no Bearer auth (credentials are in the body).
* POST /token/introspect and POST /token/revoke require Bearer auth.
*/
import { Router } from 'express';
import { TokenController } from '../controllers/TokenController.js';
import { authMiddleware } from '../middleware/auth.js';
import { rateLimitMiddleware } from '../middleware/rateLimit.js';
import { asyncHandler } from '../utils/asyncHandler.js';
/**
* Creates and returns the Express router for token endpoints.
*
* @param tokenController - The token controller instance.
* @returns Configured Express router.
*/
export function createTokenRouter(tokenController: TokenController): Router {
const router = Router();
// POST /token — Issue token (no auth — credentials in body or Basic header)
router.post('/', asyncHandler(rateLimitMiddleware), asyncHandler(tokenController.issueToken.bind(tokenController)));
// POST /token/introspect — Introspect token (requires Bearer auth)
router.post(
'/introspect',
asyncHandler(authMiddleware),
asyncHandler(rateLimitMiddleware),
asyncHandler(tokenController.introspectToken.bind(tokenController)),
);
// POST /token/revoke — Revoke token (requires Bearer auth)
router.post(
'/revoke',
asyncHandler(authMiddleware),
asyncHandler(rateLimitMiddleware),
asyncHandler(tokenController.revokeToken.bind(tokenController)),
);
return router;
}

47
src/server.ts Normal file
View File

@@ -0,0 +1,47 @@
/**
* Server entry point for SentryAgent.ai AgentIdP.
* Loads environment variables, creates the app, and starts listening.
*/
import * as dotenv from 'dotenv';
dotenv.config();
import { createApp } from './app.js';
const PORT = parseInt(process.env['PORT'] ?? '3000', 10);
/**
* Bootstraps the application and starts the HTTP server.
*/
async function main(): Promise<void> {
try {
const app = await createApp();
const server = app.listen(PORT, () => {
// eslint-disable-next-line no-console
console.log(`SentryAgent.ai AgentIdP listening on port ${PORT}`);
});
// Graceful shutdown
const shutdown = (): void => {
// eslint-disable-next-line no-console
console.log('Shutting down gracefully...');
server.close(() => {
process.exit(0);
});
};
process.on('SIGTERM', () => {
shutdown();
});
process.on('SIGINT', () => {
shutdown();
});
} catch (err) {
// eslint-disable-next-line no-console
console.error('Failed to start server:', err);
process.exit(1);
}
}
void main();

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,
{},
);
}
}

View File

@@ -0,0 +1,136 @@
/**
* Audit Log Service for SentryAgent.ai AgentIdP.
* Provides methods for logging and querying immutable audit events.
*/
import { AuditRepository } from '../repositories/AuditRepository.js';
import {
IAuditEvent,
IAuditListFilters,
IPaginatedAuditEventsResponse,
AuditAction,
AuditOutcome,
} from '../types/index.js';
import {
AuditEventNotFoundError,
RetentionWindowError,
ValidationError,
} from '../utils/errors.js';
const FREE_TIER_RETENTION_DAYS = 90;
/**
* Service for creating and querying audit log events.
* Enforces 90-day retention window on all queries.
*/
export class AuditService {
/**
* @param auditRepository - The audit event repository.
*/
constructor(private readonly auditRepository: AuditRepository) {}
/**
* Computes the earliest allowed timestamp for audit queries (90-day retention).
*
* @returns The retention cutoff Date.
*/
private getRetentionCutoff(): Date {
const cutoff = new Date();
cutoff.setUTCDate(cutoff.getUTCDate() - FREE_TIER_RETENTION_DAYS);
cutoff.setUTCHours(0, 0, 0, 0);
return cutoff;
}
/**
* Logs an audit event. This is a fire-and-forget async insert for token
* endpoints (do not await). For DB-backed operations, await this method.
*
* @param agentId - The agent that triggered the event.
* @param action - The action that occurred.
* @param outcome - Whether the action succeeded or failed.
* @param ipAddress - The client IP address.
* @param userAgent - The client User-Agent header.
* @param metadata - Action-specific structured context data.
* @returns Promise resolving to the created audit event.
*/
async logEvent(
agentId: string,
action: AuditAction,
outcome: AuditOutcome,
ipAddress: string,
userAgent: string,
metadata: Record<string, unknown>,
): Promise<IAuditEvent> {
return this.auditRepository.create({
agentId,
action,
outcome,
ipAddress,
userAgent,
metadata,
});
}
/**
* Queries the audit log with optional filters, pagination, and retention enforcement.
*
* @param filters - Query filters and pagination parameters.
* @returns Paginated audit events response.
* @throws RetentionWindowError if fromDate is before the 90-day retention cutoff.
* @throws ValidationError if fromDate is after toDate.
*/
async queryEvents(filters: IAuditListFilters): Promise<IPaginatedAuditEventsResponse> {
const retentionCutoff = this.getRetentionCutoff();
if (filters.fromDate !== undefined) {
const fromDate = new Date(filters.fromDate);
if (fromDate < retentionCutoff) {
throw new RetentionWindowError(
FREE_TIER_RETENTION_DAYS,
retentionCutoff.toISOString(),
);
}
}
if (filters.fromDate !== undefined && filters.toDate !== undefined) {
const fromDate = new Date(filters.fromDate);
const toDate = new Date(filters.toDate);
if (fromDate > toDate) {
throw new ValidationError('Invalid date range.', {
reason: 'fromDate must be before or equal to toDate.',
});
}
}
const { events, total } = await this.auditRepository.findAll(filters, retentionCutoff);
return {
data: events,
total,
page: filters.page,
limit: filters.limit,
};
}
/**
* Retrieves a single audit event by its UUID.
*
* @param eventId - The audit event UUID.
* @returns The audit event record.
* @throws AuditEventNotFoundError if the event does not exist.
*/
async getEventById(eventId: string): Promise<IAuditEvent> {
const event = await this.auditRepository.findById(eventId);
if (!event) {
throw new AuditEventNotFoundError(eventId);
}
// Check retention window — events older than 90 days are not accessible
const retentionCutoff = this.getRetentionCutoff();
if (event.timestamp < retentionCutoff) {
throw new AuditEventNotFoundError(eventId);
}
return event;
}
}

View File

@@ -0,0 +1,226 @@
/**
* Credential Management Service for SentryAgent.ai AgentIdP.
* Business logic for generating, listing, rotating, and revoking credentials.
*/
import { CredentialRepository } from '../repositories/CredentialRepository.js';
import { AgentRepository } from '../repositories/AgentRepository.js';
import { AuditService } from './AuditService.js';
import {
ICredentialWithSecret,
ICredentialListFilters,
IPaginatedCredentialsResponse,
IGenerateCredentialRequest,
} from '../types/index.js';
import {
AgentNotFoundError,
CredentialNotFoundError,
CredentialAlreadyRevokedError,
CredentialError,
} from '../utils/errors.js';
import { generateClientSecret, hashSecret } from '../utils/crypto.js';
/**
* Service for credential lifecycle management.
* The plain-text clientSecret is only returned on generation and rotation.
*/
export class CredentialService {
/**
* @param credentialRepository - The credential data repository.
* @param agentRepository - The agent repository (for status checks).
* @param auditService - The audit log service.
*/
constructor(
private readonly credentialRepository: CredentialRepository,
private readonly agentRepository: AgentRepository,
private readonly auditService: AuditService,
) {}
/**
* Generates a new client credential for an agent.
* The agent must be in 'active' status.
* Returns the plain-text clientSecret once — it is never retrievable again.
*
* @param agentId - The agent UUID.
* @param data - Optional expiry date for the credential.
* @param ipAddress - Client IP for audit logging.
* @param userAgent - Client User-Agent for audit logging.
* @returns The credential with the one-time plain-text clientSecret.
* @throws AgentNotFoundError if the agent does not exist.
* @throws CredentialError if the agent is not in 'active' status.
*/
async generateCredential(
agentId: string,
data: IGenerateCredentialRequest,
ipAddress: string,
userAgent: string,
): Promise<ICredentialWithSecret> {
const agent = await this.agentRepository.findById(agentId);
if (!agent) {
throw new AgentNotFoundError(agentId);
}
if (agent.status !== 'active') {
throw new CredentialError(
'Credentials can only be generated for active agents.',
'AGENT_NOT_ACTIVE',
{ agentId, status: agent.status },
);
}
const expiresAt = data.expiresAt !== undefined ? new Date(data.expiresAt) : null;
const plainSecret = generateClientSecret();
const secretHash = await hashSecret(plainSecret);
const credential = await this.credentialRepository.create(agentId, secretHash, expiresAt);
await this.auditService.logEvent(
agentId,
'credential.generated',
'success',
ipAddress,
userAgent,
{ credentialId: credential.credentialId },
);
return { ...credential, clientSecret: plainSecret };
}
/**
* Returns a paginated list of credentials for an agent.
* The clientSecret is never included in list responses.
*
* @param agentId - The agent UUID.
* @param filters - Pagination and optional status filter.
* @returns Paginated credentials response.
* @throws AgentNotFoundError if the agent does not exist.
*/
async listCredentials(
agentId: string,
filters: ICredentialListFilters,
): Promise<IPaginatedCredentialsResponse> {
const agent = await this.agentRepository.findById(agentId);
if (!agent) {
throw new AgentNotFoundError(agentId);
}
const { credentials, total } = await this.credentialRepository.findByAgentId(
agentId,
filters,
);
return {
data: credentials,
total,
page: filters.page,
limit: filters.limit,
};
}
/**
* Rotates a credential by generating a new secret for the same credentialId.
* Only 'active' credentials can be rotated.
* Returns the new plain-text clientSecret once.
*
* @param agentId - The agent UUID.
* @param credentialId - The credential UUID to rotate.
* @param data - Optional new expiry date.
* @param ipAddress - Client IP for audit logging.
* @param userAgent - Client User-Agent for audit logging.
* @returns The updated credential with the new one-time clientSecret.
* @throws AgentNotFoundError if the agent does not exist.
* @throws CredentialNotFoundError if the credential does not exist.
* @throws CredentialAlreadyRevokedError if the credential is already revoked.
*/
async rotateCredential(
agentId: string,
credentialId: string,
data: IGenerateCredentialRequest,
ipAddress: string,
userAgent: string,
): Promise<ICredentialWithSecret> {
const agent = await this.agentRepository.findById(agentId);
if (!agent) {
throw new AgentNotFoundError(agentId);
}
const existing = await this.credentialRepository.findById(credentialId);
if (!existing || existing.clientId !== agentId) {
throw new CredentialNotFoundError(credentialId);
}
if (existing.status === 'revoked') {
throw new CredentialAlreadyRevokedError(
credentialId,
existing.revokedAt?.toISOString() ?? new Date().toISOString(),
);
}
const expiresAt = data.expiresAt !== undefined ? new Date(data.expiresAt) : null;
const plainSecret = generateClientSecret();
const newHash = await hashSecret(plainSecret);
const updated = await this.credentialRepository.updateHash(credentialId, newHash, expiresAt);
if (!updated) {
throw new CredentialNotFoundError(credentialId);
}
await this.auditService.logEvent(
agentId,
'credential.rotated',
'success',
ipAddress,
userAgent,
{ credentialId },
);
return { ...updated, clientSecret: plainSecret };
}
/**
* Permanently revokes a credential.
* Revoking an already-revoked credential returns 409 Conflict.
*
* @param agentId - The agent UUID.
* @param credentialId - The credential UUID to revoke.
* @param ipAddress - Client IP for audit logging.
* @param userAgent - Client User-Agent for audit logging.
* @throws AgentNotFoundError if the agent does not exist.
* @throws CredentialNotFoundError if the credential does not exist or belongs to another agent.
* @throws CredentialAlreadyRevokedError if the credential is already revoked.
*/
async revokeCredential(
agentId: string,
credentialId: string,
ipAddress: string,
userAgent: string,
): Promise<void> {
const agent = await this.agentRepository.findById(agentId);
if (!agent) {
throw new AgentNotFoundError(agentId);
}
const existing = await this.credentialRepository.findById(credentialId);
if (!existing || existing.clientId !== agentId) {
throw new CredentialNotFoundError(credentialId);
}
if (existing.status === 'revoked') {
throw new CredentialAlreadyRevokedError(
credentialId,
existing.revokedAt?.toISOString() ?? new Date().toISOString(),
);
}
await this.credentialRepository.revoke(credentialId);
await this.auditService.logEvent(
agentId,
'credential.revoked',
'success',
ipAddress,
userAgent,
{ credentialId },
);
}
}

View File

@@ -0,0 +1,303 @@
/**
* OAuth 2.0 Token Service for SentryAgent.ai AgentIdP.
* Issues, introspects, and revokes RS256 JWT access tokens.
*/
import { TokenRepository } from '../repositories/TokenRepository.js';
import { CredentialRepository } from '../repositories/CredentialRepository.js';
import { AgentRepository } from '../repositories/AgentRepository.js';
import { AuditService } from './AuditService.js';
import {
ITokenPayload,
ITokenResponse,
IIntrospectResponse,
IOAuth2ErrorResponse,
} from '../types/index.js';
import {
AuthenticationError,
AuthorizationError,
FreeTierLimitError,
InsufficientScopeError,
} from '../utils/errors.js';
import { signToken, verifyToken, decodeToken, getTokenExpiresIn } from '../utils/jwt.js';
import { verifySecret } from '../utils/crypto.js';
import { v4 as uuidv4 } from 'uuid';
const FREE_TIER_MAX_MONTHLY_TOKENS = 10000;
/** Result of a token issuance, including either a success response or OAuth2 error. */
export interface IssueTokenResult {
success: boolean;
response?: ITokenResponse;
error?: IOAuth2ErrorResponse;
httpStatus?: number;
}
/**
* Service for OAuth 2.0 Client Credentials token issuance, introspection, and revocation.
*/
export class OAuth2Service {
/**
* @param tokenRepository - Repository for token revocation and monthly counts.
* @param credentialRepository - Repository for credential lookup and verification.
* @param agentRepository - Repository for agent status lookup.
* @param auditService - The audit log service.
* @param privateKey - PEM-encoded RSA private key for signing tokens.
* @param publicKey - PEM-encoded RSA public key for verifying tokens.
*/
constructor(
private readonly tokenRepository: TokenRepository,
private readonly credentialRepository: CredentialRepository,
private readonly agentRepository: AgentRepository,
private readonly auditService: AuditService,
private readonly privateKey: string,
private readonly publicKey: string,
) {}
/**
* Issues a signed RS256 JWT access token via the OAuth 2.0 Client Credentials grant.
* Validates client credentials, checks agent status, enforces 10k monthly limit,
* and writes an async fire-and-forget audit event.
*
* @param clientId - The agent UUID acting as client_id.
* @param clientSecret - The plain-text client secret.
* @param scope - Space-separated OAuth 2.0 scopes requested.
* @param ipAddress - Client IP for audit logging.
* @param userAgent - Client User-Agent for audit logging.
* @returns The token response with access_token, token_type, expires_in, scope.
* @throws AuthenticationError if the client credentials are invalid.
* @throws AuthorizationError if the agent is suspended or decommissioned.
* @throws FreeTierLimitError if the monthly token limit is reached.
*/
async issueToken(
clientId: string,
clientSecret: string,
scope: string,
ipAddress: string,
userAgent: string,
): Promise<ITokenResponse> {
// Look up the agent
const agent = await this.agentRepository.findById(clientId);
if (!agent) {
void this.auditService.logEvent(
clientId,
'auth.failed',
'failure',
ipAddress,
userAgent,
{ reason: 'agent_not_found', clientId },
);
throw new AuthenticationError('Client authentication failed. Invalid client_id or client_secret.');
}
// Find active credentials for the agent and verify secret
const { credentials } = await this.credentialRepository.findByAgentId(clientId, {
status: 'active',
page: 1,
limit: 100,
});
let credentialVerified = false;
for (const cred of credentials) {
const credRow = await this.credentialRepository.findById(cred.credentialId);
if (credRow) {
const matches = await verifySecret(clientSecret, credRow.secretHash);
if (matches) {
// Check if credential is expired
if (credRow.expiresAt !== null && credRow.expiresAt < new Date()) {
continue;
}
credentialVerified = true;
break;
}
}
}
if (!credentialVerified) {
void this.auditService.logEvent(
clientId,
'auth.failed',
'failure',
ipAddress,
userAgent,
{ reason: 'invalid_client_secret', clientId },
);
throw new AuthenticationError('Client authentication failed. Invalid client_id or client_secret.');
}
// Check agent status
if (agent.status === 'suspended') {
void this.auditService.logEvent(
clientId,
'auth.failed',
'failure',
ipAddress,
userAgent,
{ reason: 'agent_suspended', clientId },
);
throw new AuthorizationError('Agent is currently suspended and cannot obtain tokens.');
}
if (agent.status === 'decommissioned') {
void this.auditService.logEvent(
clientId,
'auth.failed',
'failure',
ipAddress,
userAgent,
{ reason: 'agent_decommissioned', clientId },
);
throw new AuthorizationError('Agent is decommissioned and cannot obtain tokens.');
}
// Check monthly token limit
const monthlyCount = await this.tokenRepository.getMonthlyCount(clientId);
if (monthlyCount >= FREE_TIER_MAX_MONTHLY_TOKENS) {
throw new FreeTierLimitError(
'Free tier monthly token limit of 10,000 requests has been reached.',
{ limit: FREE_TIER_MAX_MONTHLY_TOKENS, current: monthlyCount },
);
}
// Issue the token
const jti = uuidv4();
const now = Math.floor(Date.now() / 1000);
const expiresIn = getTokenExpiresIn();
const payload: Omit<ITokenPayload, 'iat' | 'exp'> = {
sub: clientId,
client_id: clientId,
scope,
jti,
};
const accessToken = signToken(payload, this.privateKey);
// Increment monthly count (fire-and-forget)
void this.tokenRepository.incrementMonthlyCount(clientId);
// Audit event (fire-and-forget — do not await for latency)
const expiresAtDate = new Date((now + expiresIn) * 1000);
void this.auditService.logEvent(
clientId,
'token.issued',
'success',
ipAddress,
userAgent,
{ scope, expiresAt: expiresAtDate.toISOString() },
);
return {
access_token: accessToken,
token_type: 'Bearer',
expires_in: expiresIn,
scope,
};
}
/**
* Introspects a token per RFC 7662.
* Always returns 200; check the `active` field for validity.
* Requires the caller to hold a token with `tokens:read` scope.
*
* @param token - The JWT string to introspect.
* @param callerPayload - The decoded payload of the calling agent's token (for scope check).
* @param ipAddress - Client IP for audit logging.
* @param userAgent - Client User-Agent for audit logging.
* @returns The introspection response.
* @throws InsufficientScopeError if the caller lacks `tokens:read` scope.
*/
async introspectToken(
token: string,
callerPayload: ITokenPayload,
ipAddress: string,
userAgent: string,
): Promise<IIntrospectResponse> {
// Check caller has tokens:read scope
const callerScopes = callerPayload.scope.split(' ');
if (!callerScopes.includes('tokens:read')) {
throw new InsufficientScopeError('tokens:read');
}
try {
const payload = verifyToken(token, this.publicKey);
const revoked = await this.tokenRepository.isRevoked(payload.jti);
if (revoked) {
void this.auditService.logEvent(
callerPayload.sub,
'token.introspected',
'success',
ipAddress,
userAgent,
{ targetJti: payload.jti, active: false },
);
return { active: false };
}
void this.auditService.logEvent(
callerPayload.sub,
'token.introspected',
'success',
ipAddress,
userAgent,
{ targetJti: payload.jti, active: true },
);
return {
active: true,
sub: payload.sub,
client_id: payload.client_id,
scope: payload.scope,
token_type: 'Bearer',
iat: payload.iat,
exp: payload.exp,
};
} catch {
// Token is invalid or expired — return inactive per RFC 7662
return { active: false };
}
}
/**
* Revokes a token per RFC 7009.
* Idempotent — revoking an already-revoked or expired token returns success.
* An agent may only revoke its own tokens.
*
* @param token - The JWT string to revoke.
* @param callerPayload - The decoded payload of the calling agent's token.
* @param ipAddress - Client IP for audit logging.
* @param userAgent - Client User-Agent for audit logging.
* @throws AuthorizationError if the caller tries to revoke another agent's token.
*/
async revokeToken(
token: string,
callerPayload: ITokenPayload,
ipAddress: string,
userAgent: string,
): Promise<void> {
// Decode the token without verification to extract claims
const decoded = decodeToken(token);
if (decoded !== null) {
// Only the token owner can revoke their own token
if (decoded.sub !== callerPayload.sub) {
throw new AuthorizationError('You do not have permission to revoke this token.');
}
// Add to revocation list
const expiresAt = new Date(decoded.exp * 1000);
await this.tokenRepository.addToRevocationList(decoded.jti, expiresAt);
void this.auditService.logEvent(
callerPayload.sub,
'token.revoked',
'success',
ipAddress,
userAgent,
{ jti: decoded.jti },
);
}
// If token is malformed/undecoded, per RFC 7009 we still return success
}
}

283
src/types/index.ts Normal file
View File

@@ -0,0 +1,283 @@
/**
* Shared TypeScript interfaces and types for SentryAgent.ai AgentIdP.
* All interfaces and types live here — no inline type definitions in service/controller files.
*/
// ============================================================================
// Enumerations / Union Types
// ============================================================================
/** Functional classification of an AI agent. */
export type AgentType =
| 'screener'
| 'classifier'
| 'orchestrator'
| 'extractor'
| 'summarizer'
| 'router'
| 'monitor'
| 'custom';
/** Lifecycle status of an AI agent. */
export type AgentStatus = 'active' | 'suspended' | 'decommissioned';
/** Target deployment environment for an agent. */
export type DeploymentEnv = 'development' | 'staging' | 'production';
/** Lifecycle status of an agent credential. */
export type CredentialStatus = 'active' | 'revoked';
/** OAuth 2.0 scope values supported by this IdP. */
export type OAuthScope = 'agents:read' | 'agents:write' | 'tokens:read' | 'audit:read';
/** Audit action identifiers for all significant platform events. */
export type AuditAction =
| 'agent.created'
| 'agent.updated'
| 'agent.decommissioned'
| 'agent.suspended'
| 'agent.reactivated'
| 'token.issued'
| 'token.revoked'
| 'token.introspected'
| 'credential.generated'
| 'credential.rotated'
| 'credential.revoked'
| 'auth.failed';
/** Outcome of an audited action. */
export type AuditOutcome = 'success' | 'failure';
// ============================================================================
// Agent Registry
// ============================================================================
/** Full representation of a registered AI agent identity. */
export interface IAgent {
agentId: string;
email: string;
agentType: AgentType;
version: string;
capabilities: string[];
owner: string;
deploymentEnv: DeploymentEnv;
status: AgentStatus;
createdAt: Date;
updatedAt: Date;
}
/** Request body for registering a new AI agent. */
export interface ICreateAgentRequest {
email: string;
agentType: AgentType;
version: string;
capabilities: string[];
owner: string;
deploymentEnv: DeploymentEnv;
}
/** Request body for partially updating an agent. */
export interface IUpdateAgentRequest {
agentType?: AgentType;
version?: string;
capabilities?: string[];
owner?: string;
deploymentEnv?: DeploymentEnv;
status?: AgentStatus;
}
/** Paginated list of agents. */
export interface IPaginatedAgentsResponse {
data: IAgent[];
total: number;
page: number;
limit: number;
}
/** Query filters for listing agents. */
export interface IAgentListFilters {
owner?: string;
agentType?: AgentType;
status?: AgentStatus;
page: number;
limit: number;
}
// ============================================================================
// Credentials
// ============================================================================
/** A credential record for an AI agent (clientSecret never included). */
export interface ICredential {
credentialId: string;
clientId: string;
status: CredentialStatus;
createdAt: Date;
expiresAt: Date | null;
revokedAt: Date | null;
}
/** Credential with the plain-text secret — returned once only on create/rotate. */
export interface ICredentialWithSecret extends ICredential {
clientSecret: string;
}
/** Database row for a credential, including the bcrypt hash. */
export interface ICredentialRow extends ICredential {
secretHash: string;
}
/** Request body for generating or rotating a credential. */
export interface IGenerateCredentialRequest {
expiresAt?: string | Date;
}
/** Paginated list of credentials. */
export interface IPaginatedCredentialsResponse {
data: ICredential[];
total: number;
page: number;
limit: number;
}
/** Query filters for listing credentials. */
export interface ICredentialListFilters {
status?: CredentialStatus;
page: number;
limit: number;
}
// ============================================================================
// OAuth2 Token
// ============================================================================
/** JWT access token payload (claims). */
export interface ITokenPayload {
/** Subject — agentId. */
sub: string;
/** client_id — agentId. */
client_id: string;
/** Space-separated OAuth 2.0 scopes. */
scope: string;
/** JWT ID — UUID v4. */
jti: string;
/** Issued at (Unix seconds). */
iat: number;
/** Expiry (Unix seconds). */
exp: number;
}
/** OAuth 2.0 token request (form-encoded). */
export interface ITokenRequest {
grant_type: string;
client_id?: string;
client_secret?: string;
scope?: string;
}
/** Successful OAuth 2.0 token response. */
export interface ITokenResponse {
access_token: string;
token_type: 'Bearer';
expires_in: number;
scope: string;
}
/** OAuth 2.0 error response (RFC 6749 §5.2). */
export interface IOAuth2ErrorResponse {
error: string;
error_description: string;
}
/** Token introspection request (RFC 7662). */
export interface IIntrospectRequest {
token: string;
token_type_hint?: string;
}
/** Token introspection response (RFC 7662). */
export interface IIntrospectResponse {
active: boolean;
sub?: string;
client_id?: string;
scope?: string;
token_type?: string;
iat?: number;
exp?: number;
}
/** Token revocation request (RFC 7009). */
export interface IRevokeRequest {
token: string;
token_type_hint?: string;
}
// ============================================================================
// Audit Log
// ============================================================================
/** An immutable audit event record. */
export interface IAuditEvent {
eventId: string;
agentId: string;
action: AuditAction;
outcome: AuditOutcome;
ipAddress: string;
userAgent: string;
metadata: Record<string, unknown>;
timestamp: Date;
}
/** Input for creating a new audit event. */
export interface ICreateAuditEventInput {
agentId: string;
action: AuditAction;
outcome: AuditOutcome;
ipAddress: string;
userAgent: string;
metadata: Record<string, unknown>;
}
/** Paginated list of audit events. */
export interface IPaginatedAuditEventsResponse {
data: IAuditEvent[];
total: number;
page: number;
limit: number;
}
/** Query filters for the audit log. */
export interface IAuditListFilters {
agentId?: string;
action?: AuditAction;
outcome?: AuditOutcome;
fromDate?: string;
toDate?: string;
page: number;
limit: number;
}
// ============================================================================
// API Error Response
// ============================================================================
/** Standard error response envelope used across all SentryAgent.ai APIs. */
export interface IErrorResponse {
code: string;
message: string;
details?: Record<string, unknown>;
}
// ============================================================================
// Express type augmentation
// ============================================================================
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Express {
interface Request {
/** Decoded JWT payload attached by the auth middleware. */
user?: ITokenPayload;
}
}
}

16
src/utils/asyncHandler.ts Normal file
View File

@@ -0,0 +1,16 @@
import { Request, Response, NextFunction, RequestHandler } from 'express';
/**
* Wraps an async Express handler to forward rejected promises to next().
* Required because Express 4.x does not natively handle async route errors.
*
* @param fn - Async Express handler function.
* @returns Synchronous Express RequestHandler.
*/
export function asyncHandler(
fn: (req: Request, res: Response, next: NextFunction) => Promise<void>,
): RequestHandler {
return (req, res, next) => {
fn(req, res, next).catch(next);
};
}

43
src/utils/crypto.ts Normal file
View File

@@ -0,0 +1,43 @@
/**
* Cryptographic utilities for SentryAgent.ai AgentIdP.
* Handles client secret generation and bcrypt hashing.
*/
import crypto from 'crypto';
import bcrypt from 'bcryptjs';
const BCRYPT_ROUNDS = 10;
const SECRET_PREFIX = 'sk_live_';
const SECRET_RANDOM_BYTES = 32;
/**
* Generates a new client secret with the `sk_live_` prefix followed by 64 hex chars
* (32 random bytes = 256 bits of entropy).
*
* @returns Plain-text client secret in the format `sk_live_<64 hex chars>`.
*/
export function generateClientSecret(): string {
const randomBytes = crypto.randomBytes(SECRET_RANDOM_BYTES);
return `${SECRET_PREFIX}${randomBytes.toString('hex')}`;
}
/**
* Hashes a plain-text secret using bcrypt with 10 rounds.
*
* @param plain - The plain-text secret to hash.
* @returns Promise resolving to the bcrypt hash string.
*/
export async function hashSecret(plain: string): Promise<string> {
return bcrypt.hash(plain, BCRYPT_ROUNDS);
}
/**
* Verifies a plain-text secret against a stored bcrypt hash.
*
* @param plain - The plain-text secret provided by the client.
* @param hash - The bcrypt hash stored in the database.
* @returns Promise resolving to `true` if the secret matches, `false` otherwise.
*/
export async function verifySecret(plain: string, hash: string): Promise<boolean> {
return bcrypt.compare(plain, hash);
}

170
src/utils/errors.ts Normal file
View File

@@ -0,0 +1,170 @@
/**
* SentryAgentError hierarchy.
* All custom errors extend SentryAgentError.
* Error-to-HTTP-status mapping is handled exclusively in errorHandler.ts.
*/
/**
* Base class for all SentryAgent.ai custom errors.
* Carry a machine-readable `code`, HTTP status, and optional structured details.
*/
export class SentryAgentError extends Error {
/**
* @param message - Human-readable error description.
* @param code - Machine-readable error code.
* @param httpStatus - HTTP status code to return.
* @param details - Optional structured detail map.
*/
constructor(
message: string,
public readonly code: string,
public readonly httpStatus: number,
public readonly details?: Record<string, unknown>,
) {
super(message);
this.name = this.constructor.name;
// Restore prototype chain for instanceof checks
Object.setPrototypeOf(this, new.target.prototype);
}
}
/** 400 — Request failed validation. */
export class ValidationError extends SentryAgentError {
constructor(message: string, details?: Record<string, unknown>) {
super(message, 'VALIDATION_ERROR', 400, details);
}
}
/** 404 — Referenced agent was not found. */
export class AgentNotFoundError extends SentryAgentError {
constructor(agentId?: string) {
super(
'Agent with the specified ID was not found.',
'AGENT_NOT_FOUND',
404,
agentId ? { agentId } : undefined,
);
}
}
/** 409 — Agent with this email already exists. */
export class AgentAlreadyExistsError extends SentryAgentError {
constructor(email: string) {
super(
'An agent with this email address is already registered.',
'AGENT_ALREADY_EXISTS',
409,
{ email },
);
}
}
/** 404 — Referenced credential was not found. */
export class CredentialNotFoundError extends SentryAgentError {
constructor(credentialId?: string) {
super(
'Credential with the specified ID was not found.',
'CREDENTIAL_NOT_FOUND',
404,
credentialId ? { credentialId } : undefined,
);
}
}
/** 409 — Credential is already revoked. */
export class CredentialAlreadyRevokedError extends SentryAgentError {
constructor(credentialId: string, revokedAt: string) {
super(
'This credential has already been revoked.',
'CREDENTIAL_ALREADY_REVOKED',
409,
{ credentialId, revokedAt },
);
}
}
/** 409 — Agent is already decommissioned. */
export class AgentAlreadyDecommissionedError extends SentryAgentError {
constructor(agentId: string) {
super(
'This agent has already been decommissioned.',
'AGENT_ALREADY_DECOMMISSIONED',
409,
{ agentId },
);
}
}
/** 400 — Credential operation error (e.g. agent not active). */
export class CredentialError extends SentryAgentError {
constructor(message: string, code: string, details?: Record<string, unknown>) {
super(message, code, 400, details);
}
}
/** 401 — Authentication failed (missing or invalid token). */
export class AuthenticationError extends SentryAgentError {
constructor(message = 'A valid Bearer token is required to access this resource.') {
super(message, 'UNAUTHORIZED', 401);
}
}
/** 403 — Authorisation failed (insufficient permissions). */
export class AuthorizationError extends SentryAgentError {
constructor(message = 'You do not have permission to perform this action.') {
super(message, 'FORBIDDEN', 403);
}
}
/** 429 — Rate limit exceeded. */
export class RateLimitError extends SentryAgentError {
constructor() {
super(
'Too many requests. Please retry after the rate limit window resets.',
'RATE_LIMIT_EXCEEDED',
429,
);
}
}
/** 403 — Free tier resource limit reached. */
export class FreeTierLimitError extends SentryAgentError {
constructor(message: string, details?: Record<string, unknown>) {
super(message, 'FREE_TIER_LIMIT_EXCEEDED', 403, details);
}
}
/** 403 — Token does not have the required scope. */
export class InsufficientScopeError extends SentryAgentError {
constructor(requiredScope: string) {
super(
`The '${requiredScope}' scope is required to access this resource.`,
'INSUFFICIENT_SCOPE',
403,
);
}
}
/** 404 — Audit event not found. */
export class AuditEventNotFoundError extends SentryAgentError {
constructor(eventId?: string) {
super(
'Audit event with the specified ID was not found.',
'AUDIT_EVENT_NOT_FOUND',
404,
eventId ? { eventId } : undefined,
);
}
}
/** 400 — Requested date range exceeds audit log retention window. */
export class RetentionWindowError extends SentryAgentError {
constructor(retentionDays: number, earliestAvailable: string) {
super(
`Free tier audit log retention is ${retentionDays} days. Requested date is outside the retention window.`,
'RETENTION_WINDOW_EXCEEDED',
400,
{ retentionDays, earliestAvailable },
);
}
}

69
src/utils/jwt.ts Normal file
View File

@@ -0,0 +1,69 @@
/**
* JWT utilities for SentryAgent.ai AgentIdP.
* Signs and verifies RS256 JWTs for agent access tokens.
*/
import jwt from 'jsonwebtoken';
import { ITokenPayload } from '../types/index.js';
const TOKEN_EXPIRES_IN = 3600; // 1 hour in seconds
/**
* Signs a JWT access token using RS256 (RSA private key).
*
* @param payload - The token payload containing sub, client_id, scope, jti.
* @param privateKey - PEM-encoded RSA private key.
* @returns The signed JWT string.
* @throws Error if signing fails.
*/
export function signToken(
payload: Omit<ITokenPayload, 'iat' | 'exp'>,
privateKey: string,
): string {
const now = Math.floor(Date.now() / 1000);
const fullPayload: ITokenPayload = {
...payload,
iat: now,
exp: now + TOKEN_EXPIRES_IN,
};
return jwt.sign(fullPayload, privateKey, { algorithm: 'RS256' });
}
/**
* Verifies a JWT access token using RS256 (RSA public key).
* Throws if the token is expired, has an invalid signature, or is malformed.
*
* @param token - The JWT string to verify.
* @param publicKey - PEM-encoded RSA public key.
* @returns The decoded, verified token payload.
* @throws JsonWebTokenError | TokenExpiredError if verification fails.
*/
export function verifyToken(token: string, publicKey: string): ITokenPayload {
const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] });
return decoded as ITokenPayload;
}
/**
* Decodes a JWT without verifying the signature.
* Used for extracting claims (e.g. jti, exp) from tokens that may be expired.
*
* @param token - The JWT string to decode.
* @returns The decoded payload or null if the token is malformed.
*/
export function decodeToken(token: string): ITokenPayload | null {
const decoded = jwt.decode(token);
if (!decoded || typeof decoded === 'string') {
return null;
}
return decoded as ITokenPayload;
}
/**
* Returns the token lifetime in seconds.
*
* @returns Token lifetime (3600 seconds = 1 hour).
*/
export function getTokenExpiresIn(): number {
return TOKEN_EXPIRES_IN;
}

137
src/utils/validators.ts Normal file
View File

@@ -0,0 +1,137 @@
/**
* Joi validation schemas for all request bodies and query parameters.
* All validation logic lives here — controllers invoke these schemas.
*/
import Joi from 'joi';
const SEMVER_PATTERN =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
const CAPABILITY_PATTERN = /^[a-z0-9_-]+:[a-z0-9_*-]+$/;
const AGENT_TYPES = [
'screener',
'classifier',
'orchestrator',
'extractor',
'summarizer',
'router',
'monitor',
'custom',
] as const;
const DEPLOYMENT_ENVS = ['development', 'staging', 'production'] as const;
const AGENT_STATUSES = ['active', 'suspended', 'decommissioned'] as const;
const CREDENTIAL_STATUSES = ['active', 'revoked'] as const;
const AUDIT_ACTIONS = [
'agent.created',
'agent.updated',
'agent.decommissioned',
'agent.suspended',
'agent.reactivated',
'token.issued',
'token.revoked',
'token.introspected',
'credential.generated',
'credential.rotated',
'credential.revoked',
'auth.failed',
] as const;
const AUDIT_OUTCOMES = ['success', 'failure'] as const;
const OAUTH_SCOPES = ['agents:read', 'agents:write', 'tokens:read', 'audit:read'] as const;
/** Schema for POST /agents request body. */
export const createAgentSchema = Joi.object({
email: Joi.string().email().required(),
agentType: Joi.string()
.valid(...AGENT_TYPES)
.required(),
version: Joi.string().pattern(SEMVER_PATTERN).required(),
capabilities: Joi.array()
.items(Joi.string().pattern(CAPABILITY_PATTERN))
.min(1)
.required(),
owner: Joi.string().min(1).max(128).required(),
deploymentEnv: Joi.string()
.valid(...DEPLOYMENT_ENVS)
.required(),
});
/** Schema for PATCH /agents/:agentId request body. */
export const updateAgentSchema = Joi.object({
agentType: Joi.string().valid(...AGENT_TYPES),
version: Joi.string().pattern(SEMVER_PATTERN),
capabilities: Joi.array().items(Joi.string().pattern(CAPABILITY_PATTERN)).min(1),
owner: Joi.string().min(1).max(128),
deploymentEnv: Joi.string().valid(...DEPLOYMENT_ENVS),
status: Joi.string().valid(...AGENT_STATUSES),
})
.min(1)
.options({ allowUnknown: false });
/** Schema for GET /agents query params. */
export const listAgentsQuerySchema = Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
owner: Joi.string(),
agentType: Joi.string().valid(...AGENT_TYPES),
status: Joi.string().valid(...AGENT_STATUSES),
});
/** Schema for POST /token request body (form-encoded). */
export const tokenRequestSchema = Joi.object({
grant_type: Joi.string().required(),
client_id: Joi.string().uuid(),
client_secret: Joi.string(),
scope: Joi.string().pattern(
new RegExp(
`^(${OAUTH_SCOPES.join('|')})(\\s(${OAUTH_SCOPES.join('|')}))*$`,
),
),
});
/** Schema for POST /token/introspect request body. */
export const introspectRequestSchema = Joi.object({
token: Joi.string().required(),
token_type_hint: Joi.string().valid('access_token'),
});
/** Schema for POST /token/revoke request body. */
export const revokeRequestSchema = Joi.object({
token: Joi.string().required(),
token_type_hint: Joi.string().valid('access_token'),
});
/** Schema for POST /agents/:agentId/credentials request body. */
export const generateCredentialSchema = Joi.object({
expiresAt: Joi.date().iso().min('now').optional(),
});
/** Schema for GET /agents/:agentId/credentials query params. */
export const listCredentialsQuerySchema = Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
status: Joi.string().valid(...CREDENTIAL_STATUSES),
});
/** Schema for GET /audit query params. */
export const auditQuerySchema = Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(200).default(50),
agentId: Joi.string().uuid(),
action: Joi.string().valid(...AUDIT_ACTIONS),
outcome: Joi.string().valid(...AUDIT_OUTCOMES),
fromDate: Joi.string().isoDate(),
toDate: Joi.string().isoDate(),
});
/** Schema for UUID path parameters. */
export const uuidParamSchema = Joi.object({
id: Joi.string().uuid().required(),
});