diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 4bc9cac..a12cf3f 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -6,6 +6,7 @@ import { config } from './config.js'; import { authRoutes } from './routes/auth.js'; import { serverRoutes } from './routes/servers.js'; import { oauthRoutes } from './routes/oauth.js'; +import { settingsRoutes } from './routes/settings.js'; const app = Fastify({ logger: { @@ -25,6 +26,7 @@ app.get('/health', async () => ({ ok: true, ts: Date.now() })); await app.register(authRoutes); await app.register(serverRoutes); await app.register(oauthRoutes); +await app.register(settingsRoutes); app.setErrorHandler((err, _req, reply) => { app.log.error(err); diff --git a/apps/api/src/lib/audit.ts b/apps/api/src/lib/audit.ts new file mode 100644 index 0000000..ec8ba6f --- /dev/null +++ b/apps/api/src/lib/audit.ts @@ -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; + ipAddress?: string; +} + +export async function audit(input: AuditInput): Promise { + 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); + } +} diff --git a/apps/api/src/lib/preview-cache.ts b/apps/api/src/lib/preview-cache.ts new file mode 100644 index 0000000..7479512 --- /dev/null +++ b/apps/api/src/lib/preview-cache.ts @@ -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 { + 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 { + const raw = await getRedis().get(key(previewId)); + if (!raw) return null; + try { + return JSON.parse(raw) as GeneratorSpec; + } catch { + return null; + } +} diff --git a/apps/api/src/lib/queue.ts b/apps/api/src/lib/queue.ts index 0cef9f4..bc549bf 100644 --- a/apps/api/src/lib/queue.ts +++ b/apps/api/src/lib/queue.ts @@ -10,6 +10,7 @@ export interface BuildJobData { slug: string; serverName: string; secrets: Record; + previewId?: string; } let queue: Queue | null = null; diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index b690fa2..26ae617 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -1,6 +1,7 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { consumeMagicLink, destroySession, getSession, issueMagicLink } from '@bmm/auth'; +import { audit } from '../lib/audit.js'; import { config } from '../config.js'; const SESSION_COOKIE = 'bmm_session'; @@ -39,6 +40,14 @@ export async function authRoutes(app: FastifyInstance): Promise { secure: config.NODE_ENV === 'production', 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({ ok: true, user: { id: session.userId, email: session.email, orgId: session.orgId }, @@ -58,8 +67,18 @@ export async function authRoutes(app: FastifyInstance): Promise { app.post('/v1/auth/logout', async (req, reply) => { const token = req.cookies[SESSION_COOKIE]; + const session = token ? await getSession(token) : null; if (token) await destroySession(token); 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 }); }); } diff --git a/apps/api/src/routes/servers.ts b/apps/api/src/routes/servers.ts index f3dd0da..d0c4432 100644 --- a/apps/api/src/routes/servers.ts +++ b/apps/api/src/routes/servers.ts @@ -1,11 +1,15 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; 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 { getBuildQueue } from '../lib/queue.js'; import { buildChannel, getSubscriber } from '../lib/redis.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(); @@ -20,13 +24,51 @@ export async function serverRoutes(app: FastifyInstance): Promise { 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) => { const user = req.user!; const parsed = CreateServerInput.safeParse(req.body); if (!parsed.success) { 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 .select({ id: mcpServers.id }) @@ -67,6 +109,17 @@ export async function serverRoutes(app: FastifyInstance): Promise { slug, serverName: name, 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 }); @@ -138,6 +191,16 @@ export async function serverRoutes(app: FastifyInstance): Promise { 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 }); }); @@ -238,6 +301,15 @@ export async function serverRoutes(app: FastifyInstance): Promise { .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: 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 }); }); } diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts new file mode 100644 index 0000000..5427a5f --- /dev/null +++ b/apps/api/src/routes/settings.ts @@ -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 { + 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 }); + }); +} diff --git a/apps/generator/src/worker.ts b/apps/generator/src/worker.ts index 5e6fdca..4952a8d 100644 --- a/apps/generator/src/worker.ts +++ b/apps/generator/src/worker.ts @@ -1,5 +1,6 @@ import { Worker } from 'bullmq'; import { Redis } from 'ioredis'; +import { GeneratorSpec } from '@bmm/types'; import { builds, createDb, eq, mcpServers } from '@bmm/db'; import { config } from './config.js'; import { generateSpec } from './lib/claude.js'; @@ -10,6 +11,7 @@ import { emitDone, emitError, emitLog, emitStatus } from './lib/emit.js'; const db = createDb(); const connection = new Redis(config.REDIS_URL, { maxRetriesPerRequest: null }); +const cacheReader = new Redis(config.REDIS_URL, { maxRetriesPerRequest: null }); interface JobData { buildId: string; @@ -20,22 +22,52 @@ interface JobData { slug: string; serverName: string; secrets: Record; + previewId?: string; +} + +async function loadCachedSpec(previewId: string): Promise { + 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( 'build', 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); try { 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 emitStatus(buildId, 'generating'); - await log('info', 'Generating MCP server spec...'); - const { spec, source } = await generateSpec(prompt); - await log('info', `Spec generated via ${source} (${spec.tools.length} tool(s))`); + let spec: GeneratorSpec | null = null; + 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); await db .update(builds) diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index c6de277..3ac46e0 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -120,9 +120,34 @@ export const CreateServerInput = z.object({ .regex(/^[a-z][a-z0-9-]*$/, 'lowercase, hyphenated'), prompt: z.string().min(10).max(8000), secrets: z.record(z.string(), z.string()).default({}), + previewId: z.string().min(1).max(64).optional(), }); export type CreateServerInput = z.infer; +export const PreviewInput = z.object({ + prompt: z.string().min(10).max(8000), +}); +export type PreviewInput = z.infer; + +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; + export const IterateServerInput = z.object({ prompt: z.string().min(10).max(8000), secrets: z.record(z.string(), z.string()).default({}),