Compare commits

...

10 Commits

Author SHA1 Message Date
Marco Sadjadi
c016bf237b feat(deploy): nginx vhost serves :443 with a self-signed origin cert
All checks were successful
Deploy to Production / deploy (push) Successful in 49s
Lets Cloudflare run in Full mode (encrypted Cloudflare<->origin) instead
of Flexible (plaintext origin hop). Full (strict) is a later swap to a
Cloudflare Origin Certificate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:10:22 +02:00
Marco Sadjadi
a288179954 fix(docker): healthcheck must hit 127.0.0.1, not localhost
The servers bind IPv4 (0.0.0.0) only. busybox wget resolves `localhost`
to ::1 first and does not fall back to IPv4, so the healthcheck failed
with "connection refused" and the container showed as unhealthy while
serving fine. Verified on the production api container.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:07:01 +02:00
Marco Sadjadi
c7e6537c64 fix(deploy): rework prod artifacts to match the actual Hetzner box
Server recon (read-only SSH) showed the box already runs ~8 apps behind a
host-level nginx, with Gitea + an Actions runner. The host-networking
design collided with contentra on port 3001.

- docker-compose.prod.yml: bridge networking + per-app network, house
  style; api/web/postgres/redis publish to 127.0.0.1 on verified-free
  ports (4000/4001/5440/6390); only the generator keeps host networking
  (no listening port, needs the host namespace for runner-port probing).
- Drop the Traefik config; the box uses a host nginx. Add a ready nginx
  vhost in infra/nginx/buildmymcpserver.conf (listen 80, Cloudflare TLS).
- Add .gitea/workflows/deploy.yml mirroring the buildmydiscord pipeline.
- Narrow the generated-MCP port range to 4400-4900 (clear of screencraft
  on 4321).
- .env.production.example + DEPLOY.md rewritten for buildmymcpserver.com
  and the real topology.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:48:57 +02:00
Marco Sadjadi
a54f6218a7 docs(deploy): flag buildmymcp.com vs buildmymcpserver.com domain mismatch
The request said buildmymcp.com; the GoDaddy tab and the repo are named
buildmymcpserver.com. Added a top-of-file callout so the domain is resolved
before any DNS/nameserver change rather than baked in wrong.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 00:37:59 +02:00
Marco Sadjadi
e46a9a1cf8 feat(web): surface the template marketplace on the landing page
The marketplace is the distribution channel — fork a working server or
publish your own — but it was absent from the landing page. Adds a
section between Examples and Pricing with a second conversion path into
/templates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 00:37:06 +02:00
Marco Sadjadi
8a7ffe673d feat(deploy): production Dockerfiles, compose stack, and runbook
- Multi-stage Dockerfiles for web/api/generator (pnpm workspace install,
  tsx runtime — workspace packages are raw TS, same model as runner-template).
- docker-compose.prod.yml: postgres + redis + the three app services.
  api/generator/web use host networking so the generator's host-port probe
  is correct and every service shares one address space; api + generator
  mount the Docker socket. Binds nothing on 80/443 — safe beside other apps.
- Optional Traefik reverse proxy in infra/traefik/ (heavily gated — only if
  the box has no existing proxy).
- .env.production.example, .dockerignore, DEPLOY.md (Cloudflare zone, GoDaddy
  nameserver switch, server deploy, Google Cloud Console OAuth app).
- api/generator `start` now runs via tsx; `node dist/index.js` could never
  resolve the raw-TS workspace imports.

All three images verified building clean; the API container boots under tsx.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 00:37:02 +02:00
Marco Sadjadi
2b098c5d33 fix(web): wrap useSearchParams in Suspense so next build can prerender
/servers/new and /login/callback call useSearchParams() directly, which
bails the page out of static rendering and fails `next build` during
prerender. Split each into a thin Suspense wrapper + inner component.
Latent since `next dev` never prerenders — only surfaces in a prod build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 00:36:56 +02:00
Marco Sadjadi
38aa5875d3 feat(auth): add "Continue with Google" OAuth 2.0 login
Server-side authorization-code flow: /v1/auth/google redirects to the
consent screen with a CSRF state cookie; /v1/auth/google/callback
exchanges the code, validates the ID token (iss/aud/exp/email_verified),
and mints a 30-day session via upsertOAuthLogin. /v1/auth/providers lets
the login UI hide the button until GOOGLE_OAUTH_ID/SECRET are set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 00:26:44 +02:00
Marco Sadjadi
a68e882092 feat(crypto): envelope encryption + key rotation via admin panel
Closes structural weakness #4 from the audit (single global key, no rotation,
no KMS path). Customer secrets now use envelope encryption with a real
rotation story.

