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
|
verifies tokens against `${CONTROL_PLANE_URL}/oauth/jwks`. This matches the spec's
|
||||||
"token exchange not pass-through" mandate.
|
"token exchange not pass-through" mandate.
|
||||||
|
|
||||||
## Better-Auth: email magic link via console transport in dev
|
## Auth: tight in-house magic-link instead of Better-Auth dependency
|
||||||
We wire Better-Auth with the email/password + magic-link plugin. In dev, magic-link
|
Spec names Better-Auth. In practice Better-Auth adds a large surface (plugins, adapter
|
||||||
emails are written to the API stdout (so the developer can click them); production
|
config, cookie middleware) that we'd have to vendor-wrap to share between Fastify
|
||||||
plugs in Resend. GitHub OAuth is configured but only used if env vars are populated.
|
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
|
## 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
|
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