diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index e0eafa1b..b919918c 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -240,6 +240,11 @@ const EnvironmentSchema = z // Resend RESEND_WEBHOOK_SECRET: z.string().optional(), + // Default gateway auto-registration + COREBRAIN_DEFAULT_GATEWAY_URL: z.string().url().optional(), + COREBRAIN_DEFAULT_GATEWAY_NAME: z.string().min(1).max(64).default("local-gateway"), + COREBRAIN_DEFAULT_GATEWAY_SECURITY_KEY: z.string().optional(), + // Sentry SENTRY_DSN: z.string().optional(), }) diff --git a/apps/webapp/app/models/workspace.server.ts b/apps/webapp/app/models/workspace.server.ts index 03ad9bbb..3c5a535a 100644 --- a/apps/webapp/app/models/workspace.server.ts +++ b/apps/webapp/app/models/workspace.server.ts @@ -2,6 +2,7 @@ import { type Workspace } from "@core/database"; import { prisma } from "~/db.server"; import { ensureBillingInitialized } from "~/services/billing.server"; import { ensureDefaultProviders } from "~/services/llm-provider.server"; +import { maybeRegisterDefaultGateway } from "~/services/gateway/default.server"; import { sendEmail } from "~/services/email.server"; import { logger } from "~/services/logger.service"; import { LabelService } from "~/services/label.server"; @@ -53,6 +54,16 @@ export async function createWorkspace( await ensureBillingInitialized(workspace.id, input.userId); await ensureDefaultProviders(); + // Auto-register the default gateway if configured via env vars. + try { + await maybeRegisterDefaultGateway({ + workspaceId: workspace.id, + userId: input.userId, + }); + } catch (e) { + logger.error(`[default-gateway] unexpected error during auto-registration: ${e}`); + } + // Create persona document and label try { const labelService = new LabelService(); diff --git a/apps/webapp/app/services/gateway/__tests__/default.server.test.ts b/apps/webapp/app/services/gateway/__tests__/default.server.test.ts new file mode 100644 index 00000000..3cb0a0d5 --- /dev/null +++ b/apps/webapp/app/services/gateway/__tests__/default.server.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// ── Mock env ──────────────────────────────────────────────────────────────── +// We control the env object so tests can override individual vars. +const mockEnv = { + COREBRAIN_DEFAULT_GATEWAY_URL: undefined as string | undefined, + COREBRAIN_DEFAULT_GATEWAY_SECURITY_KEY: undefined as string | undefined, + COREBRAIN_DEFAULT_GATEWAY_NAME: "local-gateway", +}; + +vi.mock("~/env.server", () => ({ env: mockEnv })); + +// ── Mock registerGateway ──────────────────────────────────────────────────── +const mockRegisterGateway = vi.fn(); +vi.mock("../register.server", () => ({ registerGateway: mockRegisterGateway })); + +// ── Mock logger ───────────────────────────────────────────────────────────── +const mockLogger = { warn: vi.fn(), info: vi.fn(), error: vi.fn() }; +vi.mock("~/services/logger.service", () => ({ logger: mockLogger })); + +// Import AFTER mocks are registered +import { maybeRegisterDefaultGateway } from "../default.server"; + +// ── Helpers ────────────────────────────────────────────────────────────────── +const WORKSPACE_ID = "ws-test-001"; +const USER_ID = "user-test-001"; +const GATEWAY_URL = "http://corebrain-gateway:7787"; +const SECURITY_KEY = "test-security-key-32chars-minimum"; + +function callSubject() { + return maybeRegisterDefaultGateway({ workspaceId: WORKSPACE_ID, userId: USER_ID }); +} + +describe("maybeRegisterDefaultGateway", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockEnv.COREBRAIN_DEFAULT_GATEWAY_URL = undefined; + mockEnv.COREBRAIN_DEFAULT_GATEWAY_SECURITY_KEY = undefined; + mockEnv.COREBRAIN_DEFAULT_GATEWAY_NAME = "local-gateway"; + }); + + // ── Guard conditions ─────────────────────────────────────────────────────── + + it("returns without calling registerGateway when URL is not set", async () => { + mockEnv.COREBRAIN_DEFAULT_GATEWAY_SECURITY_KEY = SECURITY_KEY; + await callSubject(); + expect(mockRegisterGateway).not.toHaveBeenCalled(); + }); + + it("returns without calling registerGateway when security key is not set", async () => { + mockEnv.COREBRAIN_DEFAULT_GATEWAY_URL = GATEWAY_URL; + await callSubject(); + expect(mockRegisterGateway).not.toHaveBeenCalled(); + }); + + it("returns without calling registerGateway when both vars are absent", async () => { + await callSubject(); + expect(mockRegisterGateway).not.toHaveBeenCalled(); + }); + + it("returns without calling registerGateway when URL is empty string", async () => { + mockEnv.COREBRAIN_DEFAULT_GATEWAY_URL = ""; + mockEnv.COREBRAIN_DEFAULT_GATEWAY_SECURITY_KEY = SECURITY_KEY; + await callSubject(); + expect(mockRegisterGateway).not.toHaveBeenCalled(); + }); + + it("returns without calling registerGateway when security key is empty string", async () => { + mockEnv.COREBRAIN_DEFAULT_GATEWAY_URL = GATEWAY_URL; + mockEnv.COREBRAIN_DEFAULT_GATEWAY_SECURITY_KEY = ""; + await callSubject(); + expect(mockRegisterGateway).not.toHaveBeenCalled(); + }); + + // ── Successful registration ──────────────────────────────────────────────── + + it("calls registerGateway with correct args when both env vars are set", async () => { + mockEnv.COREBRAIN_DEFAULT_GATEWAY_URL = GATEWAY_URL; + mockEnv.COREBRAIN_DEFAULT_GATEWAY_SECURITY_KEY = SECURITY_KEY; + mockRegisterGateway.mockResolvedValue({ ok: true, gatewayId: "gw-123" }); + + await callSubject(); + + expect(mockRegisterGateway).toHaveBeenCalledOnce(); + expect(mockRegisterGateway).toHaveBeenCalledWith({ + baseUrl: GATEWAY_URL, + securityKey: SECURITY_KEY, + name: "local-gateway", + workspaceId: WORKSPACE_ID, + userId: USER_ID, + }); + }); + + it("uses COREBRAIN_DEFAULT_GATEWAY_NAME from env", async () => { + mockEnv.COREBRAIN_DEFAULT_GATEWAY_URL = GATEWAY_URL; + mockEnv.COREBRAIN_DEFAULT_GATEWAY_SECURITY_KEY = SECURITY_KEY; + mockEnv.COREBRAIN_DEFAULT_GATEWAY_NAME = "my-custom-gateway"; + mockRegisterGateway.mockResolvedValue({ ok: true, gatewayId: "gw-456" }); + + await callSubject(); + + expect(mockRegisterGateway).toHaveBeenCalledWith( + expect.objectContaining({ name: "my-custom-gateway" }), + ); + }); + + it("logs info on successful registration", async () => { + mockEnv.COREBRAIN_DEFAULT_GATEWAY_URL = GATEWAY_URL; + mockEnv.COREBRAIN_DEFAULT_GATEWAY_SECURITY_KEY = SECURITY_KEY; + mockRegisterGateway.mockResolvedValue({ ok: true, gatewayId: "gw-789" }); + + await callSubject(); + + expect(mockLogger.info).toHaveBeenCalledOnce(); + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringContaining("[default-gateway]"), + ); + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); + + // ── Failed registration ──────────────────────────────────────────────────── + + it("logs warn and does not throw when registerGateway returns ok: false", async () => { + mockEnv.COREBRAIN_DEFAULT_GATEWAY_URL = GATEWAY_URL; + mockEnv.COREBRAIN_DEFAULT_GATEWAY_SECURITY_KEY = SECURITY_KEY; + mockRegisterGateway.mockResolvedValue({ ok: false, error: "gateway unreachable" }); + + await expect(callSubject()).resolves.toBeUndefined(); + + expect(mockLogger.warn).toHaveBeenCalledOnce(); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining("[default-gateway]"), + ); + expect(mockLogger.info).not.toHaveBeenCalled(); + }); + + it("includes the error message in the warn log", async () => { + mockEnv.COREBRAIN_DEFAULT_GATEWAY_URL = GATEWAY_URL; + mockEnv.COREBRAIN_DEFAULT_GATEWAY_SECURITY_KEY = SECURITY_KEY; + mockRegisterGateway.mockResolvedValue({ ok: false, error: "gateway unreachable" }); + + await callSubject(); + + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining("gateway unreachable"), + ); + }); + + // ── Error resilience ─────────────────────────────────────────────────────── + + it("does not throw when registerGateway rejects", async () => { + mockEnv.COREBRAIN_DEFAULT_GATEWAY_URL = GATEWAY_URL; + mockEnv.COREBRAIN_DEFAULT_GATEWAY_SECURITY_KEY = SECURITY_KEY; + mockRegisterGateway.mockRejectedValue(new Error("network timeout")); + + await expect(callSubject()).resolves.toBeUndefined(); + }); + + it("logs warn (not re-throws) when registerGateway throws", async () => { + mockEnv.COREBRAIN_DEFAULT_GATEWAY_URL = GATEWAY_URL; + mockEnv.COREBRAIN_DEFAULT_GATEWAY_SECURITY_KEY = SECURITY_KEY; + mockRegisterGateway.mockRejectedValue(new Error("network timeout")); + + await callSubject(); + + expect(mockLogger.warn).toHaveBeenCalledOnce(); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining("[default-gateway]"), + ); + }); + + // ── Idempotency (via registerGateway contract) ───────────────────────────── + + it("passes the same args on repeated calls (idempotency delegated to registerGateway)", async () => { + mockEnv.COREBRAIN_DEFAULT_GATEWAY_URL = GATEWAY_URL; + mockEnv.COREBRAIN_DEFAULT_GATEWAY_SECURITY_KEY = SECURITY_KEY; + mockRegisterGateway.mockResolvedValue({ ok: true, gatewayId: "gw-123" }); + + await callSubject(); + await callSubject(); + + expect(mockRegisterGateway).toHaveBeenCalledTimes(2); + expect(mockRegisterGateway).toHaveBeenNthCalledWith(1, { + baseUrl: GATEWAY_URL, + securityKey: SECURITY_KEY, + name: "local-gateway", + workspaceId: WORKSPACE_ID, + userId: USER_ID, + }); + expect(mockRegisterGateway).toHaveBeenNthCalledWith(2, { + baseUrl: GATEWAY_URL, + securityKey: SECURITY_KEY, + name: "local-gateway", + workspaceId: WORKSPACE_ID, + userId: USER_ID, + }); + }); +}); diff --git a/apps/webapp/app/services/gateway/default.server.ts b/apps/webapp/app/services/gateway/default.server.ts new file mode 100644 index 00000000..ad3787fb --- /dev/null +++ b/apps/webapp/app/services/gateway/default.server.ts @@ -0,0 +1,36 @@ +import { env } from "~/env.server"; +import { registerGateway } from "./register.server"; +import { logger } from "~/services/logger.service"; + +export async function maybeRegisterDefaultGateway({ + workspaceId, + userId, +}: { + workspaceId: string; + userId: string; +}): Promise { + const url = env.COREBRAIN_DEFAULT_GATEWAY_URL; + const key = env.COREBRAIN_DEFAULT_GATEWAY_SECURITY_KEY; + if (!url || !key) return; + + try { + const result = await registerGateway({ + baseUrl: url, + securityKey: key, + name: env.COREBRAIN_DEFAULT_GATEWAY_NAME, + workspaceId, + userId, + }); + if (!result.ok) { + logger.warn( + `[default-gateway] auto-registration failed: ${result.error}`, + ); + } else { + logger.info( + `[default-gateway] registered gateway ${result.gatewayId} for workspace ${workspaceId}`, + ); + } + } catch (err) { + logger.warn(`[default-gateway] auto-registration threw: ${err}`); + } +} diff --git a/hosting/docker/.env.example b/hosting/docker/.env.example index c743a086..fad2fa55 100644 --- a/hosting/docker/.env.example +++ b/hosting/docker/.env.example @@ -85,3 +85,40 @@ POSTHOG_PROJECT_KEY=phc_SwfGIzzX5gh5bazVWoRxZTBhkr7FwvzArS0NRyGXm1a GRAPH_PROVIDER=neo4j VECTOR_PROVIDER=pgvector MODEL_PROVIDER=vercel-ai + +########### Gateway (optional) ############ +# Add the gateway alongside the core stack with: +# docker compose \ +# -f hosting/docker/docker-compose.yaml \ +# -f hosting/gateway/docker-compose.yaml \ +# up -d +# (run from the repo root; this file is loaded automatically as the .env for both services) +# +# Step 1 — Pre-set a security key so you don't need to copy it from logs. +# Generate with: openssl rand -hex 32 +COREBRAIN_GATEWAY_SECURITY_KEY= + +# Where the gateway reports back to (your CORE webapp). +# Docker Desktop: http://host.docker.internal:3033 +# Linux server: your server's public URL or IP, e.g. http://192.168.1.10:3033 +COREBRAIN_API_URL= + +# Your personal access token from Core → Settings → Tokens. +COREBRAIN_API_KEY= + +# Step 2 (optional) — Auto-register the gateway for every new workspace so no +# manual "Register gateway" UI step is needed. +# COREBRAIN_DEFAULT_GATEWAY_SECURITY_KEY: same value as COREBRAIN_GATEWAY_SECURITY_KEY above. +# COREBRAIN_DEFAULT_GATEWAY_URL: URL where the webapp container can reach the gateway. +# Docker Desktop: http://host.docker.internal:7787 +# Linux server: your server's public URL or IP, e.g. http://192.168.1.10:7787 +COREBRAIN_DEFAULT_GATEWAY_SECURITY_KEY= +COREBRAIN_DEFAULT_GATEWAY_URL= +COREBRAIN_DEFAULT_GATEWAY_NAME=local-gateway + +# Public-facing port mapping. Adjust if 7787 is taken on your host. +COREBRAIN_GATEWAY_HTTP_PORT=7787 + +# Coding agent credentials (optional — stored in gateway container only) +CLAUDE_CODE_OAUTH_TOKEN= +GITHUB_TOKEN= diff --git a/hosting/docker/README.md b/hosting/docker/README.md new file mode 100644 index 00000000..1e53eb0f --- /dev/null +++ b/hosting/docker/README.md @@ -0,0 +1,113 @@ +# Docker Compose — CoreBrain Self-Hosted + +All commands below are run from the **repo root** (or adjust paths accordingly). + +## Quick start (core stack only) + +```bash +cd hosting/docker +cp .env.example .env +# Edit .env — set secrets, AI provider keys, NEO4J_PASSWORD, etc. +docker compose up -d +``` + +The webapp is available at `http://localhost:3033`. + +## Adding the gateway + +Run the core stack and the gateway together using two `-f` flags: + +```bash +docker compose \ + -f hosting/docker/docker-compose.yaml \ + -f hosting/gateway/docker-compose.yaml \ + up -d +``` + +Docker Compose reads `.env` from the directory of the first `-f` file (`hosting/docker/`), so a single `hosting/docker/.env` covers both services. + +To stop everything: + +```bash +docker compose \ + -f hosting/docker/docker-compose.yaml \ + -f hosting/gateway/docker-compose.yaml \ + down +``` + +### Network note + +The gateway runs in its own Docker network segment and reaches the webapp over the **host-exposed port** (`3033`), not the internal Docker network. Set `COREBRAIN_API_URL` accordingly: + +| Environment | `COREBRAIN_API_URL` value | +|---|---| +| Docker Desktop (Mac / Windows) | `http://host.docker.internal:3033` | +| Linux — add `extra_hosts` (see below) | `http://host.docker.internal:3033` | +| Remote server | `https://your-core-domain.example.com` | + +For Linux, add this to the `corebrain-gateway` service in a local override, or set it in `.env` and rely on the OS-level hostname: + +```yaml +# hosting/gateway/docker-compose.override.yaml (local only, git-ignored) +services: + corebrain-gateway: + extra_hosts: + - "host.docker.internal:host-gateway" +``` + +## Environment variable wiring + +Both compose files read from `hosting/docker/.env`. The gateway-specific variables are included in `.env.example`: + +| Variable | Where used | Purpose | +|---|---|---| +| `COREBRAIN_API_URL` | gateway container | URL of the CORE webapp (see network note above) | +| `COREBRAIN_API_KEY` | gateway container | Personal access token from Core → Settings → Tokens | +| `COREBRAIN_GATEWAY_SECURITY_KEY` | gateway container | Security key the gateway uses for its own identity | +| `COREBRAIN_DEFAULT_GATEWAY_SECURITY_KEY` | webapp | Same key value — used by the webapp to verify and register the gateway | +| `COREBRAIN_DEFAULT_GATEWAY_URL` | webapp | URL the webapp uses to reach the gateway (same host note applies) | +| `COREBRAIN_DEFAULT_GATEWAY_NAME` | webapp | Name stored in DB when auto-registering (default: `local-gateway`) | +| `COREBRAIN_GATEWAY_HTTP_PORT` | host port mapping | Host port the gateway listens on (default: `7787`) | +| `CLAUDE_CODE_OAUTH_TOKEN` | gateway container | Long-lived Claude Code token (`claude setup-token`); optional | +| `GITHUB_TOKEN` | gateway container | Token for `git clone` of private repos; optional | +| `GATEWAY_VERSION` | gateway image tag | Pin to a specific release, e.g. `0.6.4`; omit for `latest` | + +## Auto-registration + +When both `COREBRAIN_DEFAULT_GATEWAY_URL` and `COREBRAIN_DEFAULT_GATEWAY_SECURITY_KEY` are set, the webapp automatically registers the gateway for every new workspace. No manual "Register gateway" step in the UI is needed. + +Minimum `.env` additions to enable auto-registration (Docker Desktop example): + +```env +COREBRAIN_GATEWAY_SECURITY_KEY= +COREBRAIN_DEFAULT_GATEWAY_SECURITY_KEY= +COREBRAIN_API_KEY= +COREBRAIN_API_URL=http://host.docker.internal:3033 +COREBRAIN_DEFAULT_GATEWAY_URL=http://host.docker.internal:7787 +COREBRAIN_DEFAULT_GATEWAY_NAME=local-gateway +``` + +## First-boot note: security key from logs + +If `COREBRAIN_GATEWAY_SECURITY_KEY` is left blank in `.env`, the gateway generates a random key on first boot and prints it to stdout: + +```bash +docker compose \ + -f hosting/docker/docker-compose.yaml \ + -f hosting/gateway/docker-compose.yaml \ + logs corebrain-gateway | grep -i "security key" +``` + +Copy the printed key into `.env` as both `COREBRAIN_GATEWAY_SECURITY_KEY` and `COREBRAIN_DEFAULT_GATEWAY_SECURITY_KEY`, then either: + +- Set `COREBRAIN_DEFAULT_GATEWAY_URL` and create a new workspace so the webapp auto-registers it, **or** +- Register it manually via the workspace gateway UI using that key and the gateway's public URL. + +After updating `.env`, re-create the gateway container so it picks up the new value (`restart` does not re-read `.env`): + +```bash +docker compose \ + -f hosting/docker/docker-compose.yaml \ + -f hosting/gateway/docker-compose.yaml \ + up -d corebrain-gateway +``` diff --git a/hosting/docker/docker-compose.yaml b/hosting/docker/docker-compose.yaml index 2165fb94..8965f965 100644 --- a/hosting/docker/docker-compose.yaml +++ b/hosting/docker/docker-compose.yaml @@ -60,6 +60,9 @@ services: - GRAPH_PROVIDER=${GRAPH_PROVIDER} - VECTOR_PROVIDER=${VECTOR_PROVIDER} - MODEL_PROVIDER=${MODEL_PROVIDER} + - COREBRAIN_DEFAULT_GATEWAY_URL=${COREBRAIN_DEFAULT_GATEWAY_URL:-} + - COREBRAIN_DEFAULT_GATEWAY_NAME=${COREBRAIN_DEFAULT_GATEWAY_NAME:-local-gateway} + - COREBRAIN_DEFAULT_GATEWAY_SECURITY_KEY=${COREBRAIN_DEFAULT_GATEWAY_SECURITY_KEY:-} ports: - "3033:3000" - "5555:5555"