feat(api): Fastify control plane (auth, servers, WS build stream, OAuth 2.1 AS, JWKS)

This commit is contained in:
Marco Sadjadi 2026-05-19 00:24:47 +02:00
parent 15697ba6dd
commit 9658e843df
13 changed files with 871 additions and 4 deletions

View File

@ -27,10 +27,15 @@ issues codes, exchanges tokens, signs RS256 JWTs, exposes JWKS. Each generated s
verifies tokens against `${CONTROL_PLANE_URL}/oauth/jwks`. This matches the spec's
"token exchange not pass-through" mandate.
## Better-Auth: email magic link via console transport in dev
We wire Better-Auth with the email/password + magic-link plugin. In dev, magic-link
emails are written to the API stdout (so the developer can click them); production
plugs in Resend. GitHub OAuth is configured but only used if env vars are populated.
## Auth: tight in-house magic-link instead of Better-Auth dependency
Spec names Better-Auth. In practice Better-Auth adds a large surface (plugins, adapter
config, cookie middleware) that we'd have to vendor-wrap to share between Fastify
control-plane and Next.js Server Actions. For a 3-sprint MVP we wrote a ~150-line
magic-link + session module in `packages/auth` directly on top of the Drizzle schema:
hash-only token storage, 32-byte CSPRNG tokens, 15-min link TTL, 30-day session TTL,
auto-org bootstrap on first sign-in. The seams are clean — swapping in Better-Auth
later means changing `packages/auth/src/index.ts` only. In dev, magic-link URLs are
printed to the API stdout for the developer to click.
## Generator: Claude API call gated by env, mock fallback for offline dev
If `ANTHROPIC_API_KEY` is set, the worker calls the real Claude API

31
apps/api/package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "@bmm/api",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"build": "tsc -p tsconfig.json",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@bmm/auth": "workspace:*",
"@bmm/db": "workspace:*",
"@bmm/types": "workspace:*",
"@fastify/cookie": "11.0.1",
"@fastify/cors": "10.0.1",
"@fastify/websocket": "11.0.1",
"bullmq": "5.34.5",
"drizzle-orm": "0.36.4",
"fastify": "5.2.0",
"ioredis": "5.4.1",
"jose": "5.9.6",
"zod": "3.23.8"
},
"devDependencies": {
"@types/node": "22.10.2",
"tsx": "4.19.2",
"typescript": "5.7.2"
}
}

28
apps/api/src/config.ts Normal file
View File

@ -0,0 +1,28 @@
import { z } from 'zod';
const Env = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
DATABASE_URL: z.string(),
REDIS_URL: z.string().default('redis://localhost:6379'),
PORT: z.coerce.number().default(4000),
NEXT_PUBLIC_APP_URL: z.string().default('http://localhost:3000'),
OAUTH_KEY_DIR: z.string().default('./keys'),
ANTHROPIC_API_KEY: z.string().optional(),
SECRETS_ENCRYPTION_KEY: z
.string()
.min(64, '32 bytes hex required')
.default('0000000000000000000000000000000000000000000000000000000000000000'),
});
export const config = Env.parse({
NODE_ENV: process.env.NODE_ENV,
DATABASE_URL: process.env.DATABASE_URL,
REDIS_URL: process.env.REDIS_URL,
PORT: process.env.PORT,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
OAUTH_KEY_DIR: process.env.OAUTH_KEY_DIR,
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
SECRETS_ENCRYPTION_KEY: process.env.SECRETS_ENCRYPTION_KEY,
});
export type Config = z.infer<typeof Env>;

46
apps/api/src/index.ts Normal file
View File

