Skip to content
Open
5 changes: 5 additions & 0 deletions apps/webapp/app/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
})
Expand Down
11 changes: 11 additions & 0 deletions apps/webapp/app/models/workspace.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down
198 changes: 198 additions & 0 deletions apps/webapp/app/services/gateway/__tests__/default.server.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
36 changes: 36 additions & 0 deletions apps/webapp/app/services/gateway/default.server.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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}`);
}
}
37 changes: 37 additions & 0 deletions hosting/docker/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Loading