diff --git a/packages/server/package.json b/packages/server/package.json index b65ba78..bdc1f37 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -10,10 +10,12 @@ }, "dependencies": { "@agent-fox/shared": "workspace:*", + "@apidevtools/swagger-parser": "^12.1.0", "bcrypt": "^6.0.0", "cors": "^2.8.5", "express": "^5.0.0", "jsonwebtoken": "^9.0.3", + "openapi-types": "^12.1.3", "zod": "^3.24.0" }, "devDependencies": { diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index a436195..d94db1a 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1,6 +1,10 @@ import express from 'express'; import cors from 'cors'; import authRouter from './routes/auth.js'; +import projectRouter from './routes/projects.js'; +import importRouter from './routes/import.js'; +import moduleRouter from './routes/modules.js'; +import endpointRouter from './routes/endpoints.js'; const app = express(); app.use(cors()); @@ -11,6 +15,10 @@ app.get('/api/health', (_req, res) => { }); app.use('/api/auth', authRouter); +app.use('/api/projects', projectRouter); +app.use('/api/projects', importRouter); +app.use('/api/projects', moduleRouter); +app.use('/api/projects', endpointRouter); const port = process.env.SERVER_PORT || 3000; app.listen(port, () => { diff --git a/packages/server/src/lib/api-key.ts b/packages/server/src/lib/api-key.ts new file mode 100644 index 0000000..2a7e9e4 --- /dev/null +++ b/packages/server/src/lib/api-key.ts @@ -0,0 +1,14 @@ +import { randomBytes } from 'node:crypto'; +import bcrypt from 'bcrypt'; + +const PREFIX = 'afk_'; + +export function generateApiKey(): { raw: string; hash: string } { + const raw = PREFIX + randomBytes(24).toString('base64url'); + const hash = bcrypt.hashSync(raw, 8); + return { raw, hash }; +} + +export async function verifyApiKey(raw: string, hash: string): Promise { + return bcrypt.compare(raw, hash); +} diff --git a/packages/server/src/routes/endpoints.ts b/packages/server/src/routes/endpoints.ts new file mode 100644 index 0000000..0a76373 --- /dev/null +++ b/packages/server/src/routes/endpoints.ts @@ -0,0 +1,86 @@ +import { Router, type Router as RouterType } from 'express'; +import { z } from 'zod'; +import { prisma } from '@agent-fox/shared'; +import { requireAuth } from '../middleware/auth.js'; + +const router: RouterType = Router(); +router.use(requireAuth); + +router.get('/:id/endpoints', async (req, res) => { + const project = await prisma.project.findFirst({ + where: { id: req.params.id, userId: req.user!.userId }, + }); + if (!project) { + res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } }); + return; + } + const { moduleId } = req.query; + const where: any = { projectId: req.params.id }; + if (moduleId) where.moduleId = moduleId; + + const endpoints = await prisma.endpoint.findMany({ + where, + select: { + id: true, method: true, path: true, summary: true, + deprecated: true, moduleId: true, module: { select: { name: true } }, + }, + orderBy: [{ path: 'asc' }, { method: 'asc' }], + }); + res.json({ success: true, data: endpoints }); +}); + +router.get('/:id/endpoints/:eid', async (req, res) => { + const project = await prisma.project.findFirst({ + where: { id: req.params.id, userId: req.user!.userId }, + }); + if (!project) { + res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } }); + return; + } + const endpoint = await prisma.endpoint.findFirst({ + where: { id: req.params.eid, projectId: req.params.id }, + include: { module: { select: { name: true } } }, + }); + if (!endpoint) { + res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Endpoint not found' } }); + return; + } + res.json({ success: true, data: endpoint }); +}); + +const moveEndpointSchema = z.object({ + moduleId: z.string().uuid(), +}); + +router.patch('/:id/endpoints/:eid', async (req, res) => { + const project = await prisma.project.findFirst({ + where: { id: req.params.id, userId: req.user!.userId }, + }); + if (!project) { + res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } }); + return; + } + const parsed = moveEndpointSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } }); + return; + } + const targetModule = await prisma.module.findFirst({ + where: { id: parsed.data.moduleId, projectId: req.params.id }, + }); + if (!targetModule) { + res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Target module not found in this project' } }); + return; + } + const result = await prisma.endpoint.updateMany({ + where: { id: req.params.eid, projectId: req.params.id }, + data: { moduleId: parsed.data.moduleId }, + }); + if (result.count === 0) { + res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Endpoint not found' } }); + return; + } + res.json({ success: true, data: { moved: true } }); +}); + +export default router; diff --git a/packages/server/src/routes/import.ts b/packages/server/src/routes/import.ts new file mode 100644 index 0000000..c6a1c98 --- /dev/null +++ b/packages/server/src/routes/import.ts @@ -0,0 +1,76 @@ +import { Router, type Router as RouterType } from 'express'; +import { prisma } from '@agent-fox/shared'; +import { requireAuth } from '../middleware/auth.js'; +import { parseOpenApiDocument } from '../services/openapi-parser.js'; + +const router: RouterType = Router(); +router.use(requireAuth); + +router.post('/:id/reimport', async (req, res) => { + const { spec, specUrl } = req.body; + if (!spec && !specUrl) { + res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Provide spec or specUrl' } }); + return; + } + + const project = await prisma.project.findFirst({ + where: { id: req.params.id, userId: req.user!.userId }, + }); + if (!project) { + res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } }); + return; + } + + try { + const input = specUrl || spec; + const parsed = await parseOpenApiDocument(input); + + await prisma.$transaction(async (tx) => { + await tx.endpoint.deleteMany({ where: { projectId: project.id } }); + await tx.module.deleteMany({ where: { projectId: project.id } }); + + await tx.project.update({ + where: { id: project.id }, + data: { + name: parsed.name, description: parsed.description, baseUrl: parsed.baseUrl, + openApiSpec: parsed.spec as any, openApiVersion: parsed.openApiVersion, + }, + }); + + const moduleIdMap = new Map(); + for (let i = 0; i < parsed.modules.length; i++) { + const mod = parsed.modules[i]; + const created = await tx.module.create({ + data: { + projectId: project.id, name: mod.name, description: mod.description, + sortOrder: i, source: mod.source, + }, + }); + moduleIdMap.set(mod.name, created.id); + } + + for (const ep of parsed.endpoints) { + const moduleId = moduleIdMap.get(ep.moduleName); + if (!moduleId) continue; + await tx.endpoint.create({ + data: { + projectId: project.id, moduleId, method: ep.method, path: ep.path, + summary: ep.summary, description: ep.description, operationId: ep.operationId, + parameters: ep.parameters as any, requestBody: ep.requestBody as any, + responses: ep.responses as any, tags: ep.tags, deprecated: ep.deprecated, + }, + }); + } + }); + + res.json({ + success: true, + data: { stats: { modules: parsed.modules.length, endpoints: parsed.endpoints.length } }, + }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to parse OpenAPI document'; + res.status(400).json({ success: false, error: { code: 'PARSE_ERROR', message } }); + } +}); + +export default router; diff --git a/packages/server/src/routes/modules.ts b/packages/server/src/routes/modules.ts new file mode 100644 index 0000000..2e4631d --- /dev/null +++ b/packages/server/src/routes/modules.ts @@ -0,0 +1,102 @@ +import { Router, type Router as RouterType } from 'express'; +import { z } from 'zod'; +import { prisma } from '@agent-fox/shared'; +import { requireAuth } from '../middleware/auth.js'; + +const router: RouterType = Router(); +router.use(requireAuth); + +async function verifyProjectOwnership(projectId: string, userId: string) { + return prisma.project.findFirst({ where: { id: projectId, userId } }); +} + +router.get('/:id/modules', async (req, res) => { + const project = await verifyProjectOwnership(req.params.id, req.user!.userId); + if (!project) { + res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } }); + return; + } + const modules = await prisma.module.findMany({ + where: { projectId: req.params.id }, + include: { _count: { select: { endpoints: true } } }, + orderBy: { sortOrder: 'asc' }, + }); + res.json({ success: true, data: modules }); +}); + +const createModuleSchema = z.object({ + name: z.string().min(1).max(100), + description: z.string().max(500).optional(), +}); + +router.post('/:id/modules', async (req, res) => { + const project = await verifyProjectOwnership(req.params.id, req.user!.userId); + if (!project) { + res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } }); + return; + } + const parsed = createModuleSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } }); + return; + } + const maxOrder = await prisma.module.aggregate({ + where: { projectId: req.params.id }, + _max: { sortOrder: true }, + }); + const mod = await prisma.module.create({ + data: { + projectId: req.params.id, name: parsed.data.name, + description: parsed.data.description, sortOrder: (maxOrder._max.sortOrder || 0) + 1, + source: 'manual', + }, + }); + res.status(201).json({ success: true, data: mod }); +}); + +const updateModuleSchema = z.object({ + name: z.string().min(1).max(100).optional(), + description: z.string().max(500).optional(), + sortOrder: z.number().int().min(0).optional(), +}); + +router.put('/:id/modules/:mid', async (req, res) => { + const project = await verifyProjectOwnership(req.params.id, req.user!.userId); + if (!project) { + res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } }); + return; + } + const parsed = updateModuleSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } }); + return; + } + const mod = await prisma.module.updateMany({ + where: { id: req.params.mid, projectId: req.params.id }, + data: parsed.data, + }); + if (mod.count === 0) { + res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Module not found' } }); + return; + } + const updated = await prisma.module.findUnique({ where: { id: req.params.mid } }); + res.json({ success: true, data: updated }); +}); + +router.delete('/:id/modules/:mid', async (req, res) => { + const project = await verifyProjectOwnership(req.params.id, req.user!.userId); + if (!project) { + res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } }); + return; + } + const result = await prisma.module.deleteMany({ + where: { id: req.params.mid, projectId: req.params.id }, + }); + if (result.count === 0) { + res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Module not found' } }); + return; + } + res.json({ success: true, data: { deleted: true } }); +}); + +export default router; diff --git a/packages/server/src/routes/projects.ts b/packages/server/src/routes/projects.ts new file mode 100644 index 0000000..c117ecc --- /dev/null +++ b/packages/server/src/routes/projects.ts @@ -0,0 +1,152 @@ +import { Router, type Router as RouterType } from 'express'; +import { z } from 'zod'; +import { prisma } from '@agent-fox/shared'; +import { requireAuth } from '../middleware/auth.js'; +import { generateApiKey } from '../lib/api-key.js'; +import { parseOpenApiDocument } from '../services/openapi-parser.js'; + +const router: RouterType = Router(); +router.use(requireAuth); + +router.post('/', async (req, res) => { + const { spec, specUrl } = req.body; + if (!spec && !specUrl) { + res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Provide spec (JSON object) or specUrl (URL string)' } }); + return; + } + + try { + const input = specUrl || spec; + const parsed = await parseOpenApiDocument(input); + const { raw: apiKey, hash: apiKeyHash } = generateApiKey(); + + const project = await prisma.$transaction(async (tx) => { + const proj = await tx.project.create({ + data: { + userId: req.user!.userId, + name: parsed.name, + description: parsed.description, + baseUrl: parsed.baseUrl, + openApiSpec: parsed.spec as any, + openApiVersion: parsed.openApiVersion, + apiKeyHash, + }, + }); + + const moduleIdMap = new Map(); + for (let i = 0; i < parsed.modules.length; i++) { + const mod = parsed.modules[i]; + const created = await tx.module.create({ + data: { + projectId: proj.id, name: mod.name, description: mod.description, + sortOrder: i, source: mod.source, + }, + }); + moduleIdMap.set(mod.name, created.id); + } + + for (const ep of parsed.endpoints) { + const moduleId = moduleIdMap.get(ep.moduleName); + if (!moduleId) continue; + await tx.endpoint.create({ + data: { + projectId: proj.id, moduleId, method: ep.method, path: ep.path, + summary: ep.summary, description: ep.description, operationId: ep.operationId, + parameters: ep.parameters as any, requestBody: ep.requestBody as any, + responses: ep.responses as any, tags: ep.tags, deprecated: ep.deprecated, + }, + }); + } + return proj; + }); + + res.status(201).json({ + success: true, + data: { + project: { id: project.id, name: project.name }, + apiKey, + stats: { modules: parsed.modules.length, endpoints: parsed.endpoints.length }, + }, + }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to parse OpenAPI document'; + res.status(400).json({ success: false, error: { code: 'PARSE_ERROR', message } }); + } +}); + +router.get('/', async (req, res) => { + const projects = await prisma.project.findMany({ + where: { userId: req.user!.userId }, + include: { _count: { select: { endpoints: true, modules: true } } }, + orderBy: { updatedAt: 'desc' }, + }); + res.json({ success: true, data: projects }); +}); + +router.get('/:id', async (req, res) => { + const project = await prisma.project.findFirst({ + where: { id: req.params.id, userId: req.user!.userId }, + include: { + modules: { + include: { _count: { select: { endpoints: true } } }, + orderBy: { sortOrder: 'asc' }, + }, + _count: { select: { endpoints: true } }, + }, + }); + if (!project) { + res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } }); + return; + } + res.json({ success: true, data: project }); +}); + +const updateSchema = z.object({ + name: z.string().min(1).max(200).optional(), + description: z.string().max(1000).optional(), + baseUrl: z.string().url().optional(), +}); + +router.put('/:id', async (req, res) => { + const parsed = updateSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } }); + return; + } + const project = await prisma.project.updateMany({ + where: { id: req.params.id, userId: req.user!.userId }, + data: parsed.data, + }); + if (project.count === 0) { + res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } }); + return; + } + const updated = await prisma.project.findUnique({ where: { id: req.params.id } }); + res.json({ success: true, data: updated }); +}); + +router.delete('/:id', async (req, res) => { + const result = await prisma.project.deleteMany({ + where: { id: req.params.id, userId: req.user!.userId }, + }); + if (result.count === 0) { + res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } }); + return; + } + res.json({ success: true, data: { deleted: true } }); +}); + +router.post('/:id/api-key/rotate', async (req, res) => { + const { raw, hash } = generateApiKey(); + const result = await prisma.project.updateMany({ + where: { id: req.params.id, userId: req.user!.userId }, + data: { apiKeyHash: hash }, + }); + if (result.count === 0) { + res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } }); + return; + } + res.json({ success: true, data: { apiKey: raw } }); +}); + +export default router; diff --git a/packages/server/src/services/openapi-parser.ts b/packages/server/src/services/openapi-parser.ts new file mode 100644 index 0000000..533603f --- /dev/null +++ b/packages/server/src/services/openapi-parser.ts @@ -0,0 +1,108 @@ +import SwaggerParser from '@apidevtools/swagger-parser'; +import type { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types'; + +type OpenApiDoc = OpenAPIV3.Document | OpenAPIV3_1.Document; + +export type ParsedModule = { + name: string; + description: string | null; + source: 'tag' | 'path_prefix'; +}; + +export type ParsedEndpoint = { + method: string; + path: string; + summary: string | null; + description: string | null; + operationId: string | null; + parameters: unknown[]; + requestBody: unknown | null; + responses: Record; + tags: string[]; + deprecated: boolean; + moduleName: string; +}; + +export type ParseResult = { + name: string; + description: string | null; + version: string; + openApiVersion: string; + baseUrl: string | null; + spec: unknown; + modules: ParsedModule[]; + endpoints: ParsedEndpoint[]; +}; + +export async function parseOpenApiDocument(input: string | object): Promise { + const rawApi = await SwaggerParser.validate(input as any); + const api = await SwaggerParser.dereference(rawApi as any) as OpenApiDoc; + + const openApiVersion = 'openapi' in api ? api.openapi : 'unknown'; + const name = api.info.title; + const description = api.info.description || null; + const version = api.info.version; + const baseUrl = api.servers?.[0]?.url || null; + + const tagMap = new Map(); + if (api.tags) { + for (const tag of api.tags) { + tagMap.set(tag.name, tag.description || null); + } + } + + const endpoints: ParsedEndpoint[] = []; + const usedTags = new Set(); + + const paths = api.paths || {}; + for (const [pathStr, pathItem] of Object.entries(paths)) { + if (!pathItem) continue; + const methods = ['get', 'post', 'put', 'delete', 'patch', 'head', 'options'] as const; + for (const method of methods) { + const operation = (pathItem as Record)[method] as OpenAPIV3.OperationObject | undefined; + if (!operation) continue; + + const endpointTags = operation.tags || []; + for (const tag of endpointTags) { + usedTags.add(tag); + if (!tagMap.has(tag)) tagMap.set(tag, null); + } + + const prefix = pathStr.split('/').filter(Boolean)[0] || 'default'; + const moduleName = endpointTags[0] || prefix; + + endpoints.push({ + method: method.toUpperCase(), + path: pathStr, + summary: operation.summary || null, + description: operation.description || null, + operationId: operation.operationId || null, + parameters: (operation.parameters || []) as unknown[], + requestBody: operation.requestBody || null, + responses: (operation.responses || {}) as Record, + tags: endpointTags, + deprecated: operation.deprecated || false, + moduleName, + }); + } + } + + const modules: ParsedModule[] = []; + const moduleNames = new Set(); + + for (const [tagName, tagDesc] of tagMap) { + if (usedTags.has(tagName)) { + modules.push({ name: tagName, description: tagDesc, source: 'tag' }); + moduleNames.add(tagName); + } + } + + for (const endpoint of endpoints) { + if (endpoint.tags.length === 0 && !moduleNames.has(endpoint.moduleName)) { + modules.push({ name: endpoint.moduleName, description: null, source: 'path_prefix' }); + moduleNames.add(endpoint.moduleName); + } + } + + return { name, description, version, openApiVersion, baseUrl, spec: api, modules, endpoints }; +}