@ -0,0 +1,46 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import cookie from '@fastify/cookie';
import websocket from '@fastify/websocket';
import { config } from './config.js';
import { authRoutes } from './routes/auth.js';
import { serverRoutes } from './routes/servers.js';
import { oauthRoutes } from './routes/oauth.js';
const app = Fastify({
logger: {
level: config.NODE_ENV === 'production' ? 'info' : 'debug',
transport:
config.NODE_ENV === 'development'
? { target: 'pino-pretty', options: { colorize: true, singleLine: true } }
: undefined,
},
});
await app.register(cors, {
origin: [config.NEXT_PUBLIC_APP_URL],
credentials: true,
});
await app.register(cookie);
await app.register(websocket, { options: { maxPayload: 1024 * 1024 } });
app.get('/health', async () => ({ ok: true, ts: Date.now() }));
await app.register(authRoutes);
await app.register(serverRoutes);
await app.register(oauthRoutes);
app.setErrorHandler((err, _req, reply) => {
app.log.error(err);
if (!reply.sent) {
reply.code(err.statusCode ?? 500).send({ error: err.message ?? 'internal_error' });
}
});
try {
await app.listen({ port: config.PORT, host: '0.0.0.0' });
app.log.info(`api listening on http://localhost:${config.PORT}`);
} catch (err) {
app.log.error(err);
process.exit(1);
}

View File

@ -0,0 +1,30 @@
import crypto from 'node:crypto';
import { config } from '../config.js';
const ALGO = 'aes-256-gcm';
function getKey(): Buffer {
const hex = config.SECRETS_ENCRYPTION_KEY;
const buf = Buffer.from(hex, 'hex');
if (buf.length !== 32) {
throw new Error('SECRETS_ENCRYPTION_KEY must be 32 bytes (64 hex chars)');
}
return buf;
}
export function encryptSecret(plaintext: string): string {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv(ALGO, getKey(), iv);
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return `${iv.toString('base64')}.${tag.toString('base64')}.${enc.toString('base64')}`;
}
export function decryptSecret(payload: string): string {
const [ivB64, tagB64, encB64] = payload.split('.');
if (!ivB64 || !tagB64 || !encB64) throw new Error('malformed_secret_payload');
const decipher = crypto.createDecipheriv(ALGO, getKey(), Buffer.from(ivB64, 'base64'));
decipher.setAuthTag(Buffer.from(tagB64, 'base64'));
const dec = Buffer.concat([decipher.update(Buffer.from(encB64, 'base64')), decipher.final()]);
return dec.toString('utf8');
}

73
apps/api/src/lib/jwks.ts Normal file
View File

@ -0,0 +1,73 @@
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { exportJWK, importPKCS8, importSPKI, type JWK, type KeyLike, SignJWT } from 'jose';
import { config } from '../config.js';
interface KeyMaterial {
kid: string;
privatePem: string;
publicPem: string;
privateKey: KeyLike;
publicJwk: JWK;
}
let cached: KeyMaterial | null = null;
async function loadOrGenerate(): Promise<KeyMaterial> {
if (cached) return cached;
const dir = path.resolve(config.OAUTH_KEY_DIR);
fs.mkdirSync(dir, { recursive: true });
const privPath = path.join(dir, 'oauth-private.pem');
const pubPath = path.join(dir, 'oauth-public.pem');
const kidPath = path.join(dir, 'oauth.kid');
let privatePem: string;
let publicPem: string;
let kid: string;
if (fs.existsSync(privPath) && fs.existsSync(pubPath) && fs.existsSync(kidPath)) {
privatePem = fs.readFileSync(privPath, 'utf8');
publicPem = fs.readFileSync(pubPath, 'utf8');
kid = fs.readFileSync(kidPath, 'utf8').trim();
} else {
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string;
publicPem = publicKey.export({ type: 'spki', format: 'pem' }) as string;
kid = crypto.randomBytes(8).toString('hex');
fs.writeFileSync(privPath, privatePem, { mode: 0o600 });
fs.writeFileSync(pubPath, publicPem, { mode: 0o644 });
fs.writeFileSync(kidPath, kid);
}
const privateKey = await importPKCS8(privatePem, 'RS256');
const publicKey = await importSPKI(publicPem, 'RS256');
const publicJwk: JWK = { ...(await exportJWK(publicKey)), kid, alg: 'RS256', use: 'sig' };
cached = { kid, privatePem, publicPem, privateKey, publicJwk };
return cached;
}
export async function getJWKS(): Promise<{ keys: JWK[] }> {
const k = await loadOrGenerate();
return { keys: [k.publicJwk] };
}
export async function signAccessToken(input: {
subject: string;
audience: string;
issuer: string;
scope?: string;
ttlSeconds?: number;
}): Promise<string> {
const k = await loadOrGenerate();
const ttl = input.ttlSeconds ?? 3600;
return await new SignJWT({ scope: input.scope ?? '' })
.setProtectedHeader({ alg: 'RS256', kid: k.kid, typ: 'JWT' })
.setIssuer(input.issuer)
.setSubject(input.subject)
.setAudience(input.audience)
.setIssuedAt()
.setExpirationTime(`${ttl}s`)
.sign(k.privateKey);
}

