From 04ba57a9ce38381217eee11050b3e116f4386591 Mon Sep 17 00:00:00 2001 From: Mathias Klenk Date: Mon, 1 Jun 2026 10:01:01 +0300 Subject: [PATCH 01/16] feat(env): add GATEWAY_INTERNAL_URL for single-origin proxy --- packages/env/src/index.ts | 6 ++++++ packages/env/test/env.test.ts | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index e88650b4..aaa55f5c 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -90,6 +90,12 @@ const EnvSchema = z.object({ ANTHROPIC_API_KEY: z.string().optional(), LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'), MCP_PUBLIC_URL: z.url().default('http://localhost:8080'), + /** + * Where the Next.js web app proxies gateway-bound requests internally + * (Next.js rewrites). In Docker this is the compose service hostname; + * in local dev it's the gateway's published port. Never exposed publicly. + */ + GATEWAY_INTERNAL_URL: z.url().default('http://localhost:8080'), WEB_PUBLIC_URL: z.url().optional(), MCP_PORT: z.coerce.number().int().min(1).max(65535).default(8080), /** diff --git a/packages/env/test/env.test.ts b/packages/env/test/env.test.ts index c6453bcb..7ef1ba0f 100644 --- a/packages/env/test/env.test.ts +++ b/packages/env/test/env.test.ts @@ -72,3 +72,18 @@ describe('parseEnv', () => { expect(env.EMAIL_FROM).toBe('Holo '); }); }); + +describe('GATEWAY_INTERNAL_URL', () => { + it('parses when set to a valid URL', () => { + const env = parseEnv({ + ...COMPLETE_ENV, + GATEWAY_INTERNAL_URL: 'http://gateway:8080', + }); + expect(env.GATEWAY_INTERNAL_URL).toBe('http://gateway:8080'); + }); + + it('defaults to http://localhost:8080 when unset', () => { + const env = parseEnv(COMPLETE_ENV); + expect(env.GATEWAY_INTERNAL_URL).toBe('http://localhost:8080'); + }); +}); From 128bf2518b392a84fcd6ed2b17bafe0590c5943e Mon Sep 17 00:00:00 2001 From: Mathias Klenk Date: Mon, 1 Jun 2026 10:04:30 +0300 Subject: [PATCH 02/16] feat(env): derive MCP_PUBLIC_URL from WEB_PUBLIC_URL when unset --- packages/env/src/index.ts | 18 +++++++++++++++--- packages/env/test/env.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index aaa55f5c..c27aa12d 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -89,7 +89,13 @@ const EnvSchema = z.object({ NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), ANTHROPIC_API_KEY: z.string().optional(), LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'), - MCP_PUBLIC_URL: z.url().default('http://localhost:8080'), + /** + * Base URL agents use to reach the MCP gateway. In single-origin mode + * (the default) this equals WEB_PUBLIC_URL; the Next.js app proxies + * `/mcp` and friends to the gateway internally. Set this explicitly + * only if you're publishing the gateway on a separate hostname. + */ + MCP_PUBLIC_URL: z.url().optional(), /** * Where the Next.js web app proxies gateway-bound requests internally * (Next.js rewrites). In Docker this is the compose service hostname; @@ -155,7 +161,7 @@ const EnvSchema = z.object({ }, ); -export type Env = z.infer; +export type Env = z.infer & { MCP_PUBLIC_URL: string }; export function parseEnv(raw: Record): Env { const result = EnvSchema.safeParse(raw); @@ -170,5 +176,11 @@ export function parseEnv(raw: Record): Env { fix: 'Verify your .env file matches .env.example. Generate secrets with `openssl rand -base64 32`.', }); } - return result.data; + const env = result.data as Env; + // Single-origin convenience: MCP_PUBLIC_URL defaults to WEB_PUBLIC_URL, + // then to BETTER_AUTH_URL (which is required and always set in dev/prod). + if (!env.MCP_PUBLIC_URL) { + env.MCP_PUBLIC_URL = env.WEB_PUBLIC_URL ?? env.BETTER_AUTH_URL; + } + return env; } diff --git a/packages/env/test/env.test.ts b/packages/env/test/env.test.ts index 7ef1ba0f..4bcff52e 100644 --- a/packages/env/test/env.test.ts +++ b/packages/env/test/env.test.ts @@ -87,3 +87,32 @@ describe('GATEWAY_INTERNAL_URL', () => { expect(env.GATEWAY_INTERNAL_URL).toBe('http://localhost:8080'); }); }); + +describe('MCP_PUBLIC_URL derivation', () => { + it('defaults to WEB_PUBLIC_URL when MCP_PUBLIC_URL is unset', () => { + const env = parseEnv({ + ...COMPLETE_ENV, + WEB_PUBLIC_URL: 'https://holo.example.com', + MCP_PUBLIC_URL: undefined, + }); + expect(env.MCP_PUBLIC_URL).toBe('https://holo.example.com'); + }); + + it('keeps MCP_PUBLIC_URL when explicitly set (two-origin mode)', () => { + const env = parseEnv({ + ...COMPLETE_ENV, + WEB_PUBLIC_URL: 'https://holo.example.com', + MCP_PUBLIC_URL: 'https://gateway.example.com', + }); + expect(env.MCP_PUBLIC_URL).toBe('https://gateway.example.com'); + }); + + it('falls back to BETTER_AUTH_URL when neither is set (dev default)', () => { + const env = parseEnv({ + ...COMPLETE_ENV, + WEB_PUBLIC_URL: undefined, + MCP_PUBLIC_URL: undefined, + }); + expect(env.MCP_PUBLIC_URL).toBe('http://localhost:3000'); + }); +}); From df436b71f64895cc6b3fcdcc43dd98f63562212b Mon Sep 17 00:00:00 2001 From: Mathias Klenk Date: Mon, 1 Jun 2026 10:09:02 +0300 Subject: [PATCH 03/16] refactor(env): scope MCP_PUBLIC_URL cast and document Env intersection Co-Authored-By: Claude Sonnet 4.6 --- packages/env/src/index.ts | 16 ++++++++++++---- packages/env/test/env.test.ts | 2 +- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index c27aa12d..b7b0a3e9 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -92,8 +92,9 @@ const EnvSchema = z.object({ /** * Base URL agents use to reach the MCP gateway. In single-origin mode * (the default) this equals WEB_PUBLIC_URL; the Next.js app proxies - * `/mcp` and friends to the gateway internally. Set this explicitly - * only if you're publishing the gateway on a separate hostname. + * `/mcp` and friends to the gateway internally. If WEB_PUBLIC_URL is + * also unset, falls back to BETTER_AUTH_URL. Set explicitly only when + * publishing the gateway on a separate hostname. */ MCP_PUBLIC_URL: z.url().optional(), /** @@ -161,6 +162,13 @@ const EnvSchema = z.object({ }, ); +// MCP_PUBLIC_URL is optional in the schema but always populated by parseEnv's +// post-parse derivation (WEB_PUBLIC_URL → BETTER_AUTH_URL). The intersection +// preserves that guarantee for callers without touching the Zod schema. +// +// Note: some web-app call sites still use defensive `?.` access on +// env.MCP_PUBLIC_URL. They predate this guarantee and are harmless dead-code +// guards; not cleaned up in this commit to keep the diff scoped. export type Env = z.infer & { MCP_PUBLIC_URL: string }; export function parseEnv(raw: Record): Env { @@ -176,11 +184,11 @@ export function parseEnv(raw: Record): Env { fix: 'Verify your .env file matches .env.example. Generate secrets with `openssl rand -base64 32`.', }); } - const env = result.data as Env; + const env = result.data; // Single-origin convenience: MCP_PUBLIC_URL defaults to WEB_PUBLIC_URL, // then to BETTER_AUTH_URL (which is required and always set in dev/prod). if (!env.MCP_PUBLIC_URL) { env.MCP_PUBLIC_URL = env.WEB_PUBLIC_URL ?? env.BETTER_AUTH_URL; } - return env; + return env as Env; } diff --git a/packages/env/test/env.test.ts b/packages/env/test/env.test.ts index 4bcff52e..32070722 100644 --- a/packages/env/test/env.test.ts +++ b/packages/env/test/env.test.ts @@ -113,6 +113,6 @@ describe('MCP_PUBLIC_URL derivation', () => { WEB_PUBLIC_URL: undefined, MCP_PUBLIC_URL: undefined, }); - expect(env.MCP_PUBLIC_URL).toBe('http://localhost:3000'); + expect(env.MCP_PUBLIC_URL).toBe(COMPLETE_ENV.BETTER_AUTH_URL); }); }); From 748d155c1e9a56ced22395c614cd37b4b36a6b83 Mon Sep 17 00:00:00 2001 From: Mathias Klenk Date: Mon, 1 Jun 2026 10:12:20 +0300 Subject: [PATCH 04/16] docs(env): document GATEWAY_INTERNAL_URL and MCP_PUBLIC_URL derivation --- .env.example | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 06a6c27f..3e3fb96e 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,14 @@ 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 +# Public base URL agents use to reach MCP / REST. Leave unset to inherit +# from WEB_PUBLIC_URL (single-origin mode — recommended). Set explicitly +# only when publishing the gateway on a separate hostname. +# MCP_PUBLIC_URL= + +# 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 From 2f2a672906ec9ce1e6d8745542919085fb3a5277 Mon Sep 17 00:00:00 2001 From: Mathias Klenk Date: Mon, 1 Jun 2026 10:17:12 +0300 Subject: [PATCH 05/16] feat(web): proxy gateway paths via Next.js rewrites (single-origin) --- apps/web/next.config.mjs | 45 +++++++++++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 741da252..5cf2609d 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -14,20 +14,49 @@ const nextConfig = { allowedDevOrigins: [ 'holo-app.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 served by the gateway. MUST + // come before the well-known catch-all below, which would otherwise + // route to the web's local /well-known/* handler. + { + 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*`, From bd8a2657bfa997eb58e48d965117819133b5c6b0 Mon Sep 17 00:00:00 2001 From: Mathias Klenk Date: Mon, 1 Jun 2026 10:22:20 +0300 Subject: [PATCH 06/16] docs(web): explain why oauth-authorization-server isn't proxied --- apps/web/next.config.mjs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 5cf2609d..c640ed9b 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -41,9 +41,16 @@ const nextConfig = { { 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 served by the gateway. MUST - // come before the well-known catch-all below, which would otherwise - // route to the web's local /well-known/* handler. + // 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`, From ff67efbf74b3f20a788676dc51eba4542c447e08 Mon Sep 17 00:00:00 2001 From: Mathias Klenk Date: Mon, 1 Jun 2026 10:23:44 +0300 Subject: [PATCH 07/16] feat(compose): wire GATEWAY_INTERNAL_URL for single-origin web proxy --- docker-compose.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 18231de5..ef0cb6ce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: From b10b2ee08fefb586f86261cc7a2c60300817dfa5 Mon Sep 17 00:00:00 2001 From: Mathias Klenk Date: Mon, 1 Jun 2026 10:26:07 +0300 Subject: [PATCH 08/16] feat(cli): drop MCP_PUBLIC_URL from init (now derived from WEB_PUBLIC_URL) --- packages/cli/src/commands/init.ts | 3 ++- packages/cli/test/init.test.ts | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index d1295098..944fdcc7 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -142,7 +142,8 @@ export async function initCommand(_args: string[], opts: InitOptions = {}): Prom `ANTHROPIC_API_KEY=${anthropicKey || ''}`, `GITHUB_LOGIN_CLIENT_ID=${ghClientId || ''}`, `GITHUB_LOGIN_CLIENT_SECRET=${ghClientSecret || ''}`, - `MCP_PUBLIC_URL=http://localhost:8080`, + // MCP_PUBLIC_URL is derived from WEB_PUBLIC_URL in single-origin mode. + // Set it explicitly only when publishing the gateway on a separate host. `WEB_PUBLIC_URL=http://localhost:3000`, '', ]; diff --git a/packages/cli/test/init.test.ts b/packages/cli/test/init.test.ts index 938a57db..dd19716e 100644 --- a/packages/cli/test/init.test.ts +++ b/packages/cli/test/init.test.ts @@ -67,6 +67,9 @@ describe('initCommand', () => { expect(env).toMatch(/^ANTHROPIC_API_KEY=$/m); expect(env).toMatch(/^GITHUB_LOGIN_CLIENT_ID=$/m); expect(env).toMatch(/^GITHUB_LOGIN_CLIENT_SECRET=$/m); + + expect(env).toContain('WEB_PUBLIC_URL=http://localhost:3000'); + expect(env).not.toContain('MCP_PUBLIC_URL='); }); it('substitutes interactive answers into .env when provided', async () => { From 15d2346d45dc9698edb93b034348c7fc57bd70a9 Mon Sep 17 00:00:00 2001 From: Mathias Klenk Date: Mon, 1 Jun 2026 10:28:40 +0300 Subject: [PATCH 09/16] test(web): assert gateway rewrites cover Hono surface and respect order Co-Authored-By: Claude Sonnet 4.6 --- .../app/__tests__/gateway-rewrites.test.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 apps/web/src/app/__tests__/gateway-rewrites.test.ts diff --git a/apps/web/src/app/__tests__/gateway-rewrites.test.ts b/apps/web/src/app/__tests__/gateway-rewrites.test.ts new file mode 100644 index 00000000..83548ef1 --- /dev/null +++ b/apps/web/src/app/__tests__/gateway-rewrites.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +// @ts-expect-error - next.config.mjs has no type declarations +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); + }); +}); From d22060899ff992cfc820afcb4121dee7c1625255 Mon Sep 17 00:00:00 2001 From: Mathias Klenk Date: Mon, 1 Jun 2026 10:31:27 +0300 Subject: [PATCH 10/16] test(scripts): add verify:gateway HTTP smoke for single-origin proxy Co-Authored-By: Claude Sonnet 4.6 --- package.json | 1 + scripts/verify-mcp-sse.mjs | 54 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 scripts/verify-mcp-sse.mjs diff --git a/package.json b/package.json index 7b7dcb59..fdaf24ee 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "prepare": "git config core.hooksPath .githooks 2>/dev/null || true", "bootstrap": "node scripts/setup.mjs", "check:env": "node scripts/check-env.mjs", + "verify:gateway": "node scripts/verify-mcp-sse.mjs", "build": "turbo run build", "dev": "node scripts/check-env.mjs && turbo run dev", "lint": "eslint . && node eslint-plugin-local/test/no-bare-throw-error.test.js", diff --git a/scripts/verify-mcp-sse.mjs b/scripts/verify-mcp-sse.mjs new file mode 100644 index 00000000..ca1ac53d --- /dev/null +++ b/scripts/verify-mcp-sse.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node +// Manual smoke test for the single-origin gateway rewrite. +// Prereq: web + gateway running locally (pnpm dev). +// +// What this verifies: +// 1. GET http://localhost:3000/v1/health → 200, JSON, came from gateway +// 2. GET http://localhost:3000/openapi.json → 200, JSON, has paths +// 3. POST http://localhost:3000/mcp → 401 (expected; no bearer) +// and the WWW-Authenticate header points at the single-origin URL, +// not http://localhost:8080. +// +// Pass = all checks green. Doesn't verify a full MCP session — see Task 9 +// for the Claude Desktop end-to-end procedure. +const BASE = process.env.WEB_BASE_URL || 'http://localhost:3000'; + +let failed = 0; +function check(name, cond, detail = '') { + if (cond) console.log(` \x1b[32m✓\x1b[0m ${name}`); + else { console.log(` \x1b[31m✗\x1b[0m ${name} ${detail}`); failed++; } +} + +console.log(`Verifying single-origin gateway at ${BASE}\n`); + +// 1. /v1/health +{ + const r = await fetch(`${BASE}/v1/health`); + const body = await r.json().catch(() => null); + check('GET /v1/health returns 200', r.status === 200, `status=${r.status}`); + check('GET /v1/health body is JSON', body !== null); +} + +// 2. /openapi.json +{ + const r = await fetch(`${BASE}/openapi.json`); + const body = await r.json().catch(() => null); + check('GET /openapi.json returns 200', r.status === 200, `status=${r.status}`); + check('GET /openapi.json has paths', body && typeof body.paths === 'object'); +} + +// 3. /mcp 401 + correct WWW-Authenticate +{ + const r = await fetch(`${BASE}/mcp`, { method: 'POST' }); + check('POST /mcp returns 401 (no bearer)', r.status === 401, `status=${r.status}`); + const wwwAuth = r.headers.get('www-authenticate') || ''; + check( + 'WWW-Authenticate points at single-origin host', + wwwAuth.includes(BASE) && !wwwAuth.includes('localhost:8080'), + `header=${wwwAuth || '(missing)'}`, + ); +} + +console.log(''); +if (failed) { console.error(`\x1b[31m${failed} check(s) failed\x1b[0m`); process.exit(1); } +console.log('\x1b[32mAll checks passed.\x1b[0m'); From d50de77fac13de89b1b5ad605dc7c26653c15e31 Mon Sep 17 00:00:00 2001 From: Mathias Klenk Date: Mon, 1 Jun 2026 10:34:17 +0300 Subject: [PATCH 11/16] refactor(scripts): harden verify:gateway against network errors and trailing slashes --- scripts/verify-mcp-sse.mjs | 56 +++++++++++++++++++++----------------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/scripts/verify-mcp-sse.mjs b/scripts/verify-mcp-sse.mjs index ca1ac53d..845552ca 100644 --- a/scripts/verify-mcp-sse.mjs +++ b/scripts/verify-mcp-sse.mjs @@ -11,7 +11,7 @@ // // Pass = all checks green. Doesn't verify a full MCP session — see Task 9 // for the Claude Desktop end-to-end procedure. -const BASE = process.env.WEB_BASE_URL || 'http://localhost:3000'; +const BASE = (process.env.WEB_BASE_URL || 'http://localhost:3000').replace(/\/+$/, ''); let failed = 0; function check(name, cond, detail = '') { @@ -21,32 +21,38 @@ function check(name, cond, detail = '') { console.log(`Verifying single-origin gateway at ${BASE}\n`); -// 1. /v1/health -{ - const r = await fetch(`${BASE}/v1/health`); - const body = await r.json().catch(() => null); - check('GET /v1/health returns 200', r.status === 200, `status=${r.status}`); - check('GET /v1/health body is JSON', body !== null); -} +try { + // 1. /v1/health + { + const r = await fetch(`${BASE}/v1/health`); + const body = await r.json().catch(() => null); + check('GET /v1/health returns 200', r.status === 200, `status=${r.status}`); + check('GET /v1/health body is JSON', body !== null); + } -// 2. /openapi.json -{ - const r = await fetch(`${BASE}/openapi.json`); - const body = await r.json().catch(() => null); - check('GET /openapi.json returns 200', r.status === 200, `status=${r.status}`); - check('GET /openapi.json has paths', body && typeof body.paths === 'object'); -} + // 2. /openapi.json + { + const r = await fetch(`${BASE}/openapi.json`); + const body = await r.json().catch(() => null); + check('GET /openapi.json returns 200', r.status === 200, `status=${r.status}`); + check('GET /openapi.json has paths', body && typeof body.paths === 'object'); + } -// 3. /mcp 401 + correct WWW-Authenticate -{ - const r = await fetch(`${BASE}/mcp`, { method: 'POST' }); - check('POST /mcp returns 401 (no bearer)', r.status === 401, `status=${r.status}`); - const wwwAuth = r.headers.get('www-authenticate') || ''; - check( - 'WWW-Authenticate points at single-origin host', - wwwAuth.includes(BASE) && !wwwAuth.includes('localhost:8080'), - `header=${wwwAuth || '(missing)'}`, - ); + // 3. /mcp 401 + correct WWW-Authenticate + { + const r = await fetch(`${BASE}/mcp`, { method: 'POST' }); + check('POST /mcp returns 401 (no bearer)', r.status === 401, `status=${r.status}`); + const wwwAuth = r.headers.get('www-authenticate') || ''; + check( + 'WWW-Authenticate points at single-origin host', + wwwAuth.includes(BASE) && !wwwAuth.includes('localhost:8080'), + `header=${wwwAuth || '(missing)'}`, + ); + } +} catch (e) { + console.error(`\n\x1b[31mNetwork error:\x1b[0m ${e.message}`); + console.error(`Is the dev server running at ${BASE}? Try \`pnpm dev\` in another terminal.`); + process.exit(1); } console.log(''); From 9ae0aeb25dd818e55a88e26777016124e54f363e Mon Sep 17 00:00:00 2001 From: Mathias Klenk Date: Mon, 1 Jun 2026 11:49:24 +0300 Subject: [PATCH 12/16] docs(adr): 0009 single-origin gateway via Next.js rewrites --- docs/decisions/0009-single-origin-gateway.md | 70 ++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 docs/decisions/0009-single-origin-gateway.md diff --git a/docs/decisions/0009-single-origin-gateway.md b/docs/decisions/0009-single-origin-gateway.md new file mode 100644 index 00000000..c836e6c3 --- /dev/null +++ b/docs/decisions/0009-single-origin-gateway.md @@ -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. From 9ac861eb18b2dcfd0cecde32ad847251c5dc574b Mon Sep 17 00:00:00 2001 From: Mathias Klenk Date: Mon, 1 Jun 2026 11:52:42 +0300 Subject: [PATCH 13/16] docs: explain single-origin tunneling and Railway env migration Co-Authored-By: Claude Sonnet 4.6 --- CONTRIBUTING.md | 2 ++ README.md | 25 ++++++++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ca1dca76..da12c565 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/README.md b/README.md index 8eafbd43..b6001b81 100644 --- a/README.md +++ b/README.md @@ -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 +``` + +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) @@ -223,7 +235,7 @@ 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). @@ -231,6 +243,17 @@ 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 From 461a02ddeed22b2b24ebcd9a7eb058dcfdb42420 Mon Sep 17 00:00:00 2001 From: Mathias Klenk Date: Mon, 1 Jun 2026 11:55:59 +0300 Subject: [PATCH 14/16] fix(web): drop unused @ts-expect-error from gateway-rewrites test next.config.mjs resolves its types directly in TS 5.6; the suppression is no longer needed and now fails typecheck with TS2578. --- apps/web/src/app/__tests__/gateway-rewrites.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/app/__tests__/gateway-rewrites.test.ts b/apps/web/src/app/__tests__/gateway-rewrites.test.ts index 83548ef1..89f3f4d6 100644 --- a/apps/web/src/app/__tests__/gateway-rewrites.test.ts +++ b/apps/web/src/app/__tests__/gateway-rewrites.test.ts @@ -1,5 +1,4 @@ import { describe, it, expect } from 'vitest'; -// @ts-expect-error - next.config.mjs has no type declarations import nextConfig from '../../../next.config.mjs'; describe('Next.js gateway rewrites', () => { From ac695245554c7b5336cb66656084bdf797fb8b7b Mon Sep 17 00:00:00 2001 From: Mathias Klenk Date: Mon, 1 Jun 2026 11:56:10 +0300 Subject: [PATCH 15/16] chore: align .env.example and dev origin with single-origin hostname MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .env.example: drop the MCP_PUBLIC_URL override block — ADR 0009 is the canonical reference now, .env.example stays minimal. - next.config.mjs: update allowedDevOrigins from holo-app.maakle.com to holo.maakle.com to match the new single-origin tunnel hostname. --- .env.example | 4 ---- apps/web/next.config.mjs | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 3e3fb96e..be4c331f 100644 --- a/.env.example +++ b/.env.example @@ -8,10 +8,6 @@ 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). -# Public base URL agents use to reach MCP / REST. Leave unset to inherit -# from WEB_PUBLIC_URL (single-origin mode — recommended). Set explicitly -# only when publishing the gateway on a separate hostname. -# MCP_PUBLIC_URL= # Where the web app proxies gateway-bound paths internally (Next.js rewrites). # Default works for both pnpm dev and docker compose. Never exposed publicly. diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index c640ed9b..06b2225f 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -12,7 +12,7 @@ const nextConfig = { // normalization; opt out so the reverse proxy works. skipTrailingSlashRedirect: true, allowedDevOrigins: [ - 'holo-app.maakle.com', + 'holo.maakle.com', ], async rewrites() { // Keep this fallback in sync with the GATEWAY_INTERNAL_URL default in From 0435fe8be04986176103e8c8f630d45e56ebb00e Mon Sep 17 00:00:00 2001 From: Mathias Klenk Date: Mon, 1 Jun 2026 11:56:20 +0300 Subject: [PATCH 16/16] docs(plan): add 2026-06-01 single-origin mcp gateway implementation plan --- .../2026-06-01-single-origin-mcp-gateway.md | 920 ++++++++++++++++++ 1 file changed, 920 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-01-single-origin-mcp-gateway.md diff --git a/docs/superpowers/plans/2026-06-01-single-origin-mcp-gateway.md b/docs/superpowers/plans/2026-06-01-single-origin-mcp-gateway.md new file mode 100644 index 00000000..e868af91 --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-single-origin-mcp-gateway.md @@ -0,0 +1,920 @@ +# Single-Origin MCP Gateway Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Collapse the two-host web+gateway public surface into a single origin by having the Next.js web app reverse-proxy gateway paths (`/mcp`, `/v1/*`, `/slack/*`, `/teams-bot/*`, `/google-chat-app/*`, `/.well-known/oauth-protected-resource`, `/openapi.json`, `/docs`) to the local Hono gateway via Next.js `rewrites()`. + +**Architecture:** Today the gateway is exposed at its own public hostname (`holo-gateway.maakle.com`) and the web at another (`holo-app.maakle.com`). After this change, only the web hostname is publicly exposed; the gateway stays bound to the docker network (and `localhost:8080` for direct access in dev). Next.js streams traffic through to the gateway transparently. MCP_PUBLIC_URL becomes optional and defaults to WEB_PUBLIC_URL so single-origin operators set one URL, not two. + +**Tech Stack:** Next.js 16 `rewrites()`, Hono (gateway), Docker Compose (service networking), `@holo/env` zod schema. + +**Non-goals:** +- Refactoring the gateway into Next.js API routes +- Removing the gateway's `:8080` port binding (operators using their own reverse proxy in production still need direct access) +- Changing the OAuth flow code in the gateway or web + +**Risks to validate:** +- Next.js `rewrites()` must pass through Server-Sent Events without buffering (MCP streaming depends on it). Verified manually in Task 9. +- Order of rewrite rules matters — the existing catch-all `/.well-known/:path*` → `/well-known/:path*` must come AFTER the specific `/.well-known/oauth-protected-resource` proxy rule. + +--- + +## File Structure + +**Modified:** +- `packages/env/src/index.ts` — add `GATEWAY_INTERNAL_URL`, make `MCP_PUBLIC_URL` derive from `WEB_PUBLIC_URL` when unset +- `packages/env/test/env.test.ts` — assertions for the new behavior +- `.env.example` — document `GATEWAY_INTERNAL_URL`; clarify `MCP_PUBLIC_URL` semantics +- `apps/web/next.config.mjs` — add gateway-proxy rewrites +- `docker-compose.yml` — pass `GATEWAY_INTERNAL_URL=http://gateway:8080` to web +- `packages/cli/src/commands/init.ts` — drop `MCP_PUBLIC_URL` from wizard (now derived); add note +- `packages/cli/test/init.test.ts` — update assertions on generated `.env` +- `README.md` — explain single-origin model + override path +- `CONTRIBUTING.md` — update setup notes if needed +- `docs/decisions/` — add ADR 0009 documenting the single-origin choice + +**Created:** +- `apps/web/src/app/__tests__/gateway-rewrites.test.ts` — integration smoke for rewrite presence in config +- `scripts/verify-mcp-sse.mjs` — operator-facing manual SSE verification helper (curl-based) +- `docs/decisions/0009-single-origin-gateway.md` — ADR + +**Not modified:** +- `apps/gateway/src/main.ts` — gateway code is untouched; MCP_PUBLIC_URL handling already reads an env var +- `apps/web/Dockerfile` — env vars come in at runtime via compose +- `apps/web/public/install.sh` — installer doesn't configure tunneling + +--- + +## Task 1: Add GATEWAY_INTERNAL_URL to env schema + +**Files:** +- Modify: `packages/env/src/index.ts:92-93` +- Modify: `packages/env/test/env.test.ts` + +- [ ] **Step 1: Write failing test** + +Append to `packages/env/test/env.test.ts`: + +```typescript +describe('GATEWAY_INTERNAL_URL', () => { + it('parses when set to a valid URL', () => { + const env = parseEnv({ + ...COMPLETE_ENV, + GATEWAY_INTERNAL_URL: 'http://gateway:8080', + }); + expect(env.GATEWAY_INTERNAL_URL).toBe('http://gateway:8080'); + }); + + it('defaults to http://localhost:8080 when unset', () => { + const env = parseEnv(COMPLETE_ENV); + expect(env.GATEWAY_INTERNAL_URL).toBe('http://localhost:8080'); + }); +}); +``` + +Note: `COMPLETE_ENV` is the existing inline object literal at [packages/env/test/env.test.ts:5-15](../../packages/env/test/env.test.ts#L5-L15) — already exported in the test file's scope, just reference it. + +- [ ] **Step 2: Run test to confirm it fails** + +```bash +pnpm -F @holo/env test +``` + +Expected: 2 new tests FAIL (`GATEWAY_INTERNAL_URL` is undefined on `env`). + +- [ ] **Step 3: Add the field to the schema** + +In `packages/env/src/index.ts`, after the existing `MCP_PUBLIC_URL` line (line 92): + +```typescript + MCP_PUBLIC_URL: z.url().default('http://localhost:8080'), + /** + * Where the Next.js web app proxies gateway-bound requests internally + * (Next.js rewrites). In Docker this is the compose service hostname; + * in local dev it's the gateway's published port. Never exposed publicly. + */ + GATEWAY_INTERNAL_URL: z.url().default('http://localhost:8080'), + WEB_PUBLIC_URL: z.url().optional(), +``` + +- [ ] **Step 4: Run test to confirm it passes** + +```bash +pnpm -F @holo/env test +``` + +Expected: all tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/env/src/index.ts packages/env/test/env.test.ts +git commit -m "feat(env): add GATEWAY_INTERNAL_URL for single-origin proxy" +``` + +--- + +## Task 2: Make MCP_PUBLIC_URL derive from WEB_PUBLIC_URL when unset + +**Files:** +- Modify: `packages/env/src/index.ts` (the schema + a post-parse derivation) +- Modify: `packages/env/test/env.test.ts` + +**Why this design:** In single-origin mode, `MCP_PUBLIC_URL` is always identical to `WEB_PUBLIC_URL`. Forcing operators to set both is a paper cut. We keep `MCP_PUBLIC_URL` as a real field (so gateway code at [apps/gateway/src/main.ts:30,55,83,98](../../apps/gateway/src/main.ts) doesn't need to change), but auto-fill it from `WEB_PUBLIC_URL` when unset. Two-origin operators still set both explicitly. + +- [ ] **Step 1: Write failing test** + +Append to `packages/env/test/env.test.ts`: + +```typescript +describe('MCP_PUBLIC_URL derivation', () => { + it('defaults to WEB_PUBLIC_URL when MCP_PUBLIC_URL is unset', () => { + const env = parseEnv({ + ...COMPLETE_ENV, + WEB_PUBLIC_URL: 'https://holo.example.com', + MCP_PUBLIC_URL: undefined, + }); + expect(env.MCP_PUBLIC_URL).toBe('https://holo.example.com'); + }); + + it('keeps MCP_PUBLIC_URL when explicitly set (two-origin mode)', () => { + const env = parseEnv({ + ...COMPLETE_ENV, + WEB_PUBLIC_URL: 'https://holo.example.com', + MCP_PUBLIC_URL: 'https://gateway.example.com', + }); + expect(env.MCP_PUBLIC_URL).toBe('https://gateway.example.com'); + }); + + it('falls back to localhost:3000 when neither is set (dev default)', () => { + const env = parseEnv({ + ...COMPLETE_ENV, + WEB_PUBLIC_URL: undefined, + MCP_PUBLIC_URL: undefined, + }); + expect(env.MCP_PUBLIC_URL).toBe('http://localhost:3000'); + }); +}); +``` + +- [ ] **Step 2: Run test to confirm it fails** + +```bash +pnpm -F @holo/env test +``` + +Expected: the first two tests FAIL (current default is `http://localhost:8080`, not derived); the third also FAILs. + +- [ ] **Step 3: Change the schema default and add a post-parse derivation** + +In `packages/env/src/index.ts`: + +Change line 92 from: +```typescript + MCP_PUBLIC_URL: z.url().default('http://localhost:8080'), +``` +to: +```typescript + /** + * Base URL agents use to reach the MCP gateway. In single-origin mode + * (the default) this equals WEB_PUBLIC_URL; the Next.js app proxies + * `/mcp` and friends to the gateway internally. Set this explicitly + * only if you're publishing the gateway on a separate hostname. + */ + MCP_PUBLIC_URL: z.url().optional(), +``` + +Update the `parseEnv` function (around line 156) to derive the value when missing: + +```typescript +export function parseEnv(raw: Record): Env { + const result = EnvSchema.safeParse(raw); + if (!result.success) { + const issues = result.error.issues + .map((i) => `${i.path.join('.')}: ${i.message}`) + .join('; '); + throw holoError({ + code: ErrorCode.HOLO_ENV_INVALID, + problem: 'environment variables are missing or invalid', + cause: issues, + fix: 'Verify your .env file matches .env.example. Generate secrets with `openssl rand -base64 32`.', + }); + } + const env = result.data; + // Single-origin convenience: MCP_PUBLIC_URL defaults to WEB_PUBLIC_URL, + // then to BETTER_AUTH_URL (which is required and always set in dev/prod). + if (!env.MCP_PUBLIC_URL) { + env.MCP_PUBLIC_URL = env.WEB_PUBLIC_URL ?? env.BETTER_AUTH_URL; + } + return env; +} +``` + +You also need to widen the `Env` type so `MCP_PUBLIC_URL` is non-optional in the returned shape. Add right above the `export function parseEnv`: + +```typescript +export type Env = z.infer & { MCP_PUBLIC_URL: string }; +``` + +(Replace the existing `export type Env = z.infer;` line.) + +- [ ] **Step 4: Run test to confirm it passes** + +```bash +pnpm -F @holo/env test +``` + +Expected: all tests PASS. Note: the third test expects `http://localhost:3000` because `BETTER_AUTH_URL` defaults to that in the minimal valid env helper. + +- [ ] **Step 5: Verify gateway code still compiles** + +```bash +pnpm -F @holo/gateway typecheck +``` + +Expected: PASS. (`apps/gateway/src/main.ts` reads `env.MCP_PUBLIC_URL` as a non-optional string; this still works because the post-parse fill guarantees it.) + +- [ ] **Step 6: Commit** + +```bash +git add packages/env/src/index.ts packages/env/test/env.test.ts +git commit -m "feat(env): derive MCP_PUBLIC_URL from WEB_PUBLIC_URL when unset" +``` + +--- + +## Task 3: Update .env.example with new env var and clarified semantics + +**Files:** +- Modify: `.env.example` + +- [ ] **Step 1: Update .env.example** + +Find the existing `MCP_PUBLIC_URL=http://localhost:8080` line (around line 11) and replace with: + +```bash +# Public base URL agents use to reach MCP / REST. Leave unset to inherit +# from WEB_PUBLIC_URL (single-origin mode — recommended). Set explicitly +# only when publishing the gateway on a separate hostname. +# MCP_PUBLIC_URL= + +# 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 +``` + +- [ ] **Step 2: Verify pnpm bootstrap still produces a working .env** + +```bash +# In a scratch dir to avoid clobbering your real .env +cp .env /tmp/.env.bak +rm .env +pnpm bootstrap +diff <(grep -o '^[A-Z_]*=' /tmp/.env.bak | sort -u) <(grep -o '^[A-Z_]*=' .env | sort -u) +# Restore +mv /tmp/.env.bak .env +``` + +Expected: the only differences are the documented variables changing (`MCP_PUBLIC_URL` removed/commented, `GATEWAY_INTERNAL_URL` added). + +- [ ] **Step 3: Commit** + +```bash +git add .env.example +git commit -m "docs(env): document GATEWAY_INTERNAL_URL and MCP_PUBLIC_URL derivation" +``` + +--- + +## Task 4: Add Next.js rewrites to proxy gateway paths + +**Files:** +- Modify: `apps/web/next.config.mjs` + +**Why this ordering:** Next.js evaluates rewrite rules in order. The specific `/.well-known/oauth-protected-resource` proxy MUST come before the existing catch-all `/.well-known/:path*` → `/well-known/:path*` rule, or the catch-all would intercept it. + +- [ ] **Step 1: Read the current rewrites block** + +```bash +sed -n '25,50p' apps/web/next.config.mjs +``` + +Confirm the current `rewrites()` returns the array with `/.well-known/:path*` and `/ingest/*` rules. + +- [ ] **Step 2: Replace the rewrites block** + +Edit `apps/web/next.config.mjs` and replace the entire `async rewrites()` function with: + +```javascript + async rewrites() { + 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 served by the gateway. MUST + // come before the well-known catch-all below, which would otherwise + // route to the web's local /well-known/* handler. + { + 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*`, + }, + { + source: '/ingest/:path*', + destination: `${POSTHOG_HOST}/:path*`, + }, + ]; + }, +``` + +- [ ] **Step 3: Typecheck the change** + +```bash +pnpm -F @holo/web typecheck +``` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/next.config.mjs +git commit -m "feat(web): proxy gateway paths via Next.js rewrites (single-origin)" +``` + +--- + +## Task 5: Pass GATEWAY_INTERNAL_URL to web in Docker compose + +**Files:** +- Modify: `docker-compose.yml` + +- [ ] **Step 1: Read the web service block** + +```bash +sed -n '75,95p' docker-compose.yml +``` + +Confirm the web service uses `environment: *app_env`. + +- [ ] **Step 2: Override + extend the web service env** + +In `docker-compose.yml`, replace the `web:` block with: + +```yaml + web: + profiles: ["app"] + build: + context: . + dockerfile: apps/web/Dockerfile + 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 } +``` + +Note the new `depends_on: gateway` — web must wait for gateway to be up, or proxied requests fail at boot. + +- [ ] **Step 3: Validate compose file** + +```bash +docker compose --profile app config > /dev/null +``` + +Expected: no errors (exit 0). + +- [ ] **Step 4: Commit** + +```bash +git add docker-compose.yml +git commit -m "feat(compose): wire GATEWAY_INTERNAL_URL for single-origin web proxy" +``` + +--- + +## Task 6: Drop MCP_PUBLIC_URL from CLI init wizard + +**Files:** +- Modify: `packages/cli/src/commands/init.ts:145` +- Modify: `packages/cli/test/init.test.ts` (any assertion on `MCP_PUBLIC_URL`) + +- [ ] **Step 1: Check what the init test asserts** + +```bash +grep -n "MCP_PUBLIC_URL" packages/cli/test/init.test.ts +``` + +If matches exist, note line numbers; tests will need updating in Step 3. + +- [ ] **Step 2: Remove MCP_PUBLIC_URL from the generated env** + +In `packages/cli/src/commands/init.ts`, find the `envLines` array (around line 133). Remove the line: + +```typescript + `MCP_PUBLIC_URL=http://localhost:8080`, +``` + +Add this comment above the `WEB_PUBLIC_URL` line in the same array: + +```typescript + // MCP_PUBLIC_URL is derived from WEB_PUBLIC_URL in single-origin mode. + // Set it explicitly only when publishing the gateway on a separate host. + `WEB_PUBLIC_URL=http://localhost:3000`, +``` + +(Replace the existing `WEB_PUBLIC_URL=http://localhost:3000` line; if it doesn't exist, add it. Verify by reading the file first.) + +- [ ] **Step 3: Update tests** + +If Step 1 found assertions on `MCP_PUBLIC_URL` in the generated env, remove them. Add a new assertion that `WEB_PUBLIC_URL` is present: + +```typescript +expect(written).toContain('WEB_PUBLIC_URL=http://localhost:3000'); +expect(written).not.toContain('MCP_PUBLIC_URL='); +``` + +- [ ] **Step 4: Run CLI tests** + +```bash +pnpm -F @holo/cli test +``` + +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/commands/init.ts packages/cli/test/init.test.ts +git commit -m "feat(cli): drop MCP_PUBLIC_URL from init (now derived from WEB_PUBLIC_URL)" +``` + +--- + +## Task 7: Add a rewrite-presence smoke test for the web app + +**Files:** +- Create: `apps/web/src/app/__tests__/gateway-rewrites.test.ts` + +**Why this design:** A full integration test would require booting both web and gateway, which Vitest isn't set up for in `apps/web`. Instead, import the next.config and assert the rewrite array contains every path prefix the gateway exposes. Cheap, catches regressions if someone deletes a rewrite by accident. + +- [ ] **Step 1: Write the test** + +Create `apps/web/src/app/__tests__/gateway-rewrites.test.ts`: + +```typescript +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) => 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) => r.source === '/.well-known/oauth-protected-resource', + ); + const catchallIdx = rules.findIndex( + (r) => r.source === '/.well-known/:path*', + ); + expect(specificIdx).toBeGreaterThanOrEqual(0); + expect(catchallIdx).toBeGreaterThanOrEqual(0); + expect(specificIdx).toBeLessThan(catchallIdx); + }); +}); +``` + +- [ ] **Step 2: Run the test** + +```bash +pnpm -F @holo/web test +``` + +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/app/__tests__/gateway-rewrites.test.ts +git commit -m "test(web): assert gateway rewrites cover Hono surface and respect order" +``` + +--- + +## Task 8: Add a real HTTP smoke test for the proxy + +**Files:** +- Create: `scripts/verify-mcp-sse.mjs` + +**Why this design:** SSE behavior through `rewrites()` can't be unit-tested without booting both processes. This script is an operator-runnable helper that drives the boot, hits a known SSE endpoint, and reports pass/fail. Run it in CI later, but for now it's documentation + a manual gate before merge. + +- [ ] **Step 1: Write the verifier script** + +Create `scripts/verify-mcp-sse.mjs`: + +```javascript +#!/usr/bin/env node +// Manual smoke test for the single-origin gateway rewrite. +// Prereq: web + gateway running locally (pnpm dev). +// +// What this verifies: +// 1. GET http://localhost:3000/v1/health → 200, JSON, came from gateway +// 2. GET http://localhost:3000/openapi.json → 200, JSON, has paths +// 3. POST http://localhost:3000/mcp → 401 (expected; no bearer) +// and the WWW-Authenticate header points at the single-origin URL, +// not http://localhost:8080. +// +// Pass = all three checks green. Doesn't verify a full MCP session — see +// Task 9 for the Claude Desktop end-to-end procedure. +const BASE = process.env.WEB_BASE_URL || 'http://localhost:3000'; + +let failed = 0; +function check(name, cond, detail = '') { + if (cond) console.log(` \x1b[32m✓\x1b[0m ${name}`); + else { console.log(` \x1b[31m✗\x1b[0m ${name} ${detail}`); failed++; } +} + +console.log(`Verifying single-origin gateway at ${BASE}\n`); + +// 1. /v1/health +{ + const r = await fetch(`${BASE}/v1/health`); + const body = await r.json().catch(() => null); + check('GET /v1/health returns 200', r.status === 200, `status=${r.status}`); + check('GET /v1/health body is JSON', body !== null); +} + +// 2. /openapi.json +{ + const r = await fetch(`${BASE}/openapi.json`); + const body = await r.json().catch(() => null); + check('GET /openapi.json returns 200', r.status === 200, `status=${r.status}`); + check('GET /openapi.json has paths', body && typeof body.paths === 'object'); +} + +// 3. /mcp 401 + correct WWW-Authenticate +{ + const r = await fetch(`${BASE}/mcp`, { method: 'POST' }); + check('POST /mcp returns 401 (no bearer)', r.status === 401, `status=${r.status}`); + const wwwAuth = r.headers.get('www-authenticate') || ''; + check( + 'WWW-Authenticate points at single-origin host', + wwwAuth.includes(BASE) && !wwwAuth.includes('localhost:8080'), + `header=${wwwAuth || '(missing)'}`, + ); +} + +console.log(''); +if (failed) { console.error(`\x1b[31m${failed} check(s) failed\x1b[0m`); process.exit(1); } +console.log('\x1b[32mAll checks passed.\x1b[0m'); +``` + +Add to `package.json` scripts: + +```json +"verify:gateway": "node scripts/verify-mcp-sse.mjs" +``` + +(Add the line under the existing `"check:env"` entry in [package.json](../../package.json).) + +- [ ] **Step 2: Manually verify the script runs** + +In one terminal: `pnpm dev` (must still be running). In another: + +```bash +pnpm verify:gateway +``` + +Expected: all 6 checks PASS. If `WWW-Authenticate` still mentions `localhost:8080`, the gateway is advertising its own URL instead of the single-origin one — set `MCP_PUBLIC_URL=http://localhost:3000` in `.env` (or unset it so it derives from `BETTER_AUTH_URL`). + +- [ ] **Step 3: Commit** + +```bash +git add scripts/verify-mcp-sse.mjs package.json +git commit -m "test(scripts): add verify:gateway HTTP smoke for single-origin proxy" +``` + +--- + +## Task 9: Manually verify MCP SSE through the rewrite with Claude Desktop + +**Files:** none modified — this is a manual gate. + +**Why this matters:** The whole rewrite design hinges on Next.js streaming SSE through without buffering. Pure HTTP checks (Task 8) catch most regressions but not SSE-specific buffering bugs. + +- [ ] **Step 1: Start the stack and a tunnel** + +```bash +# Terminal 1 +pnpm dev + +# Terminal 2 — pick whichever tunnel you have +cloudflared tunnel run holo-dev # or: ngrok http 3000 +``` + +Record the public URL (e.g. `https://holo.maakle.com`). + +- [ ] **Step 2: Set MCP_PUBLIC_URL to the public URL temporarily** + +Edit `.env`: + +```bash +MCP_PUBLIC_URL=https://holo.maakle.com +WEB_PUBLIC_URL=https://holo.maakle.com +BETTER_AUTH_URL=https://holo.maakle.com +``` + +Restart `pnpm dev` to pick up the change. + +- [ ] **Step 3: Configure Claude Desktop** + +In `~/Library/Application Support/Claude/claude_desktop_config.json` add (or update): + +```json +{ + "mcpServers": { + "holo-single-origin": { + "url": "https://holo.maakle.com/mcp" + } + } +} +``` + +Restart Claude Desktop. + +- [ ] **Step 4: Run a tool call from Claude Desktop** + +Open Claude Desktop, complete the OAuth flow if prompted, then ask Claude to call the `search` tool with a simple query. Watch for: + +- The OAuth flow completes (proves `/.well-known/oauth-protected-resource` is served at the single origin) +- The tool list loads (proves `POST /mcp` initialization streams correctly) +- A tool call returns results (proves bidirectional streaming) + +Pass criterion: a tool call round-trips successfully. + +- [ ] **Step 5: Document the verification in the ADR (next task)** + +If pass: proceed to Task 10. If fail: stop, investigate buffering in Next.js logs, and either tune the rewrite or fall back to the cloudflared path-routing approach documented in the ADR's "Alternatives considered" section. + +--- + +## Task 10: Write ADR 0009 documenting the choice + +**Files:** +- Create: `docs/decisions/0009-single-origin-gateway.md` + +- [ ] **Step 1: Find the existing ADR template** + +```bash +cat docs/decisions/0005-github-app-over-oauth.md | head -40 +``` + +Match its structure (front matter, sections). + +- [ ] **Step 2: Write the ADR** + +Create `docs/decisions/0009-single-origin-gateway.md`: + +```markdown +# 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 (we hit a `/Users/maakle` typo this session) +- 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: +- `/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 + +`MCP_PUBLIC_URL` becomes optional in [`packages/env/src/index.ts`](../../packages/env/src/index.ts) and defaults to `WEB_PUBLIC_URL`. Two-origin operators can still publish the gateway separately by setting `MCP_PUBLIC_URL` explicitly; the gateway code is unchanged. + +## 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 and put their own reverse proxy in front of both. The gateway's `:8080` port is intentionally still published in `docker-compose.yml` to make this possible. + +## 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. + +**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 + +Single-origin SSE is verified end-to-end against Claude Desktop on the date this ADR is committed (see Task 9 of the implementation plan, [`docs/superpowers/plans/2026-06-01-single-origin-mcp-gateway.md`](../superpowers/plans/2026-06-01-single-origin-mcp-gateway.md)). The lightweight HTTP smoke lives at [`scripts/verify-mcp-sse.mjs`](../../scripts/verify-mcp-sse.mjs). +``` + +- [ ] **Step 3: Commit** + +```bash +git add docs/decisions/0009-single-origin-gateway.md +git commit -m "docs(adr): 0009 single-origin gateway via Next.js rewrites" +``` + +--- + +## Task 11: Update README and CONTRIBUTING for the new model + +**Files:** +- Modify: `README.md` (Quickstart and Development sections) +- Modify: `CONTRIBUTING.md` (Setup section) + +- [ ] **Step 1: Find the relevant README sections** + +```bash +grep -n "MCP_PUBLIC_URL\|gateway\|tunnel" README.md +``` + +Note the line ranges that mention the gateway URL or two-host setup. + +- [ ] **Step 2: Add a "Public URL" subsection to README's Quickstart** + +After the existing self-host quickstart code block, add: + +```markdown +### One public URL + +Self-hosters need **one** public URL (DNS + TLS + tunnel/proxy) pointing at `:3000`. The web app reverse-proxies agent traffic (`/mcp`, `/v1/*`, webhooks) to the gateway internally. See [ADR 0009](./docs/decisions/0009-single-origin-gateway.md) for the rationale and the two-origin override. + +For a quick local tunnel: + +\`\`\`bash +ngrok http 3000 # or: cloudflared tunnel run +\`\`\` + +Then set `WEB_PUBLIC_URL` (and `BETTER_AUTH_URL`) in `.env` to the public URL and restart. +``` + +(Use real backticks, not escaped ones, when writing the file.) + +- [ ] **Step 3: Update CONTRIBUTING.md** + +Find the existing setup block and add a one-line note below it: + +```markdown +**Public testing:** if you need a public URL for OAuth or MCP testing, run `ngrok http 3000` and set `WEB_PUBLIC_URL` in `.env` to the tunnel URL — one tunnel is enough. See [ADR 0009](./docs/decisions/0009-single-origin-gateway.md). +``` + +- [ ] **Step 4: Commit** + +```bash +git add README.md CONTRIBUTING.md +git commit -m "docs: document single-origin tunneling for self-host and dev" +``` + +--- + +## Task 12: Final integration verification + +**Files:** none modified. + +- [ ] **Step 1: Clean slate test** + +```bash +docker compose --profile app down -v +docker compose --profile app up -d --build +sleep 30 +curl -sf http://localhost:3000/v1/health | jq . +curl -sf -o /dev/null -w "%{http_code}\n" http://localhost:3000/openapi.json +curl -sf -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/mcp +``` + +Expected: `/v1/health` returns JSON; `/openapi.json` returns `200`; `/mcp` returns `401`. + +- [ ] **Step 2: Run the full test suite** + +```bash +pnpm test +``` + +Expected: all tests PASS. If anything fails that touched env/cli/web, revisit the relevant task. + +- [ ] **Step 3: Run the operator smoke** + +```bash +pnpm verify:gateway +``` + +Expected: all 6 checks PASS. + +- [ ] **Step 4: Clean up** + +```bash +docker compose --profile app down +``` + +- [ ] **Step 5: Open the PR** + +```bash +git push -u origin feat/single-origin-mcp-gateway +gh pr create --title "feat: collapse web+gateway into single public origin" --body "$(cat <<'EOF' +## Summary +- Next.js web app proxies `/mcp`, `/v1/*`, `/slack/*`, `/teams-bot/*`, `/google-chat-app/*`, `/openapi.json`, `/docs`, and `/.well-known/oauth-protected-resource` to the local gateway via `rewrites()`. +- `MCP_PUBLIC_URL` now optional — defaults to `WEB_PUBLIC_URL`. Two-origin operators unaffected (set it explicitly). +- New `GATEWAY_INTERNAL_URL` env tells Next.js where the gateway lives internally. +- Compose web service waits on gateway and gets `GATEWAY_INTERNAL_URL=http://gateway:8080`. + +## Test plan +- [x] `pnpm -F @holo/env test` — env derivation +- [x] `pnpm -F @holo/cli test` — init wizard +- [x] `pnpm -F @holo/web test` — rewrite presence + ordering +- [x] `pnpm verify:gateway` — live HTTP smoke +- [x] Claude Desktop tool call through tunneled single origin + +See [ADR 0009](./docs/decisions/0009-single-origin-gateway.md) for the design rationale. +EOF +)" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** every gateway path prefix from the explore report (Task 4) has a rewrite rule. MCP_PUBLIC_URL derivation (Task 2) preserves two-origin mode for operators who want it. +- **Streaming risk:** explicitly gated by manual Task 9. If it fails, ADR documents the cloudflared path-routing fallback. +- **Order dependency:** Task 4 step 2 spells out why `/.well-known/oauth-protected-resource` must precede the well-known catch-all, and Task 7 step 1 asserts that ordering. +- **No env regression:** Task 6 removes `MCP_PUBLIC_URL` from the wizard but the parseEnv derivation guarantees `env.MCP_PUBLIC_URL` is always a string at runtime, so gateway code at [`apps/gateway/src/main.ts:30`](../../apps/gateway/src/main.ts#L30) continues to work without a code change. +- **Rollback story:** revert the branch — schema change is forward-compatible (extra var with default), rewrites are additive, gateway code untouched. Operators on two-origin setups continue to work because `MCP_PUBLIC_URL` is honored when explicitly set.