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

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