/** * OpenAPI 3.1 specification for ABE API. * Uses @asteasolutions/zod-to-openapi to generate from Zod schemas. */ import { Router, Request, Response } from 'express'; import { z } from 'zod'; import { extendZodWithOpenApi, OpenApiGeneratorV31, OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; import { apiReference } from '@scalar/express-api-reference'; // Extend Zod with OpenAPI metadata support extendZodWithOpenApi(z); // ─── Registry ───────────────────────────────────────────────────────────────── const registry = new OpenAPIRegistry(); // ─── Reusable schemas ───────────────────────────────────────────────────────── const ErrorSchema = registry.register( 'Error', z.object({ error: z.string() }).openapi('Error') ); // Auth schemas const RegisterRequestSchema = registry.register( 'RegisterRequest', z.object({ email: z.string().email(), password: z.string().min(8), name: z.string().optional(), }).openapi('RegisterRequest') ); const LoginRequestSchema = registry.register( 'LoginRequest', z.object({ email: z.string().email(), password: z.string(), }).openapi('LoginRequest') ); const UserSchema = registry.register( 'User', z.object({ id: z.string(), email: z.string(), name: z.string().nullable(), role: z.enum(['owner', 'admin', 'member', 'viewer']), createdAt: z.number(), }).openapi('User') ); // Session schemas const SessionStatusSchema = z.enum(['running', 'completed', 'failed', 'stopped']); const CrawlSessionSchema = registry.register( 'CrawlSession', z.object({ id: z.string(), url: z.string().url(), status: SessionStatusSchema, seed: z.number(), maxStates: z.number(), statesVisited: z.number(), createdAt: z.number(), completedAt: z.number().nullable(), }).openapi('CrawlSession') ); const StartSessionRequestSchema = registry.register( 'StartSessionRequest', z.object({ url: z.string().url(), seed: z.number().optional(), maxStates: z.number().optional(), maxDepth: z.number().optional(), allowedDomains: z.array(z.string()).optional(), excludedPaths: z.array(z.string()).optional(), }).openapi('StartSessionRequest') ); // Finding schemas const SeveritySchema = z.enum(['low', 'medium', 'high', 'critical']); const FindingStatusSchema = z.enum(['open', 'investigating', 'resolved', 'closed']); const FindingSchema = registry.register( 'Finding', z.object({ id: z.string(), sessionId: z.string(), severity: SeveritySchema, type: z.string(), description: z.string(), status: FindingStatusSchema, createdAt: z.number(), resolvedAt: z.number().nullable(), }).openapi('Finding') ); // Report schemas const ReportFormatSchema = z.enum(['pdf', 'html', 'json']); const ReportSchema = registry.register( 'Report', z.object({ id: z.string(), format: ReportFormatSchema, status: z.enum(['pending', 'completed', 'failed']), createdAt: z.number(), completedAt: z.number().nullable(), }).openapi('Report') ); // Schedule schemas const ScheduleSchema = registry.register( 'Schedule', z.object({ id: z.string(), name: z.string(), url: z.string(), cronExpression: z.string(), enabled: z.boolean(), lastRunAt: z.number().nullable(), nextRunAt: z.number().nullable(), createdAt: z.number(), }).openapi('Schedule') ); // Integration schemas const IntegrationTypeSchema = z.enum(['slack', 'github', 'jira', 'webhook']); const IntegrationSchema = registry.register( 'Integration', z.object({ id: z.string(), type: IntegrationTypeSchema, name: z.string(), enabled: z.boolean(), createdAt: z.number(), }).openapi('Integration') ); // Visual comparison schemas const ComparisonStatusSchema = z.enum(['passed', 'failed', 'new_state', 'pending']); const VisualComparisonSchema = registry.register( 'VisualComparison', z.object({ id: z.string(), session_id: z.string(), state_id: z.string(), baseline_id: z.string().nullable(), current_screenshot_path: z.string(), diff_screenshot_path: z.string().nullable(), diff_pixels: z.number().nullable(), diff_percent: z.number().nullable(), status: ComparisonStatusSchema, created_at: z.number(), }).openapi('VisualComparison') ); // License schema const LicensePlanSchema = z.enum(['free', 'pro', 'enterprise']); const LicenseStatusSchema = registry.register( 'LicenseStatus', z.object({ plan: LicensePlanSchema, valid: z.boolean(), expiresAt: z.string().nullable(), features: z.array(z.string()), }).openapi('LicenseStatus') ); // ─── Route registrations ─────────────────────────────────────────────────────── const bearerAuth = registry.registerComponent('securitySchemes', 'BearerAuth', { type: 'http', scheme: 'bearer', }); // Auth endpoints registry.registerPath({ method: 'post', path: '/api/auth/register', summary: 'Register a new user', tags: ['Auth'], request: { body: { content: { 'application/json': { schema: RegisterRequestSchema } } } }, responses: { 201: { description: 'User registered', content: { 'application/json': { schema: UserSchema } } }, 400: { description: 'Validation error', content: { 'application/json': { schema: ErrorSchema } } }, }, }); registry.registerPath({ method: 'post', path: '/api/auth/login', summary: 'Login', tags: ['Auth'], request: { body: { content: { 'application/json': { schema: LoginRequestSchema } } } }, responses: { 200: { description: 'Login successful', content: { 'application/json': { schema: UserSchema } } }, 401: { description: 'Invalid credentials', content: { 'application/json': { schema: ErrorSchema } } }, }, }); registry.registerPath({ method: 'post', path: '/api/auth/logout', summary: 'Logout', tags: ['Auth'], security: [{ [bearerAuth.name]: [] }], responses: { 200: { description: 'Logged out' } }, }); registry.registerPath({ method: 'get', path: '/api/auth/me', summary: 'Get current user', tags: ['Auth'], security: [{ [bearerAuth.name]: [] }], responses: { 200: { description: 'Current user', content: { 'application/json': { schema: UserSchema } } }, 401: { description: 'Not authenticated', content: { 'application/json': { schema: ErrorSchema } } }, }, }); registry.registerPath({ method: 'get', path: '/api/auth/setup-required', summary: 'Check if first-run setup is required', tags: ['Auth'], responses: { 200: { description: 'Setup status', content: { 'application/json': { schema: z.object({ required: z.boolean() }), }, }, }, }, }); // Sessions endpoints registry.registerPath({ method: 'get', path: '/api/sessions', summary: 'List all crawl sessions', tags: ['Sessions'], security: [{ [bearerAuth.name]: [] }], responses: { 200: { description: 'List of sessions', content: { 'application/json': { schema: z.array(CrawlSessionSchema) } }, }, }, }); registry.registerPath({ method: 'post', path: '/api/sessions', summary: 'Start a new crawl session', tags: ['Sessions'], security: [{ [bearerAuth.name]: [] }], request: { body: { content: { 'application/json': { schema: StartSessionRequestSchema } } } }, responses: { 201: { description: 'Session started', content: { 'application/json': { schema: CrawlSessionSchema } } }, 400: { description: 'Validation error', content: { 'application/json': { schema: ErrorSchema } } }, }, }); registry.registerPath({ method: 'get', path: '/api/sessions/{id}', summary: 'Get session by ID', tags: ['Sessions'], security: [{ [bearerAuth.name]: [] }], request: { params: z.object({ id: z.string() }) }, responses: { 200: { description: 'Session details', content: { 'application/json': { schema: CrawlSessionSchema } } }, 404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } }, }, }); registry.registerPath({ method: 'delete', path: '/api/sessions/{id}', summary: 'Stop a crawl session', tags: ['Sessions'], security: [{ [bearerAuth.name]: [] }], request: { params: z.object({ id: z.string() }) }, responses: { 200: { description: 'Session stopped' }, 404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } }, }, }); // Findings endpoints registry.registerPath({ method: 'get', path: '/api/findings', summary: 'List findings', tags: ['Findings'], security: [{ [bearerAuth.name]: [] }], request: { query: z.object({ severity: z.string().optional(), type: z.string().optional(), status: z.string().optional(), sessionId: z.string().optional(), search: z.string().optional(), }), }, responses: { 200: { description: 'List of findings', content: { 'application/json': { schema: z.array(FindingSchema) } }, }, }, }); registry.registerPath({ method: 'get', path: '/api/findings/{id}', summary: 'Get finding by ID', tags: ['Findings'], security: [{ [bearerAuth.name]: [] }], request: { params: z.object({ id: z.string() }) }, responses: { 200: { description: 'Finding details', content: { 'application/json': { schema: FindingSchema } } }, 404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } }, }, }); registry.registerPath({ method: 'post', path: '/api/findings/{id}/resolve', summary: 'Resolve a finding', tags: ['Findings'], security: [{ [bearerAuth.name]: [] }], request: { params: z.object({ id: z.string() }) }, responses: { 200: { description: 'Finding resolved', content: { 'application/json': { schema: FindingSchema } } }, 404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } }, }, }); registry.registerPath({ method: 'get', path: '/api/findings/stats', summary: 'Get finding statistics', tags: ['Findings'], security: [{ [bearerAuth.name]: [] }], responses: { 200: { description: 'Statistics', content: { 'application/json': { schema: z.object({ total: z.number(), bySeverity: z.record(z.string(), z.number()), byType: z.record(z.string(), z.number()), byStatus: z.record(z.string(), z.number()), }), }, }, }, }, }); // Reports endpoints registry.registerPath({ method: 'post', path: '/api/reports', summary: 'Generate a report', tags: ['Reports'], security: [{ [bearerAuth.name]: [] }], request: { body: { content: { 'application/json': { schema: z.object({ sessionId: z.string().optional(), format: ReportFormatSchema, }), }, }, }, }, responses: { 202: { description: 'Report generation started', content: { 'application/json': { schema: ReportSchema } } }, }, }); registry.registerPath({ method: 'get', path: '/api/reports', summary: 'List reports', tags: ['Reports'], security: [{ [bearerAuth.name]: [] }], responses: { 200: { description: 'Reports list', content: { 'application/json': { schema: z.array(ReportSchema) } } }, }, }); registry.registerPath({ method: 'get', path: '/api/reports/{id}/download', summary: 'Download report file', tags: ['Reports'], security: [{ [bearerAuth.name]: [] }], request: { params: z.object({ id: z.string() }) }, responses: { 200: { description: 'Report file (PDF, HTML or JSON)' }, 404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } }, }, }); // Schedules endpoints registry.registerPath({ method: 'get', path: '/api/schedules', summary: 'List schedules', tags: ['Scheduling'], security: [{ [bearerAuth.name]: [] }], responses: { 200: { description: 'Schedules list', content: { 'application/json': { schema: z.array(ScheduleSchema) } } }, }, }); registry.registerPath({ method: 'post', path: '/api/schedules', summary: 'Create a schedule', tags: ['Scheduling'], security: [{ [bearerAuth.name]: [] }], request: { body: { content: { 'application/json': { schema: z.object({ name: z.string(), url: z.string().url(), cronExpression: z.string(), config: z.record(z.string(), z.unknown()).optional(), }), }, }, }, }, responses: { 201: { description: 'Schedule created', content: { 'application/json': { schema: ScheduleSchema } } }, 400: { description: 'Invalid cron expression', content: { 'application/json': { schema: ErrorSchema } } }, }, }); registry.registerPath({ method: 'patch', path: '/api/schedules/{id}/toggle', summary: 'Enable or disable a schedule', tags: ['Scheduling'], security: [{ [bearerAuth.name]: [] }], request: { params: z.object({ id: z.string() }) }, responses: { 200: { description: 'Toggled', content: { 'application/json': { schema: ScheduleSchema } } }, 404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } }, }, }); registry.registerPath({ method: 'delete', path: '/api/schedules/{id}', summary: 'Delete a schedule', tags: ['Scheduling'], security: [{ [bearerAuth.name]: [] }], request: { params: z.object({ id: z.string() }) }, responses: { 200: { description: 'Deleted' }, 404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } }, }, }); // Integrations endpoints registry.registerPath({ method: 'get', path: '/api/integrations', summary: 'List integrations', tags: ['Integrations'], security: [{ [bearerAuth.name]: [] }], responses: { 200: { description: 'Integrations list', content: { 'application/json': { schema: z.array(IntegrationSchema) } } }, }, }); registry.registerPath({ method: 'post', path: '/api/integrations', summary: 'Create an integration', tags: ['Integrations'], security: [{ [bearerAuth.name]: [] }], request: { body: { content: { 'application/json': { schema: z.object({ type: IntegrationTypeSchema, name: z.string(), config: z.record(z.string(), z.unknown()), }), }, }, }, }, responses: { 201: { description: 'Integration created', content: { 'application/json': { schema: IntegrationSchema } } }, }, }); // Visual regression endpoints registry.registerPath({ method: 'get', path: '/api/visual/comparisons', summary: 'List visual comparisons', tags: ['Visual Regression'], security: [{ [bearerAuth.name]: [] }], request: { query: z.object({ sessionId: z.string().optional(), status: ComparisonStatusSchema.optional(), }), }, responses: { 200: { description: 'Comparisons list', content: { 'application/json': { schema: z.array(VisualComparisonSchema) } }, }, }, }); registry.registerPath({ method: 'post', path: '/api/visual/baselines/{comparisonId}/approve', summary: 'Approve a comparison as baseline', tags: ['Visual Regression'], security: [{ [bearerAuth.name]: [] }], request: { params: z.object({ comparisonId: z.string() }) }, responses: { 200: { description: 'Approved', content: { 'application/json': { schema: z.object({ baselineId: z.string(), status: z.literal('approved') }), }, }, }, 404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } }, }, }); registry.registerPath({ method: 'post', path: '/api/visual/baselines/{comparisonId}/reject', summary: 'Reject a comparison', tags: ['Visual Regression'], security: [{ [bearerAuth.name]: [] }], request: { params: z.object({ comparisonId: z.string() }) }, responses: { 200: { description: 'Rejected', content: { 'application/json': { schema: z.object({ status: z.literal('rejected') }), }, }, }, 404: { description: 'Not found', content: { 'application/json': { schema: ErrorSchema } } }, }, }); registry.registerPath({ method: 'post', path: '/api/visual/baselines/approve-all', summary: 'Approve all new-state comparisons as baselines', tags: ['Visual Regression'], security: [{ [bearerAuth.name]: [] }], request: { body: { content: { 'application/json': { schema: z.object({ sessionId: z.string().optional() }), }, }, }, }, responses: { 200: { description: 'Bulk approved', content: { 'application/json': { schema: z.object({ approved: z.number() }), }, }, }, }, }); // License endpoints registry.registerPath({ method: 'get', path: '/api/license/status', summary: 'Get license status', tags: ['License'], security: [{ [bearerAuth.name]: [] }], responses: { 200: { description: 'License status', content: { 'application/json': { schema: LicenseStatusSchema } } }, }, }); registry.registerPath({ method: 'post', path: '/api/license/activate', summary: 'Activate a license key', tags: ['License'], security: [{ [bearerAuth.name]: [] }], request: { body: { content: { 'application/json': { schema: z.object({ key: z.string() }), }, }, }, }, responses: { 200: { description: 'License activated', content: { 'application/json': { schema: LicenseStatusSchema } } }, 400: { description: 'Invalid key', content: { 'application/json': { schema: ErrorSchema } } }, }, }); // Health endpoints registry.registerPath({ method: 'get', path: '/health/live', summary: 'Liveness probe', tags: ['Health'], responses: { 200: { description: 'Process alive', content: { 'application/json': { schema: z.object({ status: z.literal('ok'), uptime: z.number() }), }, }, }, }, }); registry.registerPath({ method: 'get', path: '/health/ready', summary: 'Readiness probe', tags: ['Health'], responses: { 200: { description: 'Ready', content: { 'application/json': { schema: z.object({ status: z.literal('ready'), db: z.string() }), }, }, }, 503: { description: 'Not ready', content: { 'application/json': { schema: z.object({ status: z.literal('not_ready'), db: z.string(), error: z.string() }), }, }, }, }, }); // ─── Generate spec ───────────────────────────────────────────────────────────── const generator = new OpenApiGeneratorV31(registry.definitions); export const openApiSpec = generator.generateDocument({ openapi: '3.1.0', info: { title: 'ABE — Autonomous Bug Explorer API', version: '1.0.0', description: 'ABE is an enterprise self-hosted platform for autonomous web application bug discovery. ' + 'This API allows you to manage crawl sessions, review findings, generate reports, and configure integrations.', }, servers: [{ url: 'http://localhost:3001', description: 'Local development' }], }); // ─── Express Router ──────────────────────────────────────────────────────────── export function createApiDocsRouter(): Router { const router = Router(); // Serve the raw OpenAPI JSON spec router.get('/openapi.json', (_req: Request, res: Response) => { res.json(openApiSpec); }); // Serve Scalar UI router.use( '/', apiReference({ spec: { content: openApiSpec }, theme: 'purple', }) ); return router; }