feat(api,generator): preview endpoint + spec cache + audit-log writes

- POST /v1/servers/preview runs Claude synchronously, validates output, caches spec
  in Redis under preview:<id> with 5min TTL, returns previewId+spec+detectedSecrets.
- POST /v1/servers accepts optional previewId; worker reuses the cached spec if
  the entry is still present, otherwise regenerates fresh. Skips the second
  Claude round-trip (~30s saved on the demoable path).
- audit() helper writes auth.login, auth.logout, server.create, server.iterate,
  server.delete to audit_log with ip, metadata, resourceId.
- GET /v1/me/org returns organization + members list for the settings page.
- GET /v1/audit?limit=&action=&resourceType= returns scoped audit entries.
This commit is contained in:
Marco Sadjadi 2026-05-19 18:08:29 +02:00
parent bb0d9c2cda
commit 1c92964bbd
9 changed files with 270 additions and 6 deletions

View File

@ -6,6 +6,7 @@ import { config } from './config.js';
import { authRoutes } from './routes/auth.js'; import { authRoutes } from './routes/auth.js';
import { serverRoutes } from './routes/servers.js'; import { serverRoutes } from './routes/servers.js';
import { oauthRoutes } from './routes/oauth.js'; import { oauthRoutes } from './routes/oauth.js';
import { settingsRoutes } from './routes/settings.js';
const app = Fastify({ const app = Fastify({
logger: { logger: {
@ -25,6 +26,7 @@ app.get('/health', async () => ({ ok: true, ts: Date.now() }));
await app.register(authRoutes); await app.register(authRoutes);
await app.register(serverRoutes); await app.register(serverRoutes);
await app.register(oauthRoutes); await app.register(oauthRoutes);
await app.register(settingsRoutes);
app.setErrorHandler((err, _req, reply) => { app.setErrorHandler((err, _req, reply) => {
app.log.error(err); app.log.error(err);

31
apps/api/src/lib/audit.ts Normal file
View File

@ -0,0 +1,31 @@
import { auditLog, createDb } from '@bmm/db';
const db = createDb();
export interface AuditInput {
orgId?: string;
userId?: string;
action: string;
resourceType?: string;
resourceId?: string;
metadata?: Record<string, unknown>;
ipAddress?: string;
}
export async function audit(input: AuditInput): Promise<void> {
try {
await db.insert(auditLog).values({
orgId: input.orgId ?? null,
userId: input.userId ?? null,
action: input.action,
resourceType: input.resourceType ?? null,
resourceId: input.resourceId ?? null,
metadata: input.metadata ?? null,
ipAddress: input.ipAddress ?? null,
});
} catch (err) {
// Audit failures must never block the request path.
// eslint-disable-next-line no-console
console.error('[audit] failed to write entry:', err);
}
}

View File

@ -0,0 +1,25 @@
import crypto from 'node:crypto';
import { getRedis } from './redis.js';
import type { GeneratorSpec } from '@bmm/types';
const TTL_SECONDS = 5 * 60;
function key(previewId: string): string {
return `preview:${previewId}`;
}
export async function cacheSpec(spec: GeneratorSpec): Promise<string> {
const previewId = crypto.randomBytes(12).toString('base64url');
await getRedis().set(key(previewId), JSON.stringify(spec), 'EX', TTL_SECONDS);
return previewId;
}
export async function loadSpec(previewId: string): Promise<GeneratorSpec | null> {
const raw = await getRedis().get(key(previewId));
if (!raw) return null;
try {
return JSON.parse(raw) as GeneratorSpec;
} catch {
return null;
}
}

View File

@ -10,6 +10,7 @@ export interface BuildJobData {
slug: string; slug: string;
serverName: string; serverName: string;
secrets: Record<string, string>; secrets: Record<string, string>;
previewId?: string;
} }
let queue: Queue<BuildJobData> | null = null; let queue: Queue<BuildJobData> | null = null;

View File

@ -1,6 +1,7 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import { consumeMagicLink, destroySession, getSession, issueMagicLink } from '@bmm/auth'; import { consumeMagicLink, destroySession, getSession, issueMagicLink } from '@bmm/auth';
import { audit } from '../lib/audit.js';
import { config } from '../config.js'; import { config } from '../config.js';
const SESSION_COOKIE = 'bmm_session'; const SESSION_COOKIE = 'bmm_session';
@ -39,6 +40,14 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
secure: config.NODE_ENV === 'production', secure: config.NODE_ENV === 'production',
maxAge: 30 * 24 * 60 * 60, maxAge: 30 * 24 * 60 * 60,
}); });
await audit({
orgId: session.orgId,
userId: session.userId,
action: 'auth.login',
resourceType: 'session',
metadata: { email: session.email },
ipAddress: req.ip,
});
return reply.send({ return reply.send({
ok: true, ok: true,
user: { id: session.userId, email: session.email, orgId: session.orgId }, user: { id: session.userId, email: session.email, orgId: session.orgId },
@ -58,8 +67,18 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
app.post('/v1/auth/logout', async (req, reply) => { app.post('/v1/auth/logout', async (req, reply) => {
const token = req.cookies[SESSION_COOKIE]; const token = req.cookies[SESSION_COOKIE];
const session = token ? await getSession(token) : null;
if (token) await destroySession(token); if (token) await destroySession(token);
reply.clearCookie(SESSION_COOKIE, { path: '/' }); reply.clearCookie(SESSION_COOKIE, { path: '/' });
if (session) {
await audit({
orgId: session.orgId,
userId: session.userId,
action: 'auth.logout',
resourceType: 'session',
ipAddress: req.ip,
});
}
return reply.send({ ok: true }); return reply.send({ ok: true });
}); });
} }

View File

@ -1,11 +1,15 @@
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 } from '@bmm/db';
import { CreateServerInput, IterateServerInput, BuildEvent } from '@bmm/types'; import { CreateServerInput, IterateServerInput, BuildEvent, PreviewInput } from '@bmm/types';
import { generateSpec, SpecValidationError, BannedPatternError } from '@bmm/llm';
import { requireAuth } from '../plugins/session.js'; import { requireAuth } from '../plugins/session.js';
import { getBuildQueue } from '../lib/queue.js'; import { getBuildQueue } from '../lib/queue.js';
import { buildChannel, getSubscriber } from '../lib/redis.js'; import { buildChannel, getSubscriber } from '../lib/redis.js';
import { encryptSecret } from '../lib/crypto.js'; import { encryptSecret } from '../lib/crypto.js';
import { audit } from '../lib/audit.js';
import { cacheSpec } from '../lib/preview-cache.js';
import { config } from '../config.js';
const db = createDb(); const db = createDb();
@ -20,13 +24,51 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
return reply.send({ servers: rows }); return reply.send({ servers: rows });
}); });
app.post('/v1/servers/preview', { preHandler: requireAuth }, async (req, reply) => {
const parsed = PreviewInput.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_input', issues: parsed.error.flatten() });
}
try {
const { spec, source } = await generateSpec(parsed.data.prompt, {
apiKey: config.ANTHROPIC_API_KEY,
model: 'claude-opus-4-7',
});
const previewId = await cacheSpec(spec);
return reply.send({
previewId,
source,
spec: {
name: spec.name,
description: spec.description,
tools: spec.tools.map((t) => ({
name: t.name,
description: t.description,
inputSchema: t.inputSchema,
})),
requiredSecrets: spec.requiredSecrets,
scopes: spec.scopes,
},
});
} catch (err) {
if (err instanceof SpecValidationError) {
return reply.code(422).send({ error: 'spec_invalid', detail: err.message });
}
if (err instanceof BannedPatternError) {
return reply.code(422).send({ error: 'banned_pattern', detail: err.message });
}
app.log.error(err);
return reply.code(500).send({ error: 'preview_failed', detail: (err as Error).message });
}
});
app.post('/v1/servers', { preHandler: requireAuth }, async (req, reply) => { app.post('/v1/servers', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!; const user = req.user!;
const parsed = CreateServerInput.safeParse(req.body); const parsed = CreateServerInput.safeParse(req.body);
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 } = parsed.data; const { name, slug, prompt, secrets: secretValues, previewId } = parsed.data;
const existing = await db const existing = await db
.select({ id: mcpServers.id }) .select({ id: mcpServers.id })
@ -67,6 +109,17 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
slug, slug,
serverName: name, serverName: name,
secrets: secretValues, secrets: secretValues,
previewId,
});
await audit({
orgId: user.orgId,
userId: user.userId,
action: 'server.create',
resourceType: 'server',
resourceId: server.id,
metadata: { slug, name, previewId: previewId ?? null },
ipAddress: req.ip,
}); });
return reply.send({ server, build }); return reply.send({ server, build });
@ -138,6 +191,16 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
secrets: parsed.data.secrets, secrets: parsed.data.secrets,
}); });
await audit({
orgId: user.orgId,
userId: user.userId,
action: 'server.iterate',
resourceType: 'server',
resourceId: server.id,
metadata: { version: nextVersion },
ipAddress: req.ip,
});
return reply.send({ build }); return reply.send({ build });
}); });
@ -238,6 +301,15 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
.limit(1); .limit(1);
if (!server) return reply.code(404).send({ error: 'not_found' }); if (!server) return reply.code(404).send({ error: 'not_found' });
await db.delete(mcpServers).where(eq(mcpServers.id, server.id)); await db.delete(mcpServers).where(eq(mcpServers.id, server.id));
await audit({
orgId: user.orgId,
userId: user.userId,
action: 'server.delete',
resourceType: 'server',
resourceId: server.id,
metadata: { slug: server.slug, name: server.name },
ipAddress: req.ip,
});
return reply.send({ ok: true }); return reply.send({ ok: true });
}); });
} }

