Compare commits

..

No commits in common. "c016bf237b5ea5a8340ebc7778ade6c5653fcbbe" and "9cce4a94c2f03bcadeac41d19e2c95b8aecf0ca7" have entirely different histories.

30 changed files with 60 additions and 1545 deletions

View File

@ -1,34 +0,0 @@
# Dependencies — reinstalled inside the image
node_modules
**/node_modules
# Build output / caches
.next
**/.next
dist
**/dist
.turbo
**/.turbo
*.tsbuildinfo
**/*.tsbuildinfo
coverage
# Generated MCP build contexts — recreated at runtime in a volume
build-context
# Secrets — never bake into an image (injected via env_file at runtime)
.env
.env.*
!.env.example
!.env.production.example
# OAuth signing keys — persisted in a named volume, not the image
keys
# Local / VCS noise
.git
.gitignore
.DS_Store
*.log
.vscode
.idea

View File

@ -15,17 +15,6 @@ NEXT_PUBLIC_API_URL=http://localhost:4000
GITHUB_OAUTH_ID=
GITHUB_OAUTH_SECRET=
# ---- Google OAuth (optional — "Continue with Google") ----
# Create at https://console.cloud.google.com/apis/credentials
# Authorized redirect URI must be: <CONTROL_PLANE_PUBLIC_URL>/v1/auth/google/callback
# e.g. dev: http://localhost:4000/v1/auth/google/callback
# prod: https://api.buildmymcp.com/v1/auth/google/callback
GOOGLE_OAUTH_ID=
GOOGLE_OAUTH_SECRET=
# Public URL of this API, used to build the OAuth redirect URI.
CONTROL_PLANE_PUBLIC_URL=http://localhost:4000
# ---- Anthropic ----
ANTHROPIC_API_KEY=

View File

@ -1,80 +0,0 @@
# ============================================================================
# Production environment for buildmymcpserver.com
# Copy to .env.production on the server and fill every value marked CHANGE-ME.
# Never commit the filled file — .env.production is gitignored.
#
# Used two ways by docker-compose.prod.yml:
# 1. compose interpolation -> docker compose --env-file .env.production ...
# 2. container env -> env_file: .env.production
# ============================================================================
# ---- Core ----
NODE_ENV=production
# ---- Postgres (the compose file owns the container) ----
POSTGRES_USER=bmm
POSTGRES_PASSWORD=CHANGE-ME-strong-db-password
POSTGRES_DB=bmm
# ---- Host ports (loopback only — picked free on the shared box) ----
POSTGRES_PORT=5440
REDIS_PORT=6390
API_PORT=4000
WEB_PORT=4001
# ---- Connection strings ----
# api + web reach the DBs over the compose network (service names).
# The generator overrides these to 127.0.0.1 (it uses host networking).
DATABASE_URL=postgresql://bmm:CHANGE-ME-strong-db-password@postgres:5432/bmm
REDIS_URL=redis://redis:6379
# ---- API ----
PORT=4000
# ---- Public URLs (must match the Cloudflare DNS records) ----
NEXT_PUBLIC_APP_URL=https://buildmymcpserver.com
NEXT_PUBLIC_API_URL=https://api.buildmymcpserver.com
# Used to build the Google OAuth redirect URI and as the JWKS origin.
CONTROL_PLANE_PUBLIC_URL=https://api.buildmymcpserver.com
# Reachable by generated MCP containers — must be public so they can resolve it.
CONTROL_PLANE_URL=https://api.buildmymcpserver.com
OAUTH_ISSUER=https://api.buildmymcpserver.com
# ---- Crypto ----
# REQUIRED in production. The API refuses to boot on the all-zero placeholder.
# Generate with: openssl rand -hex 32
SECRETS_ENCRYPTION_KEY=CHANGE-ME-run-openssl-rand-hex-32
# ---- Admin bootstrap (upserted idempotently on API boot) ----
ADMIN_EMAIL=marco.frangiskatos@gmail.com
ADMIN_PASSWORD=CHANGE-ME-strong-admin-password
ADMIN_NAME=Marco Frangiskatos
# ---- Anthropic (empty = mock generation; set for real Claude generation) ----
ANTHROPIC_API_KEY=
# ---- Google OAuth ("Continue with Google") ----
# Google Cloud Console -> APIs & Services -> Credentials -> OAuth client (Web).
# Authorized redirect URI must be EXACTLY:
# https://api.buildmymcpserver.com/v1/auth/google/callback
GOOGLE_OAUTH_ID=
GOOGLE_OAUTH_SECRET=
# ---- OAuth signing keys (RS256 JWKS) ----
# Auto-generated on first boot into this dir; persisted in the bmm_keys volume.
OAUTH_KEY_DIR=./keys
# ---- Runner / Generator ----
# Host used in a generated server's public URL (http://RUNNER_HOST:<port>).
# Generated MCP containers bind host ports in RUNNER_PORT_RANGE_* — this range
# is kept clear of every other app already running on the box.
# NOTE: per-server subdomain routing through nginx is not wired yet — a
# generated server is currently reachable at the host port directly. Treat
# public exposure of generated servers as a follow-up before GA. See DEPLOY.md.
RUNNER_HOST=buildmymcpserver.com
RUNNER_PORT_RANGE_START=4400
RUNNER_PORT_RANGE_END=4900
# ---- Observability (optional) ----
SENTRY_DSN=
OTEL_EXPORTER_OTLP_ENDPOINT=

View File

@ -1,36 +0,0 @@
name: Deploy to Production
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: bmm-deploy
cancel-in-progress: false
jobs:
deploy:
runs-on: hetzner
steps:
- name: Pull from Gitea + rebuild containers
run: |
set -eo pipefail
: "${HOME:=/root}"
export HOME
cd /opt/buildmymcpserver
git fetch gitea main
git reset --hard gitea/main
docker compose --env-file .env.production -f docker-compose.prod.yml up -d --build
docker system prune -f
- name: Health check
run: |
set -e
for i in $(seq 1 30); do
code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:4000/health 2>/dev/null || echo 000)
if [ "$code" = "200" ]; then echo "API healthy after $i attempts"; exit 0; fi
echo "wait $i/30 (got $code)"
sleep 5
done
docker logs bmm-api --tail 60 || true
exit 1

236
DEPLOY.md
View File

@ -1,236 +0,0 @@
# Deploying buildmymcpserver.com
End-to-end runbook for the production deploy on the shared Hetzner box.
Steps marked **[you]** require logging into a third-party account (Cloudflare,
GoDaddy, Google) — those must be done by a human. Steps marked **[server]** run
on the box over SSH.
---
## 0. The target box — what is already there
`213.239.213.217` — Debian 12, Docker 29 + Compose v5, 62 GB RAM, 151 GB free.
It is a **shared box running ~8 other production apps** (buildmydiscord,
savesphere, ava, contentra, screencraft, helixmind, prishtina-bot, …).
Verified house pattern — this deploy follows it exactly:
- Each app lives in `/opt/<app>` and runs via `docker compose` on a **bridge
network**, publishing ports to `127.0.0.1`.
- A **host-level nginx** owns `:80` / `:443`. Each app has a vhost in
`/etc/nginx/sites-enabled/` that proxies its domain to its loopback port.
- TLS is terminated by **Cloudflare** (proxied DNS); origins serve plain HTTP.
- **Gitea** runs on the box (`gitea-gitea-1`, web on `127.0.0.1:3020`, SSH on
`:2222`) with an Actions runner labelled `hetzner`. Apps deploy via a
`.gitea/workflows/deploy.yml` that does `git fetch` + `docker compose up`.
**Do not** start anything that binds `:80`/`:443` — the host nginx owns them.
### Ports this deploy uses (all verified free on the box)
| Service | Host bind | Notes |
|-----------|----------------------|----------------------------------------|
| web | `127.0.0.1:4001` | nginx → buildmymcpserver.com |
| api | `127.0.0.1:4000` | nginx → api.buildmymcpserver.com |
| postgres | `127.0.0.1:5440` | loopback only |
| redis | `127.0.0.1:6390` | loopback only |
| generated | `44004900` | MCP runner containers (host ports) |
---
## 1. Cloudflare — create the zone **[you]**
1. Log in to <https://dash.cloudflare.com>.
2. **Add a site**`buildmymcpserver.com`**Free** plan.
3. Cloudflare scans existing DNS. **Write down every record it finds first**
anything not recreated in Cloudflare stops resolving after step 3.
4. Note the **two nameservers** Cloudflare assigns.
### DNS records to create in Cloudflare
| Type | Name | Content | Proxy |
|------|-------|----------------------|--------------|
| A | `@` | `213.239.213.217` | Proxied (🟠) |
| A | `api` | `213.239.213.217` | Proxied (🟠) |
| A | `www` | `213.239.213.217` | Proxied (🟠) |
**SSL/TLS mode:** **Full**. The origin nginx vhost listens on :443 with a
self-signed cert (step 5), so Cloudflare↔origin is encrypted. Never use
**Flexible**. For **Full (strict)**, replace the self-signed cert with a
Cloudflare Origin Certificate.
---
## 2. ⚠️ Order of operations
> **Recreate ALL existing DNS records in Cloudflare (step 1) BEFORE changing the
> nameservers at GoDaddy (step 3).**
Once GoDaddy points at Cloudflare, Cloudflare's zone is authoritative. Anything
not copied into it — MX, TXT, other subdomains — stops resolving. Copy first.
---
## 3. GoDaddy — point the domain at Cloudflare **[you]**
Only after step 1's records exist in Cloudflare:
1. Log in to <https://dcc.godaddy.com>.
2. `buildmymcpserver.com`**Domain Settings → Nameservers → Change**.
3. **Enter my own nameservers (custom)** → the two from Cloudflare.
4. Save. Propagation: minutes, up to 24 h. Cloudflare shows the zone **Active**
when it has taken over.
---
## 4. Deploy the stack **[server]**
The app is installed at `/opt/buildmymcpserver`. To deploy or redeploy by hand:
```bash
cd /opt/buildmymcpserver
# First time only: create the env file and fill every CHANGE-ME value
cp .env.production.example .env.production
openssl rand -hex 32 # -> SECRETS_ENCRYPTION_KEY
nano .env.production
# Build + start (this is exactly what the Gitea pipeline runs)
docker compose --env-file .env.production -f docker-compose.prod.yml up -d --build
# First time only: create the database schema
docker compose --env-file .env.production -f docker-compose.prod.yml \
exec -T api pnpm --filter @bmm/db push
# Status / logs
docker compose --env-file .env.production -f docker-compose.prod.yml ps
docker compose --env-file .env.production -f docker-compose.prod.yml logs -f api
```
`.env.production` essentials:
- `SECRETS_ENCRYPTION_KEY` — real 32-byte hex. The API **refuses to boot** in
production on the all-zero placeholder.
- `DATABASE_URL` password must equal `POSTGRES_PASSWORD`.
- `NEXT_PUBLIC_API_URL` is compiled into the web bundle — after changing it,
rebuild web: `... up -d --build web`.
Health check: `curl http://127.0.0.1:4000/health``{"ok":true,...}`.
---
## 5. nginx vhost + origin cert **[server]**
The vhost serves :80 and :443; the :443 listener needs an origin certificate.
A self-signed cert is enough for Cloudflare **Full** mode:
```bash
mkdir -p /etc/ssl/buildmymcpserver
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
-keyout /etc/ssl/buildmymcpserver/origin.key \
-out /etc/ssl/buildmymcpserver/origin.crt \
-subj "/CN=buildmymcpserver.com"
cp /opt/buildmymcpserver/infra/nginx/buildmymcpserver.conf \
/etc/nginx/sites-available/buildmymcpserver
ln -sf /etc/nginx/sites-available/buildmymcpserver \
/etc/nginx/sites-enabled/buildmymcpserver
nginx -t && systemctl reload nginx
```
`nginx -t` must pass before the reload — a reload of a bad config is rejected,
so the other live sites are never at risk.
---
## 6. Google login — Google Cloud Console **[you]**
1. Log in to <https://console.cloud.google.com>.
2. **Create a project** — e.g. `buildmymcpserver`.
3. **APIs & Services → OAuth consent screen:** External; app name
`BuildMyMCPServer`; scopes `openid`, `userinfo.email`, `userinfo.profile`;
add yourself as a test user or **Publish**.
4. **Credentials → Create credentials → OAuth client ID → Web application.**
**Authorized redirect URI** — exactly:
```
https://api.buildmymcpserver.com/v1/auth/google/callback
```
5. Put the Client ID + secret into `.env.production`:
```
GOOGLE_OAUTH_ID=...apps.googleusercontent.com
GOOGLE_OAUTH_SECRET=...
```
6. Apply: `docker compose --env-file .env.production -f docker-compose.prod.yml up -d api`.
When `GOOGLE_OAUTH_ID`/`SECRET` are set the **Continue with Google** button
appears automatically; when unset it stays hidden and magic-link login is used.
---
## 7. Verify live
- `https://buildmymcpserver.com` — landing page over HTTPS.
- `https://api.buildmymcpserver.com/health``{"ok":true,...}`.
- `/login` — magic link, plus Continue with Google once step 6 is done.
- `/admin/login` — admin via `ADMIN_EMAIL` / `ADMIN_PASSWORD`.
- Wizard → create a server → build reaches `live`.
---
## 8. Gitea pipeline (continuous deploy)
`.gitea/workflows/deploy.yml` is in the repo and mirrors the buildmydiscord
pattern (`runs-on: hetzner`, `git fetch` + `docker compose up -d --build` +
health check). To activate it:
1. Create a repo on the box's Gitea (`https://<gitea-host>`), e.g.
`DancingTedDanson/buildmymcpserver`.
2. On the box, add it as a remote and push:
```bash
cd /opt/buildmymcpserver
git remote add gitea <gitea-ssh-url>
git push gitea main
```
3. From then on, every push to `main` rebuilds and redeploys automatically.
Until then, deploy by hand with the step 4 command — it is byte-identical to
what the pipeline runs.
---
## 9. Operations
```bash
cd /opt/buildmymcpserver
C="docker compose --env-file .env.production -f docker-compose.prod.yml"
$C ps # status
$C logs -f generator # tail a service
$C up -d --build # redeploy after a code change
$C up -d --build web # rebuild only web (e.g. NEXT_PUBLIC_API_URL changed)
$C restart api # restart one service
$C down # stop the stack — named volumes (data) survive
```
**Rollback:** `$C down`, check out the previous commit, redeploy. Volumes
`bmm_pg / bmm_redis / bmm_keys / bmm_build_context` survive `down`. `down -v`
destroys them — never use it.
**Back up the DB before a schema change:**
`$C exec -T postgres pg_dump -U bmm bmm > backup-$(date +%F).sql`
---
## Known follow-ups
1. **Generated-server routing.** Generated MCP servers get a
`http://buildmymcpserver.com:<port>` URL on ports 44004900. Those ports are
not opened on the firewall and not proxied by subdomain — wire
`*.mcp.buildmymcpserver.com` through nginx before exposing generated servers
publicly.
2. **Magic-link email** is printed to the API log, not sent. Wire a real
transport (Resend / SES) before relying on email sign-in.
3. **Cloudflare SSL** — once confirmed working on **Full**, an optional
hardening step is a Cloudflare Origin Certificate + nginx `listen 443 ssl`
for **Full (strict)**.

View File

@ -1,35 +0,0 @@
# syntax=docker/dockerfile:1
# Control plane (Fastify). Runs via tsx — workspace packages are consumed as raw
# TypeScript, so there is no separate compile step (same model as runner-template).
# Build context must be the repo root: docker build -f apps/api/Dockerfile .
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
WORKDIR /app
# ---- deps: install the whole workspace from the lockfile ----
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/api/package.json apps/api/
COPY apps/web/package.json apps/web/
COPY apps/generator/package.json apps/generator/
COPY apps/runner-template/package.json apps/runner-template/
COPY packages/auth/package.json packages/auth/
COPY packages/db/package.json packages/db/
COPY packages/llm/package.json packages/llm/
COPY packages/types/package.json packages/types/
RUN pnpm install --frozen-lockfile
# ---- runtime ----
FROM deps AS runtime
# docker CLI: the API stops/removes generated MCP containers via the host daemon.
RUN apk add --no-cache docker-cli
ENV NODE_ENV=production
COPY . .
WORKDIR /app/apps/api
EXPOSE 4000
# Use 127.0.0.1, not localhost: the server binds IPv4 only, and busybox wget
# resolves localhost to ::1 first — which would refuse and fail the check.
HEALTHCHECK --interval=20s --timeout=4s --start-period=20s --retries=3 \
CMD wget -qO- http://127.0.0.1:4000/health || exit 1
CMD ["pnpm", "start"]

View File

@ -5,7 +5,7 @@
"private": true,
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"start": "node dist/index.js",
"build": "tsc -p tsconfig.json",
"typecheck": "tsc --noEmit"
},

View File

@ -16,8 +16,6 @@ const Env = z.object({
ADMIN_EMAIL: z.string().email().optional(),
ADMIN_PASSWORD: z.string().min(8).optional(),
ADMIN_NAME: z.string().optional(),
GOOGLE_OAUTH_ID: z.string().optional(),
GOOGLE_OAUTH_SECRET: z.string().optional(),
});
export const config = Env.parse({
@ -33,8 +31,6 @@ export const config = Env.parse({
ADMIN_EMAIL: process.env.ADMIN_EMAIL,
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD,
ADMIN_NAME: process.env.ADMIN_NAME,
GOOGLE_OAUTH_ID: process.env.GOOGLE_OAUTH_ID,
GOOGLE_OAUTH_SECRET: process.env.GOOGLE_OAUTH_SECRET,
});
// INFRA-001: refuse to boot in production with the placeholder encryption key.

View File

@ -4,7 +4,6 @@ import cookie from '@fastify/cookie';
import websocket from '@fastify/websocket';
import { seedAdmin } from '@bmm/auth';
import { config } from './config.js';
import { ensureActiveKey } from './lib/crypto.js';
import { authRoutes } from './routes/auth.js';
import { serverRoutes } from './routes/servers.js';
import { oauthRoutes } from './routes/oauth.js';
@ -27,16 +26,6 @@ await app.register(websocket, { options: { maxPayload: 1024 * 1024 } });
app.get('/health', async () => ({ ok: true, ts: Date.now() }));
// Fail-closed: initialize envelope encryption before serving any request that
// could write a secret. If the encryption subsystem can't come up, don't run.
try {
await ensureActiveKey();
app.log.info('[crypto] envelope encryption ready');
} catch (err) {
app.log.error({ err }, '[crypto] failed to initialize encryption — refusing to start');
process.exit(1);
}
await app.register(authRoutes);
await app.register(serverRoutes);
await app.register(oauthRoutes);

View File

@ -1,195 +1,30 @@
import crypto from 'node:crypto';
import { count, createDb, desc, eq, encryptionKeys, secrets, sql } from '@bmm/db';
import { config } from '../config.js';
const ALGO = 'aes-256-gcm';
const db = createDb();
/**
* Envelope encryption.
*
* KEK Key Encryption Key. 32 bytes from env (SECRETS_ENCRYPTION_KEY).
* Never stored in the database. The root of trust.
* DEK Data Encryption Key. 32 random bytes, generated by us, stored in
* the encryption_keys table *wrapped* (AES-256-GCM encrypted) with
* the KEK. Secrets are encrypted with the DEK.
*
* Rotation mints a fresh DEK and re-encrypts every secret under it, so a
* suspected DEK compromise is recoverable without ever touching the KEK.
* Legacy secrets (keyId = null) were encrypted directly with the KEK before
* envelope encryption existed; decryptSecret handles them, and the first
* rotation migrates them onto a DEK.
*/
function getKEK(): Buffer {
const buf = Buffer.from(config.SECRETS_ENCRYPTION_KEY, 'hex');
function getKey(): Buffer {
const hex = config.SECRETS_ENCRYPTION_KEY;
const buf = Buffer.from(hex, 'hex');
if (buf.length !== 32) {
throw new Error('SECRETS_ENCRYPTION_KEY must be 32 bytes (64 hex chars)');
}
return buf;
}
// Low-level AES-256-GCM with an explicit key. Payload: iv.tag.ciphertext (base64).
function aesEncrypt(key: Buffer, plaintext: string): string {
export function encryptSecret(plaintext: string): string {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv(ALGO, key, iv);
const cipher = crypto.createCipheriv(ALGO, getKey(), iv);
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return `${iv.toString('base64')}.${tag.toString('base64')}.${enc.toString('base64')}`;
}
function aesDecrypt(key: Buffer, payload: string): string {
export function decryptSecret(payload: string): string {
const [ivB64, tagB64, encB64] = payload.split('.');
if (!ivB64 || !tagB64 || !encB64) throw new Error('malformed_ciphertext');
const decipher = crypto.createDecipheriv(ALGO, key, Buffer.from(ivB64, 'base64'));
if (!ivB64 || !tagB64 || !encB64) throw new Error('malformed_secret_payload');
const decipher = crypto.createDecipheriv(ALGO, getKey(), Buffer.from(ivB64, 'base64'));
decipher.setAuthTag(Buffer.from(tagB64, 'base64'));
return Buffer.concat([decipher.update(Buffer.from(encB64, 'base64')), decipher.final()]).toString(
'utf8',
);
}
// In-memory DEK cache: encryption_keys.id -> raw 32-byte DEK.
const dekCache = new Map<string, Buffer>();
let activeKeyId: string | null = null;
async function loadKeys(): Promise<void> {
const rows = await db.select().from(encryptionKeys);
const kek = getKEK();
dekCache.clear();
activeKeyId = null;
for (const row of rows) {
// wrappedDek decrypts to the base64 of the 32-byte DEK
const dek = Buffer.from(aesDecrypt(kek, row.wrappedDek), 'base64');
if (dek.length !== 32) throw new Error(`corrupt DEK for key version ${row.version}`);
dekCache.set(row.id, dek);
if (row.active) activeKeyId = row.id;
}
}
/**
* Boot-time: load existing keys and, if there is no active key yet, create
* version 1. Must run before any encryptSecret call. Fail-closed: if this
* throws, the API must not start.
*/
export async function ensureActiveKey(): Promise<void> {
await loadKeys();
if (activeKeyId) return;
const dek = crypto.randomBytes(32);
const wrapped = aesEncrypt(getKEK(), dek.toString('base64'));
const [row] = await db
.insert(encryptionKeys)
.values({ version: 1, wrappedDek: wrapped, active: true })
.returning();
if (!row) throw new Error('failed to create initial encryption key');
dekCache.set(row.id, dek);
activeKeyId = row.id;
}
export interface EncryptResult {
value: string;
keyId: string;
}
export function encryptSecret(plaintext: string): EncryptResult {
if (!activeKeyId) {
throw new Error('encryption not initialized — ensureActiveKey() must run at boot');
}
const dek = dekCache.get(activeKeyId);
if (!dek) throw new Error('active DEK missing from cache');
return { value: aesEncrypt(dek, plaintext), keyId: activeKeyId };
}
export function decryptSecret(value: string, keyId: string | null): string {
if (!keyId) {
// Legacy: encrypted directly with the KEK before envelope encryption.
return aesDecrypt(getKEK(), value);
}
const dek = dekCache.get(keyId);
if (!dek) throw new Error(`unknown encryption key id: ${keyId}`);
return aesDecrypt(dek, value);
}
export interface RotateResult {
newVersion: number;
reEncrypted: number;
}
/**
* Mint a fresh DEK and re-encrypt every secret under it in a single
* transaction. Legacy (KEK-direct) secrets are migrated in the same pass.
*/
export async function rotateKeys(rotatedBy: string): Promise<RotateResult> {
await loadKeys();
const newDek = crypto.randomBytes(32);
const wrapped = aesEncrypt(getKEK(), newDek.toString('base64'));
const [{ maxV } = { maxV: 0 }] = await db
.select({ maxV: sql<number>`coalesce(max(${encryptionKeys.version}), 0)` })
.from(encryptionKeys);
const nextVersion = Number(maxV) + 1;
const reEncrypted = await db.transaction(async (tx) => {
const [newKey] = await tx
.insert(encryptionKeys)
.values({ version: nextVersion, wrappedDek: wrapped, active: false, rotatedBy })
.returning();
if (!newKey) throw new Error('failed to insert new encryption key');
const all = await tx.select().from(secrets);
for (const s of all) {
const plain = decryptSecret(s.encryptedValue, s.keyId);
await tx
.update(secrets)
.set({ encryptedValue: aesEncrypt(newDek, plain), keyId: newKey.id })
.where(eq(secrets.id, s.id));
}
await tx
.update(encryptionKeys)
.set({ active: false, retiredAt: new Date() })
.where(eq(encryptionKeys.active, true));
await tx.update(encryptionKeys).set({ active: true }).where(eq(encryptionKeys.id, newKey.id));
return { count: all.length, keyId: newKey.id };
});
// Commit succeeded — update the in-memory cache.
dekCache.set(reEncrypted.keyId, newDek);
activeKeyId = reEncrypted.keyId;
return { newVersion: nextVersion, reEncrypted: reEncrypted.count };
}
export interface EncryptionStatus {
activeVersion: number | null;
keyCount: number;
secretCount: number;
legacySecretCount: number;
keys: {
version: number;
active: boolean;
createdAt: Date;
retiredAt: Date | null;
}[];
}
export async function encryptionStatus(): Promise<EncryptionStatus> {
const keys = await db.select().from(encryptionKeys).orderBy(desc(encryptionKeys.version));
const [{ c: secretCount } = { c: 0 }] = await db.select({ c: count() }).from(secrets);
const [{ c: legacy } = { c: 0 }] = await db
.select({ c: count() })
.from(secrets)
.where(sql`${secrets.keyId} is null`);
return {
activeVersion: keys.find((k) => k.active)?.version ?? null,
keyCount: keys.length,
secretCount: Number(secretCount),
legacySecretCount: Number(legacy),
keys: keys.map((k) => ({
version: k.version,
active: k.active,
createdAt: k.createdAt,
retiredAt: k.retiredAt,
})),
};
const dec = Buffer.concat([decipher.update(Buffer.from(encB64, 'base64')), decipher.final()]);
return dec.toString('utf8');
}

View File

@ -24,7 +24,6 @@ import { requireAdmin } from '../plugins/session.js';
import { getRedis } from '../lib/redis.js';
import { getBuildQueue } from '../lib/queue.js';
import { audit } from '../lib/audit.js';
import { encryptionStatus, rotateKeys } from '../lib/crypto.js';
const db = createDb();
@ -566,28 +565,5 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
return reply.send({ ok: true });
});
// ---- Encryption: status + key rotation ----
app.get('/v1/admin/encryption', { preHandler: requireAdmin }, async (_req, reply) => {
return reply.send(await encryptionStatus());
});
app.post('/v1/admin/encryption/rotate', { preHandler: requireAdmin }, async (req, reply) => {
try {
const result = await rotateKeys(req.user!.userId);
await audit({
orgId: req.user!.orgId,
userId: req.user!.userId,
action: 'admin.encryption.rotate',
resourceType: 'encryption_key',
metadata: { newVersion: result.newVersion, reEncrypted: result.reEncrypted },
ipAddress: req.ip,
});
return reply.send({ ok: true, ...result });
} catch (err) {
app.log.error({ err }, 'encryption key rotation failed');
return reply.code(500).send({ error: 'rotation_failed', detail: (err as Error).message });
}
});
void inArray; // referenced for future bulk operations
}

