diff --git a/CHOICES.md b/CHOICES.md index eaf4294..75f7d9b 100644 --- a/CHOICES.md +++ b/CHOICES.md @@ -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 diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..734bc67 --- /dev/null +++ b/apps/api/package.json @@ -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" + } +} diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts new file mode 100644 index 0000000..7163d5c --- /dev/null +++ b/apps/api/src/config.ts @@ -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; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 0000000..8862408 --- /dev/null +++ b/apps/api/src/index.ts @@ -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); +} diff --git a/apps/api/src/lib/crypto.ts b/apps/api/src/lib/crypto.ts new file mode 100644 index 0000000..6a0e802 --- /dev/null +++ b/apps/api/src/lib/crypto.ts @@ -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'); +} diff --git a/apps/api/src/lib/jwks.ts b/apps/api/src/lib/jwks.ts new file mode 100644 index 0000000..81f64a3 --- /dev/null +++ b/apps/api/src/lib/jwks.ts @@ -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 { + 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 { + 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); +} diff --git a/apps/api/src/lib/queue.ts b/apps/api/src/lib/queue.ts new file mode 100644 index 0000000..0cef9f4 --- /dev/null +++ b/apps/api/src/lib/queue.ts @@ -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; +} + +let queue: Queue | null = null; + +export function getBuildQueue(): Queue { + if (!queue) { + queue = new Queue('build', { connection: getRedis() }); + } + return queue; +} diff --git a/apps/api/src/lib/redis.ts b/apps/api/src/lib/redis.ts new file mode 100644 index 0000000..7299e84 --- /dev/null +++ b/apps/api/src/lib/redis.ts @@ -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}`; +} diff --git a/apps/api/src/plugins/session.ts b/apps/api/src/plugins/session.ts new file mode 100644 index 0000000..7295bb3 --- /dev/null +++ b/apps/api/src/plugins/session.ts @@ -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 { + 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 +} diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts new file mode 100644 index 0000000..b690fa2 --- /dev/null +++ b/apps/api/src/routes/auth.ts @@ -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 { + 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 }); + }); +} diff --git a/apps/api/src/routes/oauth.ts b/apps/api/src/routes/oauth.ts new file mode 100644 index 0000000..b62fd65 --- /dev/null +++ b/apps/api/src/routes/oauth.ts @@ -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 { + // 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; + 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 { + 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}`; +} diff --git a/apps/api/src/routes/servers.ts b/apps/api/src/routes/servers.ts new file mode 100644 index 0000000..445e531 --- /dev/null +++ b/apps/api/src/routes/servers.ts @@ -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 { + 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 }); + }); +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..4b1980d --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*"] +}