Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
04ba57a
feat(env): add GATEWAY_INTERNAL_URL for single-origin proxy
maakle Jun 1, 2026
128bf25
feat(env): derive MCP_PUBLIC_URL from WEB_PUBLIC_URL when unset
maakle Jun 1, 2026
df436b7
refactor(env): scope MCP_PUBLIC_URL cast and document Env intersection
maakle Jun 1, 2026
748d155
docs(env): document GATEWAY_INTERNAL_URL and MCP_PUBLIC_URL derivation
maakle Jun 1, 2026
2f2a672
feat(web): proxy gateway paths via Next.js rewrites (single-origin)
maakle Jun 1, 2026
bd8a265
docs(web): explain why oauth-authorization-server isn't proxied
maakle Jun 1, 2026
ff67efb
feat(compose): wire GATEWAY_INTERNAL_URL for single-origin web proxy
maakle Jun 1, 2026
b10b2ee
feat(cli): drop MCP_PUBLIC_URL from init (now derived from WEB_PUBLIC…
maakle Jun 1, 2026
15d2346
test(web): assert gateway rewrites cover Hono surface and respect order
maakle Jun 1, 2026
d220608
test(scripts): add verify:gateway HTTP smoke for single-origin proxy
maakle Jun 1, 2026
d50de77
refactor(scripts): harden verify:gateway against network errors and t…
maakle Jun 1, 2026
9ae0aeb
docs(adr): 0009 single-origin gateway via Next.js rewrites
maakle Jun 1, 2026
9ac861e
docs: explain single-origin tunneling and Railway env migration
maakle Jun 1, 2026
461a02d
fix(web): drop unused @ts-expect-error from gateway-rewrites test
maakle Jun 1, 2026
ac69524
chore: align .env.example and dev origin with single-origin hostname
maakle Jun 1, 2026
0435fe8
docs(plan): add 2026-06-01 single-origin mcp gateway implementation plan
maakle Jun 1, 2026
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
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ HOLO_TOKEN_ENCRYPTION_KEY= # Generate both with: openssl rand -base64 32
BETTER_AUTH_SECRET=
BETTER_AUTH_URL=http://localhost:3000
WEB_PUBLIC_URL= # Publicly reachable URL for OAuth redirect_uri callbacks (Slack, Linear).
MCP_PUBLIC_URL=http://localhost:8080

# Where the web app proxies gateway-bound paths internally (Next.js rewrites).
# Default works for both pnpm dev and docker compose. Never exposed publicly.
GATEWAY_INTERNAL_URL=http://localhost:8080
HOLO_EE_LICENSE_KEY=true # Enterprise Edition gate. Any non-empty value enables EE surfaces in dev/eval
# Billing module: when 'true', the credit ledger writes every LLM + sync event,
# enforces the per-plan connector limit, and shows /settings/billing. When
Expand Down
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ pnpm dev # runs web + gateway + worker locally with hot reload

`pnpm bootstrap` is idempotent — safe to re-run. To reset the database, run `docker compose down -v && pnpm bootstrap`.

**Public testing.** When you need a public URL for OAuth or MCP testing (e.g., wiring a real Slack workspace to a local dev environment), run `ngrok http 3000` and set `WEB_PUBLIC_URL` in `.env` to the tunnel URL. One tunnel is enough — the web app reverse-proxies `/mcp`, `/v1/*`, and webhooks to the gateway internally. See [ADR 0009](./docs/decisions/0009-single-origin-gateway.md).

Before `pnpm dev` boots, [scripts/check-env.mjs](./scripts/check-env.mjs) validates that every boot-required env var is filled in. If `.env` is missing GitHub OAuth credentials (which `pnpm bootstrap` doesn't generate — you need to create the OAuth app), it tells you exactly what to add.

## Project shape
Expand Down
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,18 @@ curl -X POST http://localhost:8080/v1/search \
-d '{"q": "how do we onboard a new ATS partner?", "topK": 5}'
```

### One public URL

Self-hosters need **one** public URL (DNS + TLS + tunnel/proxy) pointing at the web service on `:3000`. The web app reverse-proxies agent traffic (`/mcp`, `/v1/*`, webhooks like `/slack/*`) to the gateway internally — see [ADR 0009](./docs/decisions/0009-single-origin-gateway.md) for the design and the two-origin override.

Quick local tunnel:

```bash
ngrok http 3000 # or: cloudflared tunnel run <your-tunnel>
```

Then set `WEB_PUBLIC_URL` and `BETTER_AUTH_URL` in `.env` to the tunnel URL and restart. `MCP_PUBLIC_URL` derives automatically — no need to set it.

---

## Deploy (Railway)
Expand All @@ -223,14 +235,25 @@ Three categories, three different mechanisms:
|---|---|---|
| **Auto-wired by Railway** | `DATABASE_URL`, `REDIS_URL` | Reference variables (`${{Postgres.DATABASE_URL}}`, `${{Redis.REDIS_URL}}`) — set them once on `holo-web`/`holo-gateway`/`holo-worker` after the DB and Redis services come up. |
| **You generate (secrets)** | `POSTGRES_PASSWORD`, `BETTER_AUTH_SECRET`, `HOLO_TOKEN_ENCRYPTION_KEY` | `openssl rand -base64 32` for each. Paste into the project's env panel before the first deploy. `POSTGRES_PASSWORD` must match what `DATABASE_URL` references. |
| **You provide (public URLs + OAuth)** | `BETTER_AUTH_URL`, `WEB_PUBLIC_URL`, `MCP_PUBLIC_URL`, `GITHUB_LOGIN_CLIENT_ID`/`_SECRET`, `ANTHROPIC_API_KEY` | Set after the first deploy gives you the public hostnames. `BETTER_AUTH_URL` and `WEB_PUBLIC_URL` point at `holo-web`'s public URL; `MCP_PUBLIC_URL` points at `holo-gateway`'s. The GitHub OAuth app's callback must be `${BETTER_AUTH_URL}/api/auth/callback/github`. |
| **You provide (public URLs + OAuth)** | `BETTER_AUTH_URL`, `WEB_PUBLIC_URL`, `GITHUB_LOGIN_CLIENT_ID`/`_SECRET`, `ANTHROPIC_API_KEY` | Set after the first deploy gives you the public hostnames. `BETTER_AUTH_URL` and `WEB_PUBLIC_URL` point at `holo-web`'s public URL. The GitHub OAuth app's callback must be `${BETTER_AUTH_URL}/api/auth/callback/github`. **Single-origin model:** `MCP_PUBLIC_URL` is derived from `WEB_PUBLIC_URL` by default — set it explicitly only if you intentionally publish the gateway on a separate hostname (see [ADR 0009](./docs/decisions/0009-single-origin-gateway.md)). |

Connector credentials (Slack, GitHub App, GitLab, HubSpot, Salesforce, Pylon, Notion, Grain, Linear, Airtable, Asana, Jira, Confluence, Stripe, Zendesk, Google Drive / Chat service account, Prismic, Mintlify, Webcrawl/Firecrawl) are **not** required at boot — leave them blank, deploy, then add them per-connector in the Holo dashboard once `apps/web` is reachable. The only worker-side env that gates a connector at boot is `FIRECRAWL_API_KEY` (powers the Webcrawl connector, since it's Holo-team-operated rather than per-org).

Full env reference: [`.env.example`](./.env.example).

> **Note on the Railway template format.** `railway.toml`'s multi-service block (`[[services]]`) is best-effort — Railway's first-class multi-service experience is via the published Template Marketplace, which we haven't shipped yet ([`docs/ROADMAP.md` ↗](./docs/ROADMAP.md)). After clicking the button, verify each service in the Railway dashboard and set reference variables. Tracking issue welcome.

### Migrating from a two-host deployment

If you deployed Holo before [ADR 0009](./docs/decisions/0009-single-origin-gateway.md) and currently expose both `holo-web` and `holo-gateway` publicly, migrate to single-origin without downtime in this order:

1. **On `holo-web`** — add `GATEWAY_INTERNAL_URL` pointing at the gateway's internal address (e.g., `http://${{Gateway.RAILWAY_PRIVATE_DOMAIN}}:8080` on Railway, `http://gateway:8080` on Docker Compose / Coolify). Redeploy. The `/mcp` and `/v1/*` rewrites now have a working internal target.
2. **On `holo-gateway` and `holo-worker`** — set `MCP_PUBLIC_URL` to the same value as `WEB_PUBLIC_URL`. (Or unset `MCP_PUBLIC_URL` on `holo-web` only; it derives from `WEB_PUBLIC_URL`.)
3. **Update external services** — OAuth callbacks (GitHub login, connector OAuth flows) and webhook URLs (Slack Events/Commands/Interactivity, Stripe, GitHub App, Google Chat, Teams) to point at the single `holo-web` origin.
4. **Remove the gateway's public domain** in your hosting dashboard and delete the obsolete DNS record.

Doing step 1 before step 4 avoids a window where `/mcp` returns 502 because the web has no proxy target yet.

---

## Development
Expand Down
54 changes: 45 additions & 9 deletions apps/web/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,58 @@ const nextConfig = {
// normalization; opt out so the reverse proxy works.
skipTrailingSlashRedirect: true,
allowedDevOrigins: [
'holo-app.maakle.com',
'holo.maakle.com',
],
// Next.js App Router doesn't serve routes from dot-prefixed directories,
// so the OAuth metadata file lives under /well-known/* and is exposed at
// its RFC-mandated /.well-known/* path via this rewrite.
//
// /ingest/* proxies PostHog ingestion through Holo's own origin so
// browser-side analytics survive ad blockers that target *.posthog.com.
// When PostHog is not configured these routes simply 502 if hit, which
// never happens because posthog-js isn't initialized.
async rewrites() {
// Keep this fallback in sync with the GATEWAY_INTERNAL_URL default in
// packages/env/src/index.ts. Next.js loads next.config.mjs outside the
// @holo/env runtime, so the fallback is duplicated here intentionally.
const GATEWAY = process.env.GATEWAY_INTERNAL_URL || 'http://localhost:8080';
return [
// --- Gateway proxies (single-origin mode) ---
// The gateway is bound to GATEWAY_INTERNAL_URL (docker network or
// localhost) and reached publicly via these path prefixes on the web
// origin. Two-origin operators can ignore this and point clients at
// a separate hostname; these rewrites do no harm in that case.
//
// MCP transport — bidirectional Streamable HTTP. Next.js passes
// through SSE/chunked responses without buffering.
{ source: '/mcp', destination: `${GATEWAY}/mcp` },
{ source: '/mcp/:path*', destination: `${GATEWAY}/mcp/:path*` },
// REST API surface (search, skills, accounts, feedback).
{ source: '/v1/:path*', destination: `${GATEWAY}/v1/:path*` },
// OpenAPI surface (auto-generated spec + Scalar docs page).
{ source: '/openapi.json', destination: `${GATEWAY}/openapi.json` },
{ source: '/docs', destination: `${GATEWAY}/docs` },
{ source: '/docs/:path*', destination: `${GATEWAY}/docs/:path*` },
// Third-party webhook surfaces — paths are part of the signed payload
// contract; do not rewrite the path itself.
{ source: '/slack/:path*', destination: `${GATEWAY}/slack/:path*` },
{ source: '/teams-bot/:path*', destination: `${GATEWAY}/teams-bot/:path*` },
{ source: '/google-chat-app/:path*', destination: `${GATEWAY}/google-chat-app/:path*` },
// RFC 9728 protected-resource metadata is served by the gateway only
// (no equivalent route in the web app), so it MUST be proxied here.
// Order matters: this specific rule must precede the well-known catch-all
// below, which would otherwise route it to the web's local handler.
//
// Note: /.well-known/oauth-authorization-server (RFC 8414) is
// intentionally NOT proxied — the web app has its own canonical handler
// at apps/web/src/app/well-known/oauth-authorization-server/route.ts
// that derives the issuer from WEB_PUBLIC_URL. The catch-all rewrite
// below reaches it correctly.
{
source: '/.well-known/oauth-protected-resource',
destination: `${GATEWAY}/.well-known/oauth-protected-resource`,
},

// --- Existing rules ---
// App Router can't serve dot-prefixed dirs; expose /well-known/* at
// /.well-known/*. Order matters: specific gateway proxies above win.
{
source: '/.well-known/:path*',
destination: '/well-known/:path*',
},
// PostHog reverse-proxy (browser analytics survive ad blockers).
{
source: '/ingest/static/:path*',
destination: `${POSTHOG_ASSETS_HOST}/static/:path*`,
Expand Down
41 changes: 41 additions & 0 deletions apps/web/src/app/__tests__/gateway-rewrites.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import nextConfig from '../../../next.config.mjs';

describe('Next.js gateway rewrites', () => {
it('proxies every gateway path prefix to GATEWAY_INTERNAL_URL', async () => {
const rules = await nextConfig.rewrites();
const sources = rules.map((r: { source: string }) => r.source);

// Every path the Hono gateway publishes must have a corresponding
// rewrite. If you add a route to apps/gateway/src/main.ts, add the
// rewrite here and update this assertion.
const required = [
'/mcp',
'/mcp/:path*',
'/v1/:path*',
'/openapi.json',
'/docs',
'/docs/:path*',
'/slack/:path*',
'/teams-bot/:path*',
'/google-chat-app/:path*',
'/.well-known/oauth-protected-resource',
];
for (const path of required) {
expect(sources, `missing rewrite for ${path}`).toContain(path);
}
});

it('places /.well-known/oauth-protected-resource before the well-known catchall', async () => {
const rules = await nextConfig.rewrites();
const specificIdx = rules.findIndex(
(r: { source: string }) => r.source === '/.well-known/oauth-protected-resource',
);
const catchallIdx = rules.findIndex(
(r: { source: string }) => r.source === '/.well-known/:path*',
);
expect(specificIdx).toBeGreaterThanOrEqual(0);
expect(catchallIdx).toBeGreaterThanOrEqual(0);
expect(specificIdx).toBeLessThan(catchallIdx);
});
});
7 changes: 6 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,16 @@ services:
build:
context: .
dockerfile: apps/web/Dockerfile
environment: *app_env
environment:
<<: *app_env
# Inside the compose network the gateway is reachable at its service
# hostname. Used only by Next.js rewrites to proxy /mcp, /v1, etc.
GATEWAY_INTERNAL_URL: http://gateway:8080
ports:
- "3000:3000"
depends_on:
migrate: { condition: service_completed_successfully }
gateway: { condition: service_started }

volumes:
holo_pg_data:
Expand Down
70 changes: 70 additions & 0 deletions docs/decisions/0009-single-origin-gateway.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# 0009 — Single-origin gateway

**Status:** Accepted (2026-06-01)
**Supersedes:** none

## Context

Holo runs three Node processes: `apps/web` (Next.js, port 3000), `apps/gateway` (Hono, port 8080), `apps/worker` (NestJS, no public port). Before this decision, self-hosters and contributors exposed two public hostnames — one for the web, one for the gateway — typically backed by two cloudflared ingress rules or two ngrok tunnels.

Two-host setups are friction at every onboarding step:

- Two DNS records, two TLS certs, two tunnel configs to keep aligned
- ngrok free supports only one tunnel, blocking contributors testing OAuth/MCP locally
- Operators frequently typo or desync the two URLs
- OAuth callbacks and cookies have to navigate cross-origin even though both origins belong to the same operator

## Decision

The web app reverse-proxies all gateway-bound paths to the gateway via Next.js `rewrites()`. The gateway stays bound to a private endpoint (`http://gateway:8080` in Docker, `http://localhost:8080` in dev) and is no longer expected to have a public hostname.

Proxied paths (from [apps/web/next.config.mjs](../../apps/web/next.config.mjs)):
- `/mcp`, `/mcp/*` — MCP Streamable HTTP transport
- `/v1/*` — REST API (search, skills, accounts, feedback)
- `/openapi.json`, `/docs`, `/docs/*` — OpenAPI surface
- `/slack/*`, `/teams-bot/*`, `/google-chat-app/*` — third-party webhooks
- `/.well-known/oauth-protected-resource` — RFC 9728 MCP OAuth metadata

Notable non-proxied path:
- `/.well-known/oauth-authorization-server` — the web has its own canonical handler at [apps/web/src/app/well-known/oauth-authorization-server/route.ts](../../apps/web/src/app/well-known/oauth-authorization-server/route.ts) that derives the issuer from `WEB_PUBLIC_URL`. The existing `/.well-known/:path*` catch-all reaches it correctly.

`MCP_PUBLIC_URL` became optional in [packages/env/src/index.ts](../../packages/env/src/index.ts) and defaults to `WEB_PUBLIC_URL` (with `BETTER_AUTH_URL` as a final fallback). Two-origin operators can still publish the gateway separately by setting `MCP_PUBLIC_URL` explicitly; the gateway code is unchanged.

A new env var, `GATEWAY_INTERNAL_URL`, tells the web app where to proxy to. It defaults to `http://localhost:8080`. In Docker Compose the web service overrides it to `http://gateway:8080`.

## Consequences

**Positive:**
- One tunnel/cert/DNS record per self-host
- ngrok free works for contributors
- Same-origin OAuth, cookies, CORS — fewer footguns in Better Auth
- Single source of truth for the public URL

**Negative:**
- Gateway availability is coupled to web availability (if Next.js crashes, agents can't reach `/mcp`). Acceptable: if the web is down the product is down regardless.
- Slight latency from the extra Node hop. Negligible relative to LLM inherent latency.
- All gateway traffic now flows through Next.js's runtime. At very high agent volume an operator may want to bypass Next and put their own reverse proxy in front of both. The gateway's `:8080` port is intentionally still published in [`docker-compose.yml`](../../docker-compose.yml) to make this possible — operators retain the option to put the gateway back on its own public hostname.

## Alternatives considered

**Path-based routing at the tunnel layer (cloudflared `path:` ingress).** Works for cloudflared-only operators but ngrok free doesn't support it. Kept as a documented fallback if Next.js SSE proxying breaks in practice — operators can configure their tunnel to route `/mcp` and `/v1` directly to the gateway and bypass the Next.js rewrite layer.

**Fold the gateway into Next.js as API routes.** Real refactor; loses the clean separation between the agent surface (Hono, fast, no React) and the operator surface (Next.js, slower, React-heavy). Rejected.

## Verification

HTTP-level verification is automated by [`pnpm verify:gateway`](../../scripts/verify-mcp-sse.mjs) which exercises `/v1/health`, `/openapi.json`, and `/mcp` (expected 401 with `WWW-Authenticate` pointing at the single-origin URL).

Streaming behavior (MCP Streamable HTTP / SSE) is the operator's gate: before relying on this in production, run a real MCP client (Claude Desktop, Cursor, or the MCP Inspector) against `${WEB_PUBLIC_URL}/mcp`, complete the OAuth flow, and call a tool. A successful round-trip confirms Next.js `rewrites()` passes streaming responses through without buffering.

If streaming breaks in your environment, fall back to the cloudflared path-routing approach in "Alternatives considered" above and file an issue with the buffering behavior you observed.

## Migration notes for existing deployments

Operators upgrading from a two-host setup should:
1. Add `GATEWAY_INTERNAL_URL` on the web service pointing at the gateway's internal address (e.g., `http://gateway:8080` for compose, `http://${{Gateway.RAILWAY_PRIVATE_DOMAIN}}:8080` for Railway).
2. Set `MCP_PUBLIC_URL` to `WEB_PUBLIC_URL` on web/gateway/worker (or unset `MCP_PUBLIC_URL` on web — derivation takes over).
3. Update OAuth callback URLs and webhook receiver URLs (Slack, Stripe, GitHub App, Google Chat, Teams) to the single public origin.
4. Remove the gateway's public domain / DNS record once steps 1-3 are in place.

Do step 1 before step 4 to avoid a window where `/mcp` returns 502 because the web has no proxy target.
Loading
Loading