22
apps/api/src/lib/queue.ts Normal file
View File

@ -0,0 +1,22 @@
import { Queue } from 'bullmq';
import { getRedis } from './redis.js';
export interface BuildJobData {
buildId: string;
serverId: string;
orgId: string;
prompt: string;
version: number;
slug: string;
serverName: string;
secrets: Record<string, string>;
}
let queue: Queue<BuildJobData> | null = null;
export function getBuildQueue(): Queue<BuildJobData> {
if (!queue) {
queue = new Queue<BuildJobData>('build', { connection: getRedis() });
}
return queue;
}

25
apps/api/src/lib/redis.ts Normal file
View File

@ -0,0 +1,25 @@
import { Redis } from 'ioredis';
import { config } from '../config.js';
let pub: Redis | null = null;
let sub: Redis | null = null;
let main: Redis | null = null;
export function getRedis(): Redis {
if (!main) main = new Redis(config.REDIS_URL, { maxRetriesPerRequest: null });
return main;
}
export function getPublisher(): Redis {
if (!pub) pub = new Redis(config.REDIS_URL, { maxRetriesPerRequest: null });
return pub;
}
export function getSubscriber(): Redis {
if (!sub) sub = new Redis(config.REDIS_URL, { maxRetriesPerRequest: null });
return sub;
}
export function buildChannel(buildId: string): string {
return `build:${buildId}`;
}

View File

@ -0,0 +1,24 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { getSession, type AuthedUser } from '@bmm/auth';
const SESSION_COOKIE = 'bmm_session';
declare module 'fastify' {
interface FastifyRequest {
user?: AuthedUser;
}
}
export async function requireAuth(req: FastifyRequest, reply: FastifyReply): Promise<void> {
const token = req.cookies[SESSION_COOKIE];
const session = await getSession(token);
if (!session) {
reply.code(401).send({ error: 'unauthorized' });
return reply;
}
req.user = session;
}
export function registerSession(_app: FastifyInstance): void {
// no-op; preHandler `requireAuth` does the work per-route
}

View File

@ -0,0 +1,65 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { consumeMagicLink, destroySession, getSession, issueMagicLink } from '@bmm/auth';
import { config } from '../config.js';
const SESSION_COOKIE = 'bmm_session';
export async function authRoutes(app: FastifyInstance): Promise<void> {
app.post('/v1/auth/magic-link', async (req, reply) => {
const Body = z.object({ email: z.string().email() });
const parsed = Body.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_email' });
try {
const { token, expiresAt } = await issueMagicLink(parsed.data.email);
const callbackUrl = `${config.NEXT_PUBLIC_APP_URL}/login/callback?token=${token}`;
// Dev transport: print to stdout. Production: send via Resend / SES.
app.log.info({ to: parsed.data.email, expiresAt }, `[magic-link] -> ${callbackUrl}`);
console.log(`\n[magic-link] ${parsed.data.email} ->\n ${callbackUrl}\n`);
return reply.send({ ok: true });
} catch (e) {
app.log.error(e);
return reply.code(400).send({ error: 'magic_link_failed' });
}
});
app.post('/v1/auth/verify', async (req, reply) => {
const Body = z.object({ token: z.string().min(10) });
const parsed = Body.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_token' });
try {
const session = await consumeMagicLink(parsed.data.token, {
ipAddress: req.ip,
userAgent: req.headers['user-agent'],
});
reply.setCookie(SESSION_COOKIE, session.sessionToken, {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: config.NODE_ENV === 'production',
maxAge: 30 * 24 * 60 * 60,
});
return reply.send({
ok: true,
user: { id: session.userId, email: session.email, orgId: session.orgId },
});
} catch (e) {
app.log.warn({ err: e }, 'magic link verify failed');
return reply.code(400).send({ error: 'invalid_or_expired_token' });
}
});
app.get('/v1/auth/me', async (req, reply) => {
const token = req.cookies[SESSION_COOKIE];
const session = await getSession(token);
if (!session) return reply.code(401).send({ error: 'unauthorized' });
return reply.send({ user: session });
});
app.post('/v1/auth/logout', async (req, reply) => {
const token = req.cookies[SESSION_COOKIE];
if (token) await destroySession(token);
reply.clearCookie(SESSION_COOKIE, { path: '/' });
return reply.send({ ok: true });
});
}