Model:
  KEK — Key Encryption Key, 32 bytes from env (SECRETS_ENCRYPTION_KEY). Never
        stored in the DB. Root of trust.
  DEK — Data Encryption Key, 32 random bytes we generate, stored in the new
        encryption_keys table *wrapped* (AES-256-GCM encrypted) with the KEK.
        Secrets are encrypted with the DEK.

Schema:
- encryption_keys (version, wrappedDek, active, rotatedBy, createdAt, retiredAt)
- secrets.keyId — which DEK encrypted this row. NULL = legacy (KEK-direct,
  pre-envelope); decryptSecret handles both and the first rotation migrates
  legacy rows onto a DEK.

crypto.ts (full rewrite):
- ensureActiveKey() — boot-time, loads keys + creates v1 if none. Fail-closed:
  index.ts process.exit(1) if it throws — the API will not serve if encryption
  can't initialize.
- encryptSecret() — encrypts with the active DEK, returns { value, keyId }.
- decryptSecret(value, keyId) — DEK path or legacy KEK-direct path.
- rotateKeys() — mints a fresh DEK, re-encrypts EVERY secret under it inside a
  single transaction (decrypt-old / encrypt-new per row), retires the old key,
  activates the new one. A partial failure is recoverable because every row
  carries its own keyId.
- encryptionStatus() — active version, key history, secret + legacy counts.

Admin:
- GET  /v1/admin/encryption        — status
- POST /v1/admin/encryption/rotate — triggers rotateKeys, audit-logged as
  admin.encryption.rotate with { newVersion, reEncrypted }.
- /admin/encryption page — active-key/secret/legacy cards, Rotate button with
  confirm, key-history table, plain-English how-it-works. Added to admin nav.

Verified end-to-end:
- boot → encryption_keys v1 active, '[crypto] envelope encryption ready'
- created a server with secret MY_API_KEY → stored ciphertext, keyId = v1
- POST rotate → { newVersion: 2, reEncrypted: 1 }; ciphertext changed, keyId
  now v2, v1 retired, v2 active. The decrypt-then-reencrypt round-trip
  succeeded (rotation throws otherwise) — the secret is provably recoverable.
- admin UI renders the status + history correctly.

Deferred, named honestly (not built this iteration):
- worker reads secrets from the DB instead of the BullMQ job-data plaintext
  copy — would also remove plaintext secrets from Redis. Separate change with
  its own risk surface on the iterate/fork flows.
- per-server secret-value rotation UI
- audit_log hash-chaining (tamper-evidence)
- rate limiting on auth endpoints
2026-05-20 22:36:08 +02:00
Marco Sadjadi
8d47b20ae5 fix(generator): iterate orphaned the previous container — rolling deploy
Sovereign-audit follow-up. The audit's finding pass missed this: every
Iterate (version > 1) ran allocatePort -> a NEW port and deployContainer -> a
NEW container, then pointed the DB row at it — and never stopped the old
container. The previous version kept running forever, holding a host port,
with the old secrets baked into its env, untracked (its containerId was
overwritten in the DB by deployContainer). Same bug class as API-SERVERS-001
but on the iterate path.

Fix: the worker captures the server's current containerId before the build
mutates the row, and after the new container is confirmed live + the DB
updated, it stops the old one. This also makes the 'rolling deploy' the UI
promises actually true — the old version stays up until the new one is live,
then is retired.

deploy.ts stopContainer now returns { ok, detail } (was void) so the worker
can log the outcome.

Verified: generator typecheck clean.
2026-05-20 20:58:30 +02:00
30 changed files with 1545 additions and 60 deletions

34
.dockerignore Normal file
View 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

View File

@ -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
View 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=

View 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
View 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 | `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)**.

35
apps/api/Dockerfile Normal file
View 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"]

View File

@ -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"
},

View File

@ -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.

View File

@ -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);

View File

@ -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,
})),
};
}

View File

@ -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
}

View File

@ -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`);
}
});
}

View File

@ -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
View 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"]

View File

@ -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"
},

View File

@ -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}` }),
);
});
}

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 } 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) {

View File

@ -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
View 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"]

View File

@ -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',

View File

@ -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">

View 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>
);
}

View File

@ -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 },
];

View File

@ -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>
);
}

View File

@ -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&apos;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>
);
}

View File

@ -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
View 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:

View 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;
}
}

View File

@ -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;

View File

@ -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;