buildmymcpserver/apps/api/src/routes/admin.ts
Marco Sadjadi c62fcd07ef feat(admin): password-auth admin panel with 8 pages + 15 API endpoints
Schema migrations:
- users.is_admin boolean
- users.password_hash text (scrypt N=16384, 16-byte salt)
- users.last_login_at timestamp
- organizations.suspended + suspended_reason
- admin_settings table (DB-stored prompt override + future settings)

Auth (@bmm/auth):
- hashPassword + verifyPassword via node:crypto scrypt (no extra dep)
- loginWithPassword: scrypt-verifies, issues 30-day session, updates last_login_at
- seedAdmin: idempotent upsert keyed on email; creates org + membership on first run
- AuthedUser now carries isAdmin flag

API:
- POST /v1/auth/admin/login (email + password) — 300ms throttle on failure
- requireAdmin preHandler — 401 if no session, 403 if non-admin
- Bootstrap: api on boot calls seedAdmin(ADMIN_EMAIL, ADMIN_PASSWORD, ADMIN_NAME)
  if env present. Idempotent.

Admin API routes (all gated by requireAdmin):
- GET /v1/admin/overview (totals, trends 7d, server-status breakdown, builds 24h, recent activity)
- GET /v1/admin/users (search, per-row org + plan + serverCount)
- PATCH /v1/admin/users/:id (isAdmin, name)
- DELETE /v1/admin/users/:id (self-delete blocked)
- GET /v1/admin/orgs (member + server counts)
- PATCH /v1/admin/orgs/:id (plan, quota, suspended; cascades to mcp_servers.status=paused on suspend)
- GET /v1/admin/servers (cross-org with status filter)
- POST /v1/admin/servers/:id/rebuild (re-queues build using last prompt)
- DELETE /v1/admin/servers/:id
- GET /v1/admin/builds (status filter, error messages, prompt previews)
- GET /v1/admin/builds/:id/logs
- GET /v1/admin/audit (system-wide with user email join)
- GET /v1/admin/system (DB ping, Redis ping, BullMQ queue depth, docker ps count)
- GET /v1/admin/prompt (builtin + override + updatedAt)
- PATCH /v1/admin/prompt (value: string | null) — saves DB override or drops it

