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:
16
src/utils/asyncHandler.ts
Normal file
16
src/utils/asyncHandler.ts
Normal 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
43
src/utils/crypto.ts
Normal 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
170
src/utils/errors.ts
Normal 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
69
src/utils/jwt.ts
Normal 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
137
src/utils/validators.ts
Normal 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(),
|
||||
});
|
||||
Reference in New Issue
Block a user