409 lines
14 KiB
TypeScript
409 lines
14 KiB
TypeScript
|
|
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<void> {
|
||
|
|
// ---- 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<string[]>(
|
||
|
|
() => ['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<string, unknown>;
|
||
|
|
}>;
|
||
|
|
const fullSpec = {
|
||
|
|
name: template.title,
|
||
|
|
description: template.shortDescription,
|
||
|
|
tools: toolsRaw.map((t) => ({
|
||
|
|
name: t.name,
|
||
|
|
description: t.description,
|
||
|
|
inputSchema: t.inputSchema as Record<string, never>,
|
||
|
|
// 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;
|
||
|
|
}
|