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:
parent
c62fcd07ef
commit
8334de13a8
@ -9,6 +9,7 @@ import { serverRoutes } from './routes/servers.js';
|
|||||||
import { oauthRoutes } from './routes/oauth.js';
|
import { oauthRoutes } from './routes/oauth.js';
|
||||||
import { settingsRoutes } from './routes/settings.js';
|
import { settingsRoutes } from './routes/settings.js';
|
||||||
import { adminRoutes } from './routes/admin.js';
|
import { adminRoutes } from './routes/admin.js';
|
||||||
|
import { templateRoutes } from './routes/templates.js';
|
||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: {
|
logger: {
|
||||||
@ -30,6 +31,7 @@ await app.register(serverRoutes);
|
|||||||
await app.register(oauthRoutes);
|
await app.register(oauthRoutes);
|
||||||
await app.register(settingsRoutes);
|
await app.register(settingsRoutes);
|
||||||
await app.register(adminRoutes);
|
await app.register(adminRoutes);
|
||||||
|
await app.register(templateRoutes);
|
||||||
|
|
||||||
// Bootstrap admin user from env (idempotent)
|
// Bootstrap admin user from env (idempotent)
|
||||||
if (config.ADMIN_EMAIL && config.ADMIN_PASSWORD) {
|
if (config.ADMIN_EMAIL && config.ADMIN_PASSWORD) {
|
||||||
|
|||||||
@ -27,3 +27,15 @@ export async function loadSpec(previewId: string): Promise<GeneratorSpec | null>
|
|||||||
export async function overwriteSpec(previewId: string, spec: GeneratorSpec): Promise<void> {
|
export async function overwriteSpec(previewId: string, spec: GeneratorSpec): Promise<void> {
|
||||||
await getRedis().set(key(previewId), JSON.stringify(spec), 'EX', TTL_SECONDS);
|
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<void> {
|
||||||
|
await getRedis().set(codeKey(previewId), code, 'EX', TTL_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPrebuiltCode(previewId: string): Promise<string | null> {
|
||||||
|
return (await getRedis().get(codeKey(previewId))) ?? null;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
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 {
|
import {
|
||||||
CreateServerInput,
|
CreateServerInput,
|
||||||
IterateServerInput,
|
IterateServerInput,
|
||||||
@ -75,7 +75,7 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.code(400).send({ error: 'invalid_input', issues: parsed.error.flatten() });
|
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
|
// 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).
|
// 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
|
const [server] = await db
|
||||||
.insert(mcpServers)
|
.insert(mcpServers)
|
||||||
.values({ orgId: user.orgId, slug, name, status: 'queued' })
|
.values({ orgId: user.orgId, slug, name, status: 'queued', templateId: templateId ?? null })
|
||||||
.returning();
|
.returning();
|
||||||
if (!server) return reply.code(500).send({ error: 'create_failed' });
|
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)) {
|
for (const [key, value] of Object.entries(secretValues)) {
|
||||||
if (!value) continue;
|
if (!value) continue;
|
||||||
await db.insert(secrets).values({
|
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;
|
||||||
|
}
|
||||||
@ -36,6 +36,10 @@ async function loadCachedSpec(previewId: string): Promise<GeneratorSpec | null>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadPrebuiltCode(previewId: string): Promise<string | null> {
|
||||||
|
return (await cacheReader.get(`prebuilt:${previewId}`)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export const worker = new Worker<JobData>(
|
export const worker = new Worker<JobData>(
|
||||||
'build',
|
'build',
|
||||||
async (job) => {
|
async (job) => {
|
||||||
@ -68,7 +72,16 @@ export const worker = new Worker<JobData>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await log('info', `Spec ready via ${source} (${spec.tools.length} tool(s))`);
|
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
|
await db
|
||||||
.update(builds)
|
.update(builds)
|
||||||
.set({ generatedSpec: spec, generatedCode })
|
.set({ generatedSpec: spec, generatedCode })
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { CodeBlock } from '@/components/code-block';
|
|||||||
import { InstallSnippets } from '@/components/install-snippets';
|
import { InstallSnippets } from '@/components/install-snippets';
|
||||||
import { StreamingLogs } from '@/components/streaming-logs';
|
import { StreamingLogs } from '@/components/streaming-logs';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { cn } from '@/lib/cn';
|
||||||
import type { ToolSpec } from '@bmm/types';
|
import type { ToolSpec } from '@bmm/types';
|
||||||
|
|
||||||
@ -34,7 +34,7 @@ interface BuildSummary {
|
|||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tab = 'overview' | 'tools' | 'logs' | 'metrics' | 'secrets' | 'iterate';
|
type Tab = 'overview' | 'tools' | 'logs' | 'metrics' | 'secrets' | 'iterate' | 'publish';
|
||||||
|
|
||||||
export default function ServerDetailPage() {
|
export default function ServerDetailPage() {
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
@ -85,6 +85,7 @@ export default function ServerDetailPage() {
|
|||||||
{ id: 'metrics', label: 'Metrics' },
|
{ id: 'metrics', label: 'Metrics' },
|
||||||
{ id: 'secrets', label: 'Secrets' },
|
{ id: 'secrets', label: 'Secrets' },
|
||||||
{ id: 'iterate', label: 'Iterate' },
|
{ id: 'iterate', label: 'Iterate' },
|
||||||
|
{ id: 'publish', label: 'Publish' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -268,6 +269,255 @@ export default function ServerDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tab === 'publish' && <PublishPanel serverId={server.id} serverStatus={server.status} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<SecretHint[]>([]);
|
||||||
|
const [state, setState] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [publishedSlug, setPublishedSlug] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (serverStatus !== 'live') {
|
||||||
|
return (
|
||||||
|
<div className="panel p-4">
|
||||||
|
<p className="text-[13px] text-[--color-fg-muted]">
|
||||||
|
Server must be live before publishing as a template. Current status:{' '}
|
||||||
|
<span className="mono">{serverStatus}</span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addHint() {
|
||||||
|
setSecretHints((h) => [...h, { key: '', description: '', howToGetUrl: '' }]);
|
||||||
|
}
|
||||||
|
function updateHint(i: number, patch: Partial<SecretHint>) {
|
||||||
|
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 (
|
||||||
|
<div className="panel p-4">
|
||||||
|
<div className="text-[14px] font-semibold tracking-tight">Published 🎉</div>
|
||||||
|
<p className="mt-2 text-[12.5px] text-[--color-fg-muted]">
|
||||||
|
Your template is live on the marketplace.
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<a
|
||||||
|
href={`/templates/${publishedSlug}`}
|
||||||
|
className="inline-flex h-8 items-center rounded-md bg-[--color-accent] px-3 text-[12.5px] font-medium text-white transition-colors hover:bg-[#5557e8]"
|
||||||
|
>
|
||||||
|
Open in marketplace →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel p-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-[14px] font-semibold tracking-tight">Publish as template</h3>
|
||||||
|
<p className="mt-1 text-[12px] text-[--color-fg-muted]">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-[1fr_220px]">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="t-title">Title</Label>
|
||||||
|
<Input
|
||||||
|
id="t-title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Notion Reader"
|
||||||
|
maxLength={128}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="t-cat">Category</Label>
|
||||||
|
<select
|
||||||
|
id="t-cat"
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
|
className="h-8 w-full rounded-md border border-[--color-border] bg-[--color-bg-subtle] px-2.5 text-[13px] focus:border-[--color-accent] focus:outline-none"
|
||||||
|
>
|
||||||
|
{CATEGORIES.map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{c}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="t-short" hint={`${shortDescription.length}/280`}>
|
||||||
|
Short description
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="t-short"
|
||||||
|
value={shortDescription}
|
||||||
|
onChange={(e) => setShortDescription(e.target.value)}
|
||||||
|
placeholder="Search and read pages from a Notion workspace."
|
||||||
|
maxLength={280}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="t-long">Long description (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="t-long"
|
||||||
|
rows={4}
|
||||||
|
value={longDescription}
|
||||||
|
onChange={(e) => setLongDescription(e.target.value)}
|
||||||
|
placeholder="What does it do, what doesn't it do, what should the user know before forking?"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label hint="Help future users understand which credentials they need and where to get them">
|
||||||
|
Credential hints
|
||||||
|
</Label>
|
||||||
|
{secretHints.length === 0 && (
|
||||||
|
<p className="text-[11.5px] text-[--color-fg-muted]">
|
||||||
|
None. This server doesn't need any credentials.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{secretHints.map((h, i) => (
|
||||||
|
<div key={i} className="panel-subtle p-2.5 space-y-2">
|
||||||
|
<div className="grid grid-cols-[200px_1fr_auto] gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="NOTION_API_KEY"
|
||||||
|
value={h.key}
|
||||||
|
onChange={(e) => updateHint(i, { key: e.target.value.toUpperCase() })}
|
||||||
|
className="mono"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="What is this credential? One sentence."
|
||||||
|
value={h.description}
|
||||||
|
onChange={(e) => updateHint(i, { description: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Button variant="ghost" size="md" onClick={() => removeHint(i)}>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
placeholder="https://notion.so/my-integrations (optional 'How to get one' link)"
|
||||||
|
value={h.howToGetUrl}
|
||||||
|
onChange={(e) => updateHint(i, { howToGetUrl: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button variant="ghost" size="sm" onClick={addHint}>
|
||||||
|
+ Add credential hint
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-[12.5px] text-[--color-danger]">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between border-t border-[--color-border] pt-3">
|
||||||
|
<p className="text-[11px] text-[--color-fg-subtle]">
|
||||||
|
By publishing, you agree the generated code may be inspected by others.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={state === 'submitting'}
|
||||||
|
>
|
||||||
|
{state === 'submitting' ? 'Publishing…' : 'Publish'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input, Label, Textarea } from '@/components/input';
|
import { Input, Label, Textarea } from '@/components/input';
|
||||||
@ -98,10 +98,68 @@ export default function NewServerPage() {
|
|||||||
const [buildId, setBuildId] = useState<string | null>(null);
|
const [buildId, setBuildId] = useState<string | null>(null);
|
||||||
const [serverId, setServerId] = useState<string | null>(null);
|
const [serverId, setServerId] = useState<string | null>(null);
|
||||||
const [result, setResult] = useState<BuildResult | null>(null);
|
const [result, setResult] = useState<BuildResult | null>(null);
|
||||||
|
const [forkedTemplateId, setForkedTemplateId] = useState<string | null>(null);
|
||||||
|
const [forkedTemplateTitle, setForkedTemplateTitle] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const templateSlug = searchParams.get('template');
|
||||||
|
|
||||||
const trySlug = (n: string) =>
|
const trySlug = (n: string) =>
|
||||||
n.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32);
|
n.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32);
|
||||||
|
|
||||||
|
// Fork-from-template flow: skip Step 1, jump straight to Step 2 with the template's spec
|
||||||
|
useEffect(() => {
|
||||||
|
if (!templateSlug || preview) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setStep('analyzing');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch<{
|
||||||
|
previewId: string;
|
||||||
|
templateId: string;
|
||||||
|
template: {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
shortDescription: string;
|
||||||
|
tools: PreviewTool[];
|
||||||
|
requiredSecrets: Array<{
|
||||||
|
key: string;
|
||||||
|
description: string;
|
||||||
|
howToGetUrl?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}>(`/v1/templates/${templateSlug}/fork`, { method: 'POST', body: '{}' });
|
||||||
|
if (cancelled) return;
|
||||||
|
setName(res.template.title);
|
||||||
|
setSlug(trySlug(res.template.title));
|
||||||
|
setPrompt(`Fork of "${res.template.title}" template.`);
|
||||||
|
setForkedTemplateId(res.templateId);
|
||||||
|
setForkedTemplateTitle(res.template.title);
|
||||||
|
setPreview({
|
||||||
|
previewId: res.previewId,
|
||||||
|
source: 'mock',
|
||||||
|
spec: {
|
||||||
|
name: res.template.title,
|
||||||
|
description: res.template.shortDescription,
|
||||||
|
tools: res.template.tools,
|
||||||
|
requiredSecrets: res.template.requiredSecrets.map((s) => s.key),
|
||||||
|
scopes: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setEditable(null);
|
||||||
|
setStep('confirm');
|
||||||
|
} catch (e) {
|
||||||
|
if (cancelled) return;
|
||||||
|
const detail = (e as { detail?: { error?: string } }).detail;
|
||||||
|
setError(detail?.error ?? (e as Error).message);
|
||||||
|
setStep('prompt');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [templateSlug, preview]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (preview && !editable) {
|
if (preview && !editable) {
|
||||||
const e = specToEditable(preview.spec);
|
const e = specToEditable(preview.spec);
|
||||||
@ -256,7 +314,9 @@ export default function NewServerPage() {
|
|||||||
prompt,
|
prompt,
|
||||||
secrets: filledSecrets,
|
secrets: filledSecrets,
|
||||||
previewId: preview.previewId,
|
previewId: preview.previewId,
|
||||||
specEdit,
|
// Don't send specEdit when forking — the template's spec + pre-rendered code
|
||||||
|
// are already in the Redis cache. Edits would invalidate the impls.
|
||||||
|
...(forkedTemplateId ? { templateId: forkedTemplateId } : { specEdit }),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -363,6 +423,22 @@ export default function NewServerPage() {
|
|||||||
|
|
||||||
{step === 'confirm' && preview && editable && (
|
{step === 'confirm' && preview && editable && (
|
||||||
<div className="mt-7 space-y-6">
|
<div className="mt-7 space-y-6">
|
||||||
|
{forkedTemplateTitle && (
|
||||||
|
<div className="panel-subtle p-3 flex items-center justify-between">
|
||||||
|
<div className="text-[12.5px]">
|
||||||
|
Forking <span className="mono font-semibold">{forkedTemplateTitle}</span> — fill in
|
||||||
|
your own credentials below. The template author never sees them.
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={`/templates/${templateSlug}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-[11.5px] text-[--color-fg-muted] underline hover:text-[--color-fg]"
|
||||||
|
>
|
||||||
|
Template ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="panel p-4">
|
<div className="panel p-4">
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<h2 className="text-[14px] font-semibold tracking-tight">Confirm what we'll build</h2>
|
<h2 className="text-[14px] font-semibold tracking-tight">Confirm what we'll build</h2>
|
||||||
|
|||||||
@ -12,6 +12,9 @@ export default function MarketingLayout({ children }: { children: React.ReactNod
|
|||||||
<Link href="/#how" className="transition-colors hover:text-[--color-fg]">
|
<Link href="/#how" className="transition-colors hover:text-[--color-fg]">
|
||||||
How it works
|
How it works
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/templates" className="transition-colors hover:text-[--color-fg]">
|
||||||
|
Templates
|
||||||
|
</Link>
|
||||||
<Link href="/pricing" className="transition-colors hover:text-[--color-fg]">
|
<Link href="/pricing" className="transition-colors hover:text-[--color-fg]">
|
||||||
Pricing
|
Pricing
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
Wand2,
|
Wand2,
|
||||||
LogOut,
|
LogOut,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
|
Package,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
@ -30,6 +31,7 @@ const NAV: { href: string; label: string; icon: React.ComponentType<{ size?: num
|
|||||||
{ href: '/admin/users', label: 'Users', icon: Users },
|
{ href: '/admin/users', label: 'Users', icon: Users },
|
||||||
{ href: '/admin/orgs', label: 'Organizations', icon: Building2 },
|
{ href: '/admin/orgs', label: 'Organizations', icon: Building2 },
|
||||||
{ href: '/admin/servers', label: 'MCP servers', icon: Server },
|
{ href: '/admin/servers', label: 'MCP servers', icon: Server },
|
||||||
|
{ href: '/admin/templates', label: 'Templates', icon: Package },
|
||||||
{ href: '/admin/builds', label: 'Builds', icon: Hammer },
|
{ href: '/admin/builds', label: 'Builds', icon: Hammer },
|
||||||
{ href: '/admin/audit', label: 'Audit log', icon: FileClock },
|
{ href: '/admin/audit', label: 'Audit log', icon: FileClock },
|
||||||
{ href: '/admin/system', label: 'System health', icon: Activity },
|
{ href: '/admin/system', label: 'System health', icon: Activity },
|
||||||
|
|||||||
202
apps/web/app/admin/templates/page.tsx
Normal file
202
apps/web/app/admin/templates/page.tsx
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { apiFetch } from '@/lib/api';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ShieldCheck } from 'lucide-react';
|
||||||
|
|
||||||
|
interface AdminTemplate {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
shortDescription: string;
|
||||||
|
category: string;
|
||||||
|
status: 'draft' | 'public' | 'hidden' | 'takedown';
|
||||||
|
verified: boolean;
|
||||||
|
takedownReason: string | null;
|
||||||
|
forkCount: number;
|
||||||
|
activeDeployments: number;
|
||||||
|
ownerEmail: string | null;
|
||||||
|
ownerOrgName: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_FILTERS = ['', 'public', 'hidden', 'takedown', 'draft'];
|
||||||
|
|
||||||
|
export default function AdminTemplatesPage() {
|
||||||
|
const [rows, setRows] = useState<AdminTemplate[] | null>(null);
|
||||||
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
const r = await apiFetch<{ templates: AdminTemplate[] }>('/v1/admin/templates');
|
||||||
|
setRows(r.templates);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reload();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function toggleVerified(t: AdminTemplate) {
|
||||||
|
if (!confirm(`${t.verified ? 'Unverify' : 'Verify'} "${t.title}"?`)) return;
|
||||||
|
await apiFetch(`/v1/admin/templates/${t.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ verified: !t.verified }),
|
||||||
|
});
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function takedown(t: AdminTemplate) {
|
||||||
|
if (t.status === 'takedown') {
|
||||||
|
if (!confirm(`Lift takedown on "${t.title}"? Forked servers stay paused — owners must re-deploy.`)) return;
|
||||||
|
await apiFetch(`/v1/admin/templates/${t.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ status: 'public', takedownReason: null }),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const reason = prompt(
|
||||||
|
`Take down "${t.title}"? This pauses ALL ${t.activeDeployments} active fork containers. Reason:`,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
if (reason === null) return;
|
||||||
|
await apiFetch(`/v1/admin/templates/${t.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ status: 'takedown', takedownReason: reason || 'Removed by admin' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleHidden(t: AdminTemplate) {
|
||||||
|
const next = t.status === 'public' ? 'hidden' : 'public';
|
||||||
|
await apiFetch(`/v1/admin/templates/${t.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ status: next }),
|
||||||
|
});
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = rows?.filter((t) => (statusFilter ? t.status === statusFilter : true));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-8 py-8">
|
||||||
|
<header className="mb-6">
|
||||||
|
<h1 className="text-[22px] font-semibold tracking-tight">Templates moderation</h1>
|
||||||
|
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
|
||||||
|
Verify quality templates, hide low-effort ones, take down anything malicious. Takedowns
|
||||||
|
cascade-pause every fork container.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mb-4 flex gap-2">
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="h-8 rounded-md border border-[--color-border] bg-[--color-bg-subtle] px-2 text-[13px] focus:border-[--color-accent] focus:outline-none"
|
||||||
|
>
|
||||||
|
{STATUS_FILTERS.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{s ? s : 'All statuses'}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel">
|
||||||
|
{rows === null && (
|
||||||
|
<p className="px-4 py-3 text-[12.5px] text-[--color-fg-muted]">Loading…</p>
|
||||||
|
)}
|
||||||
|
{visible && visible.length === 0 && (
|
||||||
|
<p className="px-4 py-12 text-center text-[13px] text-[--color-fg-muted]">
|
||||||
|
No templates yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{visible && visible.length > 0 && (
|
||||||
|
<table className="w-full text-[12.5px]">
|
||||||
|
<thead className="border-b border-[--color-border] text-[--color-fg-subtle]">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left font-medium">Title</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium">Owner</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium">Category</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium">Status</th>
|
||||||
|
<th className="px-4 py-2 text-left font-medium">Stats</th>
|
||||||
|
<th className="px-4 py-2 text-right font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{visible.map((t) => (
|
||||||
|
<tr key={t.id} className="border-b border-[--color-border] last:border-0">
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Link
|
||||||
|
href={`/templates/${t.slug}`}
|
||||||
|
target="_blank"
|
||||||
|
className="font-medium hover:text-[--color-accent]"
|
||||||
|
>
|
||||||
|
{t.title}
|
||||||
|
</Link>
|
||||||
|
{t.verified && (
|
||||||
|
<ShieldCheck size={11} className="text-[--color-accent]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mono text-[11px] text-[--color-fg-subtle]">{t.slug}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 mono text-[--color-fg-muted]">{t.ownerEmail ?? '—'}</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<span className="mono rounded-full border border-[--color-border] bg-[--color-bg-subtle] px-2 py-0.5 text-[11px]">
|
||||||
|
{t.category}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<StatusBadge status={t.status} reason={t.takedownReason} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 mono text-[--color-fg-muted]">
|
||||||
|
{t.forkCount} forks · {t.activeDeployments} live
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-right">
|
||||||
|
<div className="inline-flex gap-1">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => toggleVerified(t)}>
|
||||||
|
{t.verified ? 'unverify' : 'verify'}
|
||||||
|
</Button>
|
||||||
|
{t.status !== 'takedown' && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => toggleHidden(t)}>
|
||||||
|
{t.status === 'public' ? 'hide' : 'show'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="danger" size="sm" onClick={() => takedown(t)}>
|
||||||
|
{t.status === 'takedown' ? 'restore' : 'takedown'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({
|
||||||
|
status,
|
||||||
|
reason,
|
||||||
|
}: {
|
||||||
|
status: AdminTemplate['status'];
|
||||||
|
reason: string | null;
|
||||||
|
}) {
|
||||||
|
const styles: Record<AdminTemplate['status'], string> = {
|
||||||
|
public: 'border-emerald-400/40 bg-emerald-400/10 text-emerald-300',
|
||||||
|
hidden: 'border-amber-400/40 bg-amber-400/10 text-amber-300',
|
||||||
|
takedown: 'border-red-400/40 bg-red-400/10 text-red-300',
|
||||||
|
draft: 'border-zinc-400/40 bg-zinc-400/10 text-zinc-300',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`mono inline-flex rounded-full border px-2 py-0.5 text-[11px] ${styles[status]}`}
|
||||||
|
title={reason ?? ''}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
266
apps/web/app/templates/[slug]/page.tsx
Normal file
266
apps/web/app/templates/[slug]/page.tsx
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import { ShieldCheck, GitFork, Activity, ExternalLink, ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/api';
|
||||||
|
import { Logo } from '@/components/logo';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { CodeBlock } from '@/components/code-block';
|
||||||
|
|
||||||
|
interface Tool {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
inputSchema: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SecretHint {
|
||||||
|
key: string;
|
||||||
|
description: string;
|
||||||
|
howToGetUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TemplateDetail {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
shortDescription: string;
|
||||||
|
longDescription: string | null;
|
||||||
|
category: string;
|
||||||
|
verified: boolean;
|
||||||
|
forkCount: number;
|
||||||
|
activeDeployments: number;
|
||||||
|
toolsSchema: Tool[];
|
||||||
|
generatedCode: string;
|
||||||
|
requiredSecrets: SecretHint[];
|
||||||
|
scopes: string[];
|
||||||
|
ownerName: string | null;
|
||||||
|
ownerOrgName: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TemplateDetail() {
|
||||||
|
const params = useParams<{ slug: string }>();
|
||||||
|
const router = useRouter();
|
||||||
|
const [template, setTemplate] = useState<TemplateDetail | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showCode, setShowCode] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiFetch<{ template: TemplateDetail }>(`/v1/templates/${params.slug}`)
|
||||||
|
.then((r) => setTemplate(r.template))
|
||||||
|
.catch((e) => {
|
||||||
|
const detail = (e as { detail?: { error?: string } }).detail;
|
||||||
|
setError(detail?.error ?? (e as Error).message);
|
||||||
|
});
|
||||||
|
}, [params.slug]);
|
||||||
|
|
||||||
|
function useTemplate() {
|
||||||
|
if (!template) return;
|
||||||
|
router.push(`/servers/new?template=${template.slug}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center px-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-[14px]">Template not found.</p>
|
||||||
|
<Link href="/templates" className="mt-3 inline-block text-[12px] text-[--color-accent] underline">
|
||||||
|
← Back to marketplace
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
return (
|
||||||
|
<div className="px-8 py-20 text-center mono text-[12px] text-[--color-fg-muted]">Loading…</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col">
|
||||||
|
<header className="sticky top-0 z-50 border-b border-[--color-border] bg-[--color-bg]/85 backdrop-blur-md">
|
||||||
|
<div className="mx-auto flex h-12 max-w-5xl items-center justify-between px-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Logo />
|
||||||
|
<span className="text-[12.5px] text-[--color-fg-subtle]">
|
||||||
|
/ <Link href="/templates" className="hover:text-[--color-fg]">templates</Link> / {template.slug}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="rounded-md bg-[--color-accent] px-3 py-1.5 text-[12.5px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8]"
|
||||||
|
>
|
||||||
|
Start building
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto w-full max-w-5xl flex-1 px-6 py-10">
|
||||||
|
<div className="grid gap-10 lg:grid-cols-[1fr_300px]">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="mono rounded-full border border-[--color-border] bg-[--color-bg-subtle] px-2 py-0.5 text-[11px] text-[--color-fg-muted]">
|
||||||
|
{template.category}
|
||||||
|
</span>
|
||||||
|
{template.verified && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full border border-[--color-accent]/40 bg-[--color-accent]/10 px-2 py-0.5 text-[11px] font-medium text-[--color-accent]">
|
||||||
|
<ShieldCheck size={10} /> verified
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-3 text-[28px] font-semibold tracking-tight">{template.title}</h1>
|
||||||
|
<p className="mt-2 text-[14px] leading-relaxed text-[--color-fg-muted]">
|
||||||
|
{template.shortDescription}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{template.longDescription && (
|
||||||
|
<p className="mt-4 whitespace-pre-wrap text-[13.5px] leading-relaxed text-[--color-fg]">
|
||||||
|
{template.longDescription}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="mt-10">
|
||||||
|
<h2 className="text-[16px] font-semibold tracking-tight">
|
||||||
|
Tools ({template.toolsSchema.length})
|
||||||
|
</h2>
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
{template.toolsSchema.map((tool) => (
|
||||||
|
<div key={tool.name} className="panel p-3">
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="mono text-[13px] font-semibold">{tool.name}</span>
|
||||||
|
<span className="mono text-[10.5px] text-[--color-fg-subtle]">
|
||||||
|
{Object.keys(tool.inputSchema ?? {}).length} param
|
||||||
|
{Object.keys(tool.inputSchema ?? {}).length === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-[12.5px] text-[--color-fg-muted]">{tool.description}</p>
|
||||||
|
{Object.keys(tool.inputSchema ?? {}).length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<CodeBlock
|
||||||
|
label="input schema"
|
||||||
|
code={JSON.stringify(tool.inputSchema, null, 2)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{template.requiredSecrets.length > 0 && (
|
||||||
|
<section className="mt-10">
|
||||||
|
<h2 className="text-[16px] font-semibold tracking-tight">
|
||||||
|
Credentials you'll need
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">
|
||||||
|
When you fork, the wizard asks you for these. Your values stay in your container —
|
||||||
|
the template author never sees them.
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{template.requiredSecrets.map((s) => (
|
||||||
|
<div key={s.key} className="panel p-3">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<span className="mono text-[13px] font-semibold">{s.key}</span>
|
||||||
|
{s.howToGetUrl && (
|
||||||
|
<a
|
||||||
|
href={s.howToGetUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-[11.5px] text-[--color-accent] hover:underline"
|
||||||
|
>
|
||||||
|
How to get one <ExternalLink size={10} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-[12.5px] text-[--color-fg-muted]">
|
||||||
|
{s.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="mt-10">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCode((s) => !s)}
|
||||||
|
className="inline-flex items-center gap-1 text-[14px] font-semibold tracking-tight text-[--color-fg] transition-colors hover:text-[--color-fg-muted]"
|
||||||
|
>
|
||||||
|
{showCode ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
Generated code ({template.generatedCode.length} chars)
|
||||||
|
</button>
|
||||||
|
<p className="mt-1 text-[12px] text-[--color-fg-muted]">
|
||||||
|
Audit before you fork. We re-scan every published template for banned patterns
|
||||||
|
(eval, child_process, prompt-injection markers).
|
||||||
|
</p>
|
||||||
|
{showCode && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<CodeBlock label="src/server.ts" code={template.generatedCode} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="space-y-3">
|
||||||
|
<div className="panel p-4">
|
||||||
|
<Button variant="primary" size="lg" className="w-full" onClick={useTemplate}>
|
||||||
|
Fork this template →
|
||||||
|
</Button>
|
||||||
|
<p className="mt-2 text-[11.5px] text-[--color-fg-muted]">
|
||||||
|
One click → your own isolated container.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel p-3 text-[12px]">
|
||||||
|
<Row label="Forks" value={template.forkCount} icon={<GitFork size={11} />} />
|
||||||
|
<Row
|
||||||
|
label="Live deployments"
|
||||||
|
value={template.activeDeployments}
|
||||||
|
icon={<Activity size={11} />}
|
||||||
|
/>
|
||||||
|
<Row label="Category" value={template.category} mono />
|
||||||
|
<Row
|
||||||
|
label="Published"
|
||||||
|
value={new Date(template.createdAt).toLocaleDateString()}
|
||||||
|
/>
|
||||||
|
<Row label="Author" value={template.ownerName ?? template.ownerOrgName ?? 'anonymous'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel p-3 text-[11.5px] leading-relaxed text-[--color-fg-muted]">
|
||||||
|
<strong className="text-[--color-fg]">Forking is safe.</strong> Your fork gets its own
|
||||||
|
Docker container, its own port, its own AES-256-encrypted secrets. The template
|
||||||
|
author has no visibility into your traffic or data.
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
mono,
|
||||||
|
icon,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
mono?: boolean;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-baseline justify-between gap-3 py-1">
|
||||||
|
<span className="text-[--color-fg-subtle]">{label}</span>
|
||||||
|
<span className={`inline-flex items-center gap-1.5 ${mono ? 'mono' : ''} text-[--color-fg]`}>
|
||||||
|
{icon}
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
197
apps/web/app/templates/page.tsx
Normal file
197
apps/web/app/templates/page.tsx
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ShieldCheck, GitFork, Activity } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/api';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
import { Logo } from '@/components/logo';
|
||||||
|
import { Input } from '@/components/input';
|
||||||
|
|
||||||
|
interface Template {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
shortDescription: string;
|
||||||
|
category: string;
|
||||||
|
verified: boolean;
|
||||||
|
forkCount: number;
|
||||||
|
activeDeployments: number;
|
||||||
|
ownerName: string | null;
|
||||||
|
ownerOrgName: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Sort = 'trending' | 'top' | 'newest';
|
||||||
|
|
||||||
|
export default function TemplatesMarketplace() {
|
||||||
|
const [templates, setTemplates] = useState<Template[] | null>(null);
|
||||||
|
const [categories, setCategories] = useState<string[]>([]);
|
||||||
|
const [sort, setSort] = useState<Sort>('trending');
|
||||||
|
const [category, setCategory] = useState('');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams({ sort });
|
||||||
|
if (category) params.set('category', category);
|
||||||
|
apiFetch<{ templates: Template[]; categories: string[] }>(`/v1/templates?${params}`).then((r) => {
|
||||||
|
setTemplates(r.templates);
|
||||||
|
setCategories(r.categories);
|
||||||
|
});
|
||||||
|
}, [sort, category]);
|
||||||
|
|
||||||
|
const visible = templates?.filter((t) =>
|
||||||
|
search
|
||||||
|
? t.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
t.shortDescription.toLowerCase().includes(search.toLowerCase())
|
||||||
|
: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col">
|
||||||
|
<header className="sticky top-0 z-50 border-b border-[--color-border] bg-[--color-bg]/85 backdrop-blur-md">
|
||||||
|
<div className="mx-auto flex h-12 max-w-6xl items-center justify-between px-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Logo />
|
||||||
|
<span className="text-[12.5px] text-[--color-fg-subtle]">/ templates</span>
|
||||||
|
</div>
|
||||||
|
<nav className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-[12.5px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg]"
|
||||||
|
>
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="rounded-md bg-[--color-accent] px-3 py-1.5 text-[12.5px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8]"
|
||||||
|
>
|
||||||
|
Start building
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto w-full max-w-6xl flex-1 px-6 py-12">
|
||||||
|
<header className="mb-8 max-w-2xl">
|
||||||
|
<div className="text-[11px] uppercase tracking-[0.16em] text-[--color-fg-subtle]">
|
||||||
|
Marketplace
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-2 text-[32px] font-semibold tracking-tight">
|
||||||
|
MCP server templates
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 text-[14px] leading-relaxed text-[--color-fg-muted]">
|
||||||
|
Pre-built MCP servers from the community. Fork in one click — your own container, your
|
||||||
|
own credentials, fully isolated. The template author never sees your data.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mb-6 flex flex-wrap items-center gap-3 border-b border-[--color-border] pb-4">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(['trending', 'top', 'newest'] as Sort[]).map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSort(s)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md px-2.5 py-1 text-[12.5px] capitalize transition-colors',
|
||||||
|
s === sort
|
||||||
|
? 'bg-[--color-bg-elevated] text-[--color-fg]'
|
||||||
|
: 'text-[--color-fg-muted] hover:text-[--color-fg]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mx-2 h-4 w-px bg-[--color-border]" />
|
||||||
|
<select
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
|
className="h-7 rounded-md border border-[--color-border] bg-[--color-bg-subtle] px-2 text-[12.5px] focus:border-[--color-accent] focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">All categories</option>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{c}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search…"
|
||||||
|
className="w-60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!visible && (
|
||||||
|
<p className="mono text-[12px] text-[--color-fg-muted]">Loading…</p>
|
||||||
|
)}
|
||||||
|
{visible && visible.length === 0 && (
|
||||||
|
<div className="panel p-12 text-center">
|
||||||
|
<p className="text-[14px] text-[--color-fg]">No templates yet.</p>
|
||||||
|
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">
|
||||||
|
Build a server you're proud of and click <span className="mono">Publish as template</span>{' '}
|
||||||
|
on its detail page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{visible && visible.length > 0 && (
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{visible.map((t) => (
|
||||||
|
<Link
|
||||||
|
key={t.id}
|
||||||
|
href={`/templates/${t.slug}`}
|
||||||
|
className="panel flex flex-col p-4 transition-colors duration-200 hover:border-[--color-border-strong]"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<h2 className="text-[14.5px] font-semibold tracking-tight">{t.title}</h2>
|
||||||
|
{t.verified && (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-0.5 rounded-full border border-[--color-accent]/40 bg-[--color-accent]/10 px-1.5 py-0.5 text-[9.5px] font-medium text-[--color-accent]"
|
||||||
|
title="Verified by BuildMyMCPServer"
|
||||||
|
>
|
||||||
|
<ShieldCheck size={9} /> verified
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="mono text-[10px] rounded-full border border-[--color-border] bg-[--color-bg-subtle] px-1.5 py-0.5 text-[--color-fg-subtle]">
|
||||||
|
{t.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 flex-1 text-[12.5px] leading-relaxed text-[--color-fg-muted]">
|
||||||
|
{t.shortDescription}
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex items-center justify-between text-[11px] text-[--color-fg-subtle]">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="inline-flex items-center gap-1 mono" title="Total forks">
|
||||||
|
<GitFork size={11} />
|
||||||
|
{t.forkCount}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1 mono" title="Active deployments">
|
||||||
|
<Activity size={11} />
|
||||||
|
{t.activeDeployments}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="truncate">
|
||||||
|
by {t.ownerName ?? t.ownerOrgName ?? 'anonymous'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="border-t border-[--color-border] py-8">
|
||||||
|
<div className="mx-auto max-w-6xl px-6 text-[12px] text-[--color-fg-subtle]">
|
||||||
|
Every template is isolated: forking creates your own container with your own secrets.
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -58,6 +58,43 @@ export const adminSettings = pgTable('admin_settings', {
|
|||||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const templateStatusEnum = pgEnum('template_status', [
|
||||||
|
'draft',
|
||||||
|
'public',
|
||||||
|
'hidden',
|
||||||
|
'takedown',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const templates = pgTable(
|
||||||
|
'templates',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
ownerUserId: uuid('owner_user_id').references(() => users.id, { onDelete: 'set null' }),
|
||||||
|
ownerOrgId: uuid('owner_org_id').references(() => organizations.id, { onDelete: 'set null' }),
|
||||||
|
sourceServerId: uuid('source_server_id'),
|
||||||
|
slug: varchar('slug', { length: 64 }).notNull().unique(),
|
||||||
|
title: varchar('title', { length: 128 }).notNull(),
|
||||||
|
shortDescription: varchar('short_description', { length: 280 }).notNull(),
|
||||||
|
longDescription: text('long_description'),
|
||||||
|
category: varchar('category', { length: 64 }).notNull(),
|
||||||
|
toolsSchema: jsonb('tools_schema').notNull(),
|
||||||
|
generatedCode: text('generated_code').notNull(),
|
||||||
|
requiredSecrets: jsonb('required_secrets').notNull(),
|
||||||
|
scopes: jsonb('scopes').notNull(),
|
||||||
|
allowedDomains: jsonb('allowed_domains'),
|
||||||
|
status: templateStatusEnum('status').default('public').notNull(),
|
||||||
|
verified: boolean('verified').default(false).notNull(),
|
||||||
|
takedownReason: text('takedown_reason'),
|
||||||
|
forkCount: integer('fork_count').default(0).notNull(),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
statusIdx: index('idx_templates_status').on(t.status, t.createdAt),
|
||||||
|
categoryIdx: index('idx_templates_category').on(t.category),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export const users = pgTable('users', {
|
export const users = pgTable('users', {
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
email: varchar('email', { length: 255 }).notNull().unique(),
|
email: varchar('email', { length: 255 }).notNull().unique(),
|
||||||
@ -126,11 +163,13 @@ export const mcpServers = pgTable(
|
|||||||
publicUrl: text('public_url'),
|
publicUrl: text('public_url'),
|
||||||
toolsSchema: jsonb('tools_schema'),
|
toolsSchema: jsonb('tools_schema'),
|
||||||
oauthEnabled: boolean('oauth_enabled').default(true).notNull(),
|
oauthEnabled: boolean('oauth_enabled').default(true).notNull(),
|
||||||
|
templateId: uuid('template_id'),
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
(t) => ({
|
(t) => ({
|
||||||
orgSlugIdx: index('idx_servers_org_slug').on(t.orgId, t.slug),
|
orgSlugIdx: index('idx_servers_org_slug').on(t.orgId, t.slug),
|
||||||
|
templateIdx: index('idx_servers_template').on(t.templateId),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -262,3 +301,4 @@ export type Build = typeof builds.$inferSelect;
|
|||||||
export type BuildLog = typeof buildLogs.$inferSelect;
|
export type BuildLog = typeof buildLogs.$inferSelect;
|
||||||
export type Secret = typeof secrets.$inferSelect;
|
export type Secret = typeof secrets.$inferSelect;
|
||||||
export type OAuthClient = typeof oauthClients.$inferSelect;
|
export type OAuthClient = typeof oauthClients.$inferSelect;
|
||||||
|
export type Template = typeof templates.$inferSelect;
|
||||||
|
|||||||
@ -139,6 +139,7 @@ export const CreateServerInput = z.object({
|
|||||||
secrets: z.record(z.string(), z.string()).default({}),
|
secrets: z.record(z.string(), z.string()).default({}),
|
||||||
previewId: z.string().min(1).max(64).optional(),
|
previewId: z.string().min(1).max(64).optional(),
|
||||||
specEdit: SpecEdit.optional(),
|
specEdit: SpecEdit.optional(),
|
||||||
|
templateId: z.string().uuid().optional(),
|
||||||
});
|
});
|
||||||
export type CreateServerInput = z.infer<typeof CreateServerInput>;
|
export type CreateServerInput = z.infer<typeof CreateServerInput>;
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user