View File

@ -0,0 +1,57 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { auditLog, createDb, desc, eq, memberships, organizations, users } from '@bmm/db';
import { requireAuth } from '../plugins/session.js';
const db = createDb();
export async function settingsRoutes(app: FastifyInstance): Promise<void> {
app.get('/v1/me/org', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const [org] = await db.select().from(organizations).where(eq(organizations.id, user.orgId)).limit(1);
if (!org) return reply.code(404).send({ error: 'not_found' });
const members = await db
.select({
id: memberships.id,
userId: users.id,
email: users.email,
name: users.name,
role: memberships.role,
createdAt: memberships.createdAt,
})
.from(memberships)
.innerJoin(users, eq(users.id, memberships.userId))
.where(eq(memberships.orgId, org.id))
.orderBy(memberships.createdAt);
return reply.send({ org, members });
});
app.get('/v1/audit', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const Query = z.object({
limit: z.coerce.number().min(1).max(500).default(100),
action: z.string().optional(),
resourceType: z.string().optional(),
});
const parsed = Query.safeParse(req.query);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_query' });
let rows = await db
.select()
.from(auditLog)
.where(eq(auditLog.orgId, user.orgId))
.orderBy(desc(auditLog.createdAt))
.limit(parsed.data.limit);
if (parsed.data.action) {
rows = rows.filter((r) => r.action === parsed.data.action);
}
if (parsed.data.resourceType) {
rows = rows.filter((r) => r.resourceType === parsed.data.resourceType);
}
return reply.send({ entries: rows });
});
}

