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

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;
serverName: string;
secrets: Record<string, string>;
previewId?: string;
}
let queue: Queue<BuildJobData> | null = null;