import crypto from 'node:crypto'; import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { and, builds, count, createDb, desc, eq, gte, mcpServers, organizations, sql, templates, users, } from '@bmm/db'; import { GeneratorSpec } from '@bmm/types'; import { requireAuth, requireAdmin } from '../plugins/session.js'; import { audit } from '../lib/audit.js'; import { cacheSpec, cachePrebuiltCode } from '../lib/preview-cache.js'; const db = createDb(); const BANNED_PATTERNS = [ /\beval\s*\(/, /\bnew\s+Function\s*\(/, /\bchild_process\b/, /ignore\s+previous\s+instructions/i, /disregard\s+(the\s+)?(above|previous)/i, ]; function scanForInjection(code: string): void { for (const pattern of BANNED_PATTERNS) { if (pattern.test(code)) throw new Error(`banned_pattern: ${pattern.source}`); } } const CATEGORIES = [ 'productivity', 'developer-tools', 'data', 'communication', 'finance', 'crm', 'analytics', 'devops', 'demo', 'other', ] as const; const SecretHint = z.object({ key: z.string().regex(/^[A-Z][A-Z0-9_]*$/), description: z.string().min(1).max(280), howToGetUrl: z.string().url().optional(), }); const PublishInput = z.object({ serverId: z.string().uuid(), title: z.string().min(3).max(128), shortDescription: z.string().min(10).max(280), longDescription: z.string().max(8000).optional(), category: z.enum(CATEGORIES), secretHints: z.array(SecretHint).max(30).default([]), allowedDomains: z.array(z.string()).max(50).optional(), }); export async function templateRoutes(app: FastifyInstance): Promise { // ---- Publish (user → template) ---- app.post('/v1/templates', { preHandler: requireAuth }, async (req, reply) => { const user = req.user!; const parsed = PublishInput.safeParse(req.body); if (!parsed.success) { return reply.code(400).send({ error: 'invalid_input', issues: parsed.error.flatten() }); } const [server] = await db .select() .from(mcpServers) .where(and(eq(mcpServers.id, parsed.data.serverId), eq(mcpServers.orgId, user.orgId))) .limit(1); if (!server) return reply.code(404).send({ error: 'server_not_found' }); if (server.status !== 'live') { return reply .code(400) .send({ error: 'server_not_live', detail: 'Only live servers can be published.' }); } if (!server.toolsSchema) { return reply.code(400).send({ error: 'no_tools_schema' }); } // Get the last successful build's generated code (this is the implementation) const [build] = await db .select() .from(builds) .where(and(eq(builds.serverId, server.id), eq(builds.status, 'success'))) .orderBy(desc(builds.version)) .limit(1); if (!build || !build.generatedCode) { return reply.code(400).send({ error: 'no_generated_code' }); } // Re-validate code against banned patterns (catch any drift since build) try { scanForInjection(build.generatedCode); } catch (err) { return reply.code(422).send({ error: 'banned_pattern', detail: (err as Error).message }); } // Build a unique template slug const baseSlug = parsed.data.title .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/(^-|-$)/g, '') .slice(0, 48); let slug = baseSlug || `template-${crypto.randomBytes(3).toString('hex')}`; let attempt = 0; while (true) { const existing = await db.select({ id: templates.id }).from(templates).where(eq(templates.slug, slug)).limit(1); if (existing.length === 0) break; attempt++; slug = `${baseSlug}-${crypto.randomBytes(2).toString('hex')}`; if (attempt > 5) { return reply.code(500).send({ error: 'slug_conflict' }); } } const [template] = await db .insert(templates) .values({ ownerUserId: user.userId, ownerOrgId: user.orgId, sourceServerId: server.id, slug, title: parsed.data.title, shortDescription: parsed.data.shortDescription, longDescription: parsed.data.longDescription ?? null, category: parsed.data.category, toolsSchema: server.toolsSchema, generatedCode: build.generatedCode, requiredSecrets: parsed.data.secretHints, scopes: (server.toolsSchema as Array<{ scopes?: string[] }>).reduce( () => ['mcp:read'], [], ), allowedDomains: parsed.data.allowedDomains ?? null, }) .returning(); if (!template) return reply.code(500).send({ error: 'template_create_failed' }); await audit({ orgId: user.orgId, userId: user.userId, action: 'template.publish', resourceType: 'template', resourceId: template.id, metadata: { slug, title: parsed.data.title, fromServerId: server.id }, ipAddress: req.ip, }); return reply.send({ template }); }); // ---- Public list with ranking ---- app.get('/v1/templates', async (req, reply) => { const Query = z.object({ category: z.string().optional(), sort: z.enum(['trending', 'top', 'newest']).default('trending'), limit: z.coerce.number().min(1).max(100).default(50), }); const parsed = Query.safeParse(req.query); if (!parsed.success) return reply.code(400).send({ error: 'invalid_query' }); const rows = await db .select({ template: templates, ownerName: users.name, ownerEmail: users.email, ownerOrgName: organizations.name, }) .from(templates) .leftJoin(users, eq(users.id, templates.ownerUserId)) .leftJoin(organizations, eq(organizations.id, templates.ownerOrgId)) .where(eq(templates.status, 'public')) .orderBy(desc(templates.createdAt)) .limit(parsed.data.limit); const filtered = parsed.data.category ? rows.filter((r) => r.template.category === parsed.data.category) : rows; // Augment with active deployment counts const enriched = await Promise.all( filtered.map(async (r) => { const [active] = await db .select({ c: count() }) .from(mcpServers) .where(and(eq(mcpServers.templateId, r.template.id), eq(mcpServers.status, 'live'))); return { ...r.template, ownerName: r.ownerName ?? r.ownerEmail?.split('@')[0] ?? null, ownerOrgName: r.ownerOrgName, activeDeployments: Number(active?.c ?? 0), }; }), ); // Sort const now = Date.now(); const ranked = [...enriched].sort((a, b) => { if (parsed.data.sort === 'newest') { return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); } if (parsed.data.sort === 'top') { return b.forkCount - a.forkCount; } // trending: (active*3 + forks) / sqrt(ageDays + 1) const score = (t: (typeof enriched)[number]): number => { const ageDays = Math.max(1, (now - new Date(t.createdAt).getTime()) / 86_400_000); return (t.activeDeployments * 3 + t.forkCount) / Math.sqrt(ageDays); }; return score(b) - score(a); }); return reply.send({ templates: ranked, categories: CATEGORIES }); }); // ---- Detail ---- app.get('/v1/templates/:slug', async (req, reply) => { const Params = z.object({ slug: z.string().min(1).max(64) }); const parsed = Params.safeParse(req.params); if (!parsed.success) return reply.code(400).send({ error: 'invalid_slug' }); const [row] = await db .select({ template: templates, ownerName: users.name, ownerEmail: users.email, ownerOrgName: organizations.name, }) .from(templates) .leftJoin(users, eq(users.id, templates.ownerUserId)) .leftJoin(organizations, eq(organizations.id, templates.ownerOrgId)) .where(eq(templates.slug, parsed.data.slug)) .limit(1); if (!row) return reply.code(404).send({ error: 'not_found' }); if (row.template.status === 'takedown') { return reply.code(410).send({ error: 'taken_down', reason: row.template.takedownReason, }); } if (row.template.status !== 'public') { // hidden / draft — only owner can view // Note: viewing endpoint is public, so we 404 to non-owners. // (Owner UI would use a separate auth'd endpoint; out of scope for v1.) return reply.code(404).send({ error: 'not_found' }); } const [active] = await db .select({ c: count() }) .from(mcpServers) .where(and(eq(mcpServers.templateId, row.template.id), eq(mcpServers.status, 'live'))); return reply.send({ template: { ...row.template, ownerName: row.ownerName ?? row.ownerEmail?.split('@')[0] ?? null, ownerOrgName: row.ownerOrgName, activeDeployments: Number(active?.c ?? 0), }, }); }); // ---- Fork (returns previewId so wizard can complete with user's secrets) ---- app.post('/v1/templates/:slug/fork', { preHandler: requireAuth }, async (req, reply) => { const Params = z.object({ slug: z.string().min(1).max(64) }); const parsed = Params.safeParse(req.params); if (!parsed.success) return reply.code(400).send({ error: 'invalid_slug' }); const [template] = await db .select() .from(templates) .where(eq(templates.slug, parsed.data.slug)) .limit(1); if (!template || template.status !== 'public') { return reply.code(404).send({ error: 'not_found' }); } // Reconstruct a GeneratorSpec from the template that the worker can render const toolsRaw = template.toolsSchema as Array<{ name: string; description: string; inputSchema: Record; }>; const fullSpec = { name: template.title, description: template.shortDescription, tools: toolsRaw.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema as Record, // The actual implementation is in template.generatedCode, but we have to // satisfy the per-tool GeneratorSpec shape. The worker uses the // cached spec only for non-impl fields; render extracts code from full spec. // For fork we ship a placeholder impl — the worker will see previewId // and would normally regenerate. We need a different mechanism: // we use the generatedCode field directly through the cache. implementation: '// implementation provided by template', })), resources: [], prompts: [], requiredSecrets: (template.requiredSecrets as Array<{ key: string }>).map((s) => s.key), scopes: template.scopes as string[], dependencies: {}, }; const validation = GeneratorSpec.safeParse(fullSpec); if (!validation.success) { return reply.code(500).send({ error: 'template_spec_invalid', detail: validation.error.flatten() }); } const previewId = await cacheSpec(validation.data); // Persist the pre-rendered code under the same previewId so the worker uses it // verbatim instead of re-rendering (which would lose the template's per-tool impls). await cachePrebuiltCode(previewId, template.generatedCode); return reply.send({ previewId, templateId: template.id, template: { slug: template.slug, title: template.title, shortDescription: template.shortDescription, tools: toolsRaw, requiredSecrets: template.requiredSecrets, }, }); }); // ---- Admin moderation ---- app.get('/v1/admin/templates', { preHandler: requireAdmin }, async (_req, reply) => { const rows = await db .select({ template: templates, ownerEmail: users.email, ownerOrgName: organizations.name, }) .from(templates) .leftJoin(users, eq(users.id, templates.ownerUserId)) .leftJoin(organizations, eq(organizations.id, templates.ownerOrgId)) .orderBy(desc(templates.createdAt)); const enriched = await Promise.all( rows.map(async (r) => { const [active] = await db .select({ c: count() }) .from(mcpServers) .where(and(eq(mcpServers.templateId, r.template.id), eq(mcpServers.status, 'live'))); return { ...r.template, ownerEmail: r.ownerEmail, ownerOrgName: r.ownerOrgName, activeDeployments: Number(active?.c ?? 0), }; }), ); return reply.send({ templates: enriched }); }); app.patch('/v1/admin/templates/:id', { preHandler: requireAdmin }, async (req, reply) => { const Params = z.object({ id: z.string().uuid() }); const Body = z.object({ verified: z.boolean().optional(), status: z.enum(['draft', 'public', 'hidden', 'takedown']).optional(), takedownReason: z.string().max(500).nullable().optional(), }); const p = Params.safeParse(req.params); const b = Body.safeParse(req.body); if (!p.success || !b.success) return reply.code(400).send({ error: 'invalid_input' }); await db .update(templates) .set({ ...b.data, updatedAt: new Date() }) .where(eq(templates.id, p.data.id)); // If takedown, also pause any forked servers — they ran code we no longer trust if (b.data.status === 'takedown') { await db .update(mcpServers) .set({ status: 'paused', updatedAt: new Date() }) .where(eq(mcpServers.templateId, p.data.id)); } await audit({ orgId: req.user!.orgId, userId: req.user!.userId, action: 'admin.template.update', resourceType: 'template', resourceId: p.data.id, metadata: b.data, ipAddress: req.ip, }); return reply.send({ ok: true }); }); // unused-import guard void gte; void sql; }