diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 2a0bba6..e204d3d 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -9,6 +9,7 @@ import { serverRoutes } from './routes/servers.js'; import { oauthRoutes } from './routes/oauth.js'; import { settingsRoutes } from './routes/settings.js'; import { adminRoutes } from './routes/admin.js'; +import { templateRoutes } from './routes/templates.js'; const app = Fastify({ logger: { @@ -30,6 +31,7 @@ await app.register(serverRoutes); await app.register(oauthRoutes); await app.register(settingsRoutes); await app.register(adminRoutes); +await app.register(templateRoutes); // Bootstrap admin user from env (idempotent) if (config.ADMIN_EMAIL && config.ADMIN_PASSWORD) { diff --git a/apps/api/src/lib/preview-cache.ts b/apps/api/src/lib/preview-cache.ts index f63afa4..1a68bcb 100644 --- a/apps/api/src/lib/preview-cache.ts +++ b/apps/api/src/lib/preview-cache.ts @@ -27,3 +27,15 @@ export async function loadSpec(previewId: string): Promise export async function overwriteSpec(previewId: string, spec: GeneratorSpec): Promise { await getRedis().set(key(previewId), JSON.stringify(spec), 'EX', TTL_SECONDS); } + +function codeKey(previewId: string): string { + return `prebuilt:${previewId}`; +} + +export async function cachePrebuiltCode(previewId: string, code: string): Promise { + await getRedis().set(codeKey(previewId), code, 'EX', TTL_SECONDS); +} + +export async function loadPrebuiltCode(previewId: string): Promise { + return (await getRedis().get(codeKey(previewId))) ?? null; +} diff --git a/apps/api/src/routes/servers.ts b/apps/api/src/routes/servers.ts index bcd876c..36e71cb 100644 --- a/apps/api/src/routes/servers.ts +++ b/apps/api/src/routes/servers.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; -import { and, builds, buildLogs, createDb, desc, eq, mcpServers, secrets } from '@bmm/db'; +import { and, builds, buildLogs, createDb, desc, eq, mcpServers, secrets, sql, templates } from '@bmm/db'; import { CreateServerInput, IterateServerInput, @@ -75,7 +75,7 @@ export async function serverRoutes(app: FastifyInstance): Promise { if (!parsed.success) { return reply.code(400).send({ error: 'invalid_input', issues: parsed.error.flatten() }); } - const { name, slug, prompt, secrets: secretValues, previewId, specEdit } = parsed.data; + const { name, slug, prompt, secrets: secretValues, previewId, specEdit, templateId } = parsed.data; // If the user edited the spec in step 2 of the wizard, merge their edits into // the cached spec (keeping the original tool implementations untouched). @@ -113,10 +113,17 @@ export async function serverRoutes(app: FastifyInstance): Promise { const [server] = await db .insert(mcpServers) - .values({ orgId: user.orgId, slug, name, status: 'queued' }) + .values({ orgId: user.orgId, slug, name, status: 'queued', templateId: templateId ?? null }) .returning(); if (!server) return reply.code(500).send({ error: 'create_failed' }); + if (templateId) { + await db + .update(templates) + .set({ forkCount: sql`${templates.forkCount} + 1`, updatedAt: new Date() }) + .where(eq(templates.id, templateId)); + } + for (const [key, value] of Object.entries(secretValues)) { if (!value) continue; await db.insert(secrets).values({ diff --git a/apps/api/src/routes/templates.ts b/apps/api/src/routes/templates.ts new file mode 100644 index 0000000..b96669c --- /dev/null +++ b/apps/api/src/routes/templates.ts @@ -0,0 +1,408 @@ +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; +} diff --git a/apps/generator/src/worker.ts b/apps/generator/src/worker.ts index 4952a8d..ba0210e 100644 --- a/apps/generator/src/worker.ts +++ b/apps/generator/src/worker.ts @@ -36,6 +36,10 @@ async function loadCachedSpec(previewId: string): Promise } } +async function loadPrebuiltCode(previewId: string): Promise { + return (await cacheReader.get(`prebuilt:${previewId}`)) ?? null; +} + export const worker = new Worker( 'build', async (job) => { @@ -68,7 +72,16 @@ export const worker = new Worker( } await log('info', `Spec ready via ${source} (${spec.tools.length} tool(s))`); - const generatedCode = renderServerCode(spec); + + // Forks supply pre-rendered code via Redis. If present, use it verbatim. + let generatedCode: string; + const prebuilt = previewId ? await loadPrebuiltCode(previewId) : null; + if (prebuilt) { + await log('info', `Using pre-rendered template code (${prebuilt.length} chars) — skipping render`); + generatedCode = prebuilt; + } else { + generatedCode = renderServerCode(spec); + } await db .update(builds) .set({ generatedSpec: spec, generatedCode }) diff --git a/apps/web/app/(dashboard)/servers/[id]/page.tsx b/apps/web/app/(dashboard)/servers/[id]/page.tsx index e4f5861..7414d6a 100644 --- a/apps/web/app/(dashboard)/servers/[id]/page.tsx +++ b/apps/web/app/(dashboard)/servers/[id]/page.tsx @@ -8,7 +8,7 @@ import { CodeBlock } from '@/components/code-block'; import { InstallSnippets } from '@/components/install-snippets'; import { StreamingLogs } from '@/components/streaming-logs'; import { Button } from '@/components/ui/button'; -import { Textarea, Label } from '@/components/input'; +import { Input, Textarea, Label } from '@/components/input'; import { cn } from '@/lib/cn'; import type { ToolSpec } from '@bmm/types'; @@ -34,7 +34,7 @@ interface BuildSummary { errorMessage: string | null; } -type Tab = 'overview' | 'tools' | 'logs' | 'metrics' | 'secrets' | 'iterate'; +type Tab = 'overview' | 'tools' | 'logs' | 'metrics' | 'secrets' | 'iterate' | 'publish'; export default function ServerDetailPage() { const params = useParams<{ id: string }>(); @@ -85,6 +85,7 @@ export default function ServerDetailPage() { { id: 'metrics', label: 'Metrics' }, { id: 'secrets', label: 'Secrets' }, { id: 'iterate', label: 'Iterate' }, + { id: 'publish', label: 'Publish' }, ]; return ( @@ -268,6 +269,255 @@ export default function ServerDetailPage() { )} + + {tab === 'publish' && } + + + ); +} + +const CATEGORIES = [ + 'productivity', + 'developer-tools', + 'data', + 'communication', + 'finance', + 'crm', + 'analytics', + 'devops', + 'demo', + 'other', +]; + +interface SecretHint { + key: string; + description: string; + howToGetUrl: string; +} + +function PublishPanel({ serverId, serverStatus }: { serverId: string; serverStatus: string }) { + const [title, setTitle] = useState(''); + const [category, setCategory] = useState('other'); + const [shortDescription, setShortDescription] = useState(''); + const [longDescription, setLongDescription] = useState(''); + const [secretHints, setSecretHints] = useState([]); + const [state, setState] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle'); + const [error, setError] = useState(null); + const [publishedSlug, setPublishedSlug] = useState(null); + + if (serverStatus !== 'live') { + return ( +
+

+ Server must be live before publishing as a template. Current status:{' '} + {serverStatus}. +

+
+ ); + } + + function addHint() { + setSecretHints((h) => [...h, { key: '', description: '', howToGetUrl: '' }]); + } + function updateHint(i: number, patch: Partial) { + setSecretHints((h) => { + const next = [...h]; + const cur = next[i]; + if (!cur) return h; + next[i] = { ...cur, ...patch }; + return next; + }); + } + function removeHint(i: number) { + setSecretHints((h) => h.filter((_, j) => j !== i)); + } + + async function submit() { + setError(null); + if (title.length < 3) { + setError('Title needs at least 3 characters.'); + return; + } + if (shortDescription.length < 10) { + setError('Short description needs at least 10 characters.'); + return; + } + // Validate secret hints + for (const h of secretHints) { + if (!h.key) { + setError('Empty secret key — remove the row or fill it in.'); + return; + } + if (!/^[A-Z][A-Z0-9_]*$/.test(h.key)) { + setError(`Secret key "${h.key}" must be UPPER_SNAKE_CASE.`); + return; + } + if (h.description.length < 1) { + setError(`Add a description for ${h.key}.`); + return; + } + } + + setState('submitting'); + try { + const res = await apiFetch<{ template: { slug: string } }>('/v1/templates', { + method: 'POST', + body: JSON.stringify({ + serverId, + title, + category, + shortDescription, + longDescription: longDescription || undefined, + secretHints: secretHints.map((h) => ({ + key: h.key, + description: h.description, + ...(h.howToGetUrl ? { howToGetUrl: h.howToGetUrl } : {}), + })), + }), + }); + setPublishedSlug(res.template.slug); + setState('success'); + } catch (e) { + const detail = (e as { detail?: { error?: string; detail?: string } }).detail; + setError(detail?.detail ?? detail?.error ?? (e as Error).message); + setState('error'); + } + } + + if (state === 'success' && publishedSlug) { + return ( +
+
Published 🎉
+

+ Your template is live on the marketplace. +

+ +
+ ); + } + + return ( +
+
+

Publish as template

+

+ Share this server's spec on the public marketplace. Others fork in one click — they + run their own container with their own credentials. Your secrets are never shared. +

+
+ +
+
+ + setTitle(e.target.value)} + placeholder="Notion Reader" + maxLength={128} + /> +
+
+ + +
+
+ +
+ + setShortDescription(e.target.value)} + placeholder="Search and read pages from a Notion workspace." + maxLength={280} + /> +
+ +
+ +