Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions docs/mcp/DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<project-ref>:<password>@aws-0-<region>.pooler.supabase.com:6543/<db>
```

The Session Pooler (port 5432 on the pooler host) also works but has higher
per-connection overhead. The **direct** connection string (`db.<project-ref>.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
Expand Down
52 changes: 51 additions & 1 deletion src/commands/serve-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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);

// ---------------------------------------------------------------------------
Expand Down
Loading