View File

@ -1,50 +1,16 @@
import crypto from 'node:crypto';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
consumeMagicLink,
destroySession,
getSession,
issueMagicLink,
loginWithPassword,
upsertOAuthLogin,
} from '@bmm/auth';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { config } from '../config.js';
import { audit } from '../lib/audit.js';
import { config } from '../config.js';
const SESSION_COOKIE = 'bmm_session';
const OAUTH_STATE_COOKIE = 'bmm_oauth_state';
const GoogleClaims = z.object({
iss: z.string(),
aud: z.string(),
exp: z.number(),
email: z.string().email(),
email_verified: z.union([z.boolean(), z.string()]).optional(),
name: z.string().optional(),
});
/**
* Decode (NOT signature-verify) a Google ID token payload. Signature verification
* is unnecessary here because the token is fetched directly from Google's token
* endpoint over TLS, authenticated with our client secret an intermediary-free
* channel, per Google's own guidance. We still validate iss / aud / exp / email
* below as defense-in-depth.
*/
function decodeGoogleIdToken(idToken: string): z.infer<typeof GoogleClaims> {
const parts = idToken.split('.');
if (parts.length !== 3 || !parts[1]) throw new Error('malformed_id_token');
const json = Buffer.from(parts[1], 'base64url').toString('utf8');
return GoogleClaims.parse(JSON.parse(json));
}
function googleRedirectUri(): string {
return `${config.CONTROL_PLANE_PUBLIC_URL}/v1/auth/google/callback`;
}
function googleConfigured(): boolean {
return Boolean(config.GOOGLE_OAUTH_ID && config.GOOGLE_OAUTH_SECRET);
}
export async function authRoutes(app: FastifyInstance): Promise<void> {
app.post('/v1/auth/magic-link', async (req, reply) => {
@ -166,114 +132,4 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
}
return reply.send({ ok: true });
});
// 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() });
});
// Step 1: hand the browser off to Google's consent screen.
app.get('/v1/auth/google', async (_req, reply) => {
if (!config.GOOGLE_OAUTH_ID || !config.GOOGLE_OAUTH_SECRET) {
return reply.code(503).send({ error: 'google_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://accounts.google.com/o/oauth2/v2/auth');
url.searchParams.set('client_id', config.GOOGLE_OAUTH_ID);
url.searchParams.set('redirect_uri', googleRedirectUri());
url.searchParams.set('response_type', 'code');
url.searchParams.set('scope', 'openid email profile');
url.searchParams.set('state', state);
url.searchParams.set('access_type', 'online');
url.searchParams.set('prompt', 'select_account');
return reply.redirect(url.toString());
});
// Step 2: Google redirects back here with an auth code. Exchange it, verify
// the ID token, mint a session, drop the user on the dashboard.
app.get('/v1/auth/google/callback', async (req, reply) => {
const loginUrl = `${config.NEXT_PUBLIC_APP_URL}/login`;
const Query = z.object({
code: z.string().min(10).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=google_failed`);
}
// CSRF: the state echoed back by Google must match the one we set.
// Length-check first — timingSafeEqual throws on a length mismatch.
if (
!cookieState ||
cookieState.length !== q.data.state.length ||
!crypto.timingSafeEqual(Buffer.from(cookieState), Buffer.from(q.data.state))
) {
return reply.redirect(`${loginUrl}?error=google_state`);
}
if (!config.GOOGLE_OAUTH_ID || !config.GOOGLE_OAUTH_SECRET) {
return reply.redirect(`${loginUrl}?error=google_failed`);
}
try {
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code: q.data.code,
client_id: config.GOOGLE_OAUTH_ID,
client_secret: config.GOOGLE_OAUTH_SECRET,
redirect_uri: googleRedirectUri(),
grant_type: 'authorization_code',
}),
});
if (!tokenRes.ok) throw new Error(`token_exchange_${tokenRes.status}`);
const tokens = (await tokenRes.json()) as { id_token?: string };
if (!tokens.id_token) throw new Error('no_id_token');
const claims = decodeGoogleIdToken(tokens.id_token);
if (claims.iss !== 'accounts.google.com' && claims.iss !== 'https://accounts.google.com') {
throw new Error('bad_iss');
}
if (claims.aud !== config.GOOGLE_OAUTH_ID) throw new Error('bad_aud');
if (claims.exp * 1000 < Date.now()) throw new Error('token_expired');
const verified = claims.email_verified === true || claims.email_verified === 'true';
if (!verified) throw new Error('email_unverified');
const session = await upsertOAuthLogin(
{ email: claims.email, name: claims.name ?? 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: 'google' },
ipAddress: req.ip,
});
return reply.redirect(`${config.NEXT_PUBLIC_APP_URL}/dashboard`);
} catch (err) {
app.log.warn({ err }, 'google oauth callback failed');
return reply.redirect(`${loginUrl}?error=google_failed`);
}
});
}

View File

@ -164,12 +164,10 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
for (const [key, value] of Object.entries(secretValues)) {
if (!value) continue;
const enc = encryptSecret(value);
await db.insert(secrets).values({
serverId: server.id,
key,
encryptedValue: enc.value,
keyId: enc.keyId,
encryptedValue: encryptSecret(value),
});
}

View File

@ -1,34 +0,0 @@
# syntax=docker/dockerfile:1
# Generator worker (BullMQ). Renders generated MCP servers, builds their Docker
# images and runs them as sibling containers on the host daemon.
# Build context must be the repo root: docker build -f apps/generator/Dockerfile .
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
WORKDIR /app
# ---- deps ----
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/api/package.json apps/api/
COPY apps/web/package.json apps/web/
COPY apps/generator/package.json apps/generator/
COPY apps/runner-template/package.json apps/runner-template/
COPY packages/auth/package.json packages/auth/
COPY packages/db/package.json packages/db/
COPY packages/llm/package.json packages/llm/
COPY packages/types/package.json packages/types/
RUN pnpm install --frozen-lockfile
# ---- runtime ----
FROM deps AS runtime
# docker CLI: the worker shells out to `docker build` / `docker run` against the
# host daemon (socket mounted in compose). apps/runner-template is copied below
# and used as the build context template for every generated server.
RUN apk add --no-cache docker-cli
ENV NODE_ENV=production
COPY . .
# build-context is a mounted volume at runtime; create the dir so the path exists.
RUN mkdir -p /app/build-context
WORKDIR /app/apps/generator
CMD ["pnpm", "start"]

View File

@ -5,7 +5,7 @@
"private": true,
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"start": "node dist/index.js",
"build": "tsc -p tsconfig.json",
"typecheck": "tsc --noEmit"
},

View File

@ -104,25 +104,12 @@ export async function deployContainer(input: DeployInput): Promise<DeployHandle>
});
}
export async function stopContainer(
containerId: string,
): Promise<{ ok: boolean; detail: string }> {
if (!containerId || containerId.length < 4) {
return { ok: false, detail: 'invalid_container_id' };
}
export async function stopContainer(containerId: string): Promise<void> {
const { spawn } = await import('node:child_process');
return await new Promise<{ ok: boolean; detail: string }>((resolve) => {
const child = spawn('docker', ['rm', '-f', containerId], {
stdio: ['ignore', 'pipe', 'pipe'],
});
let err = '';
child.stderr?.on('data', (d: Buffer) => {
err += d.toString();
});
child.on('error', () => resolve({ ok: false, detail: 'spawn_failed' }));
child.on('close', (code) =>
resolve(code === 0 ? { ok: true, detail: '' } : { ok: false, detail: err.trim() || `exit ${code}` }),
);
await new Promise<void>((resolve) => {
const child = spawn('docker', ['rm', '-f', containerId], { stdio: 'ignore' });
child.on('close', () => resolve());
child.on('error', () => resolve());
});
}

View File

@ -6,7 +6,7 @@ import { config } from './config.js';
import { generateSpec } from './lib/claude.js';
import { renderServerCode } from './lib/render.js';
import { dockerBuild, prepareBuildContext, staticCheck } from './lib/build.js';
import { allocatePort, deployContainer, dockerAvailable, stopContainer } from './lib/deploy.js';
import { allocatePort, deployContainer, dockerAvailable } from './lib/deploy.js';
import { emitDone, emitError, emitLog, emitStatus } from './lib/emit.js';
const db = createDb();
@ -46,16 +46,6 @@ export const worker = new Worker<JobData>(
const { buildId, serverId, prompt, version, slug, secrets, previewId } = job.data;
const log = (level: 'info' | 'warn' | 'error', msg: string) => emitLog(buildId, level, msg);
// Capture the container currently serving this server (if any) BEFORE the
// build mutates the row. On an iterate (version > 1) we deploy the new
// container, then tear this old one down — rolling-deploy, no orphan.
const [priorState] = await db
.select({ containerId: mcpServers.containerId })
.from(mcpServers)
.where(eq(mcpServers.id, serverId))
.limit(1);
const oldContainerId = priorState?.containerId ?? null;
try {
await db.update(builds).set({ status: 'generating', startedAt: new Date() }).where(eq(builds.id, buildId));
await db.update(mcpServers).set({ status: 'generating', updatedAt: new Date() }).where(eq(mcpServers.id, serverId));
@ -151,18 +141,6 @@ export const worker = new Worker<JobData>(
.set({ status: 'live', currentVersion: version, publicUrl: handle.publicUrl, updatedAt: new Date() })
.where(eq(mcpServers.id, serverId));
// Rolling deploy: the new container is live — now retire the previous one.
// Without this every iterate would leave an orphan holding a host port.
if (oldContainerId && oldContainerId !== handle.containerId) {
const stopped = await stopContainer(oldContainerId);
await log(
stopped.ok ? 'info' : 'warn',
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 emitDone(buildId, 'success', serverId, handle.publicUrl);
} catch (err) {

View File

@ -10,8 +10,6 @@ COPY --from=deps /app/node_modules ./node_modules
COPY package.json tsconfig.json ./
COPY src ./src
EXPOSE 3000
# 127.0.0.1, not localhost: busybox wget resolves localhost to ::1 first and
# the server binds IPv4 only, so a localhost check would wrongly fail.
HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/health || exit 1
CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["npx", "tsx", "src/server.ts"]

View File

@ -1,37 +0,0 @@
# syntax=docker/dockerfile:1
# Web app (Next.js 15). NEXT_PUBLIC_API_URL is inlined into the client bundle at
# BUILD time — it must be passed as a build arg, not just a runtime env var.
# Build context must be the repo root: docker build -f apps/web/Dockerfile .
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
WORKDIR /app
# ---- deps ----
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/api/package.json apps/api/
COPY apps/web/package.json apps/web/
COPY apps/generator/package.json apps/generator/
COPY apps/runner-template/package.json apps/runner-template/
COPY packages/auth/package.json packages/auth/
COPY packages/db/package.json packages/db/
COPY packages/llm/package.json packages/llm/
COPY packages/types/package.json packages/types/
RUN pnpm install --frozen-lockfile
# ---- build ----
FROM deps AS build
ARG NEXT_PUBLIC_API_URL=http://localhost:4000
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_TELEMETRY_DISABLED=1
COPY . .
RUN pnpm --filter @bmm/web build
# ---- runtime ----
FROM build AS runtime
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
WORKDIR /app/apps/web
EXPOSE 3001
CMD ["pnpm", "start"]

View File

@ -1,6 +1,6 @@
'use client';
import { Suspense, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { apiFetch } from '@/lib/api';
import { Button } from '@/components/ui/button';
@ -82,7 +82,7 @@ function specToEditable(spec: PreviewResponse['spec']): EditableSpec {
};
}
function NewServerPageInner() {
export default function NewServerPage() {
const router = useRouter();
const [step, setStep] = useState<Step>('prompt');
@ -708,26 +708,6 @@ function NewServerPageInner() {
);
}
// useSearchParams() forces client-side rendering — Next requires a Suspense
// boundary around it, or `next build` bails out of static generation.
export default function NewServerPage() {
return (
<Suspense
fallback={
<div className="mx-auto max-w-3xl px-6 py-8">
<h1 className="text-[22px] font-semibold tracking-tight">New MCP server</h1>
<div className="panel mt-10 p-8 text-center">
<Loader2 className="mx-auto animate-spin text-[--color-fg-muted]" size={20} />
<p className="mt-4 text-[13px] text-[--color-fg-muted]">Loading</p>
</div>
</div>
}
>
<NewServerPageInner />
</Suspense>
);
}
const SHARE_CATEGORIES = [
'productivity',
'developer-tools',

View File

@ -30,21 +30,6 @@ const EXAMPLES: { title: string; desc: string }[] = [
{ title: 'Custom REST', desc: 'Wrap any HTTP API behind one prompt-defined tool surface.' },
];
const MARKETPLACE_POINTS: { t: string; d: string }[] = [
{
t: 'Fork and own',
d: 'Start from a server someone already shipped. Fork it, paste your own credentials, deploy — no prompt required.',
},
{
t: 'Secrets never travel',
d: "A template carries the spec and generated code, never the author's API keys. You add your own on fork.",
},
{
t: 'Ranked by real usage',
d: 'Templates rise on fork count and active deploys — not vanity stars. The useful ones surface themselves.',
},
];
const FAQ: { q: string; a: string }[] = [
{
q: 'What is MCP?',
@ -227,37 +212,6 @@ export default function Landing() {
</div>
</section>
{/* Marketplace */}
<section className="border-b border-[--color-border] py-20">
<div className="mx-auto max-w-6xl px-6">
<div className="mb-10 flex flex-wrap items-end justify-between gap-4">
<div className="max-w-2xl">
<h2 className="text-[28px] font-semibold tracking-tight">
Start from a template, ship in seconds
</h2>
<p className="mt-2 text-[14px] text-[--color-fg-muted]">
The marketplace is a library of working MCP servers the community already built.
Fork one to skip the prompt or publish your own and let others build on it.
</p>
</div>
<Link
href="/templates"
className="inline-flex h-9 shrink-0 items-center justify-center rounded-md border border-[--color-border] bg-[--color-bg-elevated] px-4 text-[13px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg]"
>
Browse the marketplace
</Link>
</div>
<div className="grid gap-3 md:grid-cols-3">
{MARKETPLACE_POINTS.map((p) => (
<div key={p.t} className="panel p-5">
<h3 className="text-[15px] font-semibold tracking-tight">{p.t}</h3>
<p className="mt-2 text-[13px] leading-relaxed text-[--color-fg-muted]">{p.d}</p>
</div>
))}
</div>
</div>
</section>
{/* Pricing */}
<section id="pricing" className="border-b border-[--color-border] py-20">
<div className="mx-auto max-w-6xl px-6">

View File

@ -1,178 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { apiFetch } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/cn';
interface KeyRow {
version: number;
active: boolean;
createdAt: string;
retiredAt: string | null;
}
interface Status {
activeVersion: number | null;
keyCount: number;
secretCount: number;
legacySecretCount: number;
keys: KeyRow[];
}
export default function AdminEncryptionPage() {
const [status, setStatus] = useState<Status | null>(null);
const [rotating, setRotating] = useState(false);
const [message, setMessage] = useState<string | null>(null);
async function reload() {
setStatus(await apiFetch<Status>('/v1/admin/encryption'));
}
useEffect(() => {
reload();
}, []);
async function rotate() {
if (
!confirm(
'Rotate the encryption key?\n\nA fresh Data Encryption Key is generated and EVERY stored secret is re-encrypted under it in one transaction. The environment KEK is untouched. This is safe to run any time you suspect key compromise.',
)
) {
return;
}
setRotating(true);
setMessage(null);
try {
const r = await apiFetch<{ newVersion: number; reEncrypted: number }>(
'/v1/admin/encryption/rotate',
{ method: 'POST', body: '{}' },
);
setMessage(`Rotated to key v${r.newVersion}${r.reEncrypted} secret(s) re-encrypted.`);
await reload();
} catch (e) {
const detail = (e as { detail?: { detail?: string; error?: string } }).detail;
setMessage(`Rotation failed: ${detail?.detail ?? detail?.error ?? (e as Error).message}`);
} finally {
setRotating(false);
}
}
return (
<div className="px-8 py-8">
<header className="mb-6">
<h1 className="text-[22px] font-semibold tracking-tight">Encryption</h1>
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
Envelope encryption for customer secrets. The KEK lives only in the environment;
Data Encryption Keys are stored wrapped and rotated here.
</p>
</header>
{!status && <div className="mono text-[12px] text-[--color-fg-muted]">Loading</div>}
{status && (
<>
<div className="grid gap-3 md:grid-cols-3">
<Card label="Active key" value={status.activeVersion ? `v${status.activeVersion}` : '—'} />
<Card label="Secrets encrypted" value={status.secretCount.toLocaleString()} />
<Card
label="Legacy (pre-envelope)"
value={status.legacySecretCount.toLocaleString()}
sub={
status.legacySecretCount > 0
? 'rotate once to migrate them onto a DEK'
: 'all on a managed DEK'
}
/>
</div>
<div className="panel mt-6 p-4">
<div className="flex items-baseline justify-between">
<div>
<h2 className="text-[14px] font-semibold tracking-tight">Rotate encryption key</h2>
<p className="mt-1 text-[12.5px] leading-relaxed text-[--color-fg-muted]">
Generates a new DEK and re-encrypts all {status.secretCount} secret(s) under it
atomically. The environment KEK is never exposed or changed.
</p>
</div>
<Button variant="primary" size="md" onClick={rotate} disabled={rotating}>
{rotating ? 'Rotating…' : 'Rotate key'}
</Button>
</div>
{message && (
<p
className={cn(
'mt-3 text-[12.5px]',
message.startsWith('Rotation failed')
? 'text-[--color-danger]'
: 'text-emerald-300',
)}
>
{message}
</p>
)}
</div>
<div className="mt-6">
<h2 className="text-[14px] font-semibold tracking-tight">Key history</h2>
<div className="panel mt-3">
<table className="w-full text-[12.5px]">
<thead className="border-b border-[--color-border] text-[--color-fg-subtle]">
<tr>
<th className="px-4 py-2 text-left font-medium">Version</th>
<th className="px-4 py-2 text-left font-medium">Status</th>
<th className="px-4 py-2 text-left font-medium">Created</th>
<th className="px-4 py-2 text-left font-medium">Retired</th>
</tr>
</thead>
<tbody>
{status.keys.map((k) => (
<tr key={k.version} className="border-b border-[--color-border] last:border-0">
<td className="px-4 py-2.5 mono">v{k.version}</td>
<td className="px-4 py-2.5">
<span
className={cn(
'mono rounded-full border px-2 py-0.5 text-[11px]',
k.active
? 'border-emerald-400/40 bg-emerald-400/10 text-emerald-300'
: 'border-[--color-border] bg-[--color-bg-subtle] text-[--color-fg-subtle]',
)}
>
{k.active ? 'active' : 'retired'}
</span>
</td>
<td className="px-4 py-2.5 mono text-[--color-fg-muted]">
{new Date(k.createdAt).toLocaleString()}
</td>
<td className="px-4 py-2.5 mono text-[--color-fg-muted]">
{k.retiredAt ? new Date(k.retiredAt).toLocaleString() : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<p className="mt-6 text-[11.5px] leading-relaxed text-[--color-fg-subtle]">
How it works: a 32-byte Data Encryption Key (DEK) is generated, AES-256-GCM encrypted
with the environment Key Encryption Key (KEK = SECRETS_ENCRYPTION_KEY), and stored
wrapped. Secrets are encrypted with the DEK. Rotation mints a fresh DEK, re-encrypts
every secret, and retires the old one recoverable from a suspected DEK compromise
without ever touching the KEK.
</p>
</>
)}
</div>
);
}
function Card({ label, value, sub }: { label: string; value: string; sub?: string }) {
return (
<div className="panel p-4">
<div className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">{label}</div>
<div className="mt-1.5 text-[24px] font-semibold tabular-nums tracking-tight">{value}</div>
{sub && <div className="mt-1 text-[12px] text-[--color-fg-muted]">{sub}</div>}
</div>
);
}

View File

@ -15,7 +15,6 @@ import {
LogOut,
ShieldAlert,
Package,
KeyRound,
} from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/cn';
@ -36,7 +35,6 @@ const NAV: { href: string; label: string; icon: React.ComponentType<{ size?: num
{ href: '/admin/builds', label: 'Builds', icon: Hammer },
{ href: '/admin/audit', label: 'Audit log', icon: FileClock },
{ href: '/admin/system', label: 'System health', icon: Activity },
{ href: '/admin/encryption', label: 'Encryption', icon: KeyRound },
{ href: '/admin/prompt', label: 'AI prompt', icon: Wand2 },
];

View File

@ -1,11 +1,11 @@
'use client';
import { Suspense, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Logo } from '@/components/logo';
import { apiFetch } from '@/lib/api';
function CallbackInner() {
export default function CallbackPage() {
const router = useRouter();
const params = useSearchParams();
const token = params.get('token');
@ -53,19 +53,3 @@ function CallbackInner() {
</div>
);
}
// useSearchParams() requires a Suspense boundary or `next build` cannot
// statically render this route.
export default function CallbackPage() {
return (
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center px-6">
<p className="text-[13px] text-[--color-fg-muted]">Verifying your magic link</p>
</div>
}
>
<CallbackInner />
</Suspense>
);
}

View File

@ -1,32 +1,16 @@
'use client';
import { Input, Label } from '@/components/input';
import Link from 'next/link';
import { useState } from 'react';
import { Logo } from '@/components/logo';
import { Button } from '@/components/ui/button';
import { apiFetch, apiUrl } from '@/lib/api';
import Link from 'next/link';
import { useEffect, useState } from 'react';
const ERROR_COPY: Record<string, string> = {
google_failed: 'Google sign-in could not be completed. Please try again.',
google_state: 'Google sign-in expired or was interrupted. Please try again.',
};
import { Input, Label } from '@/components/input';
import { apiFetch } from '@/lib/api';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [state, setState] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
const [error, setError] = useState<string | null>(null);
const [googleEnabled, setGoogleEnabled] = useState(false);
useEffect(() => {
apiFetch<{ google: boolean }>('/v1/auth/providers')
.then((r) => setGoogleEnabled(r.google))
.catch(() => setGoogleEnabled(false));
const params = new URLSearchParams(window.location.search);
const err = params.get('error');
if (err && ERROR_COPY[err]) setError(ERROR_COPY[err]);
}, []);
async function submit(e: React.FormEvent) {
e.preventDefault();
@ -50,54 +34,34 @@ export default function LoginPage() {
<Logo className="mb-10" />
<h1 className="text-[20px] font-semibold tracking-tight">Sign in to your workspace</h1>
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
Continue with Google, or get a magic link by email.
We&apos;ll email you a magic link. No password.
</p>
{state !== 'sent' ? (
<>
{googleEnabled && (
<>
<a
href={apiUrl('/v1/auth/google')}
className="mt-7 flex h-10 w-full items-center justify-center gap-2.5 rounded-md border border-[--color-border] bg-[--color-bg-elevated] text-[13px] font-medium text-[--color-fg] transition-colors duration-200 hover:border-[--color-border-strong]"
>
<GoogleIcon />
Continue with Google
</a>
<div className="my-5 flex items-center gap-3">
<span className="h-px flex-1 bg-[--color-border]" />
<span className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">
or
</span>
<span className="h-px flex-1 bg-[--color-border]" />
</div>
</>
)}
<form onSubmit={submit} className={googleEnabled ? 'space-y-3' : 'mt-7 space-y-3'}>
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
required
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@company.com"
/>
</div>
<Button
type="submit"
variant="primary"
size="lg"
className="w-full"
disabled={state === 'sending'}
>
{state === 'sending' ? 'Sending…' : 'Send magic link'}
</Button>
{error && <p className="text-[12px] text-[--color-danger]">{error}</p>}
</form>
</>
<form onSubmit={submit} className="mt-7 space-y-3">
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
required
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@company.com"
/>
</div>
<Button
type="submit"
variant="primary"
size="lg"
className="w-full"
disabled={state === 'sending'}
>
{state === 'sending' ? 'Sending…' : 'Send magic link'}
</Button>
{error && <p className="text-[12px] text-[--color-danger]">{error}</p>}
</form>
) : (
<div className="panel mt-7 p-4">
<p className="text-[13px]">
@ -118,26 +82,3 @@ export default function LoginPage() {
</div>
);
}
function GoogleIcon() {
return (
<svg width="16" height="16" viewBox="0 0 18 18" aria-hidden="true">
<path
fill="#4285F4"
d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844a4.14 4.14 0 0 1-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615Z"
/>
<path
fill="#34A853"
d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18Z"
/>
<path
fill="#FBBC05"
d="M3.964 10.706A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.706V4.962H.957A8.997 8.997 0 0 0 0 9c0 1.452.348 2.827.957 4.038l3.007-2.332Z"
/>
<path
fill="#EA4335"
d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.962L3.964 7.294C4.672 5.167 6.656 3.58 9 3.58Z"
/>
</svg>
);
}

View File

@ -1,6 +1,9 @@
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000';
export async function apiFetch<T = unknown>(path: string, init: RequestInit = {}): Promise<T> {
export async function apiFetch<T = unknown>(
path: string,
init: RequestInit = {},
): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
credentials: 'include',
cache: 'no-store',
@ -23,11 +26,6 @@ export async function apiFetch<T = unknown>(path: string, init: RequestInit = {}
return (await res.json()) as T;
}
/** Absolute API URL for a path — use for full-page navigations (OAuth redirects). */
export function apiUrl(path: string): string {
return `${API_BASE}${path}`;
}
export function apiWebSocketURL(path: string): string {
const httpBase = API_BASE;
const wsBase = httpBase.replace(/^http/, 'ws');

View File

@ -1,121 +0,0 @@
# Production stack for buildmymcpserver.com — Linux host only.
#
# Run with:
# docker compose --env-file .env.production -f docker-compose.prod.yml up -d --build
#
# Topology — matches the house pattern on the shared Hetzner box:
# - Bridge networking + per-app network, like every other app on the box.
# - api / web / postgres / redis publish to 127.0.0.1 only. The host nginx
# reverse-proxies the public domains to these loopback ports. Nothing here
# binds 0.0.0.0:80/443 — the box's existing nginx owns those.
# - generator uses host networking: it has no listening port of its own (no
# collision risk) and it must allocate + probe host ports for the MCP
# containers it spawns, which is only correct in the host namespace.
# - api + generator mount the Docker socket: the API removes generated
# containers, the generator builds + runs them as host siblings.
#
# Ports are picked to not collide with the other apps already on this box.
name: buildmymcpserver
services:
postgres:
image: postgres:16-alpine
container_name: bmm-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-bmm}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env.production}
POSTGRES_DB: ${POSTGRES_DB:-bmm}
ports:
- "127.0.0.1:${POSTGRES_PORT:-5440}:5432"
volumes:
- bmm_pg:/var/lib/postgresql/data
networks: [bmm-network]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-bmm} -d ${POSTGRES_DB:-bmm}"]
interval: 5s
timeout: 5s
retries: 20
redis:
image: redis:7-alpine
container_name: bmm-redis
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
ports:
- "127.0.0.1:${REDIS_PORT:-6390}:6379"
volumes:
- bmm_redis:/data
networks: [bmm-network]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 20
api:
build:
context: .
dockerfile: apps/api/Dockerfile
container_name: bmm-api
restart: unless-stopped
env_file: .env.production
ports:
- "127.0.0.1:${API_PORT:-4000}:4000"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- bmm_keys:/app/apps/api/keys
networks: [bmm-network]
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
web:
build:
context: .
dockerfile: apps/web/Dockerfile
args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:?set NEXT_PUBLIC_API_URL in .env.production}
container_name: bmm-web
restart: unless-stopped
env_file: .env.production
ports:
- "127.0.0.1:${WEB_PORT:-4001}:3001"
networks: [bmm-network]
depends_on:
- api
generator:
build:
context: .
dockerfile: apps/generator/Dockerfile
container_name: bmm-generator
restart: unless-stopped
network_mode: host
env_file: .env.production
environment:
# Host networking — reach the DBs via their published loopback ports
# instead of the compose-network service names.
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}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- bmm_build_context:/app/build-context
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
bmm-network:
driver: bridge
volumes:
bmm_pg:
bmm_redis:
bmm_keys:
bmm_build_context:

View File

@ -1,79 +0,0 @@
# nginx vhost for buildmymcpserver.com — install on the host nginx:
# cp this to /etc/nginx/sites-available/buildmymcpserver
# ln -s /etc/nginx/sites-available/buildmymcpserver /etc/nginx/sites-enabled/
# nginx -t && systemctl reload nginx
#
# Serves both :80 and :443. The :443 listener uses a self-signed origin cert
# (see DEPLOY.md) so Cloudflare can run in "Full" mode — TLS all the way to the
# origin — instead of "Flexible" (plaintext origin hop). For "Full (strict)",
# swap the self-signed cert for a Cloudflare Origin Certificate.
# --- Web app: buildmymcpserver.com ---
server {
listen 80;
listen [::]:80;
listen 443 ssl;
listen [::]:443 ssl;
server_name buildmymcpserver.com www.buildmymcpserver.com;
ssl_certificate /etc/ssl/buildmymcpserver/origin.crt;
ssl_certificate_key /etc/ssl/buildmymcpserver/origin.key;
client_max_body_size 12M;
location / {
proxy_pass http://127.0.0.1:4001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 120s;
}
}
# --- Control plane API: api.buildmymcpserver.com ---
server {
listen 80;
listen [::]:80;
listen 443 ssl;
listen [::]:443 ssl;
server_name api.buildmymcpserver.com;
ssl_certificate /etc/ssl/buildmymcpserver/origin.crt;
ssl_certificate_key /etc/ssl/buildmymcpserver/origin.key;
client_max_body_size 12M;
# Build-log WebSocket stream (/v1/builds/:id/stream) — needs the upgrade
# headers and a long read timeout; buffering off so frames are not held.
location /v1/builds/ {
proxy_pass http://127.0.0.1:4000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 600s;
}
location / {
proxy_pass http://127.0.0.1:4000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 120s;
}
}

View File

@ -123,60 +123,6 @@ export async function consumeMagicLink(
return { sessionToken, userId: user.id, orgId: membership.orgId, email: user.email };
}
/**
* Get-or-create a user from a verified third-party identity (Google, etc.) and
* mint a session. The caller is responsible for verifying the identity provider's
* token BEFORE calling this `email` must already be proven to belong to the user.
*/
export async function upsertOAuthLogin(
input: { email: string; name?: string | null },
meta: { ipAddress?: string; userAgent?: string } = {},
db: Database = createDb(),
): Promise<ConsumedSession> {
const email = input.email.trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
throw new Error('invalid_email');
}
let user = (await db.select().from(users).where(eq(users.email, email)).limit(1))[0];
if (!user) {
[user] = await db
.insert(users)
.values({ email, emailVerified: true, name: input.name ?? undefined })
.returning();
if (!user) throw new Error('user_create_failed');
const orgSlug = `${slugify(email.split('@')[0] ?? 'me')}-${randomToken(3).toLowerCase()}`;
const [org] = await db
.insert(organizations)
.values({ slug: orgSlug, name: `${email.split('@')[0]}'s workspace` })
.returning();
if (!org) throw new Error('org_create_failed');
await db.insert(memberships).values({ orgId: org.id, userId: user.id, role: 'owner' });
} else if (!user.emailVerified) {
await db.update(users).set({ emailVerified: true }).where(eq(users.id, user.id));
}
const resolved = user;
const [membership] = await db
.select()
.from(memberships)
.where(eq(memberships.userId, resolved.id))
.limit(1);
if (!membership) throw new Error('no_org_membership');
const sessionToken = randomToken(32);
await db.insert(sessions).values({
userId: resolved.id,
tokenHash: sha256(sessionToken),
expiresAt: new Date(Date.now() + SESSION_TTL_MS),
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, resolved.id));
return { sessionToken, userId: resolved.id, orgId: membership.orgId, email: resolved.email };
}
export interface AuthedUser {
userId: string;
orgId: string;

View File

@ -211,20 +211,6 @@ export const buildLogs = pgTable(
}),
);
// Envelope encryption: a Data Encryption Key (DEK) is generated, wrapped (itself
// AES-256-GCM encrypted) with the Key Encryption Key (KEK) from the env, and
// stored here. Secrets are encrypted with the DEK. Rotation mints a fresh DEK
// and re-encrypts every secret — the KEK never leaves the environment.
export const encryptionKeys = pgTable('encryption_keys', {
id: uuid('id').defaultRandom().primaryKey(),
version: integer('version').notNull().unique(),
wrappedDek: text('wrapped_dek').notNull(),
active: boolean('active').default(false).notNull(),
rotatedBy: uuid('rotated_by').references(() => users.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
retiredAt: timestamp('retired_at'),
});
export const secrets = pgTable('secrets', {
id: uuid('id').defaultRandom().primaryKey(),
serverId: uuid('server_id')
@ -232,9 +218,6 @@ export const secrets = pgTable('secrets', {
.notNull(),
key: varchar('key', { length: 128 }).notNull(),
encryptedValue: text('encrypted_value').notNull(),
// null = legacy (encrypted directly with the KEK, pre-envelope). Non-null
// rows are encrypted with the referenced key's DEK.
keyId: uuid('key_id').references(() => encryptionKeys.id),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
@ -319,4 +302,3 @@ export type BuildLog = typeof buildLogs.$inferSelect;
export type Secret = typeof secrets.$inferSelect;
export type OAuthClient = typeof oauthClients.$inferSelect;
export type Template = typeof templates.$inferSelect;
export type EncryptionKey = typeof encryptionKeys.$inferSelect;