UI (apps/web/app/admin/*):
- /admin/login — password form, separate from /login magic-link
- AdminLayout — Linear-style sidebar (8 nav items), bottom panel with user email +
  'user view' shortcut + logout, client-side requireAdmin guard with redirect
- /admin — overview dashboard with 4 metric cards, 2 panels (status + 24h builds),
  recent activity table linking to full audit
- /admin/users — search + admin toggle + delete (self-delete blocked)
- /admin/orgs — plan/quota/suspend actions via prompts
- /admin/servers — cross-org table with rebuild + delete actions, status filter
- /admin/builds — every build cross-fleet with error vs prompt preview
- /admin/audit — system-wide log + CSV export + filter dropdowns
- /admin/system — auto-refreshing 5s health probes for Postgres, Redis, queue, Docker
- /admin/prompt — live editor for the LLM system prompt with built-in baseline,
  override-state badge, drop-override action, diff preview, save-as-override

End-to-end verified: login as marco.frangiskatos@gmail.com + Melusa112233.*, every
admin page returns 200, admin login + overview tested via screenshot, docker probe
returns true count of running MCP containers.
2026-05-19 23:01:26 +02:00

570 lines
18 KiB
TypeScript

import type { FastifyInstance } from 'fastify';
import { spawn } from 'node:child_process';
import { z } from 'zod';
import {
adminSettings,
auditLog,
buildLogs,
builds,
count,
createDb,
desc,
eq,
gte,
inArray,
mcpServers,
memberships,
organizations,
sql,
toolCallMetrics,
users,
} from '@bmm/db';
import { SYSTEM_PROMPT } from '@bmm/llm';
import { requireAdmin } from '../plugins/session.js';
import { getRedis } from '../lib/redis.js';
import { getBuildQueue } from '../lib/queue.js';
import { audit } from '../lib/audit.js';
const db = createDb();
const PROMPT_KEY = 'system_prompt_override';
export async function adminRoutes(app: FastifyInstance): Promise<void> {
// ---- Overview metrics ----
app.get('/v1/admin/overview', { preHandler: requireAdmin }, async (_req, reply) => {
const dayAgo = new Date(Date.now() - 24 * 3600_000);
const sevenDaysAgo = new Date(Date.now() - 7 * 86400_000);
const [
userCount,
orgCount,
serverCount,
buildCount,
callCount,
liveCount,
failedBuildCount,
newUsersLast7d,
newServersLast7d,
] = await Promise.all([
db.select({ c: count() }).from(users).then((r) => Number(r[0]?.c ?? 0)),
db.select({ c: count() }).from(organizations).then((r) => Number(r[0]?.c ?? 0)),
db.select({ c: count() }).from(mcpServers).then((r) => Number(r[0]?.c ?? 0)),
db.select({ c: count() }).from(builds).then((r) => Number(r[0]?.c ?? 0)),
db.select({ c: count() }).from(toolCallMetrics).then((r) => Number(r[0]?.c ?? 0)),
db
.select({ c: count() })
.from(mcpServers)
.where(eq(mcpServers.status, 'live'))
.then((r) => Number(r[0]?.c ?? 0)),
db
.select({ c: count() })
.from(builds)
.where(eq(builds.status, 'failed'))
.then((r) => Number(r[0]?.c ?? 0)),
db
.select({ c: count() })
.from(users)
.where(gte(users.createdAt, sevenDaysAgo))
.then((r) => Number(r[0]?.c ?? 0)),
db
.select({ c: count() })
.from(mcpServers)
.where(gte(mcpServers.createdAt, sevenDaysAgo))
.then((r) => Number(r[0]?.c ?? 0)),
]);
// Server status breakdown
const statusBreakdown = await db
.select({ status: mcpServers.status, c: count() })
.from(mcpServers)
.groupBy(mcpServers.status);
// Recent activity from audit log
const recent = await db
.select()
.from(auditLog)
.orderBy(desc(auditLog.createdAt))
.limit(15);
// Builds in last 24h with status
const recentBuilds = await db
.select({ status: builds.status, c: count() })
.from(builds)
.where(gte(builds.createdAt, dayAgo))
.groupBy(builds.status);
return reply.send({
totals: {
users: userCount,
orgs: orgCount,
servers: serverCount,
liveServers: liveCount,
builds: buildCount,
failedBuilds: failedBuildCount,
toolCalls: callCount,
},
trends: {
newUsersLast7d,
newServersLast7d,
},
statusBreakdown,
recentBuilds24h: recentBuilds,
recentActivity: recent,
});
});
// ---- Users ----
app.get('/v1/admin/users', { preHandler: requireAdmin }, async (req, reply) => {
const Query = z.object({
search: z.string().optional(),
limit: z.coerce.number().min(1).max(500).default(100),
});
const parsed = Query.safeParse(req.query);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_query' });
const rows = await db.select().from(users).orderBy(desc(users.createdAt)).limit(parsed.data.limit);
const filtered = parsed.data.search
? rows.filter((u) =>
u.email.toLowerCase().includes(parsed.data.search!.toLowerCase()) ||
(u.name?.toLowerCase().includes(parsed.data.search!.toLowerCase()) ?? false),
)
: rows;
// attach org + server count
const enriched = await Promise.all(
filtered.map(async (u) => {
const [m] = await db
.select({
orgId: memberships.orgId,
orgName: organizations.name,
orgSlug: organizations.slug,
plan: organizations.plan,
role: memberships.role,
})
.from(memberships)
.innerJoin(organizations, eq(organizations.id, memberships.orgId))
.where(eq(memberships.userId, u.id))
.limit(1);
const [sc] = await db
.select({ c: count() })
.from(mcpServers)
.where(m ? eq(mcpServers.orgId, m.orgId) : sql`false`);
return {
id: u.id,
email: u.email,
name: u.name,
isAdmin: u.isAdmin,
emailVerified: u.emailVerified,
lastLoginAt: u.lastLoginAt,
createdAt: u.createdAt,
org: m ?? null,
serverCount: Number(sc?.c ?? 0),
};
}),
);
return reply.send({ users: enriched });
});
app.patch('/v1/admin/users/:id', { preHandler: requireAdmin }, async (req, reply) => {
const Params = z.object({ id: z.string().uuid() });
const Body = z.object({
isAdmin: z.boolean().optional(),
name: z.string().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(users).set(b.data).where(eq(users.id, p.data.id));
await audit({
orgId: req.user!.orgId,
userId: req.user!.userId,
action: 'admin.user.update',
resourceType: 'user',
resourceId: p.data.id,
metadata: b.data,
ipAddress: req.ip,
});
return reply.send({ ok: true });
});
app.delete('/v1/admin/users/:id', { preHandler: requireAdmin }, async (req, reply) => {
const Params = z.object({ id: z.string().uuid() });
const p = Params.safeParse(req.params);
if (!p.success) return reply.code(400).send({ error: 'invalid_id' });
if (p.data.id === req.user!.userId) {
return reply.code(400).send({ error: 'cannot_delete_self' });
}
await db.delete(users).where(eq(users.id, p.data.id));
await audit({
orgId: req.user!.orgId,
userId: req.user!.userId,
action: 'admin.user.delete',
resourceType: 'user',
resourceId: p.data.id,
ipAddress: req.ip,
});
return reply.send({ ok: true });
});
// ---- Organizations ----
app.get('/v1/admin/orgs', { preHandler: requireAdmin }, async (_req, reply) => {
const rows = await db.select().from(organizations).orderBy(desc(organizations.createdAt));
const enriched = await Promise.all(
rows.map(async (o) => {
const [mc] = await db
.select({ c: count() })
.from(memberships)
.where(eq(memberships.orgId, o.id));
const [sc] = await db
.select({ c: count() })
.from(mcpServers)
.where(eq(mcpServers.orgId, o.id));
return {
...o,
memberCount: Number(mc?.c ?? 0),
serverCount: Number(sc?.c ?? 0),
};
}),
);
return reply.send({ orgs: enriched });
});
app.patch('/v1/admin/orgs/:id', { preHandler: requireAdmin }, async (req, reply) => {
const Params = z.object({ id: z.string().uuid() });
const Body = z.object({
plan: z.enum(['hobby', 'pro', 'team', 'enterprise']).optional(),
monthlyCallQuota: z.coerce.number().int().min(0).optional(),
suspended: z.boolean().optional(),
suspendedReason: 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(organizations).set(b.data).where(eq(organizations.id, p.data.id));
// If suspended, pause all the org's servers
if (b.data.suspended === true) {
await db
.update(mcpServers)
.set({ status: 'paused', updatedAt: new Date() })
.where(eq(mcpServers.orgId, p.data.id));
}
await audit({
orgId: req.user!.orgId,
userId: req.user!.userId,
action: 'admin.org.update',
resourceType: 'org',
resourceId: p.data.id,
metadata: b.data,
ipAddress: req.ip,
});
return reply.send({ ok: true });
});
// ---- Servers ----
app.get('/v1/admin/servers', { preHandler: requireAdmin }, async (req, reply) => {
const Query = z.object({
status: z.string().optional(),
limit: z.coerce.number().min(1).max(500).default(200),
});
const parsed = Query.safeParse(req.query);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_query' });
const rows = await db
.select({
server: mcpServers,
org: { id: organizations.id, name: organizations.name, slug: organizations.slug, plan: organizations.plan },
})
.from(mcpServers)
.innerJoin(organizations, eq(organizations.id, mcpServers.orgId))
.orderBy(desc(mcpServers.updatedAt))
.limit(parsed.data.limit);
const filtered = parsed.data.status
? rows.filter((r) => r.server.status === parsed.data.status)
: rows;
return reply.send({ servers: filtered });
});
app.post('/v1/admin/servers/:id/rebuild', { preHandler: requireAdmin }, async (req, reply) => {
const Params = z.object({ id: z.string().uuid() });
const p = Params.safeParse(req.params);
if (!p.success) return reply.code(400).send({ error: 'invalid_id' });
const [server] = await db.select().from(mcpServers).where(eq(mcpServers.id, p.data.id)).limit(1);
if (!server) return reply.code(404).send({ error: 'not_found' });
// Get last build's prompt
const [lastBuild] = await db
.select()
.from(builds)
.where(eq(builds.serverId, server.id))
.orderBy(desc(builds.version))
.limit(1);
if (!lastBuild) return reply.code(400).send({ error: 'no_previous_build' });
const nextVersion = (server.currentVersion ?? 0) + 1;
const [build] = await db
.insert(builds)
.values({
serverId: server.id,
version: nextVersion,
prompt: lastBuild.prompt,
status: 'queued',
})
.returning();
if (!build) return reply.code(500).send({ error: 'build_create_failed' });
await db
.update(mcpServers)
.set({ status: 'queued', updatedAt: new Date() })
.where(eq(mcpServers.id, server.id));
await getBuildQueue().add('generate', {
buildId: build.id,
serverId: server.id,
orgId: server.orgId,
prompt: lastBuild.prompt,
version: nextVersion,
slug: server.slug,
serverName: server.name,
secrets: {},
});
await audit({
orgId: req.user!.orgId,
userId: req.user!.userId,
action: 'admin.server.rebuild',
resourceType: 'server',
resourceId: server.id,
metadata: { newVersion: nextVersion },
ipAddress: req.ip,
});
return reply.send({ build });
});
app.delete('/v1/admin/servers/:id', { preHandler: requireAdmin }, async (req, reply) => {
const Params = z.object({ id: z.string().uuid() });
const p = Params.safeParse(req.params);
if (!p.success) return reply.code(400).send({ error: 'invalid_id' });
const [server] = await db.select().from(mcpServers).where(eq(mcpServers.id, p.data.id)).limit(1);
if (!server) return reply.code(404).send({ error: 'not_found' });
await db.delete(mcpServers).where(eq(mcpServers.id, server.id));
await audit({
orgId: req.user!.orgId,
userId: req.user!.userId,
action: 'admin.server.delete',
resourceType: 'server',
resourceId: server.id,
metadata: { slug: server.slug, name: server.name },
ipAddress: req.ip,
});
return reply.send({ ok: true });
});
// ---- Builds ----
app.get('/v1/admin/builds', { preHandler: requireAdmin }, async (req, reply) => {
const Query = z.object({
status: z.string().optional(),
limit: z.coerce.number().min(1).max(500).default(100),
});
const parsed = Query.safeParse(req.query);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_query' });
const rows = await db
.select({
build: builds,
server: { id: mcpServers.id, name: mcpServers.name, slug: mcpServers.slug },
org: { id: organizations.id, name: organizations.name },
})
.from(builds)
.innerJoin(mcpServers, eq(mcpServers.id, builds.serverId))
.innerJoin(organizations, eq(organizations.id, mcpServers.orgId))
.orderBy(desc(builds.createdAt))
.limit(parsed.data.limit);
const filtered = parsed.data.status
? rows.filter((r) => r.build.status === parsed.data.status)
: rows;
return reply.send({ builds: filtered });
});
app.get('/v1/admin/builds/:id/logs', { preHandler: requireAdmin }, async (req, reply) => {
const Params = z.object({ id: z.string().uuid() });
const p = Params.safeParse(req.params);
if (!p.success) return reply.code(400).send({ error: 'invalid_id' });
const logs = await db
.select()
.from(buildLogs)
.where(eq(buildLogs.buildId, p.data.id))
.orderBy(buildLogs.timestamp);
return reply.send({ logs });
});
// ---- Audit (system-wide) ----
app.get('/v1/admin/audit', { preHandler: requireAdmin }, async (req, reply) => {
const Query = z.object({
action: z.string().optional(),
resourceType: z.string().optional(),
limit: z.coerce.number().min(1).max(1000).default(200),
});
const parsed = Query.safeParse(req.query);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_query' });
let rows = await db
.select({
entry: auditLog,
userEmail: users.email,
})
.from(auditLog)
.leftJoin(users, eq(users.id, auditLog.userId))
.orderBy(desc(auditLog.createdAt))
.limit(parsed.data.limit);
if (parsed.data.action) {
rows = rows.filter((r) => r.entry.action === parsed.data.action);
}
if (parsed.data.resourceType) {
rows = rows.filter((r) => r.entry.resourceType === parsed.data.resourceType);
}
return reply.send({ entries: rows });
});
// ---- System health ----
app.get('/v1/admin/system', { preHandler: requireAdmin }, async (_req, reply) => {
const t0 = Date.now();
// DB probe
let dbOk = false;
let dbLatency: number | null = null;
try {
const start = Date.now();
await db.execute(sql`SELECT 1`);
dbLatency = Date.now() - start;
dbOk = true;
} catch {
// remains false
}
// Redis probe
let redisOk = false;
let redisLatency: number | null = null;
let queueDepth: number | null = null;
try {
const start = Date.now();
const pong = await getRedis().ping();
redisLatency = Date.now() - start;
redisOk = pong === 'PONG';
const q = getBuildQueue();
const counts = await q.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed');
queueDepth =
(counts.waiting ?? 0) +
(counts.active ?? 0) +
(counts.delayed ?? 0);
} catch {
// remains false
}
// Docker container count
const containerCount = await new Promise<number | null>((resolve) => {
const child = spawn('docker', ['ps', '--filter', 'name=bmm-mcp-', '-q'], {
stdio: ['ignore', 'pipe', 'ignore'],
shell: process.platform === 'win32',
});
let out = '';
child.stdout?.on('data', (d: Buffer) => {
out += d.toString();
});
child.on('close', (code) => {
if (code !== 0) {
resolve(null);
return;
}
const lines = out
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
resolve(lines.length);
});
child.on('error', () => resolve(null));
});
return reply.send({
probedAtMs: Date.now() - t0,
db: { ok: dbOk, latencyMs: dbLatency },
redis: { ok: redisOk, latencyMs: redisLatency, queueDepth },
docker: { containerCount },
});
});
// ---- System prompt (read + override) ----
app.get('/v1/admin/prompt', { preHandler: requireAdmin }, async (_req, reply) => {
const [row] = await db
.select()
.from(adminSettings)
.where(eq(adminSettings.key, PROMPT_KEY))
.limit(1);
return reply.send({
builtin: SYSTEM_PROMPT,
override: row?.value ?? null,
updatedAt: row?.updatedAt ?? null,
});
});
app.patch('/v1/admin/prompt', { preHandler: requireAdmin }, async (req, reply) => {
const Body = z.object({
value: z.string().min(1).max(20_000).nullable(),
});
const parsed = Body.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
if (parsed.data.value === null) {
await db.delete(adminSettings).where(eq(adminSettings.key, PROMPT_KEY));
} else {
const [existing] = await db
.select()
.from(adminSettings)
.where(eq(adminSettings.key, PROMPT_KEY))
.limit(1);
if (existing) {
await db
.update(adminSettings)
.set({
value: parsed.data.value,
updatedAt: new Date(),
updatedBy: req.user!.userId,
})
.where(eq(adminSettings.id, existing.id));
} else {
await db.insert(adminSettings).values({
key: PROMPT_KEY,
value: parsed.data.value,
updatedBy: req.user!.userId,
});
}
}
await audit({
orgId: req.user!.orgId,
userId: req.user!.userId,
action: 'admin.prompt.update',
resourceType: 'admin_settings',
resourceId: PROMPT_KEY,
metadata: { hasOverride: parsed.data.value !== null },
ipAddress: req.ip,
});
return reply.send({ ok: true });
});
void inArray; // referenced for future bulk operations
}