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

@@ -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 });
});
}