fix(security): sovereign-audit hardening pass — RCE, multi-tenant, reliability Reasoning-based audit fixes (all verified by typecheck, attack paths re-traced): - build-time RCE: validate spec.dependencies to npm-registry semver only (no git/url/file specifiers) + --ignore-scripts in runner Dockerfile. - container hardening fail-CLOSED: harden unless RUNNER_DISABLE_HARDENING=1, no longer gated on a fragile NODE_ENV string compare. - secret env keys validated (UPPER_SNAKE, reject NODE_*/PATH/LD_*). - cross-org image-tag collision: qualify tag with serverId. - /iterate now enforces suspension + daily-build limits like /servers. - preview SSE: clear keepalive in finally + on client close (timer/FD leak). - SMS OTP: atomic attempt counter (lt(attempts,MAX) in UPDATE) — brute-force race. - getSession orders membership by createdAt (deterministic primary org). - template scopes aggregated from real tool scopes (was hardcoded mcp:read). - template category filter pushed into WHERE (was applied after LIMIT). - support admin reply/status: 404 on unknown ticket; status change now audited. - build worker: queue defaultJobOptions, docker build/run/stop timeouts, old-container teardown in finally (no orphan on post-deploy DB failure). - nginx: HSTS, X-Frame-Options DENY, nosniff, Referrer-Policy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> @
615 lines
22 KiB
TypeScript
615 lines
22 KiB
TypeScript
import crypto from 'node:crypto';
|
|
import { getSession } from '@bmm/auth';
|
|
import {
|
|
and,
|
|
builds,
|
|
count,
|
|
createDb,
|
|
desc,
|
|
eq,
|
|
gte,
|
|
mcpServers,
|
|
organizations,
|
|
sql,
|
|
templates,
|
|
users,
|
|
} from '@bmm/db';
|
|
import { SHARED_BANNED_PATTERNS } from '@bmm/llm';
|
|
import { GeneratorSpec } from '@bmm/types';
|
|
import type { FastifyInstance } from 'fastify';
|
|
import { z } from 'zod';
|
|
import { audit } from '../lib/audit.js';
|
|
import { stopContainer } from '../lib/docker.js';
|
|
import { cachePrebuiltCode, cacheSpec } from '../lib/preview-cache.js';
|
|
import { getRedis } from '../lib/redis.js';
|
|
import { requireAdmin, requireAuth } from '../plugins/session.js';
|
|
|
|
const db = createDb();
|
|
|
|
// Code-level extras on top of SHARED_BANNED_PATTERNS — these are concerns
|
|
// that only make sense scanning a fully-rendered server.ts (not a spec).
|
|
// Keeping them additive means @bmm/llm stays the single source of truth for
|
|
// "obvious-malicious patterns", and publish-time gets stricter checks on top.
|
|
// (Zc-001 consolidation.)
|
|
const CODE_EXTRA_PATTERNS: RegExp[] = [
|
|
/\bimport\s*\(/, // dynamic import (escape from bundle scope)
|
|
/\bsetTimeout\s*\(\s*['"`]/, // setTimeout('code', ms) eval form
|
|
/\bsetInterval\s*\(\s*['"`]/,
|
|
/\bfs\s*\.\s*(unlink|rmdir|rm)\b/,
|
|
/\bprocess\s*\.\s*kill\b/,
|
|
/you\s+are\s+now\s+(in\s+)?(developer|jailbreak|dan)\s+mode/i,
|
|
];
|
|
const PUBLISH_BANNED_PATTERNS: readonly RegExp[] = [
|
|
...SHARED_BANNED_PATTERNS,
|
|
...CODE_EXTRA_PATTERNS,
|
|
];
|
|
|
|
// Hardcoded-credential patterns. If Claude embedded a literal API key into the
|
|
// generated code (publisher pasted it into the prompt), block the publish.
|
|
const SECRET_PATTERNS = [
|
|
{ name: 'anthropic_key', re: /\bsk-ant-(?:api|sid)\d+-[A-Za-z0-9_-]{20,}/ },
|
|
{ name: 'openai_key', re: /\bsk-[A-Za-z0-9_-]{30,}/ },
|
|
{ name: 'stripe_secret', re: /\bsk_(live|test)_[A-Za-z0-9]{20,}/ },
|
|
{ name: 'github_pat', re: /\bghp_[A-Za-z0-9]{30,}/ },
|
|
{ name: 'github_fine_grained', re: /\bgithub_pat_[A-Za-z0-9_]{30,}/ },
|
|
{ name: 'slack_token', re: /\bxox[bpoasr]-[A-Za-z0-9-]{10,}/ },
|
|
{ name: 'aws_access_key', re: /\bAKIA[0-9A-Z]{16}\b/ },
|
|
{ name: 'rsa_private_key', re: /-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----/i },
|
|
];
|
|
|
|
function scanForInjection(code: string): void {
|
|
for (const pattern of PUBLISH_BANNED_PATTERNS) {
|
|
if (pattern.test(code)) throw new Error(`banned_pattern: ${pattern.source}`);
|
|
}
|
|
}
|
|
|
|
function scanForLeakedSecrets(code: string): void {
|
|
for (const { name, re } of SECRET_PATTERNS) {
|
|
if (re.test(code)) {
|
|
throw new Error(
|
|
`hardcoded_${name}_detected: a literal credential was found in the generated code; remove it before publishing`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const SLUG_REGEX = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
|
|
// Per-fork link: ties a previewId back to the template it came from. Set during
|
|
// fork, consumed by the create-server endpoint to prove the user actually went
|
|
// through the fork flow before we accept templateId or bump forkCount.
|
|
const FORK_REF_TTL_SECONDS = 5 * 60;
|
|
async function setForkRef(previewId: string, templateId: string): Promise<void> {
|
|
await getRedis().set(`fork-ref:${previewId}`, templateId, 'EX', FORK_REF_TTL_SECONDS);
|
|
}
|
|
export async function getForkRefTemplate(previewId: string): Promise<string | null> {
|
|
return (await getRedis().get(`fork-ref:${previewId}`)) ?? null;
|
|
}
|
|
|
|
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 AND hardcoded secrets
|
|
try {
|
|
scanForInjection(build.generatedCode);
|
|
scanForLeakedSecrets(build.generatedCode);
|
|
} catch (err) {
|
|
return reply.code(422).send({ error: 'publish_blocked', 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,
|
|
// Aggregate the distinct scopes actually declared by the server's tools
|
|
// (deduped), falling back to read-only. The previous reduce ignored its
|
|
// input and hardcoded ['mcp:read'] for every template regardless of what
|
|
// its tools did. (TPL-003)
|
|
scopes: (() => {
|
|
const tools = (server.toolsSchema as Array<{ scopes?: string[] }> | null) ?? [];
|
|
const all = [...new Set(tools.flatMap((t) => t.scopes ?? []))];
|
|
return all.length > 0 ? all : ['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 });
|
|
});
|
|
|
|
// ---- "Is this server already published?" (owner lookup, drives the detail-page tab) ----
|
|
app.get('/v1/servers/:id/template', { preHandler: requireAuth }, async (req, reply) => {
|
|
const user = req.user!;
|
|
const Params = z.object({ id: z.string().uuid() });
|
|
const parsed = Params.safeParse(req.params);
|
|
if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' });
|
|
|
|
// Verify the server belongs to the caller's org
|
|
const [server] = await db
|
|
.select({ id: mcpServers.id })
|
|
.from(mcpServers)
|
|
.where(and(eq(mcpServers.id, parsed.data.id), eq(mcpServers.orgId, user.orgId)))
|
|
.limit(1);
|
|
if (!server) return reply.code(404).send({ error: 'not_found' });
|
|
|
|
const [template] = await db
|
|
.select({
|
|
id: templates.id,
|
|
slug: templates.slug,
|
|
title: templates.title,
|
|
status: templates.status,
|
|
verified: templates.verified,
|
|
forkCount: templates.forkCount,
|
|
})
|
|
.from(templates)
|
|
.where(eq(templates.sourceServerId, parsed.data.id))
|
|
.orderBy(desc(templates.createdAt))
|
|
.limit(1);
|
|
|
|
return reply.send({ template: template ?? null });
|
|
});
|
|
|
|
// ---- Owner visibility toggle (unshare / re-share anytime) ----
|
|
app.patch('/v1/templates/:slug/visibility', { preHandler: requireAuth }, async (req, reply) => {
|
|
const user = req.user!;
|
|
const Params = z.object({ slug: z.string().regex(SLUG_REGEX) });
|
|
const Body = z.object({ shared: z.boolean() });
|
|
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' });
|
|
|
|
const [template] = await db
|
|
.select()
|
|
.from(templates)
|
|
.where(eq(templates.slug, p.data.slug))
|
|
.limit(1);
|
|
if (!template) return reply.code(404).send({ error: 'not_found' });
|
|
|
|
// Only the owner can toggle their own template. Admins use /v1/admin/templates.
|
|
if (template.ownerUserId !== user.userId) {
|
|
return reply.code(403).send({ error: 'forbidden' });
|
|
}
|
|
// A template the admin took down cannot be re-shared by the owner.
|
|
if (template.status === 'takedown') {
|
|
return reply.code(409).send({ error: 'taken_down', detail: template.takedownReason });
|
|
}
|
|
|
|
const nextStatus = b.data.shared ? 'public' : 'hidden';
|
|
await db
|
|
.update(templates)
|
|
.set({ status: nextStatus, updatedAt: new Date() })
|
|
.where(eq(templates.id, template.id));
|
|
|
|
await audit({
|
|
orgId: user.orgId,
|
|
userId: user.userId,
|
|
action: b.data.shared ? 'template.reshare' : 'template.unshare',
|
|
resourceType: 'template',
|
|
resourceId: template.id,
|
|
metadata: { slug: template.slug },
|
|
ipAddress: req.ip,
|
|
});
|
|
return reply.send({ ok: true, status: nextStatus });
|
|
});
|
|
|
|
// ---- 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))
|
|
// Category filter belongs in the WHERE, BEFORE limit — filtering in JS
|
|
// after `.limit(50)` meant `?category=x` searched only the 50 newest
|
|
// public templates (any category), returning far fewer than `limit`. (TPL-008)
|
|
.where(
|
|
and(
|
|
eq(templates.status, 'public'),
|
|
parsed.data.category ? eq(templates.category, parsed.data.category) : undefined,
|
|
),
|
|
)
|
|
.orderBy(desc(templates.createdAt))
|
|
.limit(parsed.data.limit);
|
|
|
|
const filtered = rows;
|
|
|
|
// Single grouped query — was N+1 (one COUNT per template). On a 100-row
|
|
// listing that's 101 round-trips → p95 latency cliff once the marketplace
|
|
// grows. (Zc-002.)
|
|
const templateIds = filtered.map((r) => r.template.id);
|
|
const activeCounts = new Map<string, number>();
|
|
if (templateIds.length > 0) {
|
|
const grouped = await db
|
|
.select({ id: mcpServers.templateId, c: count() })
|
|
.from(mcpServers)
|
|
.where(and(eq(mcpServers.status, 'live'), sql`${mcpServers.templateId} = ANY(${templateIds})`))
|
|
.groupBy(mcpServers.templateId);
|
|
for (const g of grouped) {
|
|
if (g.id) activeCounts.set(g.id, Number(g.c));
|
|
}
|
|
}
|
|
const enriched = filtered.map((r) => ({
|
|
...r.template,
|
|
ownerName: r.ownerName ?? r.ownerEmail?.split('@')[0] ?? null,
|
|
ownerOrgName: r.ownerOrgName,
|
|
activeDeployments: activeCounts.get(r.template.id) ?? 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 });
|
|
});
|
|
|
|
// ---- My templates (authed — all statuses, for the marketplace "Mine" filter) ----
|
|
// Registered before /:slug so the static segment always wins the router match.
|
|
app.get('/v1/templates/mine', { preHandler: requireAuth }, async (req, reply) => {
|
|
const user = req.user!;
|
|
const rows = await db
|
|
.select()
|
|
.from(templates)
|
|
.where(eq(templates.ownerUserId, user.userId))
|
|
.orderBy(desc(templates.createdAt));
|
|
|
|
const enriched = await Promise.all(
|
|
rows.map(async (t) => {
|
|
const [active] = await db
|
|
.select({ c: count() })
|
|
.from(mcpServers)
|
|
.where(and(eq(mcpServers.templateId, t.id), eq(mcpServers.status, 'live')));
|
|
return {
|
|
...t,
|
|
ownerName: user.email?.split('@')[0] ?? user.phone ?? 'you',
|
|
ownerOrgName: null,
|
|
activeDeployments: Number(active?.c ?? 0),
|
|
};
|
|
}),
|
|
);
|
|
return reply.send({ templates: enriched, categories: CATEGORIES });
|
|
});
|
|
|
|
// ---- Detail ----
|
|
app.get('/v1/templates/:slug', async (req, reply) => {
|
|
const Params = z.object({ slug: z.string().regex(SLUG_REGEX) });
|
|
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') {
|
|
// Takedown is an admin decision — sealed for everyone, including the owner.
|
|
return reply.code(410).send({
|
|
error: 'taken_down',
|
|
reason: row.template.takedownReason,
|
|
});
|
|
}
|
|
if (row.template.status !== 'public') {
|
|
// hidden / draft — visible only to the owner (optional auth check).
|
|
const session = await getSession(req.cookies['bmm_session']);
|
|
const isOwner = session != null && session.userId === row.template.ownerUserId;
|
|
if (!isOwner) {
|
|
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().regex(SLUG_REGEX) });
|
|
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);
|
|
// Record the fork→template link so /v1/servers can verify the user actually
|
|
// went through this endpoint before accepting templateId.
|
|
await setForkRef(previewId, template.id);
|
|
|
|
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));
|
|
|
|
// Takedown cascade: stop every fork's container, then mark them paused.
|
|
// Just flipping the DB status leaves the container running and serving
|
|
// traffic; we MUST hard-stop them or the takedown is cosmetic.
|
|
let stoppedContainers = 0;
|
|
if (b.data.status === 'takedown') {
|
|
const forkedServers = await db
|
|
.select({ id: mcpServers.id, containerId: mcpServers.containerId, slug: mcpServers.slug })
|
|
.from(mcpServers)
|
|
.where(eq(mcpServers.templateId, p.data.id));
|
|
for (const fork of forkedServers) {
|
|
if (fork.containerId) {
|
|
const result = await stopContainer(fork.containerId, fork.slug);
|
|
if (result.ok) stoppedContainers++;
|
|
else
|
|
app.log.warn(
|
|
{ containerId: fork.containerId, detail: result.detail },
|
|
'takedown: stop failed',
|
|
);
|
|
}
|
|
}
|
|
await db
|
|
.update(mcpServers)
|
|
.set({
|
|
status: 'paused',
|
|
containerId: null,
|
|
publicUrl: null,
|
|
hostPort: null,
|
|
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, stoppedContainers },
|
|
ipAddress: req.ip,
|
|
});
|
|
return reply.send({ ok: true, stoppedContainers });
|
|
});
|
|
|
|
// unused-import guard
|
|
void gte;
|
|
void sql;
|
|
}
|