feat(auth): GitHub OAuth login + SMS one-time-code login
Some checks failed
Deploy to Production / deploy (push) Failing after 1m8s
Some checks failed
Deploy to Production / deploy (push) Failing after 1m8s
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches the verified primary email via /user/emails, reuses upsertOAuthLogin. SMS: phone is now a first-class login identity. - schema: users.email nullable, users.phone added, new sms_codes table. - @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create user by phone. - apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK), per-IP throttle. /v1/auth/providers now reports google/github/sms. - login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS (number -> 6-digit code with one-time-code autofill). SMS link was rejected in favour of an OTP code — carrier link-scanners consume magic-link tokens before the user taps them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,4 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
adminSettings,
|
||||
auditLog,
|
||||
@@ -20,11 +18,13 @@ import {
|
||||
users,
|
||||
} from '@bmm/db';
|
||||
import { SYSTEM_PROMPT } from '@bmm/llm';
|
||||
import { requireAdmin } from '../plugins/session.js';
|
||||
import { getRedis } from '../lib/redis.js';
|
||||
import { getBuildQueue } from '../lib/queue.js';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { audit } from '../lib/audit.js';
|
||||
import { encryptionStatus, rotateKeys } from '../lib/crypto.js';
|
||||
import { getBuildQueue } from '../lib/queue.js';
|
||||
import { getRedis } from '../lib/redis.js';
|
||||
import { requireAdmin } from '../plugins/session.js';
|
||||
|
||||
const db = createDb();
|
||||
|
||||
@@ -47,11 +47,26 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||
newUsersLast7d,
|
||||
newServersLast7d,
|
||||
] = await Promise.all([
|
||||
db.select({ c: count() }).from(users).then((r) => Number(r[0]?.c ?? 0)),
|
||||
db.select({ c: count() }).from(organizations).then((r) => Number(r[0]?.c ?? 0)),
|
||||
db.select({ c: count() }).from(mcpServers).then((r) => Number(r[0]?.c ?? 0)),
|
||||
db.select({ c: count() }).from(builds).then((r) => Number(r[0]?.c ?? 0)),
|
||||
db.select({ c: count() }).from(toolCallMetrics).then((r) => Number(r[0]?.c ?? 0)),
|
||||
db
|
||||
.select({ c: count() })
|
||||
.from(users)
|
||||
.then((r) => Number(r[0]?.c ?? 0)),
|
||||
db
|
||||
.select({ c: count() })
|
||||
.from(organizations)
|
||||
.then((r) => Number(r[0]?.c ?? 0)),
|
||||
db
|
||||
.select({ c: count() })
|
||||
.from(mcpServers)
|
||||
.then((r) => Number(r[0]?.c ?? 0)),
|
||||
db
|
||||
.select({ c: count() })
|
||||
.from(builds)
|
||||
.then((r) => Number(r[0]?.c ?? 0)),
|
||||
db
|
||||
.select({ c: count() })
|
||||
.from(toolCallMetrics)
|
||||
.then((r) => Number(r[0]?.c ?? 0)),
|
||||
db
|
||||
.select({ c: count() })
|
||||
.from(mcpServers)
|
||||
@@ -81,11 +96,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||
.groupBy(mcpServers.status);
|
||||
|
||||
// Recent activity from audit log
|
||||
const recent = await db
|
||||
.select()
|
||||
.from(auditLog)
|
||||
.orderBy(desc(auditLog.createdAt))
|
||||
.limit(15);
|
||||
const recent = await db.select().from(auditLog).orderBy(desc(auditLog.createdAt)).limit(15);
|
||||
|
||||
// Builds in last 24h with status
|
||||
const recentBuilds = await db
|
||||
@@ -123,12 +134,20 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||
const parsed = Query.safeParse(req.query);
|
||||
if (!parsed.success) return reply.code(400).send({ error: 'invalid_query' });
|
||||
|
||||
const rows = await db.select().from(users).orderBy(desc(users.createdAt)).limit(parsed.data.limit);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.orderBy(desc(users.createdAt))
|
||||
.limit(parsed.data.limit);
|
||||
const filtered = parsed.data.search
|
||||
? rows.filter((u) =>
|
||||
u.email.toLowerCase().includes(parsed.data.search!.toLowerCase()) ||
|
||||
(u.name?.toLowerCase().includes(parsed.data.search!.toLowerCase()) ?? false),
|
||||
)
|
||||
? rows.filter((u) => {
|
||||
const q = parsed.data.search!.toLowerCase();
|
||||
return (
|
||||
(u.email?.toLowerCase().includes(q) ?? false) ||
|
||||
(u.phone?.toLowerCase().includes(q) ?? false) ||
|
||||
(u.name?.toLowerCase().includes(q) ?? false)
|
||||
);
|
||||
})
|
||||
: rows;
|
||||
|
||||
// attach org + server count
|
||||
@@ -280,7 +299,12 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||
const rows = await db
|
||||
.select({
|
||||
server: mcpServers,
|
||||
org: { id: organizations.id, name: organizations.name, slug: organizations.slug, plan: organizations.plan },
|
||||
org: {
|
||||
id: organizations.id,
|
||||
name: organizations.name,
|
||||
slug: organizations.slug,
|
||||
plan: organizations.plan,
|
||||
},
|
||||
})
|
||||
.from(mcpServers)
|
||||
.innerJoin(organizations, eq(organizations.id, mcpServers.orgId))
|
||||
@@ -299,7 +323,11 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||
const p = Params.safeParse(req.params);
|
||||
if (!p.success) return reply.code(400).send({ error: 'invalid_id' });
|
||||
|
||||
const [server] = await db.select().from(mcpServers).where(eq(mcpServers.id, p.data.id)).limit(1);
|
||||
const [server] = await db
|
||||
.select()
|
||||
.from(mcpServers)
|
||||
.where(eq(mcpServers.id, p.data.id))
|
||||
.limit(1);
|
||||
if (!server) return reply.code(404).send({ error: 'not_found' });
|
||||
|
||||
// Get last build's prompt
|
||||
@@ -357,7 +385,11 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||
const p = Params.safeParse(req.params);
|
||||
if (!p.success) return reply.code(400).send({ error: 'invalid_id' });
|
||||
|
||||
const [server] = await db.select().from(mcpServers).where(eq(mcpServers.id, p.data.id)).limit(1);
|
||||
const [server] = await db
|
||||
.select()
|
||||
.from(mcpServers)
|
||||
.where(eq(mcpServers.id, p.data.id))
|
||||
.limit(1);
|
||||
if (!server) return reply.code(404).send({ error: 'not_found' });
|
||||
|
||||
await db.delete(mcpServers).where(eq(mcpServers.id, server.id));
|
||||
@@ -468,10 +500,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||
redisOk = pong === 'PONG';
|
||||
const q = getBuildQueue();
|
||||
const counts = await q.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed');
|
||||
queueDepth =
|
||||
(counts.waiting ?? 0) +
|
||||
(counts.active ?? 0) +
|
||||
(counts.delayed ?? 0);
|
||||
queueDepth = (counts.waiting ?? 0) + (counts.active ?? 0) + (counts.delayed ?? 0);
|
||||
} catch {
|
||||
// remains false
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import crypto from 'node:crypto';
|
||||
import {
|
||||
consumeMagicLink,
|
||||
consumeSmsCode,
|
||||
destroySession,
|
||||
getSession,
|
||||
issueMagicLink,
|
||||
issueSmsCode,
|
||||
loginWithPassword,
|
||||
upsertOAuthLogin,
|
||||
} from '@bmm/auth';
|
||||
@@ -11,6 +13,7 @@ import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { config } from '../config.js';
|
||||
import { audit } from '../lib/audit.js';
|
||||
import { sendSms, smsConfigured } from '../lib/sms.js';
|
||||
|
||||
const SESSION_COOKIE = 'bmm_session';
|
||||
const OAUTH_STATE_COOKIE = 'bmm_oauth_state';
|
||||
@@ -46,6 +49,29 @@ function googleConfigured(): boolean {
|
||||
return Boolean(config.GOOGLE_OAUTH_ID && config.GOOGLE_OAUTH_SECRET);
|
||||
}
|
||||
|
||||
function githubConfigured(): boolean {
|
||||
return Boolean(config.GITHUB_OAUTH_ID && config.GITHUB_OAUTH_SECRET);
|
||||
}
|
||||
|
||||
function githubRedirectUri(): string {
|
||||
return `${config.CONTROL_PLANE_PUBLIC_URL}/v1/auth/github/callback`;
|
||||
}
|
||||
|
||||
// In-memory per-IP throttle for SMS-code requests — SMS costs money per send,
|
||||
// so cap how often one IP can trigger a send regardless of which number.
|
||||
const smsIpHits = new Map<string, number[]>();
|
||||
function smsIpRateOk(ip: string, max = 5, windowMs = 10 * 60 * 1000): boolean {
|
||||
const now = Date.now();
|
||||
const hits = (smsIpHits.get(ip) ?? []).filter((t) => now - t < windowMs);
|
||||
if (hits.length >= max) {
|
||||
smsIpHits.set(ip, hits);
|
||||
return false;
|
||||
}
|
||||
hits.push(now);
|
||||
smsIpHits.set(ip, hits);
|
||||
return true;
|
||||
}
|
||||
|
||||
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() });
|
||||
@@ -170,7 +196,11 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
// Which third-party login providers are configured. Lets the UI hide the
|
||||
// Google button when no credentials are set, instead of showing a dead button.
|
||||
app.get('/v1/auth/providers', async (_req, reply) => {
|
||||
return reply.send({ google: googleConfigured() });
|
||||
return reply.send({
|
||||
google: googleConfigured(),
|
||||
github: githubConfigured(),
|
||||
sms: smsConfigured(),
|
||||
});
|
||||
});
|
||||
|
||||
// Step 1: hand the browser off to Google's consent screen.
|
||||
@@ -276,4 +306,177 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.redirect(`${loginUrl}?error=google_failed`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- GitHub OAuth ----
|
||||
app.get('/v1/auth/github', async (_req, reply) => {
|
||||
if (!config.GITHUB_OAUTH_ID || !config.GITHUB_OAUTH_SECRET) {
|
||||
return reply.code(503).send({ error: 'github_oauth_not_configured' });
|
||||
}
|
||||
const state = crypto.randomBytes(16).toString('base64url');
|
||||
reply.setCookie(OAUTH_STATE_COOKIE, state, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
secure: config.NODE_ENV === 'production',
|
||||
maxAge: 600,
|
||||
});
|
||||
const url = new URL('https://github.com/login/oauth/authorize');
|
||||
url.searchParams.set('client_id', config.GITHUB_OAUTH_ID);
|
||||
url.searchParams.set('redirect_uri', githubRedirectUri());
|
||||
url.searchParams.set('scope', 'read:user user:email');
|
||||
url.searchParams.set('state', state);
|
||||
return reply.redirect(url.toString());
|
||||
});
|
||||
|
||||
app.get('/v1/auth/github/callback', async (req, reply) => {
|
||||
const loginUrl = `${config.NEXT_PUBLIC_APP_URL}/login`;
|
||||
const Query = z.object({
|
||||
code: z.string().min(8).optional(),
|
||||
state: z.string().min(8).optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
const q = Query.safeParse(req.query);
|
||||
const cookieState = req.cookies[OAUTH_STATE_COOKIE];
|
||||
reply.clearCookie(OAUTH_STATE_COOKIE, { path: '/' });
|
||||
|
||||
if (!q.success || q.data.error || !q.data.code || !q.data.state) {
|
||||
return reply.redirect(`${loginUrl}?error=github_failed`);
|
||||
}
|
||||
if (
|
||||
!cookieState ||
|
||||
cookieState.length !== q.data.state.length ||
|
||||
!crypto.timingSafeEqual(Buffer.from(cookieState), Buffer.from(q.data.state))
|
||||
) {
|
||||
return reply.redirect(`${loginUrl}?error=github_state`);
|
||||
}
|
||||
if (!config.GITHUB_OAUTH_ID || !config.GITHUB_OAUTH_SECRET) {
|
||||
return reply.redirect(`${loginUrl}?error=github_failed`);
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenRes = await fetch('https://github.com/login/oauth/access_token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: config.GITHUB_OAUTH_ID,
|
||||
client_secret: config.GITHUB_OAUTH_SECRET,
|
||||
code: q.data.code,
|
||||
redirect_uri: githubRedirectUri(),
|
||||
}),
|
||||
});
|
||||
if (!tokenRes.ok) throw new Error(`token_exchange_${tokenRes.status}`);
|
||||
const tokens = (await tokenRes.json()) as { access_token?: string };
|
||||
if (!tokens.access_token) throw new Error('no_access_token');
|
||||
|
||||
// GitHub's API rejects requests without a User-Agent header.
|
||||
const ghHeaders = {
|
||||
authorization: `Bearer ${tokens.access_token}`,
|
||||
accept: 'application/vnd.github+json',
|
||||
'user-agent': 'BuildMyMCPServer',
|
||||
};
|
||||
const userRes = await fetch('https://api.github.com/user', { headers: ghHeaders });
|
||||
if (!userRes.ok) throw new Error(`user_fetch_${userRes.status}`);
|
||||
const ghUser = (await userRes.json()) as { name?: string; login?: string };
|
||||
|
||||
// /user omits the email when it is private — /user/emails always lists it.
|
||||
const emailRes = await fetch('https://api.github.com/user/emails', { headers: ghHeaders });
|
||||
if (!emailRes.ok) throw new Error(`email_fetch_${emailRes.status}`);
|
||||
const emails = (await emailRes.json()) as Array<{
|
||||
email: string;
|
||||
primary: boolean;
|
||||
verified: boolean;
|
||||
}>;
|
||||
const primary = emails.find((e) => e.primary && e.verified) ?? emails.find((e) => e.verified);
|
||||
if (!primary) throw new Error('no_verified_email');
|
||||
|
||||
const session = await upsertOAuthLogin(
|
||||
{ email: primary.email, name: ghUser.name ?? ghUser.login ?? null },
|
||||
{ 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,
|
||||
});
|
||||
await audit({
|
||||
orgId: session.orgId,
|
||||
userId: session.userId,
|
||||
action: 'auth.login',
|
||||
resourceType: 'session',
|
||||
metadata: { email: session.email, provider: 'github' },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
return reply.redirect(`${config.NEXT_PUBLIC_APP_URL}/dashboard`);
|
||||
} catch (err) {
|
||||
app.log.warn({ err }, 'github oauth callback failed');
|
||||
return reply.redirect(`${loginUrl}?error=github_failed`);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- SMS one-time-code login ----
|
||||
app.post('/v1/auth/sms/request', async (req, reply) => {
|
||||
if (!smsConfigured()) return reply.code(503).send({ error: 'sms_not_configured' });
|
||||
const Body = z.object({ phone: z.string().min(8).max(24) });
|
||||
const parsed = Body.safeParse(req.body);
|
||||
if (!parsed.success) return reply.code(400).send({ error: 'invalid_phone' });
|
||||
if (!smsIpRateOk(req.ip)) return reply.code(429).send({ error: 'rate_limited' });
|
||||
try {
|
||||
const { phone, code } = await issueSmsCode(parsed.data.phone);
|
||||
await sendSms(phone, `${code} is your BuildMyMCPServer login code. Valid for 10 minutes.`);
|
||||
return reply.send({ ok: true });
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message;
|
||||
if (msg === 'invalid_phone') return reply.code(400).send({ error: 'invalid_phone' });
|
||||
if (msg === 'rate_limited') return reply.code(429).send({ error: 'rate_limited' });
|
||||
app.log.warn({ err: e }, 'sms request failed');
|
||||
return reply.code(400).send({ error: 'sms_request_failed' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/v1/auth/sms/verify', async (req, reply) => {
|
||||
const Body = z.object({
|
||||
phone: z.string().min(8).max(24),
|
||||
code: z.string().regex(/^\d{6}$/),
|
||||
});
|
||||
const parsed = Body.safeParse(req.body);
|
||||
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
|
||||
try {
|
||||
const session = await consumeSmsCode(parsed.data.phone, parsed.data.code, {
|
||||
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,
|
||||
});
|
||||
await audit({
|
||||
orgId: session.orgId,
|
||||
userId: session.userId,
|
||||
action: 'auth.login',
|
||||
resourceType: 'session',
|
||||
metadata: { provider: 'sms' },
|
||||
ipAddress: req.ip,
|
||||
});
|
||||
return reply.send({ ok: true, user: { id: session.userId, orgId: session.orgId } });
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message;
|
||||
const status: Record<string, number> = {
|
||||
invalid_or_expired_code: 400,
|
||||
invalid_code: 400,
|
||||
too_many_attempts: 429,
|
||||
invalid_phone: 400,
|
||||
};
|
||||
if (status[msg]) return reply.code(status[msg]).send({ error: msg });
|
||||
app.log.warn({ err: e }, 'sms verify failed');
|
||||
return reply.code(400).send({ error: 'sms_verify_failed' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { getSession } from '@bmm/auth';
|
||||
import {
|
||||
and,
|
||||
builds,
|
||||
@@ -16,21 +15,22 @@ import {
|
||||
users,
|
||||
} from '@bmm/db';
|
||||
import { GeneratorSpec } from '@bmm/types';
|
||||
import { getSession } from '@bmm/auth';
|
||||
import { requireAuth, requireAdmin } from '../plugins/session.js';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { audit } from '../lib/audit.js';
|
||||
import { cacheSpec, cachePrebuiltCode } from '../lib/preview-cache.js';
|
||||
import { getRedis } from '../lib/redis.js';
|
||||
import { stopContainer } from '../lib/docker.js';
|
||||
import { cachePrebuiltCode, cacheSpec } from '../lib/preview-cache.js';
|
||||
import { getRedis } from '../lib/redis.js';
|
||||
import { requireAdmin, requireAuth } from '../plugins/session.js';
|
||||
|
||||
const db = createDb();
|
||||
|
||||
const BANNED_PATTERNS = [
|
||||
/\beval\s*\(/,
|
||||
/\bnew\s+Function\s*\(/,
|
||||
/\bFunction\s*\(\s*['"`]/, // Function('code')() — no `new` needed
|
||||
/\bimport\s*\(/, // dynamic import (escape from bundle scope)
|
||||
/\bsetTimeout\s*\(\s*['"`]/, // setTimeout('code', ms) eval form
|
||||
/\bFunction\s*\(\s*['"`]/, // Function('code')() — no `new` needed
|
||||
/\bimport\s*\(/, // dynamic import (escape from bundle scope)
|
||||
/\bsetTimeout\s*\(\s*['"`]/, // setTimeout('code', ms) eval form
|
||||
/\bsetInterval\s*\(\s*['"`]/,
|
||||
/\bchild_process\b/,
|
||||
/\bfs\s*\.\s*(unlink|rmdir|rm)\b/,
|
||||
@@ -163,7 +163,11 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
|
||||
let slug = baseSlug || `template-${crypto.randomBytes(3).toString('hex')}`;
|
||||
let attempt = 0;
|
||||
while (true) {
|
||||
const existing = await db.select({ id: templates.id }).from(templates).where(eq(templates.slug, slug)).limit(1);
|
||||
const existing = await db
|
||||
.select({ id: templates.id })
|
||||
.from(templates)
|
||||
.where(eq(templates.slug, slug))
|
||||
.limit(1);
|
||||
if (existing.length === 0) break;
|
||||
attempt++;
|
||||
slug = `${baseSlug}-${crypto.randomBytes(2).toString('hex')}`;
|
||||
@@ -364,7 +368,7 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
|
||||
.where(and(eq(mcpServers.templateId, t.id), eq(mcpServers.status, 'live')));
|
||||
return {
|
||||
...t,
|
||||
ownerName: user.email.split('@')[0],
|
||||
ownerName: user.email?.split('@')[0] ?? user.phone ?? 'you',
|
||||
ownerOrgName: null,
|
||||
activeDeployments: Number(active?.c ?? 0),
|
||||
};
|
||||
@@ -468,7 +472,9 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
const validation = GeneratorSpec.safeParse(fullSpec);
|
||||
if (!validation.success) {
|
||||
return reply.code(500).send({ error: 'template_spec_invalid', detail: validation.error.flatten() });
|
||||
return reply
|
||||
.code(500)
|
||||
.send({ error: 'template_spec_invalid', detail: validation.error.flatten() });
|
||||
}
|
||||
const previewId = await cacheSpec(validation.data);
|
||||
// Persist the pre-rendered code under the same previewId so the worker uses it
|
||||
@@ -550,7 +556,11 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
|
||||
if (fork.containerId) {
|
||||
const result = await stopContainer(fork.containerId);
|
||||
if (result.ok) stoppedContainers++;
|
||||
else app.log.warn({ containerId: fork.containerId, detail: result.detail }, 'takedown: stop failed');
|
||||
else
|
||||
app.log.warn(
|
||||
{ containerId: fork.containerId, detail: result.detail },
|
||||
'takedown: stop failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
await db
|
||||
|
||||
Reference in New Issue
Block a user