View File

@ -1,5 +1,6 @@
import { Worker } from 'bullmq'; import { Worker } from 'bullmq';
import { Redis } from 'ioredis'; import { Redis } from 'ioredis';
import { GeneratorSpec } from '@bmm/types';
import { builds, createDb, eq, mcpServers } from '@bmm/db'; import { builds, createDb, eq, mcpServers } from '@bmm/db';
import { config } from './config.js'; import { config } from './config.js';
import { generateSpec } from './lib/claude.js'; import { generateSpec } from './lib/claude.js';
@ -10,6 +11,7 @@ import { emitDone, emitError, emitLog, emitStatus } from './lib/emit.js';
const db = createDb(); const db = createDb();
const connection = new Redis(config.REDIS_URL, { maxRetriesPerRequest: null }); const connection = new Redis(config.REDIS_URL, { maxRetriesPerRequest: null });
const cacheReader = new Redis(config.REDIS_URL, { maxRetriesPerRequest: null });
interface JobData { interface JobData {
buildId: string; buildId: string;
@ -20,22 +22,52 @@ interface JobData {
slug: string; slug: string;
serverName: string; serverName: string;
secrets: Record<string, string>; secrets: Record<string, string>;
previewId?: string;
}
async function loadCachedSpec(previewId: string): Promise<GeneratorSpec | null> {
const raw = await cacheReader.get(`preview:${previewId}`);
if (!raw) return null;
try {
const parsed = GeneratorSpec.safeParse(JSON.parse(raw));
return parsed.success ? parsed.data : null;
} catch {
return null;
}
} }
export const worker = new Worker<JobData>( export const worker = new Worker<JobData>(
'build', 'build',
async (job) => { async (job) => {
const { buildId, serverId, prompt, version, slug, secrets } = job.data; const { buildId, serverId, prompt, version, slug, secrets, previewId } = job.data;
const log = (level: 'info' | 'warn' | 'error', msg: string) => emitLog(buildId, level, msg); const log = (level: 'info' | 'warn' | 'error', msg: string) => emitLog(buildId, level, msg);
try { try {
await db.update(builds).set({ status: 'generating', startedAt: new Date() }).where(eq(builds.id, buildId)); await db.update(builds).set({ status: 'generating', startedAt: new Date() }).where(eq(builds.id, buildId));
await db.update(mcpServers).set({ status: 'generating', updatedAt: new Date() }).where(eq(mcpServers.id, serverId)); await db.update(mcpServers).set({ status: 'generating', updatedAt: new Date() }).where(eq(mcpServers.id, serverId));
await emitStatus(buildId, 'generating'); await emitStatus(buildId, 'generating');
await log('info', 'Generating MCP server spec...');
const { spec, source } = await generateSpec(prompt); let spec: GeneratorSpec | null = null;
await log('info', `Spec generated via ${source} (${spec.tools.length} tool(s))`); let source: 'claude' | 'mock' | 'cached' = 'mock';
if (previewId) {
spec = await loadCachedSpec(previewId);
if (spec) {
source = 'cached';
await log('info', `Re-using preview spec ${previewId} (skipping Claude call)`);
} else {
await log('warn', `Preview ${previewId} cache miss — regenerating`);
}
}
if (!spec) {
await log('info', 'Generating MCP server spec...');
const result = await generateSpec(prompt);
spec = result.spec;
source = result.source;
}
await log('info', `Spec ready via ${source} (${spec.tools.length} tool(s))`);
const generatedCode = renderServerCode(spec); const generatedCode = renderServerCode(spec);
await db await db
.update(builds) .update(builds)

View File

@ -120,9 +120,34 @@ export const CreateServerInput = z.object({
.regex(/^[a-z][a-z0-9-]*$/, 'lowercase, hyphenated'), .regex(/^[a-z][a-z0-9-]*$/, 'lowercase, hyphenated'),
prompt: z.string().min(10).max(8000), prompt: z.string().min(10).max(8000),
secrets: z.record(z.string(), z.string()).default({}), secrets: z.record(z.string(), z.string()).default({}),
previewId: z.string().min(1).max(64).optional(),
}); });
export type CreateServerInput = z.infer<typeof CreateServerInput>; export type CreateServerInput = z.infer<typeof CreateServerInput>;
export const PreviewInput = z.object({
prompt: z.string().min(10).max(8000),
});
export type PreviewInput = z.infer<typeof PreviewInput>;
export const PreviewResult = z.object({
previewId: z.string(),
source: z.enum(['claude', 'mock']),
spec: z.object({
name: z.string(),
description: z.string().optional(),
tools: z.array(
z.object({
name: z.string(),
description: z.string(),
inputSchema: z.record(z.string(), z.unknown()),
}),
),
requiredSecrets: z.array(z.string()),
scopes: z.array(z.string()),
}),
});
export type PreviewResult = z.infer<typeof PreviewResult>;
export const IterateServerInput = z.object({ export const IterateServerInput = z.object({
prompt: z.string().min(10).max(8000), prompt: z.string().min(10).max(8000),
secrets: z.record(z.string(), z.string()).default({}), secrets: z.record(z.string(), z.string()).default({}),