feat(marketplace): template publish + fork + voting/ranking + admin moderation
What this enables:
- A user builds an MCP server. If others would benefit, they click 'Publish as
template' on their server detail page. The spec + pre-rendered TypeScript
snapshot is preserved.
- Visitors browse /templates, filter by category, sort by trending/top/newest.
Each template card shows fork count + active deployment count as natural
manipulation-resistant popularity signal.
- /templates/[slug] shows the full plan: tool list with input schemas,
required-credential explanations (with 'how to get one' deep links), and a
collapsible code preview so users can audit before forking.
- Fork is one click → /servers/new?template=slug. The wizard skips Step 1 and
pre-fills Step 2 with the template's parsed spec. Forker only fills in their
own credentials. mcp_servers.template_id is recorded; template.fork_count is
bumped atomically. Each fork gets its own isolated container with its own
port, its own AES-256 secrets — the template author has zero visibility into
the fork's traffic or data.
- Admin /admin/templates moderation: verify quality templates (shows shield
badge in marketplace), hide low-effort ones, takedown anything malicious.
Takedowns cascade-pause every fork container — owners must re-deploy.
Why template+fork instead of shared-container:
- Shared containers would mean the publisher's quota + their secrets + their
logs are exposed to forkers. Bad ergonomics, bad security, bad ownership.
- Templates/forks decouple the spec (shared, vouched-for) from the runtime
(isolated per user). Network-effect moat without the trust collapse.
Why no 5-star voting in v1:
- Manipulation-anfällig, empty lists without adoption. We use fork count +
active deploys + verified badge. Trending algorithm:
score = (activeDeploys * 3 + forks) / sqrt(ageDays + 1)
Real signal, no brigading attack surface.
Backend:
- New schema: templates table (16 cols incl. tools_schema, generated_code,
required_secrets, allowedDomains, status enum, verified, fork_count).
- mcp_servers.template_id FK + idx for fork lookup.
- @bmm/types: SpecEdit unchanged, CreateServerInput accepts optional templateId.
- preview-cache.ts: new cachePrebuiltCode/loadPrebuiltCode for storing the
template's full rendered server.ts alongside the spec. Generator worker
detects this and skips the render step — uses the audited pre-built code
verbatim. Banned-pattern re-scan at publish time.
- routes/templates.ts: 5 public/auth routes + 2 admin routes. Banned-pattern
re-scan before publish. Slug auto-uniqued. forkCount atomic-increment via
SQL.
UI:
- /templates marketplace with trending/top/newest tabs, category filter, search.
Cards show forks + live count + author + verified badge.
- /templates/[slug] full detail with tools, credentials-with-hints, expandable
code preview, fork CTA, ownership + stats sidebar, 'forking is safe' explainer.
- /servers/new?template=slug — wizard auto-jumps to Step 2 with template spec
pre-filled, fork banner at top with link back to template.
- /servers/[id] new Publish tab with title, category, descriptions, per-secret
hint fields (description + howToGetUrl per UPPER_SNAKE_CASE key).
- /admin/templates moderation with verify/hide/takedown actions.
- Marketing nav now includes /templates.
Verified end-to-end:
- Published Echo Demo Template from marco@test.local's live server
- Marketplace lists it correctly with stats
- Detail page renders with all sections
- Fork CTA navigates to wizard with ?template= param
- Wizard skips Step 1, shows fork banner, pre-fills spec
- Build succeeds in ~10s (cached spec + prebuilt code path skips Claude AND
render), container live on :4109 with proper OAuth 401 → token → 200 flow
- DB: templates.fork_count=1, activeDeployments=1, mcp_servers.template_id
populated on the fork
- /admin/templates shows the new template with verify/hide/takedown controls
This commit is contained in:
@@ -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<void> {
|
||||
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<void> {
|
||||
|
||||
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({
|
||||
|
||||
408
apps/api/src/routes/templates.ts
Normal file
408
apps/api/src/routes/templates.ts
Normal file
@@ -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<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;
|
||||
}
|
||||
Reference in New Issue
Block a user