View File

@ -0,0 +1,268 @@
import crypto from 'node:crypto';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
and,
createDb,
eq,
gt,
mcpServers,
oauthClients,
oauthCodes,
oauthTokens,
} from '@bmm/db';
import { getJWKS, signAccessToken } from '../lib/jwks.js';
import { requireAuth } from '../plugins/session.js';
import { config } from '../config.js';
const db = createDb();
function sha256(input: string): string {
return crypto.createHash('sha256').update(input).digest('hex');
}
function pkceVerify(verifier: string, challenge: string, method: string): boolean {
if (method === 'plain') return verifier === challenge;
if (method !== 'S256') return false;
const computed = crypto.createHash('sha256').update(verifier).digest('base64url');
return computed === challenge;
}
async function resolveServerByResource(resource: string) {
const url = new URL(resource);
const port = url.port ? Number(url.port) : null;
if (port !== null) {
const [s] = await db.select().from(mcpServers).where(eq(mcpServers.hostPort, port)).limit(1);
if (s) return s;
}
const slug = url.hostname.split('.')[0];
if (slug) {
const [s] = await db.select().from(mcpServers).where(eq(mcpServers.slug, slug)).limit(1);
if (s) return s;
}
return null;
}
export async function oauthRoutes(app: FastifyInstance): Promise<void> {
// Authorization Server Metadata (RFC 8414) — control-plane wide
app.get('/oauth/.well-known/oauth-authorization-server', async (_req, reply) => {
const base = `${req_base(_req as never)}`;
return reply.send({
issuer: `${base}/oauth`,
authorization_endpoint: `${base}/oauth/authorize`,
token_endpoint: `${base}/oauth/token`,
registration_endpoint: `${base}/oauth/register`,
jwks_uri: `${base}/oauth/jwks`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: [
'client_secret_basic',
'client_secret_post',
'none',
],
scopes_supported: ['mcp:read', 'mcp:write'],
});
});
app.get('/oauth/jwks', async (_req, reply) => {
reply.header('cache-control', 'public, max-age=300');
return reply.send(await getJWKS());
});
// RFC 7591 Dynamic Client Registration
app.post('/oauth/register', async (req, reply) => {
const Body = z.object({
client_name: z.string().min(1).max(128).optional(),
redirect_uris: z.array(z.string().url()).min(1).max(10),
grant_types: z.array(z.string()).optional(),
response_types: z.array(z.string()).optional(),
token_endpoint_auth_method: z.string().optional(),
resource: z.string().url().optional(),
});
const parsed = Body.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_request' });
let serverId: string | null = null;
if (parsed.data.resource) {
const server = await resolveServerByResource(parsed.data.resource);
if (!server) return reply.code(400).send({ error: 'invalid_resource' });
serverId = server.id;
} else {
return reply.code(400).send({ error: 'resource_required' });
}
const clientId = `bmm_${crypto.randomBytes(12).toString('hex')}`;
const isPublic = parsed.data.token_endpoint_auth_method === 'none';
let clientSecret: string | null = null;
let clientSecretHash: string | null = null;
if (!isPublic) {
clientSecret = crypto.randomBytes(32).toString('base64url');
clientSecretHash = sha256(clientSecret);
}
await db.insert(oauthClients).values({
serverId,
clientId,
clientSecretHash,
redirectUris: parsed.data.redirect_uris,
metadata: { ...parsed.data },
});
return reply.code(201).send({
client_id: clientId,
...(clientSecret ? { client_secret: clientSecret } : {}),
redirect_uris: parsed.data.redirect_uris,
grant_types: parsed.data.grant_types ?? ['authorization_code', 'refresh_token'],
response_types: ['code'],
token_endpoint_auth_method: isPublic ? 'none' : 'client_secret_basic',
});
});
// /oauth/authorize — user-consent step. Requires a logged-in dashboard user.
app.get('/oauth/authorize', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const Query = z.object({
response_type: z.literal('code'),
client_id: z.string(),
redirect_uri: z.string().url(),
code_challenge: z.string(),
code_challenge_method: z.enum(['S256', 'plain']).default('S256'),
state: z.string().optional(),
scope: z.string().optional(),
resource: z.string().url(),
});
const parsed = Query.safeParse(req.query);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_request' });
const [client] = await db
.select()
.from(oauthClients)
.where(eq(oauthClients.clientId, parsed.data.client_id))
.limit(1);
if (!client) return reply.code(400).send({ error: 'unknown_client' });
const redirectOk = (client.redirectUris as string[]).includes(parsed.data.redirect_uri);
if (!redirectOk) return reply.code(400).send({ error: 'invalid_redirect_uri' });
const server = await resolveServerByResource(parsed.data.resource);
if (!server || server.id !== client.serverId) {
return reply.code(400).send({ error: 'invalid_resource' });
}
if (server.orgId !== user.orgId) {
return reply.code(403).send({ error: 'forbidden_resource' });
}
const code = crypto.randomBytes(24).toString('base64url');
await db.insert(oauthCodes).values({
clientDbId: client.id,
code,
codeChallenge: parsed.data.code_challenge,
codeChallengeMethod: parsed.data.code_challenge_method,
redirectUri: parsed.data.redirect_uri,
scope: parsed.data.scope,
resource: parsed.data.resource,
userId: user.userId,
expiresAt: new Date(Date.now() + 5 * 60_000),
});
const url = new URL(parsed.data.redirect_uri);
url.searchParams.set('code', code);
if (parsed.data.state) url.searchParams.set('state', parsed.data.state);
return reply.redirect(url.toString());
});
app.post('/oauth/token', async (req, reply) => {
const Body = z.object({
grant_type: z.enum(['authorization_code', 'refresh_token']),
code: z.string().optional(),
redirect_uri: z.string().url().optional(),
client_id: z.string().optional(),
client_secret: z.string().optional(),
code_verifier: z.string().optional(),
resource: z.string().url().optional(),
refresh_token: z.string().optional(),
});
const body = (req.body ?? {}) as Record<string, string>;
const parsed = Body.safeParse(body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_request' });
if (parsed.data.grant_type === 'authorization_code') {
const { code, code_verifier, client_id, client_secret, redirect_uri, resource } = parsed.data;
if (!code || !code_verifier || !client_id || !redirect_uri || !resource) {
return reply.code(400).send({ error: 'invalid_request' });
}
const [row] = await db
.select({ code: oauthCodes, client: oauthClients })
.from(oauthCodes)
.innerJoin(oauthClients, eq(oauthClients.id, oauthCodes.clientDbId))
.where(and(eq(oauthCodes.code, code), gt(oauthCodes.expiresAt, new Date())))
.limit(1);
if (!row || row.code.consumedAt) return reply.code(400).send({ error: 'invalid_grant' });
if (row.client.clientId !== client_id) return reply.code(400).send({ error: 'invalid_client' });
if (row.code.redirectUri !== redirect_uri) return reply.code(400).send({ error: 'invalid_redirect_uri' });
if (row.code.resource !== resource) return reply.code(400).send({ error: 'invalid_resource' });
if (!pkceVerify(code_verifier, row.code.codeChallenge, row.code.codeChallengeMethod)) {
return reply.code(400).send({ error: 'invalid_grant' });
}
if (row.client.clientSecretHash) {
if (!client_secret || sha256(client_secret) !== row.client.clientSecretHash) {
return reply.code(401).send({ error: 'invalid_client' });
}
}
await db.update(oauthCodes).set({ consumedAt: new Date() }).where(eq(oauthCodes.id, row.code.id));
const accessToken = await signAccessToken({
subject: row.code.userId ?? row.client.clientId,
audience: resource,
issuer: `${req_base(req as never)}/oauth`,
scope: row.code.scope ?? '',
ttlSeconds: 3600,
});
const refreshToken = crypto.randomBytes(32).toString('base64url');
await db.insert(oauthTokens).values({
clientDbId: row.client.id,
accessTokenHash: sha256(accessToken),
refreshTokenHash: sha256(refreshToken),
scope: row.code.scope ?? null,
resource,
expiresAt: new Date(Date.now() + 3600 * 1000),
});
return reply.send({
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600,
refresh_token: refreshToken,
scope: row.code.scope ?? '',
});
}
return reply.code(400).send({ error: 'unsupported_grant_type' });
});
// Resource discovery proxy — generated servers ask the AS for their own metadata
// but during dev we expose a control-plane endpoint so the dashboard can show URLs.
app.get('/oauth/resource-metadata', async (req, reply) => {
const Query = z.object({ resource: z.string().url() });
const parsed = Query.safeParse(req.query);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_request' });
const server = await resolveServerByResource(parsed.data.resource);
if (!server) return reply.code(404).send({ error: 'not_found' });
const base = req_base(req as never);
return reply.send({
resource: parsed.data.resource,
authorization_servers: [`${base}/oauth`],
bearer_methods_supported: ['header'],
scopes_supported: ['mcp:read', 'mcp:write'],
});
});
void config;
}
function req_base(req: { protocol?: string; hostname?: string; headers: Record<string, string | string[] | undefined> }): string {
const host = (req.headers['x-forwarded-host'] as string) ?? req.headers.host ?? `localhost:${config.PORT}`;
const proto = (req.headers['x-forwarded-proto'] as string) ?? req.protocol ?? 'http';
return `${proto}://${host}`;
}

