diff --git a/docs/mcp/DEPLOY.md b/docs/mcp/DEPLOY.md index 4f508bf35..f280a8766 100644 --- a/docs/mcp/DEPLOY.md +++ b/docs/mcp/DEPLOY.md @@ -216,6 +216,52 @@ paths outside cwd are rejected. Page slugs and filenames are allowlist-validated CLI callers (`gbrain file upload ...`) keep unrestricted filesystem access since the user owns the machine. +## Supabase Deployment Caveat + +If your brain is backed by Supabase and you are deploying `gbrain serve --http` +to an external host (Fly.io, Render, Railway, your own VPS, etc.), you must +set two environment variables: + +```bash +GBRAIN_DISABLE_DIRECT_POOL=1 +GBRAIN_POOL_SIZE=1 +``` + +**Why this is needed:** + +GBrain's dual-pool routing tries to open a second direct connection to the +Postgres host (port 5432) alongside the standard pooler connection. On +Supabase that direct host is only reachable from inside Supabase's VPC — it +is not accessible from external deployments. Without `GBRAIN_DISABLE_DIRECT_POOL=1` +the startup sequence attempts an IPv6 connection to the direct host, times out +(or receives a connection-refused), and either crashes or falls back to a +degraded state. + +**Connection string:** + +Use the **Transaction Pooler** URL (port **6543**, not 5432): + +``` +postgresql://postgres.:@aws-0-.pooler.supabase.com:6543/ +``` + +The Session Pooler (port 5432 on the pooler host) also works but has higher +per-connection overhead. The **direct** connection string (`db..supabase.co:5432`) +will not work from outside the Supabase VPC and should not be used for +externally-deployed servers. + +**Minimal Fly.io `fly.toml` env block example:** + +```toml +[env] + GBRAIN_DISABLE_DIRECT_POOL = "1" + GBRAIN_POOL_SIZE = "1" + GBRAIN_TRUST_PROXY = "1" +``` + +Set `DATABASE_URL` as a secret (`fly secrets set DATABASE_URL=...`) — never +in `fly.toml`. + ## Deployment Options See [ALTERNATIVES.md](ALTERNATIVES.md) for a comparison of ngrok, Tailscale diff --git a/src/commands/serve-http.ts b/src/commands/serve-http.ts index 25d1f6148..4cddb8e9a 100644 --- a/src/commands/serve-http.ts +++ b/src/commands/serve-http.ts @@ -211,7 +211,32 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption // Express 5 app const app = express(); - app.set('trust proxy', 'loopback'); // Caddy/Tailscale reverse proxy on localhost + + // --------------------------------------------------------------------------- + // Proxy trust configuration + // + // Default: trust exactly one upstream hop (the PaaS edge proxy on Fly.io, + // Render, Heroku, Railway, etc.). This makes req.ip resolve to the real + // client IP so downstream rate limiters (ccRateLimiter, adminAuthRateLimiter, + // registerRateLimiter) work per-client instead of seeing every request from + // the same PaaS-internal address. + // + // GBRAIN_TRUST_PROXY values: + // "1" (default) — trust one upstream hop (Fly.io / standard PaaS) + // "2", "3", ... — trust N hops (multi-layer PaaS/CDN setups) + // "false" — no proxy; req.ip is the TCP peer (self-hosted, no proxy) + // "true" — trust all hops (only for fully controlled infra) + // loopback-only deployments (Caddy/Tailscale on localhost) should use "false" + // + // See https://expressjs.com/en/guide/behind-proxies.html + // --------------------------------------------------------------------------- + const trustProxy = process.env.GBRAIN_TRUST_PROXY ?? '1'; + const tpValue: boolean | number | string = + trustProxy === 'true' ? true + : trustProxy === 'false' ? false + : /^\d+$/.test(trustProxy) ? parseInt(trustProxy, 10) + : trustProxy; + app.set('trust proxy', tpValue); // --------------------------------------------------------------------------- // Cookie parsing — required for /admin auth (express 5 has no built-in) @@ -329,6 +354,31 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption next(); }); + // --------------------------------------------------------------------------- + // DCR (Dynamic Client Registration) rate limiter + // + // /register is mounted by mcpAuthRouter when --enable-dcr is on. Without a + // limiter, any internet client can flood it: each registration hashes a + // secret, INSERTs into oauth_clients, and writes to the database pool. At + // Supabase transaction-pooler sizes a sustained flood exhausts the pool, + // causing 503s for all other requests. + // + // Placed before app.use(authRouter) so it fires regardless of SDK internals. + // Only meaningful when --enable-dcr is passed; no-ops otherwise (the SDK + // never mounts /register so traffic never reaches this middleware). + // + // Depends on Issue 1 (trust proxy) being correct: without real client IPs, + // all registrations share one bucket and the limiter is trivially bypassed. + // --------------------------------------------------------------------------- + const registerRateLimiter = rateLimit({ + windowMs: 60 * 1000, // 1 minute + max: 10, // 10 registrations per minute per IP + standardHeaders: true, + legacyHeaders: false, + message: { error: 'too_many_requests', error_description: 'Too many client registrations from this IP. Try again later.' }, + }); + + app.use('/register', registerRateLimiter); app.use(authRouter); // ---------------------------------------------------------------------------