Compare commits

..

No commits in common. "cf423de3d5b58973041de912bf417def782b3875" and "092290bb38b422c44a798f90bdd3eb3c58b7ca38" have entirely different histories.

20 changed files with 156 additions and 594 deletions

View File

@ -1,8 +1,5 @@
# ---- Core ---- # ---- Core ----
NODE_ENV=development NODE_ENV=development
# Local dev only: skip runner container hardening (--read-only etc. break on
# Windows Docker Desktop). NEVER set this in .env.production. (GEN-002)
RUNNER_DISABLE_HARDENING=1
# ---- Database ---- # ---- Database ----
DATABASE_URL=postgresql://bmm:bmm@localhost:5440/bmm DATABASE_URL=postgresql://bmm:bmm@localhost:5440/bmm

View File

@ -46,9 +46,9 @@ OAUTH_ISSUER=https://api.buildmymcpserver.com
SECRETS_ENCRYPTION_KEY=CHANGE-ME-run-openssl-rand-hex-32 SECRETS_ENCRYPTION_KEY=CHANGE-ME-run-openssl-rand-hex-32
# ---- Admin bootstrap (upserted idempotently on API boot) ---- # ---- Admin bootstrap (upserted idempotently on API boot) ----
ADMIN_EMAIL=CHANGE-ME-admin@example.com ADMIN_EMAIL=marco.frangiskatos@gmail.com
ADMIN_PASSWORD=CHANGE-ME-strong-admin-password ADMIN_PASSWORD=CHANGE-ME-strong-admin-password
ADMIN_NAME=CHANGE-ME-Admin ADMIN_NAME=Marco Frangiskatos
# ---- Anthropic (empty = mock generation; set for real Claude generation) ---- # ---- Anthropic (empty = mock generation; set for real Claude generation) ----
ANTHROPIC_API_KEY= ANTHROPIC_API_KEY=
@ -75,23 +75,6 @@ RUNNER_HOST=buildmymcpserver.com
RUNNER_PORT_RANGE_START=4400 RUNNER_PORT_RANGE_START=4400
RUNNER_PORT_RANGE_END=4900 RUNNER_PORT_RANGE_END=4900
# ---- Stripe (billing) ----
# Secret key (server-side only — NEVER expose). From Stripe Dashboard → Developers → API keys.
STRIPE_SECRET_KEY=CHANGE-ME-sk_live_...
# Publishable key (safe to expose). Used by the embedded in-app checkout.
STRIPE_PUBLISHABLE_KEY=CHANGE-ME-pk_live_...
# Same publishable key, exposed to the web client bundle at BUILD time (the web
# image is rebuilt by the deploy, so this must be set before deploying or the
# in-app checkout shows "not configured"). Keep it identical to STRIPE_PUBLISHABLE_KEY.
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=CHANGE-ME-pk_live_...
# Webhook signing secret — from the endpoint you create at /v1/billing/webhook.
STRIPE_WEBHOOK_SECRET=CHANGE-ME-whsec_...
# Price IDs (price_… not prod_…) from each product's pricing in the Dashboard.
STRIPE_PRICE_PRO_MONTHLY=CHANGE-ME-price_...
STRIPE_PRICE_PRO_YEARLY=CHANGE-ME-price_...
STRIPE_PRICE_TEAM_MONTHLY=CHANGE-ME-price_...
STRIPE_PRICE_TEAM_YEARLY=CHANGE-ME-price_...
# ---- Observability (optional) ---- # ---- Observability (optional) ----
SENTRY_DSN= SENTRY_DSN=
OTEL_EXPORTER_OTLP_ENDPOINT= OTEL_EXPORTER_OTLP_ENDPOINT=

View File

@ -17,14 +17,7 @@ let queue: Queue<BuildJobData> | null = null;
export function getBuildQueue(): Queue<BuildJobData> { export function getBuildQueue(): Queue<BuildJobData> {
if (!queue) { if (!queue) {
queue = new Queue<BuildJobData>('build', { queue = new Queue<BuildJobData>('build', { connection: getRedis() });
connection: getRedis(),
// Explicit job lifecycle. attempts:1 because a build is non-idempotent
// (allocates a host port, runs a container, spends an LLM call) — a blind
// BullMQ retry would double-spend; users re-run via /iterate instead.
// removeOnComplete/Fail caps Redis growth. (GEN-007)
defaultJobOptions: { attempts: 1, removeOnComplete: 100, removeOnFail: 500 },
});
} }
return queue; return queue;
} }

View File

