feat(api): Fastify control plane (auth, servers, WS build stream, OAuth 2.1 AS, JWKS)
This commit is contained in:
parent
15697ba6dd
commit
9658e843df
13
CHOICES.md
13
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
|
||||
|
||||
31
apps/api/package.json
Normal file
31
apps/api/package.json
Normal 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
28
apps/api/src/config.ts
Normal 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
46
apps/api/src/index.ts
Normal 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);
|
||||
}
|
||||
30
apps/api/src/lib/crypto.ts
Normal file
30
apps/api/src/lib/crypto.ts
Normal 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
73
apps/api/src/lib/jwks.ts
Normal 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
22
apps/api/src/lib/queue.ts
Normal 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
25
apps/api/src/lib/redis.ts
Normal 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}`;
|
||||
}
|
||||
24
apps/api/src/plugins/session.ts
Normal file
24
apps/api/src/plugins/session.ts
Normal 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
|
||||
}
|
||||
65
apps/api/src/routes/auth.ts
Normal file
65
apps/api/src/routes/auth.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
268
apps/api/src/routes/oauth.ts
Normal file
268
apps/api/src/routes/oauth.ts
Normal 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}`;
|
||||
}
|
||||
241
apps/api/src/routes/servers.ts
Normal file
241
apps/api/src/routes/servers.ts
Normal 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
9
apps/api/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user