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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 21 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,23 @@ APP_ENV=
# 888888 and keep APP_ENV non-production. This is ignored when APP_ENV=production.
MULTICA_DEV_VERIFICATION_CODE=
PORT=8080
# Optional aliases for the local/self-host backend port. If one is set, it
# takes precedence over PORT in compose, Makefile, and installer helpers.
# BACKEND_PORT=8080
# API_PORT=8080
# SERVER_PORT=8080
# Prometheus metrics are disabled by default. When enabled, bind to loopback
# unless you protect the listener with private networking, allowlists, or
# proxy auth. Do not expose this endpoint through the public app/API ingress.
# HTTP request metrics start accumulating only when this listener is enabled.
# METRICS_ADDR=127.0.0.1:9090
JWT_SECRET=change-me-in-production
MULTICA_SERVER_URL=ws://localhost:8080/ws
MULTICA_APP_URL=http://localhost:3000
# Derived by Makefile / local scripts from the backend port.
# Set explicitly only when the daemon reaches the API through a different URL.
# MULTICA_SERVER_URL=ws://localhost:8080/ws
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
# Set explicitly only when the app's public URL differs from local frontend.
# MULTICA_APP_URL=http://localhost:3000
# Public URL the API is reachable at from the open internet (no trailing
# slash). Used to mint absolute webhook URLs for autopilot webhook
# triggers. Leave unset behind a same-origin reverse proxy or for plain
Expand Down Expand Up @@ -91,7 +100,9 @@ SMTP_TLS_INSECURE=false
# rebuild is needed.
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
# Set explicitly only when your OAuth callback URL differs from local frontend.
# GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback

# S3 / CloudFront
# S3_BUCKET — bucket NAME only (e.g. "my-bucket"). Do NOT include the
Expand Down Expand Up @@ -121,7 +132,9 @@ COOKIE_DOMAIN=

# Local file storage (fallback when S3_BUCKET is not set)
LOCAL_UPLOAD_DIR=./data/uploads
LOCAL_UPLOAD_BASE_URL=http://localhost:8080
# Derived by Makefile / local scripts from the backend port.
# Set explicitly only when uploads are served through a different public URL.
# LOCAL_UPLOAD_BASE_URL=http://localhost:8080

# Security
# Comma-separated list of allowed origins for CORS and WebSocket connections.
Expand Down Expand Up @@ -170,9 +183,11 @@ GITHUB_WEBHOOK_SECRET=

# Frontend
FRONTEND_PORT=3000
FRONTEND_ORIGIN=http://localhost:3000
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
# Set explicitly only when serving frontend on a different origin/domain.
# FRONTEND_ORIGIN=http://localhost:3000
# Leave empty — auto-derived from page origin in browser, set by Makefile for local dev.
# Only set explicitly if frontend and backend are on different domains.
# NEXT_PUBLIC_API_URL also feeds the Next.js SSR proxy when explicitly set.
NEXT_PUBLIC_API_URL=
NEXT_PUBLIC_WS_URL=

Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ jobs:
- name: Install dependencies
run: pnpm install

- name: Test self-host env derivation
run: bash scripts/selfhost-config.test.sh

- name: Verify reserved-slugs.ts is up to date
# Re-runs the generator and fails on any drift from the
# checked-in TypeScript output. The Go side embeds the JSON
Expand Down
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ POSTGRES_DB ?= multica
POSTGRES_USER ?= multica
POSTGRES_PASSWORD ?= multica
POSTGRES_PORT ?= 5432
PORT ?= 8080
PORT := $(or $(BACKEND_PORT),$(API_PORT),$(SERVER_PORT),$(PORT),8080)
FRONTEND_PORT ?= 3000
FRONTEND_ORIGIN ?= http://localhost:$(FRONTEND_PORT)
MULTICA_APP_URL ?= $(FRONTEND_ORIGIN)
Expand All @@ -21,6 +21,7 @@ NEXT_PUBLIC_API_URL ?= http://localhost:$(PORT)
NEXT_PUBLIC_WS_URL ?= ws://localhost:$(PORT)/ws
GOOGLE_REDIRECT_URI ?= $(FRONTEND_ORIGIN)/auth/callback
MULTICA_SERVER_URL ?= ws://localhost:$(PORT)/ws
LOCAL_UPLOAD_BASE_URL ?= http://localhost:$(PORT)

export

Expand Down
82 changes: 82 additions & 0 deletions apps/web/config/runtime-urls.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";

import { resolveRemoteApiUrl } from "./runtime-urls";

