feat: oauth refresh-token grant + per-runner subdomain TLS plumbing
All checks were successful
Deploy to Production / deploy (push) Successful in 52s
All checks were successful
Deploy to Production / deploy (push) Successful in 52s
OAUTH REFRESH-TOKEN
- oauth_tokens.subject column added (migration applied to prod DB): stores
the JWT sub claim from the original authorization so refreshes can
re-mint with the same identity without re-walking the (consumed) code.
- Authorization-code branch now writes subject AND uses a 30-day
expires_at for the row (was 1h — same as access token, which killed
refresh after 1h).
- New refresh_token grant branch:
* looks up token by refresh-hash + expiry
* client_id must match, client_secret verified if confidential
* RFC 8707: requested resource must equal stored resource
* OAuth 2.1 rotation: atomic UPDATE WHERE old_hash → new access JWT,
new refresh token, extended expiry; loser of a race sees invalid_grant
- Access TTL (1h) and refresh TTL (30d) extracted as constants.
Clients no longer have to re-authorize hourly. Closes Zb-001.
PER-RUNNER SUBDOMAIN TLS (Z1-002)
Code path:
- New MCP_DOMAIN env (e.g. "mcp.buildmymcpserver.com") + RUNNER_MAP_DIR
(default /var/runner-map) in generator config.
- deployContainer: writes /var/runner-map/<slug>.conf with content
"slug.MCP_DOMAIN port;" and computes publicUrl as
https://<slug>.<MCP_DOMAIN>. Falls back to http://host:port when
MCP_DOMAIN is unset (zero behaviour change until host is configured).
- stopContainer (both api/lib/docker.ts and generator/lib/deploy.ts) now
accepts an optional slug arg and removes the map fragment. Callers
(DELETE /v1/servers/:id, admin template takedown) updated.
Infra path (one-time host setup — Marco runs as root):
- scripts/setup-runner-tls.sh:
1. nginx vhost matching *.mcp.buildmymcpserver.com via regex →
reads slug→port from /opt/buildmymcpserver/runner-map.combined
2. systemd inotify service watches the map dir, combines fragments
on any change, reloads nginx
3. installs inotify-tools if missing, idempotent
- Prereqs documented at top: Cloudflare wildcard DNS proxied, Origin CA
cert for *.mcp.buildmymcpserver.com, SSL mode Full (strict).
- After running: edit docker-compose.prod.yml to mount the map dir into
api + generator, set MCP_DOMAIN in env, recreate containers.
Closes Zb-001 fully. Closes Z1-002 on the code side; one Marco-on-host
action away from closing it on the infra side.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,15 @@ const Env = z.object({
|
||||
OAUTH_ISSUER: z.string().optional(),
|
||||
MODEL_GENERATE: z.string().default('glm-4.5'),
|
||||
MODEL_FIX: z.string().default('claude-haiku-4-5-20251001'),
|
||||
// When set (e.g. "mcp.buildmymcpserver.com"), each deployed runner gets a
|
||||
// public URL of the form https://<slug>.<MCP_DOMAIN> instead of the legacy
|
||||
// http://<RUNNER_HOST>:<port> form. Requires host-side nginx + DNS setup
|
||||
// (see scripts/setup-runner-tls.sh). When unset, falls back to plain HTTP.
|
||||
MCP_DOMAIN: z.string().optional(),
|
||||
// Directory the generator drops per-runner map fragments into. A host-side
|
||||
// inotify service combines them and reloads nginx. Mounted as a volume by
|
||||
// docker-compose (see setup-runner-tls.sh).
|
||||
RUNNER_MAP_DIR: z.string().default('/var/runner-map'),
|
||||
});
|
||||
|
||||
export const config = Env.parse(process.env);
|
||||
|
||||
@@ -1,7 +1,51 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import { createDb, eq, isNotNull, mcpServers } from '@bmm/db';
|
||||
import { config } from '../config.js';
|
||||
|
||||
/**
|
||||
* Per-runner subdomain TLS support. When MCP_DOMAIN is set, the generator
|
||||
* publishes each container under https://<slug>.<MCP_DOMAIN> via a host-side
|
||||
* nginx that reads a slug→port map. The generator writes a tiny config
|
||||
* fragment per server; a systemd inotify watcher combines them and reloads
|
||||
* nginx. See scripts/setup-runner-tls.sh for the one-time host setup.
|
||||
*
|
||||
* If MCP_DOMAIN is unset, both the URL formatter and the map writer no-op
|
||||
* and we fall back to the legacy http://host:port URL — zero behaviour
|
||||
* change without the host-side infra in place.
|
||||
*/
|
||||
function runnerMapPath(slug: string): string {
|
||||
return path.join(config.RUNNER_MAP_DIR, `${slug}.conf`);
|
||||
}
|
||||
|
||||
async function writeRunnerMapEntry(slug: string, port: number): Promise<void> {
|
||||
if (!config.MCP_DOMAIN) return;
|
||||
const line = `${slug}.${config.MCP_DOMAIN} ${port};\n`;
|
||||
try {
|
||||
await fs.mkdir(config.RUNNER_MAP_DIR, { recursive: true });
|
||||
await fs.writeFile(runnerMapPath(slug), line, 'utf8');
|
||||
} catch (err) {
|
||||
// Don't fail the deploy if the map dir isn't mounted yet — runner still
|
||||
// serves on http://host:port and the user can manually proxy.
|
||||
console.warn(`[runner-tls] could not write map entry for ${slug}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRunnerMapEntry(slug: string): Promise<void> {
|
||||
if (!config.MCP_DOMAIN) return;
|
||||
try {
|
||||
await fs.rm(runnerMapPath(slug), { force: true });
|
||||
} catch {
|
||||
// Idempotent — missing file is fine.
|
||||
}
|
||||
}
|
||||
|
||||
function computePublicUrl(slug: string, port: number): string {
|
||||
if (config.MCP_DOMAIN) return `https://${slug}.${config.MCP_DOMAIN}`;
|
||||
return `http://${config.RUNNER_HOST}:${port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Container hardening flags applied on every runner deployment on Linux
|
||||
* production hosts. Skipped only when explicitly disabled (dev/Windows
|
||||
@@ -115,7 +159,10 @@ export async function deployContainer(input: DeployInput): Promise<DeployHandle>
|
||||
return;
|
||||
}
|
||||
const containerId = out.trim().slice(0, 64);
|
||||
const publicUrl = `http://${config.RUNNER_HOST}:${input.hostPort}`;
|
||||
const publicUrl = computePublicUrl(input.slug, input.hostPort);
|
||||
// Drop the nginx map fragment BEFORE persisting publicUrl so the
|
||||
// user-visible URL is reachable by the time the wizard polls "live".
|
||||
await writeRunnerMapEntry(input.slug, input.hostPort);
|
||||
await db
|
||||
.update(mcpServers)
|
||||
.set({
|
||||
@@ -133,10 +180,16 @@ export async function deployContainer(input: DeployInput): Promise<DeployHandle>
|
||||
|
||||
export async function stopContainer(
|
||||
containerId: string,
|
||||
slug?: string,
|
||||
): Promise<{ ok: boolean; detail: string }> {
|
||||
if (!containerId || containerId.length < 4) {
|
||||
return { ok: false, detail: 'invalid_container_id' };
|
||||
}
|
||||
// Remove the nginx map fragment first so the slug stops serving 502 from
|
||||
// the proxy as soon as the container goes down. Idempotent — called
|
||||
// multiple times with the same slug is fine.
|
||||
if (slug) await removeRunnerMapEntry(slug);
|
||||
|
||||
const { spawn } = await import('node:child_process');
|
||||
return await new Promise<{ ok: boolean; detail: string }>((resolve) => {
|
||||
const child = spawn('docker', ['rm', '-f', containerId], {
|
||||
|
||||
Reference in New Issue
Block a user