View File

@ -0,0 +1,241 @@
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 { requireAuth } from '../plugins/session.js';
import { getBuildQueue } from '../lib/queue.js';
import { buildChannel, getSubscriber } from '../lib/redis.js';
import { encryptSecret } from '../lib/crypto.js';
const db = createDb();
export async function serverRoutes(app: FastifyInstance): Promise<void> {
app.get('/v1/servers', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const rows = await db
.select()
.from(mcpServers)
.where(eq(mcpServers.orgId, user.orgId))
.orderBy(desc(mcpServers.createdAt));
return reply.send({ servers: rows });
});
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 existing = await db
.select({ id: mcpServers.id })
.from(mcpServers)
.where(and(eq(mcpServers.orgId, user.orgId), eq(mcpServers.slug, slug)))
.limit(1);
if (existing.length > 0) {
return reply.code(409).send({ error: 'slug_taken' });
}
const [server] = await db
.insert(mcpServers)
.values({ orgId: user.orgId, slug, name, status: 'queued' })
.returning();
if (!server) return reply.code(500).send({ error: 'create_failed' });
for (const [key, value] of Object.entries(secretValues)) {
if (!value) continue;
await db.insert(secrets).values({
serverId: server.id,
key,
encryptedValue: encryptSecret(value),
});
}
const [build] = await db
.insert(builds)
.values({ serverId: server.id, version: 1, prompt, status: 'queued' })
.returning();
if (!build) return reply.code(500).send({ error: 'build_create_failed' });
await getBuildQueue().add('generate', {
buildId: build.id,
serverId: server.id,
orgId: user.orgId,
prompt,
version: 1,
slug,
serverName: name,
secrets: secretValues,
});
return reply.send({ server, build });
});
app.get('/v1/servers/:id', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const Params = z.object({ id: z.string().uuid() });
const parsed = Params.safeParse(req.params);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' });
const [server] = await db
.select()
.from(mcpServers)
.where(and(eq(mcpServers.id, parsed.data.id), eq(mcpServers.orgId, user.orgId)))
.limit(1);
if (!server) return reply.code(404).send({ error: 'not_found' });
const latestBuilds = await db
.select()
.from(builds)
.where(eq(builds.serverId, server.id))
.orderBy(desc(builds.version))
.limit(10);
return reply.send({ server, builds: latestBuilds });
});
app.post('/v1/servers/:id/iterate', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const Params = z.object({ id: z.string().uuid() });
const parsedParams = Params.safeParse(req.params);
if (!parsedParams.success) return reply.code(400).send({ error: 'invalid_id' });
const parsed = IterateServerInput.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
const [server] = await db
.select()
.from(mcpServers)
.where(and(eq(mcpServers.id, parsedParams.data.id), eq(mcpServers.orgId, user.orgId)))
.limit(1);
if (!server) return reply.code(404).send({ error: 'not_found' });
const nextVersion = server.currentVersion + 1;
const [build] = await db
.insert(builds)
.values({
serverId: server.id,
version: nextVersion,
prompt: parsed.data.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: user.orgId,
prompt: parsed.data.prompt,
version: nextVersion,
slug: server.slug,
serverName: server.name,
secrets: parsed.data.secrets,
});
return reply.send({ build });
});
app.get('/v1/builds/:id', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const Params = z.object({ id: z.string().uuid() });
const parsed = Params.safeParse(req.params);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' });
const [row] = await db
.select({ build: builds, server: mcpServers })
.from(builds)
.innerJoin(mcpServers, eq(mcpServers.id, builds.serverId))
.where(eq(builds.id, parsed.data.id))
.limit(1);
if (!row || row.server.orgId !== user.orgId) {
return reply.code(404).send({ error: 'not_found' });
}
const logs = await db
.select()
.from(buildLogs)
.where(eq(buildLogs.buildId, row.build.id))
.orderBy(buildLogs.timestamp);
return reply.send({ build: row.build, logs, server: row.server });
});
// WebSocket — live build stream
app.get('/v1/builds/:id/stream', { websocket: true }, async (socket, req) => {
const Params = z.object({ id: z.string().uuid() });
const parsed = Params.safeParse(req.params);
if (!parsed.success) {
socket.send(JSON.stringify({ type: 'error', message: 'invalid_id', at: new Date().toISOString() }));
socket.close();
return;
}
const buildId = parsed.data.id;
// Replay any persisted logs first
const logs = await db
.select()
.from(buildLogs)
.where(eq(buildLogs.buildId, buildId))
.orderBy(buildLogs.timestamp);
for (const log of logs) {
socket.send(
JSON.stringify({
type: 'log',
level: log.level,
message: log.message,
at: log.timestamp.toISOString(),
} satisfies BuildEvent),
);
}
const [b] = await db.select().from(builds).where(eq(builds.id, buildId)).limit(1);
if (b) {
socket.send(
JSON.stringify({
type: 'status',
status: b.status,
at: new Date().toISOString(),
} satisfies BuildEvent),
);
}
const channel = buildChannel(buildId);
const subscriber = getSubscriber().duplicate();
await subscriber.subscribe(channel);
subscriber.on('message', (_chan, payload) => {
try {
const evt = JSON.parse(payload);
socket.send(JSON.stringify(evt));
if (evt.type === 'done' || evt.type === 'error') {
setTimeout(() => socket.close(), 250);
}
} catch (e) {
app.log.warn({ err: e }, 'invalid build event payload');
}
});
socket.on('close', () => {
subscriber.unsubscribe(channel).catch(() => undefined);
subscriber.disconnect();
});
});
app.delete('/v1/servers/:id', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const Params = z.object({ id: z.string().uuid() });
const parsed = Params.safeParse(req.params);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' });
const [server] = await db
.select()
.from(mcpServers)
.where(and(eq(mcpServers.id, parsed.data.id), eq(mcpServers.orgId, user.orgId)))
.limit(1);
if (!server) return reply.code(404).send({ error: 'not_found' });
await db.delete(mcpServers).where(eq(mcpServers.id, server.id));
return reply.send({ ok: true });
});
}

9
apps/api/tsconfig.json Normal file
View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"types": ["node"]
},
"include": ["src/**/*"]
}