describe("resolveRemoteApiUrl", () => {
it("prefers REMOTE_API_URL when explicitly configured", () => {
expect(
resolveRemoteApiUrl({
REMOTE_API_URL: "http://backend:8080",
NEXT_PUBLIC_API_URL: "http://localhost:19000",
PORT: "18080",
}),
).toBe("http://backend:8080");
});

it("uses NEXT_PUBLIC_API_URL when REMOTE_API_URL is unset", () => {
expect(
resolveRemoteApiUrl({
NEXT_PUBLIC_API_URL: "http://localhost:19000",
PORT: "18080",
}),
).toBe("http://localhost:19000");
});

it("derives localhost backend URL from PORT when no API URL is set", () => {
expect(resolveRemoteApiUrl({ PORT: "19080" })).toBe("http://localhost:19080");
});

it("supports explicit backend port aliases before PORT", () => {
expect(resolveRemoteApiUrl({ BACKEND_PORT: "28080", PORT: "19080" })).toBe(
"http://localhost:28080",
);
expect(resolveRemoteApiUrl({ API_PORT: "38080", PORT: "19080" })).toBe(
"http://localhost:38080",
);
expect(resolveRemoteApiUrl({ SERVER_PORT: "48080", PORT: "19080" })).toBe(
"http://localhost:48080",
);
});

it("prefers backend port aliases by documented precedence", () => {
expect(
resolveRemoteApiUrl({
BACKEND_PORT: "28080",
API_PORT: "38080",
SERVER_PORT: "48080",
PORT: "19080",
}),
).toBe("http://localhost:28080");

expect(
resolveRemoteApiUrl({
API_PORT: "38080",
SERVER_PORT: "48080",
PORT: "19080",
}),
).toBe("http://localhost:38080");

expect(resolveRemoteApiUrl({ SERVER_PORT: "48080", PORT: "19080" })).toBe(
"http://localhost:48080",
);
});

it("ignores whitespace-only backend URL values", () => {
expect(
resolveRemoteApiUrl({
REMOTE_API_URL: " ",
NEXT_PUBLIC_API_URL: " ",
BACKEND_PORT: " ",
API_PORT: " ",
SERVER_PORT: " ",
PORT: "19080",
}),
).toBe("http://localhost:19080");

expect(resolveRemoteApiUrl({ PORT: " " })).toBe("http://localhost:8080");
});

it("falls back to the historical backend port when no env is configured", () => {
expect(resolveRemoteApiUrl({})).toBe("http://localhost:8080");
});
});
18 changes: 18 additions & 0 deletions apps/web/config/runtime-urls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
type RuntimeEnv = Record<string, string | undefined>;

export function resolveRemoteApiUrl(env: RuntimeEnv): string {
const explicitRemote = env.REMOTE_API_URL?.trim();
if (explicitRemote) return explicitRemote;

const publicApi = env.NEXT_PUBLIC_API_URL?.trim();
if (publicApi) return publicApi;

const port =
env.BACKEND_PORT?.trim() ||
env.API_PORT?.trim() ||
env.SERVER_PORT?.trim() ||
env.PORT?.trim();
if (port) return `http://localhost:${port}`;

return "http://localhost:8080";
}
3 changes: 2 additions & 1 deletion apps/web/next.config.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { NextConfig } from "next";
import { config } from "dotenv";
import { resolve } from "path";
import { resolveRemoteApiUrl } from "./config/runtime-urls";

// Load root .env so REMOTE_API_URL is available to next.config.ts
config({ path: resolve(__dirname, "../../.env") });

const remoteApiUrl = process.env.REMOTE_API_URL || "http://localhost:8080";
const remoteApiUrl = resolveRemoteApiUrl(process.env);
const docsUrl = process.env.DOCS_URL || "http://localhost:4000";

// Parse hostnames from CORS_ALLOWED_ORIGINS so that Next.js dev server
Expand Down
12 changes: 6 additions & 6 deletions docker-compose.selfhost.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
# # Edit .env — change JWT_SECRET at minimum
# docker compose -f docker-compose.selfhost.yml up -d
#
# Frontend: http://localhost:3000
# Backend: http://localhost:8080 (also used by CLI/daemon)
# Frontend: http://localhost:${FRONTEND_PORT:-3000}
# Backend: http://localhost:${BACKEND_PORT:-${API_PORT:-${SERVER_PORT:-${PORT:-8080}}}}

name: multica

