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:
@@ -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);
|
||||
|
||||
31
apps/api/src/lib/audit.ts
Normal file
31
apps/api/src/lib/audit.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
25
apps/api/src/lib/preview-cache.ts
Normal file
25
apps/api/src/lib/preview-cache.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export interface BuildJobData {
|
||||
slug: string;
|
||||
serverName: string;
|
||||
secrets: Record<string, string>;
|
||||
previewId?: string;
|
||||
}
|
||||
|
||||
let queue: Queue<BuildJobData> | null = null;
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
|
||||
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 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
.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 });
|
||||
});
|
||||
}
|
||||
|
||||
57
apps/api/src/routes/settings.ts
Normal file
57
apps/api/src/routes/settings.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user