@ -63,17 +63,6 @@ export async function isDuplicateEvent(eventId: string): Promise<boolean> {
return set === null; return set === null;
} }
/**
* Roll back the idempotency marker for an event whose handler FAILED, so
* Stripe's retry re-processes it. Without this, the marker set by the failed
* first attempt makes every retry look like a duplicate and the event is lost
* forever (e.g. a paid org that never gets upgraded). (BILL-003)
*/
export async function clearProcessedEvent(eventId: string): Promise<void> {
const redis = getRedis();
await redis.del(`stripe:event:${eventId}`);
}
/** /**
* Sanity-check that price-id env vars actually contain price ids a common * Sanity-check that price-id env vars actually contain price ids a common
* setup mistake is to paste the product id (prod_) instead. Logs loudly on * setup mistake is to paste the product id (prod_) instead. Logs loudly on

View File

@ -6,7 +6,6 @@ import { config } from '../config.js';
import { audit } from '../lib/audit.js'; import { audit } from '../lib/audit.js';
import { import {
type PriceTier, type PriceTier,
clearProcessedEvent,
isDuplicateEvent, isDuplicateEvent,
planFromPriceId, planFromPriceId,
priceIdForTier, priceIdForTier,
@ -42,14 +41,6 @@ export async function billingRoutes(app: FastifyInstance): Promise<void> {
try { try {
const session = await stripe.checkout.sessions.create({ const session = await stripe.checkout.sessions.create({
// Embedded UI: the payment form mounts INSIDE our dashboard via Stripe.js
// instead of redirecting to checkout.stripe.com. Keeps the flow in-app
// (critical for the installed PWA, which otherwise pops out to the
// system browser). Embedded mode uses return_url, not success/cancel_url.
// NOTE: stripe-node v22 / API 2025-10 renamed this enum 'embedded' →
// 'embedded_page'; it returns a client_secret for @stripe/react-stripe-js
// EmbeddedCheckout. ('hosted' is now 'hosted_page'.)
ui_mode: 'embedded_page',
mode: 'subscription', mode: 'subscription',
payment_method_types: ['card', 'sepa_debit'], payment_method_types: ['card', 'sepa_debit'],
line_items: [{ price: priceId, quantity: 1 }], line_items: [{ price: priceId, quantity: 1 }],
@ -63,7 +54,8 @@ export async function billingRoutes(app: FastifyInstance): Promise<void> {
subscription_data: { subscription_data: {
metadata: { orgId: user.orgId, userId: user.userId }, metadata: { orgId: user.orgId, userId: user.userId },
}, },
return_url: `${config.NEXT_PUBLIC_APP_URL}/settings/billing?success=true&session_id={CHECKOUT_SESSION_ID}`, success_url: `${config.NEXT_PUBLIC_APP_URL}/settings/billing?success=true`,
cancel_url: `${config.NEXT_PUBLIC_APP_URL}/settings/billing?cancelled=true`,
automatic_tax: { enabled: true }, automatic_tax: { enabled: true },
tax_id_collection: { enabled: true }, tax_id_collection: { enabled: true },
billing_address_collection: 'required', billing_address_collection: 'required',
@ -79,8 +71,7 @@ export async function billingRoutes(app: FastifyInstance): Promise<void> {
ipAddress: req.ip, ipAddress: req.ip,
}); });
// client_secret drives the embedded form; sessionId for optional verification. return reply.send({ url: session.url, sessionId: session.id });
return reply.send({ clientSecret: session.client_secret, sessionId: session.id });
} catch (err) { } catch (err) {
app.log.error({ err }, 'checkout session create failed'); app.log.error({ err }, 'checkout session create failed');
const msg = err instanceof Error ? err.message : 'unknown_error'; const msg = err instanceof Error ? err.message : 'unknown_error';
@ -281,14 +272,6 @@ export async function billingRoutes(app: FastifyInstance): Promise<void> {
items: [{ id: itemId, price: newPriceId }], items: [{ id: itemId, price: newPriceId }],
proration_behavior: 'create_prorations', proration_behavior: 'create_prorations',
}); });
// Reconcile the local plan immediately instead of waiting for the
// customer.subscription.updated webhook — otherwise quota enforcement
// reads a stale tier in the gap between this call and webhook delivery.
// Idempotent: the webhook will set the same value. (BILL-001)
await db
.update(organizations)
.set({ plan: planFromPriceId(newPriceId) })
.where(eq(organizations.id, user.orgId));
await audit({ await audit({
orgId: user.orgId, orgId: user.orgId,
userId: user.userId, userId: user.userId,
@ -345,11 +328,6 @@ export async function billingRoutes(app: FastifyInstance): Promise<void> {
await handleStripeEvent(app, event); await handleStripeEvent(app, event);
return reply.send({ ok: true }); return reply.send({ ok: true });
} catch (err) { } catch (err) {
// Roll back the idempotency marker so the retry actually re-runs the
// handler instead of being skipped as a duplicate. Handlers are
// idempotent (they SET state, not increment), so a rare double-process
// on concurrent retries is safe. (BILL-003)
await clearProcessedEvent(event.id);
// Return 5xx so Stripe retries with exponential backoff. // Return 5xx so Stripe retries with exponential backoff.
app.log.error( app.log.error(
{ err, eventId: event.id, type: event.type }, { err, eventId: event.id, type: event.type },
@ -386,26 +364,11 @@ async function handleStripeEvent(app: FastifyInstance, event: Stripe.Event): Pro
} }
async function findOrgIdForSubscription(sub: Stripe.Subscription): Promise<string | null> { async function findOrgIdForSubscription(sub: Stripe.Subscription): Promise<string | null> {
// Prefer the metadata we set at checkout — but DON'T blindly trust it. A // Prefer the metadata we set at checkout — it's the most reliable mapping.
// webhook signature proves the event came from Stripe, not that // Fallback: look the org up by stored customer id.
// sub.metadata.orgId is honest (metadata is editable in the dashboard/portal).
// Only honour the metadata orgId if the subscription's customer actually
// matches that org's stored stripeCustomerId; otherwise fall back to the
// customer lookup. This prevents a sub with a forged metadata.orgId from
// re-planning a victim org. (BILL-004)
const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer.id;
const metaOrgId = sub.metadata?.orgId; const metaOrgId = sub.metadata?.orgId;
if (typeof metaOrgId === 'string' && metaOrgId.length > 0) { if (typeof metaOrgId === 'string' && metaOrgId.length > 0) return metaOrgId;
const [byMeta] = await db const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer.id;
.select({ id: organizations.id, customer: organizations.stripeCustomerId })
.from(organizations)
.where(eq(organizations.id, metaOrgId))
.limit(1);
if (byMeta && (byMeta.customer === null || byMeta.customer === customerId)) {
return byMeta.id;
}
// metadata orgId does not own this customer — ignore it and fall through.
}
const [row] = await db const [row] = await db
.select({ id: organizations.id }) .select({ id: organizations.id })
.from(organizations) .from(organizations)
@ -511,18 +474,15 @@ async function handleSubscriptionDeleted(
async function handleInvoicePaid(_app: FastifyInstance, invoice: Stripe.Invoice): Promise<void> { async function handleInvoicePaid(_app: FastifyInstance, invoice: Stripe.Invoice): Promise<void> {
const orgId = await findOrgIdForInvoice(invoice); const orgId = await findOrgIdForInvoice(invoice);
if (!orgId) return; if (!orgId) return;
// Only the actual monthly renewal (`subscription_cycle`) resets the usage // Successful renewal — clear any past-due suspension and reset the usage
// counter. Stripe also sends `invoice.paid` for proration/manual/one-off // period (so the new month's call quota starts fresh).
// invoices (e.g. every plan up/downgrade); resetting on those would let a
// user zero their call quota on demand by churning plan changes. For
// non-cycle invoices we only clear a past-due suspension. (BILL-002)
const isRenewal = invoice.billing_reason === 'subscription_cycle';
await db await db
.update(organizations) .update(organizations)
.set({ .set({
suspended: false, suspended: false,
suspendedReason: null, suspendedReason: null,
...(isRenewal ? { callsThisPeriod: 0, periodStartsAt: new Date() } : {}), callsThisPeriod: 0,
periodStartsAt: new Date(),
}) })
.where(eq(organizations.id, orgId)); .where(eq(organizations.id, orgId));
await audit({ await audit({

View File

@ -219,8 +219,7 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
if (choice.provider !== 'anthropic' || !config.ANTHROPIC_API_KEY) { if (choice.provider !== 'anthropic' || !config.ANTHROPIC_API_KEY) {
return reply.code(409).send({ return reply.code(409).send({
error: 'streaming_unavailable', error: 'streaming_unavailable',
detail: detail: 'Streaming preview is only available for Anthropic-backed tiers. Use POST /v1/servers/preview instead.',
'Streaming preview is only available for Anthropic-backed tiers. Use POST /v1/servers/preview instead.',
}); });
} }
@ -255,10 +254,7 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
// open as long as bytes flow; comments are SSE-noop but count as bytes. // open as long as bytes flow; comments are SSE-noop but count as bytes.
const keepalive = setInterval(() => reply.raw.write(`: ping\n\n`), 15_000); const keepalive = setInterval(() => reply.raw.write(`: ping\n\n`), 15_000);
const abort = new AbortController(); const abort = new AbortController();
req.raw.on('close', () => { req.raw.on('close', () => abort.abort());
abort.abort();
clearInterval(keepalive);
});
// `resolved` is set inside the awaited handlers below — by the time // `resolved` is set inside the awaited handlers below — by the time
// streamSpecFromAnthropic returns, exactly one of onSpec/onError will // streamSpecFromAnthropic returns, exactly one of onSpec/onError will
@ -267,105 +263,95 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
// ended without either handler running (which would be a programming // ended without either handler running (which would be a programming
// bug, not a runtime path). // bug, not a runtime path).
let resolved = false; let resolved = false;
try { await streamSpecFromAnthropic(
await streamSpecFromAnthropic( parsed.data.prompt,
parsed.data.prompt, {
{ apiKey: config.ANTHROPIC_API_KEY,
apiKey: config.ANTHROPIC_API_KEY, model: choice.model,
model: choice.model, maxTokens: choice.maxTokens,
maxTokens: choice.maxTokens, signal: abort.signal,
signal: abort.signal, },
}, {
{ onText: (delta) => send('text', delta),
onText: (delta) => send('text', delta), onSpec: async ({ spec, source }) => {
onSpec: async ({ spec, source }) => { const previewId = await cacheSpec(spec);
const previewId = await cacheSpec(spec); send('spec', {
send('spec', { previewId,
source,
plan,
modelDisplayName: choice.displayName,
modelBadge: choice.displayBadge,
upgradeHint: plan === 'hobby',
spec: {
name: spec.name,
description: spec.description,
tools: spec.tools.map((t) => ({
name: t.name,
description: t.description,
inputSchema: t.inputSchema,
})),
requiredSecrets: spec.requiredSecrets,
scopes: spec.scopes,
},
});
app.log.info(
{
previewId, previewId,
source, tools: spec.tools.length,
plan, prompt: parsed.data.prompt.slice(0, 200),
modelDisplayName: choice.displayName, model: choice.displayName,
modelBadge: choice.displayBadge, },
upgradeHint: plan === 'hobby', 'preview_spec_ready',
spec: { );
name: spec.name, resolved = true;
description: spec.description, },
tools: spec.tools.map((t) => ({ onError: (err) => {
name: t.name, if (err instanceof SpecTruncatedError) {
description: t.description, app.log.warn(
inputSchema: t.inputSchema,
})),
requiredSecrets: spec.requiredSecrets,
scopes: spec.scopes,
},
});
app.log.info(
{ {
previewId, reason: err.message,
tools: spec.tools.length,
prompt: parsed.data.prompt.slice(0, 200), prompt: parsed.data.prompt.slice(0, 200),
model: choice.displayName, model: choice.displayName,
}, },
'preview_spec_ready', 'preview_spec_truncated',
); );
resolved = true; send('error', {
}, error: 'spec_too_large',
onError: (err) => { detail:
if (err instanceof SpecTruncatedError) { 'The spec for this prompt exceeded the maximum response size. Split it into fewer tools or describe one capability per prompt.',
app.log.warn( });
{ } else if (err instanceof SpecValidationError) {
reason: err.message, app.log.warn(
prompt: parsed.data.prompt.slice(0, 200), {
model: choice.displayName, zod_message: err.message,
}, prompt: parsed.data.prompt.slice(0, 200),
'preview_spec_truncated', model: choice.displayName,
); },
send('error', { 'preview_spec_invalid',
error: 'spec_too_large', );
detail: send('error', { error: 'spec_invalid', detail: err.message });
'The spec for this prompt exceeded the maximum response size. Split it into fewer tools or describe one capability per prompt.', } else if (err instanceof BannedPatternError) {
}); send('error', { error: 'banned_pattern', detail: err.message });
} else if (err instanceof SpecValidationError) { } else if (err instanceof SpecTimeoutError) {
app.log.warn( send('error', {
{ error: 'preview_timeout',
zod_message: err.message, detail: 'Spec generation took too long. Try a shorter, more specific prompt.',
prompt: parsed.data.prompt.slice(0, 200), });
model: choice.displayName, } else {
}, app.log.error(err);
'preview_spec_invalid', send('error', { error: 'preview_failed', detail: err.message });
); }
send('error', { error: 'spec_invalid', detail: err.message }); resolved = true;
} else if (err instanceof BannedPatternError) {
send('error', { error: 'banned_pattern', detail: err.message });
} else if (err instanceof SpecTimeoutError) {
send('error', {
error: 'preview_timeout',
detail: 'Spec generation took too long. Try a shorter, more specific prompt.',
});
} else {
app.log.error(err);
send('error', { error: 'preview_failed', detail: err.message });
}
resolved = true;
},
}, },
); },
);
if (!resolved) { if (!resolved) {
app.log.error({ prompt: parsed.data.prompt.slice(0, 200) }, 'preview_stream_unresolved'); app.log.error({ prompt: parsed.data.prompt.slice(0, 200) }, 'preview_stream_unresolved');
send('error', { error: 'preview_failed', detail: 'stream ended without a final event' }); send('error', { error: 'preview_failed', detail: 'stream ended without a final event' });
}
} catch (err) {
// If the stream itself rejects (e.g. cacheSpec/Redis throws inside onSpec,
// or a network error before either handler runs) we must still tear down
// the keepalive timer and close the socket — otherwise the interval keeps
// writing to a dead connection forever, leaking a timer + FD per failure. (SRV-004)
app.log.error({ err, prompt: parsed.data.prompt.slice(0, 200) }, 'preview_stream_threw');
if (!resolved) send('error', { error: 'preview_failed', detail: 'spec generation failed' });
} finally {
clearInterval(keepalive);
reply.raw.end();
} }
clearInterval(keepalive);
reply.raw.end();
}); });
app.post('/v1/servers', { preHandler: requireAuth }, async (req, reply) => { app.post('/v1/servers', { preHandler: requireAuth }, async (req, reply) => {
@ -588,32 +574,6 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
.limit(1); .limit(1);
if (!server) return reply.code(404).send({ error: 'not_found' }); if (!server) return reply.code(404).send({ error: 'not_found' });
// iterate queues a full paid LLM build exactly like POST /v1/servers, so it
// must enforce the same suspension + daily-build gates. Without these a
// suspended (non-paying) or rate-capped org could generate unlimited builds
// by hitting iterate instead of create. (SRV-003)
const billing = await getOrgBilling(user.orgId);
if (billing.suspended) {
return reply.code(402).send({
error: 'subscription_suspended',
detail:
billing.suspendedReason === 'payment_failed'
? 'Your subscription is paused due to a payment issue. Update your payment method in /settings/billing.'
: 'Your subscription is paused. Visit /settings/billing for details.',
suspendedReason: billing.suspendedReason,
});
}
const iterateRl = await checkDailyLimit('build', user.userId, BUILD_DAILY_LIMIT[billing.plan]);
if (!iterateRl.ok) {
return reply.code(429).send({
error: 'rate_limited',
detail: `Daily build limit reached for plan "${billing.plan}" (${BUILD_DAILY_LIMIT[billing.plan]}/day). Resets in ${Math.ceil(iterateRl.resetIn / 3600)}h.`,
plan: billing.plan,
limit: BUILD_DAILY_LIMIT[billing.plan],
resetIn: iterateRl.resetIn,
});
}
const nextVersion = server.currentVersion + 1; const nextVersion = server.currentVersion + 1;
const [build] = await db const [build] = await db
.insert(builds) .insert(builds)

View File

@ -250,15 +250,6 @@ export async function supportRoutes(app: FastifyInstance): Promise<void> {
const body = NewMessageBody.safeParse(req.body); const body = NewMessageBody.safeParse(req.body);
if (!body.success) return reply.code(400).send({ error: 'invalid_input' }); if (!body.success) return reply.code(400).send({ error: 'invalid_input' });
// Confirm the ticket exists first — otherwise the insert below hits a raw
// FK violation (500) instead of a clean 404. (SUP-002)
const [ticket] = await db
.select({ id: supportTickets.id })
.from(supportTickets)
.where(eq(supportTickets.id, parsed.data.id))
.limit(1);
if (!ticket) return reply.code(404).send({ error: 'not_found' });
await db.insert(supportMessages).values({ await db.insert(supportMessages).values({
ticketId: parsed.data.id, ticketId: parsed.data.id,
authorUserId: user.userId, authorUserId: user.userId,
@ -291,22 +282,12 @@ export async function supportRoutes(app: FastifyInstance): Promise<void> {
'/v1/admin/support/tickets/:id/status', '/v1/admin/support/tickets/:id/status',
{ preHandler: requireAdmin }, { preHandler: requireAdmin },
async (req, reply) => { async (req, reply) => {
const user = req.user!;
const Params = z.object({ id: z.string().uuid() }); const Params = z.object({ id: z.string().uuid() });
const parsed = Params.safeParse(req.params); const parsed = Params.safeParse(req.params);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' }); if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' });
const body = StatusBody.safeParse(req.body); const body = StatusBody.safeParse(req.body);
if (!body.success) return reply.code(400).send({ error: 'invalid_input' }); if (!body.success) return reply.code(400).send({ error: 'invalid_input' });
// 404 on unknown ticket instead of a silent no-op `UPDATE ... WHERE id=?`
// that returns ok:true and masks the bad id. (SUP-002)
const [ticket] = await db
.select({ id: supportTickets.id })
.from(supportTickets)
.where(eq(supportTickets.id, parsed.data.id))
.limit(1);
if (!ticket) return reply.code(404).send({ error: 'not_found' });
await db await db
.update(supportTickets) .update(supportTickets)
.set({ .set({
@ -316,17 +297,6 @@ export async function supportRoutes(app: FastifyInstance): Promise<void> {
}) })
.where(eq(supportTickets.id, parsed.data.id)); .where(eq(supportTickets.id, parsed.data.id));
// Status changes were previously unaudited, unlike admin replies — close
// the compliance-trail gap. (SUP-002)
await audit({
orgId: user.orgId,
userId: user.userId,
action: 'support.status_changed',
resourceType: 'support_ticket',
resourceId: parsed.data.id,
metadata: { status: body.data.status },
});
return reply.send({ ok: true }); return reply.send({ ok: true });
}, },
); );

View File

@ -194,15 +194,10 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
toolsSchema: server.toolsSchema, toolsSchema: server.toolsSchema,
generatedCode: build.generatedCode, generatedCode: build.generatedCode,
requiredSecrets: parsed.data.secretHints, requiredSecrets: parsed.data.secretHints,
// Aggregate the distinct scopes actually declared by the server's tools scopes: (server.toolsSchema as Array<{ scopes?: string[] }>).reduce<string[]>(
// (deduped), falling back to read-only. The previous reduce ignored its () => ['mcp:read'],
// input and hardcoded ['mcp:read'] for every template regardless of what [],
// its tools did. (TPL-003) ),
scopes: (() => {
const tools = (server.toolsSchema as Array<{ scopes?: string[] }> | null) ?? [];
const all = [...new Set(tools.flatMap((t) => t.scopes ?? []))];
return all.length > 0 ? all : ['mcp:read'];
})(),
allowedDomains: parsed.data.allowedDomains ?? null, allowedDomains: parsed.data.allowedDomains ?? null,
}) })
.returning(); .returning();
@ -315,19 +310,13 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
.from(templates) .from(templates)
.leftJoin(users, eq(users.id, templates.ownerUserId)) .leftJoin(users, eq(users.id, templates.ownerUserId))
.leftJoin(organizations, eq(organizations.id, templates.ownerOrgId)) .leftJoin(organizations, eq(organizations.id, templates.ownerOrgId))
// Category filter belongs in the WHERE, BEFORE limit — filtering in JS .where(eq(templates.status, 'public'))
// after `.limit(50)` meant `?category=x` searched only the 50 newest
// public templates (any category), returning far fewer than `limit`. (TPL-008)
.where(
and(
eq(templates.status, 'public'),
parsed.data.category ? eq(templates.category, parsed.data.category) : undefined,
),
)
.orderBy(desc(templates.createdAt)) .orderBy(desc(templates.createdAt))
.limit(parsed.data.limit); .limit(parsed.data.limit);
const filtered = rows; const filtered = parsed.data.category
? rows.filter((r) => r.template.category === parsed.data.category)
: rows;
// Single grouped query — was N+1 (one COUNT per template). On a 100-row // Single grouped query — was N+1 (one COUNT per template). On a 100-row
// listing that's 101 round-trips → p95 latency cliff once the marketplace // listing that's 101 round-trips → p95 latency cliff once the marketplace

View File

@ -39,10 +39,7 @@ export async function prepareBuildContext(
pkg.dependencies = { ...pkg.dependencies, ...spec.dependencies }; pkg.dependencies = { ...pkg.dependencies, ...spec.dependencies };
await fs.writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8'); await fs.writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8');
// Include serverId in the tag: `slug` is unique only per-org, so two orgs const imageTag = `bmm-mcp-${slug}:v${version}`;
// sharing a slug at the same version would otherwise collide on one global
// image tag and run each other's code. Matches the contextDir scheme. (GEN-009)
const imageTag = `bmm-mcp-${serverId.slice(0, 8)}-${slug}:v${version}`;
return { contextDir, imageTag }; return { contextDir, imageTag };
} }
@ -73,21 +70,12 @@ export async function staticCheck(contextDir: string): Promise<void> {
} }
} }
// A hung `docker build` (stalled npm install, wedged daemon) must not pin a
// worker slot forever — concurrency is 2, so two stuck builds = zero throughput
// with no alarm. Kill and fail the build past this ceiling. (GEN-008)
const BUILD_TIMEOUT_MS = 10 * 60 * 1000;
export async function dockerBuild(contextDir: string, imageTag: string, onLog: (msg: string) => void): Promise<void> { export async function dockerBuild(contextDir: string, imageTag: string, onLog: (msg: string) => void): Promise<void> {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const child = spawn('docker', ['build', '-t', imageTag, '.'], { const child = spawn('docker', ['build', '-t', imageTag, '.'], {
cwd: contextDir, cwd: contextDir,
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
}); });
const timer = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error(`docker_build_timeout (exceeded ${BUILD_TIMEOUT_MS / 1000}s)`));
}, BUILD_TIMEOUT_MS);
child.stdout.on('data', (d) => { child.stdout.on('data', (d) => {
for (const line of d.toString().split(/\r?\n/)) { for (const line of d.toString().split(/\r?\n/)) {
if (line.trim()) onLog(line.trim()); if (line.trim()) onLog(line.trim());
@ -98,12 +86,8 @@ export async function dockerBuild(contextDir: string, imageTag: string, onLog: (
if (line.trim()) onLog(line.trim()); if (line.trim()) onLog(line.trim());
} }
}); });
child.on('error', (e) => { child.on('error', (e) => reject(e));
clearTimeout(timer);
reject(e);
});
child.on('close', (code) => { child.on('close', (code) => {
clearTimeout(timer);
if (code === 0) resolve(); if (code === 0) resolve();
else reject(new Error(`docker_build_failed (exit ${code})`)); else reject(new Error(`docker_build_failed (exit ${code})`));
}); });

View File

@ -81,26 +81,13 @@ const HARDENING_FLAGS = [
]; ];
function shouldHarden(): boolean { function shouldHarden(): boolean {
// Fail-CLOSED: harden by default everywhere. The only opt-out is the explicit // Explicit opt-out for local dev on Windows where --read-only conflicts
// RUNNER_DISABLE_HARDENING=1 flag (local Windows Docker Desktop, where // with how Docker Desktop binds volumes. Production must always harden.
// --read-only conflicts with how volumes bind). The previous NODE_ENV gate was if (process.env.RUNNER_DISABLE_HARDENING === '1') return false;
// fail-OPEN — a missing/typo'd NODE_ENV silently ran tenant containers as root const env = process.env.NODE_ENV;
// with full caps on the shared host, which is the one defense the LLM return env === 'production' || env === 'staging';
// static-check explicitly is NOT. (GEN-002)
if (process.env.RUNNER_DISABLE_HARDENING === '1') {
console.warn(
'[deploy] container hardening DISABLED via RUNNER_DISABLE_HARDENING=1 — never set this in production',
);
return false;
}
return true;
} }
// docker run / rm should return in seconds; cap them so a wedged daemon can't
// hang a worker slot indefinitely. (GEN-008)
const DOCKER_RUN_TIMEOUT_MS = 60 * 1000;
const DOCKER_STOP_TIMEOUT_MS = 60 * 1000;
const db = createDb(); const db = createDb();
async function portFree(port: number, host = '127.0.0.1'): Promise<boolean> { async function portFree(port: number, host = '127.0.0.1'): Promise<boolean> {
@ -170,24 +157,14 @@ export async function deployContainer(input: DeployInput): Promise<DeployHandle>
const child = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] }); const child = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] });
let out = ''; let out = '';
let err = ''; let err = '';
// `docker run -d` returns promptly; if it hangs (wedged daemon) don't pin a
// worker slot forever. (GEN-008)
const timer = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error('docker_run_timeout'));
}, DOCKER_RUN_TIMEOUT_MS);
child.stdout.on('data', (d) => { child.stdout.on('data', (d) => {
out += d.toString(); out += d.toString();
}); });
child.stderr.on('data', (d) => { child.stderr.on('data', (d) => {
err += d.toString(); err += d.toString();
}); });
child.on('error', (e) => { child.on('error', (e) => reject(e));
clearTimeout(timer);
reject(e);
});
child.on('close', async (code) => { child.on('close', async (code) => {
clearTimeout(timer);
if (code !== 0) { if (code !== 0) {
reject(new Error(`docker_run_failed (exit ${code}): ${err.trim() || out.trim()}`)); reject(new Error(`docker_run_failed (exit ${code}): ${err.trim() || out.trim()}`));
return; return;
@ -230,21 +207,13 @@ export async function stopContainer(
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
}); });
let err = ''; let err = '';
const timer = setTimeout(() => {
child.kill('SIGKILL');
resolve({ ok: false, detail: 'stop_timeout' });
}, DOCKER_STOP_TIMEOUT_MS);
child.stderr?.on('data', (d: Buffer) => { child.stderr?.on('data', (d: Buffer) => {
err += d.toString(); err += d.toString();
}); });
child.on('error', () => { child.on('error', () => resolve({ ok: false, detail: 'spawn_failed' }));
clearTimeout(timer); child.on('close', (code) =>
resolve({ ok: false, detail: 'spawn_failed' }); resolve(code === 0 ? { ok: true, detail: '' } : { ok: false, detail: err.trim() || `exit ${code}` }),
}); );
child.on('close', (code) => {
clearTimeout(timer);
resolve(code === 0 ? { ok: true, detail: '' } : { ok: false, detail: err.trim() || `exit ${code}` });
});
}); });
} }

View File

@ -184,34 +184,30 @@ export const worker = new Worker<JobData>(
`Container ${handle.containerId.slice(0, 12)} running at ${handle.publicUrl}`, `Container ${handle.containerId.slice(0, 12)} running at ${handle.publicUrl}`,
); );
try { await db
await db .update(builds)
.update(builds) .set({ status: 'success', finishedAt: new Date() })
.set({ status: 'success', finishedAt: new Date() }) .where(eq(builds.id, buildId));
.where(eq(builds.id, buildId)); await db
await db .update(mcpServers)
.update(mcpServers) .set({
.set({ status: 'live',
status: 'live', currentVersion: version,
currentVersion: version, publicUrl: handle.publicUrl,
publicUrl: handle.publicUrl, updatedAt: new Date(),
updatedAt: new Date(), })
}) .where(eq(mcpServers.id, serverId));
.where(eq(mcpServers.id, serverId));
} finally { // Rolling deploy: the new container is live — now retire the previous one.
// Rolling deploy: retire the previous container even if the success DB // Without this every iterate would leave an orphan holding a host port.
// writes above threw — otherwise a DB hiccup after a healthy deploy if (oldContainerId && oldContainerId !== handle.containerId) {
// leaves the old container orphaned, holding its host port. The new const stopped = await stopContainer(oldContainerId);
// container is already live and its id is persisted in deployContainer. (GEN-007) await log(
if (oldContainerId && oldContainerId !== handle.containerId) { stopped.ok ? 'info' : 'warn',
const stopped = await stopContainer(oldContainerId); stopped.ok
await log( ? `Retired previous container ${oldContainerId.slice(0, 12)}`
stopped.ok ? 'info' : 'warn', : `Could not stop previous container ${oldContainerId.slice(0, 12)}: ${stopped.detail}`,
stopped.ok );
? `Retired previous container ${oldContainerId.slice(0, 12)}`
: `Could not stop previous container ${oldContainerId.slice(0, 12)}: ${stopped.detail}`,
);
}
} }
await emitStatus(buildId, 'success'); await emitStatus(buildId, 'success');

View File

@ -1,11 +1,7 @@
FROM node:20-alpine AS deps FROM node:20-alpine AS deps
WORKDIR /app WORKDIR /app
COPY package.json ./ COPY package.json ./
# --ignore-scripts: generated package.json carries LLM/user-chosen dependencies. RUN npm install --omit=dev --no-audit --no-fund && npm install --no-save tsx@4.19.2 typescript@5.7.2
# Without this, a malicious dependency's postinstall lifecycle script would run
# at `docker build` time on the shared host. Specifiers are also validated to
# registry semver ranges at the API boundary (DependencyMap). (GEN-001)
RUN npm install --omit=dev --ignore-scripts --no-audit --no-fund && npm install --no-save --ignore-scripts tsx@4.19.2 typescript@5.7.2
FROM node:20-alpine AS runtime FROM node:20-alpine AS runtime
WORKDIR /app WORKDIR /app

View File

@ -24,11 +24,6 @@ RUN pnpm install --frozen-lockfile
FROM deps AS build FROM deps AS build
ARG NEXT_PUBLIC_API_URL=http://localhost:4000 ARG NEXT_PUBLIC_API_URL=http://localhost:4000
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
# Stripe publishable key — inlined into the client bundle so the embedded
# checkout can initialise. Safe to expose (publishable, not secret). Empty
# build = embedded checkout shows a "not configured" message until set.
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
COPY . . COPY . .
RUN pnpm --filter @bmm/web build RUN pnpm --filter @bmm/web build
@ -39,9 +34,4 @@ ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
WORKDIR /app/apps/web WORKDIR /app/apps/web
EXPOSE 3001 EXPOSE 3001
# NOTE (INF-003): non-root `USER node` was reverted — `pnpm start` via corepack
# can't reach its root-owned cache as the node user and the deploy health-check
# doesn't cover web, so a broken web would deploy "green" but take the site down.
# Re-enable only after switching the runtime CMD to invoke next directly
# (node_modules/.bin/next) and smoke-testing the image locally.
CMD ["pnpm", "start"] CMD ["pnpm", "start"]

View File

@ -2,19 +2,11 @@
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api'; import { apiFetch } from '@/lib/api';
import { EmbeddedCheckout, EmbeddedCheckoutProvider } from '@stripe/react-stripe-js'; import { Loader2 } from 'lucide-react';
import { loadStripe } from '@stripe/stripe-js';
import { Loader2, X } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useCallback, useEffect, useState } from 'react'; import { Suspense, useCallback, useEffect, useState } from 'react';
// Load Stripe.js once at module scope (Stripe's recommendation). Null when the
// publishable key isn't baked into the build — the modal then shows a clear
// "not configured" message instead of throwing.
const STRIPE_PK = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
const stripePromise = STRIPE_PK ? loadStripe(STRIPE_PK) : null;
type Plan = 'hobby' | 'pro' | 'team' | 'enterprise'; type Plan = 'hobby' | 'pro' | 'team' | 'enterprise';
type Tier = 'pro_monthly' | 'pro_yearly' | 'team_monthly' | 'team_yearly'; type Tier = 'pro_monthly' | 'pro_yearly' | 'team_monthly' | 'team_yearly';
@ -76,8 +68,6 @@ function BillingInner() {
const [status, setStatus] = useState<BillingStatus | null>(null); const [status, setStatus] = useState<BillingStatus | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null);
// When set, the in-app embedded Stripe checkout modal is open.
const [clientSecret, setClientSecret] = useState<string | null>(null);
const loadStatus = useCallback(() => { const loadStatus = useCallback(() => {
apiFetch<BillingStatus>('/v1/billing/status') apiFetch<BillingStatus>('/v1/billing/status')
@ -108,17 +98,15 @@ function BillingInner() {
setBusy(tier); setBusy(tier);
setError(null); setError(null);
try { try {
const res = await apiFetch<{ clientSecret: string }>('/v1/billing/checkout-session', { const res = await apiFetch<{ url: string }>('/v1/billing/checkout-session', {
method: 'POST', method: 'POST',
body: JSON.stringify({ tier }), body: JSON.stringify({ tier }),
}); });
// Open the embedded checkout in-app instead of redirecting to Stripe. window.location.href = res.url;
setClientSecret(res.clientSecret);
} catch (e) { } catch (e) {
setBusy(null);
const detail = (e as { detail?: { detail?: string; error?: string } }).detail; const detail = (e as { detail?: { detail?: string; error?: string } }).detail;
setError(detail?.detail ?? detail?.error ?? (e as Error).message); setError(detail?.detail ?? detail?.error ?? (e as Error).message);
} finally {
setBusy(null);
} }
}, []); }, []);
@ -195,15 +183,6 @@ function BillingInner() {
return ( return (
<div className="mx-auto max-w-3xl px-6 py-10"> <div className="mx-auto max-w-3xl px-6 py-10">
{clientSecret && (
<CheckoutModal
clientSecret={clientSecret}
onClose={() => {
setClientSecret(null);
setBusy(null);
}}
/>
)}
<div> <div>
<h1 className="text-[22px] font-semibold tracking-tight">Billing</h1> <h1 className="text-[22px] font-semibold tracking-tight">Billing</h1>
<p className="mt-1 text-[13px] text-[--color-fg-muted]"> <p className="mt-1 text-[13px] text-[--color-fg-muted]">
@ -442,44 +421,6 @@ function BillingInner() {
); );
} }
function CheckoutModal({
clientSecret,
onClose,
}: {
clientSecret: string;
onClose: () => void;
}) {
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 p-4 backdrop-blur-sm sm:p-8"
role="dialog"
aria-modal="true"
>
<div className="relative my-auto w-full max-w-xl rounded-lg border border-[--color-border] bg-[--color-bg] p-1 shadow-xl">
<button
type="button"
onClick={onClose}
aria-label="Close checkout"
className="absolute right-2 top-2 z-10 rounded-md p-1.5 text-[--color-fg-muted] hover:bg-[--color-bg-subtle] hover:text-[--color-fg]"
>
<X size={18} />
</button>
{stripePromise ? (
<EmbeddedCheckoutProvider stripe={stripePromise} options={{ clientSecret }}>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
) : (
<div className="p-6">
<Alert tone="error">
Payments arent configured (missing Stripe publishable key). Please contact support.
</Alert>
</div>
)}
</div>
</div>
);
}
function Alert({ function Alert({
tone, tone,
children, children,
@ -544,7 +485,7 @@ function TierCard({
onClick={() => onSubscribe(monthlyTier)} onClick={() => onSubscribe(monthlyTier)}
disabled={Boolean(busy)} disabled={Boolean(busy)}
> >
{busy === monthlyTier ? 'Loading…' : `Subscribe — €${monthly}/mo`} {busy === monthlyTier ? 'Redirecting…' : `Subscribe — €${monthly}/mo`}
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@ -552,7 +493,7 @@ function TierCard({
onClick={() => onSubscribe(yearlyTier)} onClick={() => onSubscribe(yearlyTier)}
disabled={Boolean(busy)} disabled={Boolean(busy)}
> >
{busy === yearlyTier ? 'Loading…' : `Or €${yearly}/year — 2 months free`} {busy === yearlyTier ? 'Redirecting…' : `Or €${yearly}/year — 2 months free`}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -11,8 +11,6 @@
}, },
"dependencies": { "dependencies": {
"@bmm/types": "workspace:*", "@bmm/types": "workspace:*",
"@stripe/react-stripe-js": "^6.4.0",
"@stripe/stripe-js": "^9.7.0",
"clsx": "2.1.1", "clsx": "2.1.1",
"framer-motion": "11.18.2", "framer-motion": "11.18.2",
"geist": "1.3.1", "geist": "1.3.1",

View File

@ -54,36 +54,6 @@ services:
timeout: 5s timeout: 5s
retries: 20 retries: 20
# Restricted Docker API gateway for the control plane. The API only needs to
# stop/remove generated containers (`docker rm -f`), so it talks to this proxy
# — which exposes ONLY the containers endpoints + write methods — instead of
# mounting the raw root-equivalent /var/run/docker.sock. A compromised API can
# no longer build images, create privileged containers, exec, or mount host
# paths. (INF-003) NOTE: the generator still mounts the raw socket because it
# legitimately builds+runs containers (an inherently privileged operation);
# that residual is tracked in the audit backlog (rootless buildkit / build VM).
docker-socket-proxy:
image: tecnativa/docker-socket-proxy:0.2.0
container_name: bmm-docker-proxy
restart: unless-stopped
environment:
CONTAINERS: 1
POST: 1
# everything else stays at the image default (0 = blocked)
IMAGES: 0
BUILD: 0
NETWORKS: 0
VOLUMES: 0
EXEC: 0
INFO: 0
AUTH: 0
SECRETS: 0
SWARM: 0
SYSTEM: 0
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks: [bmm-network]
api: api:
build: build:
context: . context: .
@ -91,13 +61,10 @@ services:
container_name: bmm-api container_name: bmm-api
restart: unless-stopped restart: unless-stopped
env_file: .env.production env_file: .env.production
environment:
# Route docker CLI calls through the restricted proxy instead of a raw
# socket mount. (INF-003)
DOCKER_HOST: tcp://docker-socket-proxy:2375
ports: ports:
- "127.0.0.1:${API_PORT:-4000}:4000" - "127.0.0.1:${API_PORT:-4000}:4000"
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock
- bmm_keys:/app/apps/api/keys - bmm_keys:/app/apps/api/keys
# Per-runner nginx snippets — written by the generator, deleted by the # Per-runner nginx snippets — written by the generator, deleted by the
# api when a server is removed. The host-side systemd watcher combines # api when a server is removed. The host-side systemd watcher combines
@ -112,8 +79,6 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
docker-socket-proxy:
condition: service_started
web: web:
build: build:
@ -121,9 +86,6 @@ services:
dockerfile: apps/web/Dockerfile dockerfile: apps/web/Dockerfile
args: args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:?set NEXT_PUBLIC_API_URL in .env.production} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:?set NEXT_PUBLIC_API_URL in .env.production}
# Publishable (not secret) — baked into the client bundle for embedded
# checkout. Default empty so the build never fails when it's unset.
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:-}
container_name: bmm-web container_name: bmm-web
restart: unless-stopped restart: unless-stopped
env_file: .env.production env_file: .env.production
@ -147,12 +109,6 @@ services:
DATABASE_URL: postgresql://${POSTGRES_USER:-bmm}:${POSTGRES_PASSWORD}@127.0.0.1:${POSTGRES_PORT:-5440}/${POSTGRES_DB:-bmm} DATABASE_URL: postgresql://${POSTGRES_USER:-bmm}:${POSTGRES_PASSWORD}@127.0.0.1:${POSTGRES_PORT:-5440}/${POSTGRES_DB:-bmm}
REDIS_URL: redis://127.0.0.1:${REDIS_PORT:-6390} REDIS_URL: redis://127.0.0.1:${REDIS_PORT:-6390}
volumes: volumes:
# SECURITY (INF-003): the generator mounts the RAW docker socket because it
# builds images and runs containers — inherently root-equivalent on this
# host, and a socket-proxy can't filter that (container-create with host
# binds is the dangerous primitive it legitimately needs). It is NOT
# internet-facing (driven only by the Redis build queue). Real remediation
# = rootless buildkit or a dedicated build VM; tracked in the audit backlog.
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- bmm_build_context:/app/build-context - bmm_build_context:/app/build-context
# Same runner-map mount as the api — generator drops the snippet on # Same runner-map mount as the api — generator drops the snippet on

View File

@ -21,14 +21,6 @@ server {
client_max_body_size 12M; client_max_body_size 12M;
# Security headers (INF-002). Cloudflare sits in front — if it also injects
# HSTS, drop that line here to avoid duplication. CSP intentionally omitted
# for now (a wrong policy breaks Next/Tailwind inline) — track separately.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location / { location / {
proxy_pass http://127.0.0.1:4001; proxy_pass http://127.0.0.1:4001;
proxy_http_version 1.1; proxy_http_version 1.1;
@ -56,13 +48,6 @@ server {
client_max_body_size 12M; client_max_body_size 12M;
# Security headers (INF-002). nosniff matters for the JSON API; XFO/HSTS are
# belt-and-suspenders for any HTML the API might ever serve.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Build-log WebSocket stream (/v1/builds/:id/stream) — needs the upgrade # Build-log WebSocket stream (/v1/builds/:id/stream) — needs the upgrade
# headers and a long read timeout; buffering off so frames are not held. # headers and a long read timeout; buffering off so frames are not held.
location /v1/builds/ { location /v1/builds/ {

View File

@ -7,8 +7,6 @@ import {
eq, eq,
gt, gt,
isNull, isNull,
lt,
sql,
magicLinks, magicLinks,
memberships, memberships,
organizations, organizations,
@ -274,18 +272,12 @@ export async function consumeSmsCode(
.orderBy(desc(smsCodes.createdAt)) .orderBy(desc(smsCodes.createdAt))
.limit(1); .limit(1);
if (!row || row.consumedAt) throw new Error('invalid_or_expired_code'); if (!row || row.consumedAt) throw new Error('invalid_or_expired_code');
// Atomically claim one guess attempt. The increment is gated on if (row.attempts >= SMS_MAX_ATTEMPTS) throw new Error('too_many_attempts');
// `attempts < MAX` inside the same UPDATE, so the DB row-lock serialises
// concurrent verifies and at most MAX increments ever succeed for a code.
// The previous read-then-write (`row.attempts + 1`) let N parallel requests
// all pass a stale `attempts` read and brute-force the 6-digit code. (AUTH-001)
const slot = await db
.update(smsCodes)
.set({ attempts: sql`${smsCodes.attempts} + 1` })
.where(and(eq(smsCodes.id, row.id), lt(smsCodes.attempts, SMS_MAX_ATTEMPTS)))
.returning({ attempts: smsCodes.attempts });
if (slot.length === 0) throw new Error('too_many_attempts');
if (sha256(`${phone}:${code}`) !== row.codeHash) { if (sha256(`${phone}:${code}`) !== row.codeHash) {
await db
.update(smsCodes)
.set({ attempts: row.attempts + 1 })
.where(eq(smsCodes.id, row.id));
throw new Error('invalid_code'); throw new Error('invalid_code');
} }
await db.update(smsCodes).set({ consumedAt: new Date() }).where(eq(smsCodes.id, row.id)); await db.update(smsCodes).set({ consumedAt: new Date() }).where(eq(smsCodes.id, row.id));
@ -352,16 +344,10 @@ export async function getSession(
.where(eq(sessions.tokenHash, hash)) .where(eq(sessions.tokenHash, hash))
.limit(1); .limit(1);
if (!row || row.expiresAt < new Date()) return null; if (!row || row.expiresAt < new Date()) return null;
// Deterministic primary-org selection — must match the login flows
// (consumeMagicLink / loginWithPassword order by oldest membership). Without
// this orderBy, a user with >1 membership (once org-invites land) would get a
// nondeterministic org per request, silently scoping their reads/writes to a
// different tenant than they logged in as. (SRV-001)
const [membership] = await db const [membership] = await db
.select({ orgId: memberships.orgId, role: memberships.role }) .select({ orgId: memberships.orgId, role: memberships.role })
.from(memberships) .from(memberships)
.where(eq(memberships.userId, row.userId)) .where(eq(memberships.userId, row.userId))
.orderBy(memberships.createdAt)
.limit(1); .limit(1);
if (!membership) return null; if (!membership) return null;
return { return {

View File

@ -68,33 +68,6 @@ export const PromptSpec = z.object({
}); });
export type PromptSpec = z.infer<typeof PromptSpec>; export type PromptSpec = z.infer<typeof PromptSpec>;
// Dependency specifiers come from untrusted LLM output and are merged into the
// build's package.json, then `npm install`-ed inside `docker build` on the
// SHARED host. Restrict to npm-registry semver ranges only: no `git+`, `http(s):`,
// `file:` or tarball-URL specifiers (which fetch/checkout+run arbitrary code at
// install time), and valid (optionally-scoped) npm package names. Combined with
// `--ignore-scripts` in the runner Dockerfile this closes the build-time RCE. (GEN-001)
const DepName = z
.string()
.max(214)
.regex(/^(@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*$/i, 'invalid npm package name');
const DepRange = z
.string()
.max(64)
.regex(/^([\^~]?\d+(\.\d+){0,2}(-[\w.]+)?|\*|latest)$/, 'must be a plain semver range');
export const DependencyMap = z.record(DepName, DepRange);
// Secret KEYS become `-e KEY=VALUE` docker run args and the runtime env of the
// tenant container. Constrain to UPPER_SNAKE_CASE (matches requiredSecrets) and
// reject names that could hijack the Node runtime/loader if the container
// hardening ever regressed. Values stay free-form. (GEN-003)
const RESERVED_ENV = new Set(['PATH', 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'NODE_ENV']);
const SecretKey = z
.string()
.regex(/^[A-Z][A-Z0-9_]*$/, 'UPPER_SNAKE_CASE env var name required')
.refine((k) => !RESERVED_ENV.has(k) && !k.startsWith('NODE_'), 'reserved env var name');
export const SecretMap = z.record(SecretKey, z.string());
export const GeneratorSpec = z.object({ export const GeneratorSpec = z.object({
name: z.string().min(1).max(128), name: z.string().min(1).max(128),
description: z.string().max(2000).optional(), description: z.string().max(2000).optional(),
@ -103,7 +76,7 @@ export const GeneratorSpec = z.object({
prompts: z.array(PromptSpec).max(50).default([]), prompts: z.array(PromptSpec).max(50).default([]),
requiredSecrets: z.array(z.string().regex(/^[A-Z][A-Z0-9_]*$/)).max(30).default([]), requiredSecrets: z.array(z.string().regex(/^[A-Z][A-Z0-9_]*$/)).max(30).default([]),
scopes: z.array(z.string()).max(50).default([]), scopes: z.array(z.string()).max(50).default([]),
dependencies: DependencyMap.default({}), dependencies: z.record(z.string(), z.string()).default({}),
}); });
export type GeneratorSpec = z.infer<typeof GeneratorSpec>; export type GeneratorSpec = z.infer<typeof GeneratorSpec>;
@ -163,7 +136,7 @@ export const CreateServerInput = z.object({
.max(64) .max(64)
.regex(/^[a-z][a-z0-9-]*$/, 'lowercase, hyphenated'), .regex(/^[a-z][a-z0-9-]*$/, 'lowercase, hyphenated'),
prompt: z.string().min(10).max(8000), prompt: z.string().min(10).max(8000),
secrets: SecretMap.default({}), secrets: z.record(z.string(), z.string()).default({}),
previewId: z.string().min(1).max(64).optional(), previewId: z.string().min(1).max(64).optional(),
specEdit: SpecEdit.optional(), specEdit: SpecEdit.optional(),
templateId: z.string().uuid().optional(), templateId: z.string().uuid().optional(),
@ -196,7 +169,7 @@ export type PreviewResult = z.infer<typeof PreviewResult>;
export const IterateServerInput = z.object({ export const IterateServerInput = z.object({
prompt: z.string().min(10).max(8000), prompt: z.string().min(10).max(8000),
secrets: SecretMap.default({}), secrets: z.record(z.string(), z.string()).default({}),
}); });
export type IterateServerInput = z.infer<typeof IterateServerInput>; export type IterateServerInput = z.infer<typeof IterateServerInput>;

53
pnpm-lock.yaml generated
View File

@ -137,12 +137,6 @@ importers:
'@bmm/types': '@bmm/types':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/types version: link:../../packages/types
'@stripe/react-stripe-js':
specifier: ^6.4.0
version: 6.4.0(@stripe/stripe-js@9.7.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@stripe/stripe-js':
specifier: ^9.7.0
version: 9.7.0
clsx: clsx:
specifier: 2.1.1 specifier: 2.1.1
version: 2.1.1 version: 2.1.1
@ -1250,17 +1244,6 @@ packages:
'@remotion/studio@4.0.220': '@remotion/studio@4.0.220':
resolution: {integrity: sha512-97sb8ta+4cRmsntAWPgyNkAGo3i9/q/j+jLBvhbUF0njC+rFs3QVb/qx27mEI8/Avw3har7Nu7BR62eH8TNfcg==} resolution: {integrity: sha512-97sb8ta+4cRmsntAWPgyNkAGo3i9/q/j+jLBvhbUF0njC+rFs3QVb/qx27mEI8/Avw3har7Nu7BR62eH8TNfcg==}
'@stripe/react-stripe-js@6.4.0':
resolution: {integrity: sha512-5cAf7GAqf8VHPoPLVnJQ2MHtDWLdiEPs5GW/loU7iDEue3xf620v11skrk5HICUraDOhODPYpttgFC5N579NxA==}
peerDependencies:
'@stripe/stripe-js': '>=9.5.0 <10.0.0'
react: '>=16.8.0 <20.0.0'
react-dom: '>=16.8.0 <20.0.0'
'@stripe/stripe-js@9.7.0':
resolution: {integrity: sha512-r1ElolvWXM4aYnZZVHvKW3EDL8JcwEuIgTuWxlB5lvC+YsvjkQ0gX35x9d8dTDubX395fViLVqkaolVs1PmIQQ==}
engines: {node: '>=12.16'}
'@swc/counter@0.1.3': '@swc/counter@0.1.3':
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
@ -2120,9 +2103,6 @@ packages:
jose@6.2.3: jose@6.2.3:
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
json-parse-even-better-errors@2.3.1: json-parse-even-better-errors@2.3.1:
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
@ -2237,10 +2217,6 @@ packages:
lodash.sortby@4.7.0: lodash.sortby@4.7.0:
resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
lru-cache@6.0.0: lru-cache@6.0.0:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -2495,9 +2471,6 @@ packages:
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
proxy-addr@2.0.7: proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
@ -2529,9 +2502,6 @@ packages:
peerDependencies: peerDependencies:
react: ^19.0.0 react: ^19.0.0
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-refresh@0.9.0: react-refresh@0.9.0:
resolution: {integrity: sha512-Gvzk7OZpiqKSkxsQvO/mbTN1poglhmAV7gR/DdIrRrSMXraRQQlfikRJOr3Nb9GTMPC5kof948Zy6jJZIFtDvQ==} resolution: {integrity: sha512-Gvzk7OZpiqKSkxsQvO/mbTN1poglhmAV7gR/DdIrRrSMXraRQQlfikRJOr3Nb9GTMPC5kof948Zy6jJZIFtDvQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -3733,15 +3703,6 @@ snapshots:
- supports-color - supports-color
- utf-8-validate - utf-8-validate
'@stripe/react-stripe-js@6.4.0(@stripe/stripe-js@9.7.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@stripe/stripe-js': 9.7.0
prop-types: 15.8.1
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
'@stripe/stripe-js@9.7.0': {}
'@swc/counter@0.1.3': {} '@swc/counter@0.1.3': {}
'@swc/helpers@0.5.15': '@swc/helpers@0.5.15':
@ -4646,8 +4607,6 @@ snapshots:
jose@6.2.3: {} jose@6.2.3: {}
js-tokens@4.0.0: {}
json-parse-even-better-errors@2.3.1: {} json-parse-even-better-errors@2.3.1: {}
json-schema-ref-resolver@3.0.0: json-schema-ref-resolver@3.0.0:
@ -4733,10 +4692,6 @@ snapshots:
lodash.sortby@4.7.0: {} lodash.sortby@4.7.0: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
lru-cache@6.0.0: lru-cache@6.0.0:
dependencies: dependencies:
yallist: 4.0.0 yallist: 4.0.0
@ -4964,12 +4919,6 @@ snapshots:
kleur: 3.0.3 kleur: 3.0.3
sisteransi: 1.0.5 sisteransi: 1.0.5
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
object-assign: 4.1.1
react-is: 16.13.1
proxy-addr@2.0.7: proxy-addr@2.0.7:
dependencies: dependencies:
forwarded: 0.2.0 forwarded: 0.2.0
@ -5002,8 +4951,6 @@ snapshots:
react: 19.0.0 react: 19.0.0
scheduler: 0.25.0 scheduler: 0.25.0
react-is@16.13.1: {}
react-refresh@0.9.0: {} react-refresh@0.9.0: {}
react@19.0.0: {} react@19.0.0: {}