Expand Down Expand Up @@ -42,15 +42,15 @@ services:
postgres:
condition: service_healthy
ports:
- "127.0.0.1:${PORT:-8080}:8080"
- "127.0.0.1:${BACKEND_PORT:-${API_PORT:-${SERVER_PORT:-${PORT:-8080}}}}:8080"
volumes:
- backend_uploads:/app/data/uploads
environment:
DATABASE_URL: postgres://${POSTGRES_USER:-multica}:${POSTGRES_PASSWORD:-multica}@postgres:5432/${POSTGRES_DB:-multica}?sslmode=disable
PORT: "8080"
METRICS_ADDR: ${METRICS_ADDR:-}
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000}
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:${FRONTEND_PORT:-3000}}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
RESEND_API_KEY: ${RESEND_API_KEY:-}
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-noreply@multica.ai}
Expand All @@ -61,7 +61,7 @@ services:
SMTP_TLS_INSECURE: ${SMTP_TLS_INSECURE:-false}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI:-http://localhost:3000/auth/callback}
GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI:-http://localhost:${FRONTEND_PORT:-3000}/auth/callback}
S3_BUCKET: ${S3_BUCKET:-}
S3_REGION: ${S3_REGION:-us-west-2}
CLOUDFRONT_DOMAIN: ${CLOUDFRONT_DOMAIN:-}
Expand All @@ -70,7 +70,7 @@ services:
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
APP_ENV: ${APP_ENV:-production}
MULTICA_DEV_VERIFICATION_CODE: ${MULTICA_DEV_VERIFICATION_CODE:-}
MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:3000}
MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:${FRONTEND_PORT:-3000}}
ALLOW_SIGNUP: ${ALLOW_SIGNUP:-true}
ALLOWED_EMAILS: ${ALLOWED_EMAILS:-}
ALLOWED_EMAIL_DOMAINS: ${ALLOWED_EMAIL_DOMAINS:-}
Expand Down
9 changes: 2 additions & 7 deletions scripts/check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,8 @@ set -a
. "$ENV_FILE"
set +a

POSTGRES_DB="${POSTGRES_DB:-multica}"
POSTGRES_USER="${POSTGRES_USER:-multica}"
POSTGRES_PORT="${POSTGRES_PORT:-5432}"
PORT="${PORT:-8080}"
FRONTEND_PORT="${FRONTEND_PORT:-3000}"
PLAYWRIGHT_BASE_URL="${PLAYWRIGHT_BASE_URL:-http://localhost:${FRONTEND_PORT}}"
export PLAYWRIGHT_BASE_URL
# shellcheck disable=SC1091
. scripts/local-env.sh

BACKEND_PID=""
FRONTEND_PID=""
Expand Down
3 changes: 3 additions & 0 deletions scripts/dev.sh
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ set -a
. "$ENV_FILE"
set +a

# shellcheck disable=SC1091
. scripts/local-env.sh

# ---------- Install dependencies ----------
if [ ! -d node_modules ]; then
echo "==> Installing dependencies..."
Expand Down
49 changes: 46 additions & 3 deletions scripts/install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,46 @@ function Test-CommandExists {
$null -ne (Get-Command $Name -ErrorAction SilentlyContinue)
}

function Get-EnvFileValue {
param(
[string]$Path,
[string]$Name,
[string]$Default
)

if (-not (Test-Path $Path)) {
return $Default
}

$prefix = "$Name="
$line = Get-Content $Path |
Where-Object { $_.StartsWith($prefix) } |
Select-Object -Last 1
if (-not $line) {
return $Default
}

$value = $line.Substring($prefix.Length).Trim().Trim('"').Trim("'")
if ([string]::IsNullOrWhiteSpace($value)) {
return $Default
}
return $value
}

function Get-SelfHostBackendPort {
foreach ($name in @("BACKEND_PORT", "API_PORT", "SERVER_PORT", "PORT")) {
$value = Get-EnvFileValue -Path (Join-Path $InstallDir ".env") -Name $name -Default ""
if (-not [string]::IsNullOrWhiteSpace($value)) {
return $value
}
}
return "8080"
}

function Get-SelfHostFrontendPort {
return Get-EnvFileValue -Path (Join-Path $InstallDir ".env") -Name "FRONTEND_PORT" -Default "3000"
}

function Get-LatestVersion {
try {
$release = Invoke-RestMethod -Uri "https://api.github.com/repos/multica-ai/multica/releases/latest" -ErrorAction Stop
Expand Down Expand Up @@ -386,10 +426,11 @@ function Install-Server {
docker compose -f docker-compose.selfhost.yml up -d

Write-Info "Waiting for backend to be ready..."
$backendPort = Get-SelfHostBackendPort
$ready = $false
for ($i = 1; $i -le 45; $i++) {
try {
$null = Invoke-WebRequest -Uri "http://localhost:8080/health" -UseBasicParsing -TimeoutSec 2
$null = Invoke-WebRequest -Uri "http://localhost:$backendPort/health" -UseBasicParsing -TimeoutSec 2
$ready = $true
break
} catch {
Expand Down Expand Up @@ -451,8 +492,10 @@ function Start-LocalInstall {
Write-Host " [OK] Multica server is running and CLI is ready!" -ForegroundColor Green
Write-Host " ============================================" -ForegroundColor Green
Write-Host ""
Write-Host " Frontend: http://localhost:3000"
Write-Host " Backend: http://localhost:8080"
$frontendPort = Get-SelfHostFrontendPort
$backendPort = Get-SelfHostBackendPort
Write-Host " Frontend: http://localhost:$frontendPort"
Write-Host " Backend: http://localhost:$backendPort"
Write-Host " Server at: $InstallDir"
Write-Host ""
Write-Host " Next: configure your CLI to connect"
Expand Down
Loading
Loading