Compare commits
10 Commits
9cce4a94c2
...
c016bf237b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c016bf237b | ||
|
|
a288179954 | ||
|
|
c7e6537c64 | ||
|
|
a54f6218a7 | ||
|
|
e46a9a1cf8 | ||
|
|
8a7ffe673d | ||
|
|
2b098c5d33 | ||
|
|
38aa5875d3 | ||
|
|
a68e882092 | ||
|
|
8d47b20ae5 |
34
.dockerignore
Normal file
34
.dockerignore
Normal file
@ -0,0 +1,34 @@
|
||||
# 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
|
||||
11
.env.example
11
.env.example
@ -15,6 +15,17 @@ 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=
|
||||
|
||||
|
||||
80
.env.production.example
Normal file
80
.env.production.example
Normal file
@ -0,0 +1,80 @@
|
||||
# ============================================================================
|
||||
# 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=
|
||||
36
.gitea/workflows/deploy.yml
Normal file
36
.gitea/workflows/deploy.yml
Normal file
@ -0,0 +1,36 @@
|
||||
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
Normal file
236
DEPLOY.md
Normal file
@ -0,0 +1,236 @@
|
||||
# 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 | `4400–4900` | 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 4400–4900. 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)**.
|
||||
35
apps/api/Dockerfile
Normal file
35
apps/api/Dockerfile
Normal file
@ -0,0 +1,35 @@
|
||||
# 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"]
|
||||
@ -5,7 +5,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"start": "tsx src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@ -16,6 +16,8 @@ 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({
|
||||
@ -31,6 +33,8 @@ 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.
|
||||
|
||||
@ -4,6 +4,7 @@ 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';
|
||||
@ -26,6 +27,16 @@ 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);
|
||||
|
||||
@ -1,30 +1,195 @@
|
||||
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();
|
||||
|
||||
function getKey(): Buffer {
|
||||
const hex = config.SECRETS_ENCRYPTION_KEY;
|
||||
const buf = Buffer.from(hex, 'hex');
|
||||
/**
|
||||
* 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');
|
||||
if (buf.length !== 32) {
|
||||
throw new Error('SECRETS_ENCRYPTION_KEY must be 32 bytes (64 hex chars)');
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
export function encryptSecret(plaintext: string): string {
|
||||
// Low-level AES-256-GCM with an explicit key. Payload: iv.tag.ciphertext (base64).
|
||||
function aesEncrypt(key: Buffer, plaintext: string): string {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv(ALGO, getKey(), iv);
|
||||
const cipher = crypto.createCipheriv(ALGO, key, iv);
|
||||
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return `${iv.toString('base64')}.${tag.toString('base64')}.${enc.toString('base64')}`;
|
||||
}
|
||||
|
||||
export function decryptSecret(payload: string): string {
|
||||
function aesDecrypt(key: Buffer, payload: string): string {
|
||||
const [ivB64, tagB64, encB64] = payload.split('.');
|
||||
if (!ivB64 || !tagB64 || !encB64) throw new Error('malformed_secret_payload');
|
||||
const decipher = crypto.createDecipheriv(ALGO, getKey(), Buffer.from(ivB64, 'base64'));
|
||||
if (!ivB64 || !tagB64 || !encB64) throw new Error('malformed_ciphertext');
|
||||
const decipher = crypto.createDecipheriv(ALGO, key, Buffer.from(ivB64, 'base64'));
|
||||
decipher.setAuthTag(Buffer.from(tagB64, 'base64'));
|
||||
const dec = Buffer.concat([decipher.update(Buffer.from(encB64, 'base64')), decipher.final()]);
|
||||
return dec.toString('utf8');
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@ -24,6 +24,7 @@ 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();
|
||||
|
||||
@ -565,5 +566,28 @@ 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
|
||||
}
|
||||
|
||||
@ -1,16 +1,50 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import crypto from 'node:crypto';
|
||||
import {
|
||||
consumeMagicLink,
|
||||
destroySession,
|
||||
getSession,
|
||||
issueMagicLink,
|
||||
loginWithPassword,
|
||||
upsertOAuthLogin,
|
||||
} from '@bmm/auth';
|
||||
import { audit } from '../lib/audit.js';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { config } from '../config.js';
|
||||
import { audit } from '../lib/audit.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) => {
|
||||
@ -132,4 +166,114 @@ 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`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -164,10 +164,12 @@ 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: encryptSecret(value),
|
||||
encryptedValue: enc.value,
|
||||
keyId: enc.keyId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
34
apps/generator/Dockerfile
Normal file
34
apps/generator/Dockerfile
Normal file
@ -0,0 +1,34 @@
|
||||
# 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"]
|
||||
@ -5,7 +5,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"start": "tsx src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@ -104,12 +104,25 @@ export async function deployContainer(input: DeployInput): Promise<DeployHandle>
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopContainer(containerId: string): Promise<void> {
|
||||
export async function stopContainer(
|
||||
containerId: string,
|
||||
): Promise<{ ok: boolean; detail: string }> {
|
||||
if (!containerId || containerId.length < 4) {
|
||||
return { ok: false, detail: 'invalid_container_id' };
|
||||
}
|
||||
const { spawn } = await import('node:child_process');
|
||||
await new Promise<void>((resolve) => {
|
||||
const child = spawn('docker', ['rm', '-f', containerId], { stdio: 'ignore' });
|
||||
child.on('close', () => resolve());
|
||||
child.on('error', () => resolve());
|
||||
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}` }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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 } from './lib/deploy.js';
|
||||
import { allocatePort, deployContainer, dockerAvailable, stopContainer } from './lib/deploy.js';
|
||||
import { emitDone, emitError, emitLog, emitStatus } from './lib/emit.js';
|
||||
|
||||
const db = createDb();
|
||||
@ -46,6 +46,16 @@ 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));
|
||||
@ -141,6 +151,18 @@ 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) {
|
||||
|
||||
@ -10,6 +10,8 @@ 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://localhost:3000/health || exit 1
|
||||
CMD wget -qO- http://127.0.0.1:3000/health || exit 1
|
||||
CMD ["npx", "tsx", "src/server.ts"]
|
||||
|
||||
37
apps/web/Dockerfile
Normal file
37
apps/web/Dockerfile
Normal file
@ -0,0 +1,37 @@
|
||||
# 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"]
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Suspense, 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 {
|
||||
};
|
||||
}
|
||||
|
||||
export default function NewServerPage() {
|
||||
function NewServerPageInner() {
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState<Step>('prompt');
|
||||
|
||||
@ -708,6 +708,26 @@ export default function NewServerPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// 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',
|
||||
|
||||
@ -30,6 +30,21 @@ 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?',
|
||||
@ -212,6 +227,37 @@ 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">
|
||||
|
||||
178
apps/web/app/admin/encryption/page.tsx
Normal file
178
apps/web/app/admin/encryption/page.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -15,6 +15,7 @@ import {
|
||||
LogOut,
|
||||
ShieldAlert,
|
||||
Package,
|
||||
KeyRound,
|
||||
} from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { cn } from '@/lib/cn';
|
||||
@ -35,6 +36,7 @@ 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 },
|
||||
];
|
||||
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Logo } from '@/components/logo';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
|
||||
export default function CallbackPage() {
|
||||
function CallbackInner() {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const token = params.get('token');
|
||||
@ -53,3 +53,19 @@ export default function CallbackPage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,16 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { Input, Label } from '@/components/input';
|
||||
import { Logo } from '@/components/logo';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input, Label } from '@/components/input';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
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.',
|
||||
};
|
||||
|
||||
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();
|
||||
@ -34,11 +50,30 @@ 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]">
|
||||
We'll email you a magic link. No password.
|
||||
Continue with Google, or get a magic link by email.
|
||||
</p>
|
||||
|
||||
{state !== 'sent' ? (
|
||||
<form onSubmit={submit} className="mt-7 space-y-3">
|
||||
<>
|
||||
{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
|
||||
@ -62,6 +97,7 @@ export default function LoginPage() {
|
||||
</Button>
|
||||
{error && <p className="text-[12px] text-[--color-danger]">{error}</p>}
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<div className="panel mt-7 p-4">
|
||||
<p className="text-[13px]">
|
||||
@ -82,3 +118,26 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
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',
|
||||
@ -26,6 +23,11 @@ export async function apiFetch<T = unknown>(
|
||||
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');
|
||||
|
||||
121
docker-compose.prod.yml
Normal file
121
docker-compose.prod.yml
Normal file
@ -0,0 +1,121 @@
|
||||
# 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:
|
||||
79
infra/nginx/buildmymcpserver.conf
Normal file
79
infra/nginx/buildmymcpserver.conf
Normal file
@ -0,0 +1,79 @@
|
||||
# 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;
|
||||
}
|
||||
}
|
||||
@ -123,6 +123,60 @@ 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;
|
||||
|
||||
@ -211,6 +211,20 @@ 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')
|
||||
@ -218,6 +232,9 @@ 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(),
|
||||
});
|
||||
|
||||
@ -302,3 +319,4 @@ 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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user