feat(api): Fastify control plane (auth, servers, WS build stream, OAuth 2.1 AS, JWKS)
This commit is contained in:
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 });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user