diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 01277709375..f3482d665d4 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -21,7 +21,7 @@ services: - COPILOT_API_KEY=${COPILOT_API_KEY} - SIM_AGENT_API_URL=${SIM_AGENT_API_URL} - OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434} - - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-http://localhost:3002} + - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-} - BUN_INSTALL_CACHE_DIR=/home/bun/.bun/cache depends_on: db: diff --git a/.gitignore b/.gitignore index 61566eeeafd..2031f178ded 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,4 @@ i18n.cache ## Claude Code .claude/launch.json .claude/worktrees/ +.claude/scheduled_tasks.lock diff --git a/apps/docs/content/docs/de/self-hosting/docker.mdx b/apps/docs/content/docs/de/self-hosting/docker.mdx index fe7ee620d3f..52fb5f19cb1 100644 --- a/apps/docs/content/docs/de/self-hosting/docker.mdx +++ b/apps/docs/content/docs/de/self-hosting/docker.mdx @@ -29,7 +29,6 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) INTERNAL_API_SECRET=$(openssl rand -hex 32) NEXT_PUBLIC_APP_URL=https://sim.yourdomain.com BETTER_AUTH_URL=https://sim.yourdomain.com -NEXT_PUBLIC_SOCKET_URL=https://sim.yourdomain.com EOF ``` diff --git a/apps/docs/content/docs/de/self-hosting/environment-variables.mdx b/apps/docs/content/docs/de/self-hosting/environment-variables.mdx index a64c6fc89e1..ea393581891 100644 --- a/apps/docs/content/docs/de/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/de/self-hosting/environment-variables.mdx @@ -15,7 +15,7 @@ import { Callout } from 'fumadocs-ui/components/callout' | `ENCRYPTION_KEY` | Verschlüsselungsschlüssel (32 Hex-Zeichen): `openssl rand -hex 32` | | `INTERNAL_API_SECRET` | Internes API-Secret (32 Hex-Zeichen): `openssl rand -hex 32` | | `NEXT_PUBLIC_APP_URL` | Öffentliche App-URL | -| `NEXT_PUBLIC_SOCKET_URL` | WebSocket-URL (Standard: `http://localhost:3002`) | +| `NEXT_PUBLIC_SOCKET_URL` | Optional. WebSocket-URL — verwendet standardmäßig den Seitenursprung; nur setzen, wenn Realtime auf einem separaten Host läuft. | ## KI-Anbieter @@ -80,7 +80,6 @@ BETTER_AUTH_URL=https://sim.yourdomain.com ENCRYPTION_KEY= INTERNAL_API_SECRET= NEXT_PUBLIC_APP_URL=https://sim.yourdomain.com -NEXT_PUBLIC_SOCKET_URL=https://sim.yourdomain.com OPENAI_API_KEY=sk-... ``` diff --git a/apps/docs/content/docs/de/self-hosting/platforms.mdx b/apps/docs/content/docs/de/self-hosting/platforms.mdx index 721383b2fa7..dca80e553dc 100644 --- a/apps/docs/content/docs/de/self-hosting/platforms.mdx +++ b/apps/docs/content/docs/de/self-hosting/platforms.mdx @@ -77,7 +77,6 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) INTERNAL_API_SECRET=$(openssl rand -hex 32) NEXT_PUBLIC_APP_URL=https://sim.yourdomain.com BETTER_AUTH_URL=https://sim.yourdomain.com -NEXT_PUBLIC_SOCKET_URL=https://sim.yourdomain.com EOF # Start diff --git a/apps/docs/content/docs/de/self-hosting/troubleshooting.mdx b/apps/docs/content/docs/de/self-hosting/troubleshooting.mdx index 97b4839984e..605d74b3412 100644 --- a/apps/docs/content/docs/de/self-hosting/troubleshooting.mdx +++ b/apps/docs/content/docs/de/self-hosting/troubleshooting.mdx @@ -27,7 +27,7 @@ OLLAMA_URL=http://192.168.1.x:11434 # Linux (use actual IP) ## WebSocket/Echtzeit funktioniert nicht -1. Prüfen Sie, ob `NEXT_PUBLIC_SOCKET_URL` mit Ihrer Domain übereinstimmt +1. Stellen Sie sicher, dass der Reverse Proxy `/socket.io` an den Realtime-Service (Standardport 3002) weiterleitet. `NEXT_PUBLIC_SOCKET_URL` ist nur erforderlich, wenn Realtime auf einem separaten Host läuft. 2. Überprüfen Sie, ob der Echtzeit-Dienst läuft: `docker compose ps realtime` 3. Stellen Sie sicher, dass der Reverse-Proxy WebSocket-Upgrades weiterleitet (siehe [Docker-Anleitung](/self-hosting/docker)) diff --git a/apps/docs/content/docs/en/self-hosting/docker.mdx b/apps/docs/content/docs/en/self-hosting/docker.mdx index f15bba7a11e..2ec82d0ccd9 100644 --- a/apps/docs/content/docs/en/self-hosting/docker.mdx +++ b/apps/docs/content/docs/en/self-hosting/docker.mdx @@ -30,7 +30,6 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) INTERNAL_API_SECRET=$(openssl rand -hex 32) NEXT_PUBLIC_APP_URL=https://sim.yourdomain.com BETTER_AUTH_URL=https://sim.yourdomain.com -NEXT_PUBLIC_SOCKET_URL=https://sim.yourdomain.com EOF ``` diff --git a/apps/docs/content/docs/en/self-hosting/environment-variables.mdx b/apps/docs/content/docs/en/self-hosting/environment-variables.mdx index 327b0657a1d..1a048777552 100644 --- a/apps/docs/content/docs/en/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/en/self-hosting/environment-variables.mdx @@ -15,7 +15,7 @@ import { Callout } from 'fumadocs-ui/components/callout' | `ENCRYPTION_KEY` | Encryption key (32 hex chars): `openssl rand -hex 32` | | `INTERNAL_API_SECRET` | Internal API secret (32 hex chars): `openssl rand -hex 32` | | `NEXT_PUBLIC_APP_URL` | Public app URL | -| `NEXT_PUBLIC_SOCKET_URL` | WebSocket URL (default: `http://localhost:3002`) | +| `NEXT_PUBLIC_SOCKET_URL` | Optional. WebSocket URL — defaults to the page origin; set only if realtime is on a separate host. | ## AI Providers @@ -80,7 +80,6 @@ BETTER_AUTH_URL=https://sim.yourdomain.com ENCRYPTION_KEY= INTERNAL_API_SECRET= NEXT_PUBLIC_APP_URL=https://sim.yourdomain.com -NEXT_PUBLIC_SOCKET_URL=https://sim.yourdomain.com OPENAI_API_KEY=sk-... ``` diff --git a/apps/docs/content/docs/en/self-hosting/platforms.mdx b/apps/docs/content/docs/en/self-hosting/platforms.mdx index d17455a877f..437e26b4ff5 100644 --- a/apps/docs/content/docs/en/self-hosting/platforms.mdx +++ b/apps/docs/content/docs/en/self-hosting/platforms.mdx @@ -70,7 +70,6 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) INTERNAL_API_SECRET=$(openssl rand -hex 32) NEXT_PUBLIC_APP_URL=https://sim.yourdomain.com BETTER_AUTH_URL=https://sim.yourdomain.com -NEXT_PUBLIC_SOCKET_URL=https://sim.yourdomain.com EOF # Start diff --git a/apps/docs/content/docs/en/self-hosting/troubleshooting.mdx b/apps/docs/content/docs/en/self-hosting/troubleshooting.mdx index 54e5224ce1f..63cc71491fb 100644 --- a/apps/docs/content/docs/en/self-hosting/troubleshooting.mdx +++ b/apps/docs/content/docs/en/self-hosting/troubleshooting.mdx @@ -27,7 +27,7 @@ OLLAMA_URL=http://192.168.1.x:11434 # Linux (use actual IP) ## WebSocket/Realtime Not Working -1. Check `NEXT_PUBLIC_SOCKET_URL` matches your domain +1. Verify reverse proxy routes `/socket.io` to the realtime service (default port 3002). `NEXT_PUBLIC_SOCKET_URL` is only needed if realtime is on a separate host. 2. Verify realtime service is running: `docker compose ps realtime` 3. Ensure reverse proxy passes WebSocket upgrades (see [Docker guide](/self-hosting/docker)) diff --git a/apps/docs/content/docs/es/self-hosting/docker.mdx b/apps/docs/content/docs/es/self-hosting/docker.mdx index 370ae4376a6..8f38d77da31 100644 --- a/apps/docs/content/docs/es/self-hosting/docker.mdx +++ b/apps/docs/content/docs/es/self-hosting/docker.mdx @@ -29,7 +29,6 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) INTERNAL_API_SECRET=$(openssl rand -hex 32) NEXT_PUBLIC_APP_URL=https://sim.yourdomain.com BETTER_AUTH_URL=https://sim.yourdomain.com -NEXT_PUBLIC_SOCKET_URL=https://sim.yourdomain.com EOF ``` diff --git a/apps/docs/content/docs/es/self-hosting/environment-variables.mdx b/apps/docs/content/docs/es/self-hosting/environment-variables.mdx index bd9e04027fa..05d8c98a668 100644 --- a/apps/docs/content/docs/es/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/es/self-hosting/environment-variables.mdx @@ -15,7 +15,7 @@ import { Callout } from 'fumadocs-ui/components/callout' | `ENCRYPTION_KEY` | Clave de cifrado (32 caracteres hex): `openssl rand -hex 32` | | `INTERNAL_API_SECRET` | Secreto de API interna (32 caracteres hex): `openssl rand -hex 32` | | `NEXT_PUBLIC_APP_URL` | URL pública de la aplicación | -| `NEXT_PUBLIC_SOCKET_URL` | URL de WebSocket (predeterminado: `http://localhost:3002`) | +| `NEXT_PUBLIC_SOCKET_URL` | Opcional. URL de WebSocket — predetermina al origen de la página; configúrala solo si realtime está en un host separado. | ## Proveedores de IA @@ -80,7 +80,6 @@ BETTER_AUTH_URL=https://sim.yourdomain.com ENCRYPTION_KEY= INTERNAL_API_SECRET= NEXT_PUBLIC_APP_URL=https://sim.yourdomain.com -NEXT_PUBLIC_SOCKET_URL=https://sim.yourdomain.com OPENAI_API_KEY=sk-... ``` diff --git a/apps/docs/content/docs/es/self-hosting/platforms.mdx b/apps/docs/content/docs/es/self-hosting/platforms.mdx index 1cbf28685dd..8fe15c4da9b 100644 --- a/apps/docs/content/docs/es/self-hosting/platforms.mdx +++ b/apps/docs/content/docs/es/self-hosting/platforms.mdx @@ -77,7 +77,6 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) INTERNAL_API_SECRET=$(openssl rand -hex 32) NEXT_PUBLIC_APP_URL=https://sim.yourdomain.com BETTER_AUTH_URL=https://sim.yourdomain.com -NEXT_PUBLIC_SOCKET_URL=https://sim.yourdomain.com EOF # Start diff --git a/apps/docs/content/docs/es/self-hosting/troubleshooting.mdx b/apps/docs/content/docs/es/self-hosting/troubleshooting.mdx index 9fdb0957c9a..230d8a7b1c9 100644 --- a/apps/docs/content/docs/es/self-hosting/troubleshooting.mdx +++ b/apps/docs/content/docs/es/self-hosting/troubleshooting.mdx @@ -27,7 +27,7 @@ OLLAMA_URL=http://192.168.1.x:11434 # Linux (use actual IP) ## WebSocket/Tiempo real no funciona -1. Comprueba que `NEXT_PUBLIC_SOCKET_URL` coincida con tu dominio +1. Verifica que el proxy inverso enrute `/socket.io` al servicio realtime (puerto 3002 por defecto). `NEXT_PUBLIC_SOCKET_URL` solo es necesaria si realtime está en un host separado. 2. Verifica que el servicio en tiempo real esté funcionando: `docker compose ps realtime` 3. Asegúrate de que el proxy inverso pase las actualizaciones de WebSocket (consulta la [guía de Docker](/self-hosting/docker)) diff --git a/apps/docs/content/docs/fr/self-hosting/docker.mdx b/apps/docs/content/docs/fr/self-hosting/docker.mdx index d4cd1ac17de..9ec36378684 100644 --- a/apps/docs/content/docs/fr/self-hosting/docker.mdx +++ b/apps/docs/content/docs/fr/self-hosting/docker.mdx @@ -29,7 +29,6 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) INTERNAL_API_SECRET=$(openssl rand -hex 32) NEXT_PUBLIC_APP_URL=https://sim.yourdomain.com BETTER_AUTH_URL=https://sim.yourdomain.com -NEXT_PUBLIC_SOCKET_URL=https://sim.yourdomain.com EOF ``` diff --git a/apps/docs/content/docs/fr/self-hosting/environment-variables.mdx b/apps/docs/content/docs/fr/self-hosting/environment-variables.mdx index dc428373a3f..fb101425cb3 100644 --- a/apps/docs/content/docs/fr/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/fr/self-hosting/environment-variables.mdx @@ -15,7 +15,7 @@ import { Callout } from 'fumadocs-ui/components/callout' | `ENCRYPTION_KEY` | Clé de chiffrement (32 caractères hexadécimaux) : `openssl rand -hex 32` | | `INTERNAL_API_SECRET` | Secret API interne (32 caractères hexadécimaux) : `openssl rand -hex 32` | | `NEXT_PUBLIC_APP_URL` | URL publique de l'application | -| `NEXT_PUBLIC_SOCKET_URL` | URL WebSocket (par défaut : `http://localhost:3002`) | +| `NEXT_PUBLIC_SOCKET_URL` | Optionnel. URL WebSocket — utilise par défaut l'origine de la page ; à définir uniquement si realtime est sur un hôte séparé. | ## Fournisseurs d'IA @@ -80,7 +80,6 @@ BETTER_AUTH_URL=https://sim.yourdomain.com ENCRYPTION_KEY= INTERNAL_API_SECRET= NEXT_PUBLIC_APP_URL=https://sim.yourdomain.com -NEXT_PUBLIC_SOCKET_URL=https://sim.yourdomain.com OPENAI_API_KEY=sk-... ``` diff --git a/apps/docs/content/docs/fr/self-hosting/platforms.mdx b/apps/docs/content/docs/fr/self-hosting/platforms.mdx index 826fe8fb07d..cb0225e0b5e 100644 --- a/apps/docs/content/docs/fr/self-hosting/platforms.mdx +++ b/apps/docs/content/docs/fr/self-hosting/platforms.mdx @@ -77,7 +77,6 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) INTERNAL_API_SECRET=$(openssl rand -hex 32) NEXT_PUBLIC_APP_URL=https://sim.yourdomain.com BETTER_AUTH_URL=https://sim.yourdomain.com -NEXT_PUBLIC_SOCKET_URL=https://sim.yourdomain.com EOF # Start diff --git a/apps/docs/content/docs/fr/self-hosting/troubleshooting.mdx b/apps/docs/content/docs/fr/self-hosting/troubleshooting.mdx index 961c344d876..2f3d807cb2b 100644 --- a/apps/docs/content/docs/fr/self-hosting/troubleshooting.mdx +++ b/apps/docs/content/docs/fr/self-hosting/troubleshooting.mdx @@ -27,7 +27,7 @@ OLLAMA_URL=http://192.168.1.x:11434 # Linux (use actual IP) ## WebSocket/Temps réel ne fonctionne pas -1. Vérifiez que `NEXT_PUBLIC_SOCKET_URL` correspond à votre domaine +1. Vérifiez que le reverse proxy route `/socket.io` vers le service realtime (port 3002 par défaut). `NEXT_PUBLIC_SOCKET_URL` n'est nécessaire que si realtime est sur un hôte séparé. 2. Vérifiez que le service temps réel est en cours d'exécution : `docker compose ps realtime` 3. Assurez-vous que le proxy inverse transmet les mises à niveau WebSocket (voir [Guide Docker](/self-hosting/docker)) diff --git a/apps/docs/content/docs/ja/self-hosting/docker.mdx b/apps/docs/content/docs/ja/self-hosting/docker.mdx index 7832a494c58..3bd19092dd2 100644 --- a/apps/docs/content/docs/ja/self-hosting/docker.mdx +++ b/apps/docs/content/docs/ja/self-hosting/docker.mdx @@ -29,7 +29,6 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) INTERNAL_API_SECRET=$(openssl rand -hex 32) NEXT_PUBLIC_APP_URL=https://sim.yourdomain.com BETTER_AUTH_URL=https://sim.yourdomain.com -NEXT_PUBLIC_SOCKET_URL=https://sim.yourdomain.com EOF ``` diff --git a/apps/docs/content/docs/ja/self-hosting/environment-variables.mdx b/apps/docs/content/docs/ja/self-hosting/environment-variables.mdx index bd3e2c59268..b4b9ae669f9 100644 --- a/apps/docs/content/docs/ja/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/ja/self-hosting/environment-variables.mdx @@ -15,7 +15,7 @@ import { Callout } from 'fumadocs-ui/components/callout' | `ENCRYPTION_KEY` | 暗号化キー(32桁の16進数): `openssl rand -hex 32` | | `INTERNAL_API_SECRET` | 内部APIシークレット(32桁の16進数): `openssl rand -hex 32` | | `NEXT_PUBLIC_APP_URL` | 公開アプリURL | -| `NEXT_PUBLIC_SOCKET_URL` | WebSocket URL(デフォルト: `http://localhost:3002`) | +| `NEXT_PUBLIC_SOCKET_URL` | 任意。WebSocket URL — デフォルトはページのオリジン。realtime が別ホストの場合のみ設定。 | ## AIプロバイダー @@ -80,7 +80,6 @@ BETTER_AUTH_URL=https://sim.yourdomain.com ENCRYPTION_KEY= INTERNAL_API_SECRET= NEXT_PUBLIC_APP_URL=https://sim.yourdomain.com -NEXT_PUBLIC_SOCKET_URL=https://sim.yourdomain.com OPENAI_API_KEY=sk-... ``` diff --git a/apps/docs/content/docs/ja/self-hosting/platforms.mdx b/apps/docs/content/docs/ja/self-hosting/platforms.mdx index 7d43a5db922..f83fdb290f2 100644 --- a/apps/docs/content/docs/ja/self-hosting/platforms.mdx +++ b/apps/docs/content/docs/ja/self-hosting/platforms.mdx @@ -77,7 +77,6 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) INTERNAL_API_SECRET=$(openssl rand -hex 32) NEXT_PUBLIC_APP_URL=https://sim.yourdomain.com BETTER_AUTH_URL=https://sim.yourdomain.com -NEXT_PUBLIC_SOCKET_URL=https://sim.yourdomain.com EOF # Start diff --git a/apps/docs/content/docs/ja/self-hosting/troubleshooting.mdx b/apps/docs/content/docs/ja/self-hosting/troubleshooting.mdx index 23b0c7a888e..970b07d008f 100644 --- a/apps/docs/content/docs/ja/self-hosting/troubleshooting.mdx +++ b/apps/docs/content/docs/ja/self-hosting/troubleshooting.mdx @@ -27,7 +27,7 @@ OLLAMA_URL=http://192.168.1.x:11434 # Linux (use actual IP) ## WebSocket/リアルタイム機能が動作しない -1. `NEXT_PUBLIC_SOCKET_URL` がドメインと一致しているか確認する +1. リバースプロキシが `/socket.io` を realtime サービス(デフォルトポート 3002)にルーティングしているか確認してください。`NEXT_PUBLIC_SOCKET_URL` は realtime が別ホストにある場合のみ必要です。 2. リアルタイムサービスが実行されているか確認する: `docker compose ps realtime` 3. リバースプロキシがWebSocketアップグレードを通過させていることを確認する([Dockerガイド](/self-hosting/docker)を参照) diff --git a/apps/docs/content/docs/zh/self-hosting/docker.mdx b/apps/docs/content/docs/zh/self-hosting/docker.mdx index 90900860c48..46897f5ea11 100644 --- a/apps/docs/content/docs/zh/self-hosting/docker.mdx +++ b/apps/docs/content/docs/zh/self-hosting/docker.mdx @@ -29,7 +29,6 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) INTERNAL_API_SECRET=$(openssl rand -hex 32) NEXT_PUBLIC_APP_URL=https://sim.yourdomain.com BETTER_AUTH_URL=https://sim.yourdomain.com -NEXT_PUBLIC_SOCKET_URL=https://sim.yourdomain.com EOF ``` diff --git a/apps/docs/content/docs/zh/self-hosting/environment-variables.mdx b/apps/docs/content/docs/zh/self-hosting/environment-variables.mdx index 212751ab9b7..67c154950fc 100644 --- a/apps/docs/content/docs/zh/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/zh/self-hosting/environment-variables.mdx @@ -15,7 +15,7 @@ import { Callout } from 'fumadocs-ui/components/callout' | `ENCRYPTION_KEY` | 加密密钥(32 个十六进制字符):`openssl rand -hex 32` | | `INTERNAL_API_SECRET` | 内部 API 密钥(32 个十六进制字符):`openssl rand -hex 32` | | `NEXT_PUBLIC_APP_URL` | 公共应用程序 URL | -| `NEXT_PUBLIC_SOCKET_URL` | WebSocket URL(默认值:`http://localhost:3002`) | +| `NEXT_PUBLIC_SOCKET_URL` | 可选。WebSocket URL — 默认使用页面来源;仅当 realtime 部署在独立主机时才需要设置。 | ## AI 提供商 @@ -80,7 +80,6 @@ BETTER_AUTH_URL=https://sim.yourdomain.com ENCRYPTION_KEY= INTERNAL_API_SECRET= NEXT_PUBLIC_APP_URL=https://sim.yourdomain.com -NEXT_PUBLIC_SOCKET_URL=https://sim.yourdomain.com OPENAI_API_KEY=sk-... ``` diff --git a/apps/docs/content/docs/zh/self-hosting/platforms.mdx b/apps/docs/content/docs/zh/self-hosting/platforms.mdx index 14c43fb9cc4..0c663f6a421 100644 --- a/apps/docs/content/docs/zh/self-hosting/platforms.mdx +++ b/apps/docs/content/docs/zh/self-hosting/platforms.mdx @@ -77,7 +77,6 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) INTERNAL_API_SECRET=$(openssl rand -hex 32) NEXT_PUBLIC_APP_URL=https://sim.yourdomain.com BETTER_AUTH_URL=https://sim.yourdomain.com -NEXT_PUBLIC_SOCKET_URL=https://sim.yourdomain.com EOF # Start diff --git a/apps/docs/content/docs/zh/self-hosting/troubleshooting.mdx b/apps/docs/content/docs/zh/self-hosting/troubleshooting.mdx index 292231de2eb..04933465fbc 100644 --- a/apps/docs/content/docs/zh/self-hosting/troubleshooting.mdx +++ b/apps/docs/content/docs/zh/self-hosting/troubleshooting.mdx @@ -27,7 +27,7 @@ OLLAMA_URL=http://192.168.1.x:11434 # Linux (use actual IP) ## WebSocket/实时功能无法正常工作 -1. 检查 `NEXT_PUBLIC_SOCKET_URL` 是否与您的域名匹配 +1. 确认反向代理将 `/socket.io` 路由到 realtime 服务(默认端口 3002)。仅当 realtime 部署在独立主机时才需要设置 `NEXT_PUBLIC_SOCKET_URL`。 2. 验证实时服务是否正在运行:`docker compose ps realtime` 3. 确保反向代理支持 WebSocket 升级(参见 [Docker 指南](/self-hosting/docker)) diff --git a/apps/sim/.env.example b/apps/sim/.env.example index 6490656a146..6f7aa473666 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -11,6 +11,7 @@ BETTER_AUTH_URL=http://localhost:3000 # NextJS (Required) NEXT_PUBLIC_APP_URL=http://localhost:3000 # INTERNAL_API_BASE_URL=http://sim-app.default.svc.cluster.local:3000 # Optional: internal URL for server-side /api self-calls; defaults to NEXT_PUBLIC_APP_URL +# TRUSTED_ORIGINS=https://www.example.com,https://app.example.com # Optional: comma-separated additional public origins to trust for auth (apex+www, alias domains). Merged into Better Auth trustedOrigins. # Security (Required) ENCRYPTION_KEY=your_encryption_key # Use `openssl rand -hex 32` to generate, used to encrypt environment variables diff --git a/apps/sim/lib/auth/auth-client.ts b/apps/sim/lib/auth/auth-client.ts index a0ce3585b00..4163dc63f55 100644 --- a/apps/sim/lib/auth/auth-client.ts +++ b/apps/sim/lib/auth/auth-client.ts @@ -12,16 +12,11 @@ import { createAuthClient } from 'better-auth/react' import type { auth } from '@/lib/auth' import { env } from '@/lib/core/config/env' import { isBillingEnabled, isOrganizationsEnabled } from '@/lib/core/config/feature-flags' -import { getBaseUrl } from '@/lib/core/utils/urls' +import { getBaseUrl, getBrowserOrigin } from '@/lib/core/utils/urls' import { SessionContext, type SessionHookResult } from '@/app/_shell/providers/session-provider' function getAuthBaseUrl(): string { - try { - return getBaseUrl() - } catch (e) { - if (typeof window !== 'undefined') return window.location.origin - throw e - } + return getBrowserOrigin() ?? getBaseUrl() } export const client = createAuthClient({ diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 3bc9286c6e1..b446fff9727 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -79,7 +79,7 @@ import { isSignupEmailValidationEnabled, } from '@/lib/core/config/feature-flags' import { PlatformEvents } from '@/lib/core/telemetry' -import { getBaseUrl } from '@/lib/core/utils/urls' +import { getBaseUrl, isLocalhostUrl, parseOriginList } from '@/lib/core/utils/urls' import { processCredentialDraft } from '@/lib/credentials/draft-processor' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils' @@ -145,6 +145,20 @@ const blockedSignupDomains = env.BLOCKED_SIGNUP_DOMAINS ? new Set(env.BLOCKED_SIGNUP_DOMAINS.split(',').map((d) => d.trim().toLowerCase())) : null +const additionalTrustedOrigins = parseOriginList(env.TRUSTED_ORIGINS, (value) => + logger.warn('Ignoring invalid entry in TRUSTED_ORIGINS', { value }) +) + +if (env.NODE_ENV === 'production') { + const baseUrl = getBaseUrl() + if (isLocalhostUrl(baseUrl)) { + logger.warn( + 'NEXT_PUBLIC_APP_URL points to localhost in production. Self-hosted deployments must set NEXT_PUBLIC_APP_URL to the public URL users access (e.g. https://sim.example.com), otherwise auth POST requests from any non-localhost origin will be rejected by trustedOrigins. Set TRUSTED_ORIGINS to allow additional public origins.', + { baseUrl } + ) + } +} + const validStripeKey = env.STRIPE_SECRET_KEY let stripeClient = null @@ -159,6 +173,7 @@ export const auth = betterAuth({ trustedOrigins: [ getBaseUrl(), ...(env.NEXT_PUBLIC_SOCKET_URL ? [env.NEXT_PUBLIC_SOCKET_URL] : []), + ...additionalTrustedOrigins, 'https://claude.ai', 'https://claude.com', ].filter(Boolean), diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index aa569acb767..084523e11dc 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -25,6 +25,7 @@ export const env = createEnv({ ALLOWED_LOGIN_EMAILS: z.string().optional(), // Comma-separated list of allowed email addresses for login ALLOWED_LOGIN_DOMAINS: z.string().optional(), // Comma-separated list of allowed email domains for login BLOCKED_SIGNUP_DOMAINS: z.string().optional(), // Comma-separated list of email domains blocked from signing up (e.g., "gmail.com,yahoo.com") + TRUSTED_ORIGINS: z.string().optional(), // Comma-separated additional origins to trust for auth (e.g., "https://app.example.com,https://www.example.com"). Merged into Better Auth trustedOrigins. TURNSTILE_SECRET_KEY: z.string().min(1).optional(), // Cloudflare Turnstile secret key for captcha verification SIGNUP_EMAIL_VALIDATION_ENABLED: z.boolean().optional(), // Enable disposable email blocking via better-auth-harmony (55K+ domains) ENCRYPTION_KEY: z.string().min(32), // Key for encrypting sensitive data diff --git a/apps/sim/lib/core/utils/urls.test.ts b/apps/sim/lib/core/utils/urls.test.ts new file mode 100644 index 00000000000..689f5b51a9b --- /dev/null +++ b/apps/sim/lib/core/utils/urls.test.ts @@ -0,0 +1,135 @@ +/** + * @vitest-environment jsdom + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetEnv } = vi.hoisted(() => ({ + mockGetEnv: vi.fn<(key: string) => string | undefined>(), +})) + +vi.mock('@/lib/core/config/env', () => ({ + env: {}, + getEnv: mockGetEnv, +})) + +vi.mock('@/lib/core/config/feature-flags', () => ({ + isProd: false, +})) + +import { + getBrowserOrigin, + getSocketUrl, + isLocalhostUrl, + parseOriginList, +} from '@/lib/core/utils/urls' + +function setLocation(url: string) { + Object.defineProperty(window, 'location', { + value: new URL(url), + writable: true, + configurable: true, + }) +} + +describe('getBrowserOrigin', () => { + it('returns the page origin in the browser', () => { + setLocation('https://example.com/some/path') + expect(getBrowserOrigin()).toBe('https://example.com') + }) +}) + +describe('getSocketUrl', () => { + beforeEach(() => { + mockGetEnv.mockReset() + mockGetEnv.mockReturnValue(undefined) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('uses NEXT_PUBLIC_SOCKET_URL when explicitly set', () => { + mockGetEnv.mockImplementation((key) => + key === 'NEXT_PUBLIC_SOCKET_URL' ? 'https://socket.example.com' : undefined + ) + setLocation('https://app.example.com/') + expect(getSocketUrl()).toBe('https://socket.example.com') + }) + + it('returns the page origin when served from a non-localhost host', () => { + setLocation('https://10.0.3.36/signup') + expect(getSocketUrl()).toBe('https://10.0.3.36') + }) + + it('falls back to localhost:3002 when served from localhost', () => { + setLocation('http://localhost:3000/') + expect(getSocketUrl()).toBe('http://localhost:3002') + }) + + it('falls back to localhost:3002 when served from 127.0.0.1', () => { + setLocation('http://127.0.0.1:3000/') + expect(getSocketUrl()).toBe('http://localhost:3002') + }) + + it('explicit env var wins over the localhost fallback', () => { + mockGetEnv.mockImplementation((key) => + key === 'NEXT_PUBLIC_SOCKET_URL' ? 'http://realtime.local:3002' : undefined + ) + setLocation('http://localhost:3000/') + expect(getSocketUrl()).toBe('http://realtime.local:3002') + }) + + it('treats whitespace-only env var as unset', () => { + mockGetEnv.mockImplementation((key) => (key === 'NEXT_PUBLIC_SOCKET_URL' ? ' ' : undefined)) + setLocation('https://app.example.com/') + expect(getSocketUrl()).toBe('https://app.example.com') + }) +}) + +describe('parseOriginList', () => { + it('returns an empty array for undefined, null, or empty input', () => { + expect(parseOriginList(undefined)).toEqual([]) + expect(parseOriginList(null)).toEqual([]) + expect(parseOriginList('')).toEqual([]) + expect(parseOriginList(' ')).toEqual([]) + }) + + it('parses comma-separated origins and normalizes them', () => { + expect(parseOriginList('https://a.example.com, https://b.example.com/path')).toEqual([ + 'https://a.example.com', + 'https://b.example.com', + ]) + }) + + it('dedupes equal origins after normalization', () => { + expect( + parseOriginList('https://a.example.com,https://a.example.com/foo,https://a.example.com') + ).toEqual(['https://a.example.com']) + }) + + it('drops invalid entries and reports them via the callback', () => { + const invalid: string[] = [] + const result = parseOriginList('https://ok.example.com, not-a-url, ', (v) => invalid.push(v)) + expect(result).toEqual(['https://ok.example.com']) + expect(invalid).toEqual(['not-a-url']) + }) + + it('preserves non-default ports in the origin', () => { + expect(parseOriginList('http://10.0.3.36:8080')).toEqual(['http://10.0.3.36:8080']) + }) +}) + +describe('isLocalhostUrl', () => { + it('matches localhost variants', () => { + expect(isLocalhostUrl('http://localhost:3000')).toBe(true) + expect(isLocalhostUrl('http://127.0.0.1')).toBe(true) + expect(isLocalhostUrl('https://localhost')).toBe(true) + }) + + it('does not match public hostnames or invalid URLs', () => { + expect(isLocalhostUrl('https://10.0.3.36')).toBe(false) + expect(isLocalhostUrl('https://app.example.com')).toBe(false) + expect(isLocalhostUrl('not-a-url')).toBe(false) + expect(isLocalhostUrl('')).toBe(false) + }) +}) diff --git a/apps/sim/lib/core/utils/urls.ts b/apps/sim/lib/core/utils/urls.ts index 8381fe58ca0..0ead2240e52 100644 --- a/apps/sim/lib/core/utils/urls.ts +++ b/apps/sim/lib/core/utils/urls.ts @@ -103,6 +103,62 @@ export function getEmailDomain(): string { const DEFAULT_SOCKET_URL = 'http://localhost:3002' const DEFAULT_OLLAMA_URL = 'http://localhost:11434' +const LOCALHOST_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]', '::1']) + +/** + * Parses a comma-separated list of origins (e.g. from a `TRUSTED_ORIGINS` env + * var) into a deduped array of normalized origins. Invalid entries are dropped. + * + * @param raw - Comma-separated origin list, or undefined/empty + * @param onInvalid - Optional callback invoked once per invalid entry + */ +export function parseOriginList( + raw: string | undefined | null, + onInvalid?: (value: string) => void +): string[] { + if (!raw) return [] + const seen = new Set() + const origins: string[] = [] + for (const candidate of raw.split(',')) { + const trimmed = candidate.trim() + if (!trimmed) continue + try { + const { origin } = new URL(trimmed) + if (!seen.has(origin)) { + seen.add(origin) + origins.push(origin) + } + } catch { + onInvalid?.(trimmed) + } + } + return origins +} + +/** + * Returns true when the given URL points at a localhost loopback host. + * Used to detect misconfigured deployments where `NEXT_PUBLIC_APP_URL` is left + * at its development default in production. + */ +export function isLocalhostUrl(url: string): boolean { + try { + const { hostname } = new URL(url) + return LOCALHOST_HOSTNAMES.has(hostname) + } catch { + return false + } +} + +/** + * Returns the current browser origin, or `null` when called server-side. + * + * Use this when an absolute URL is needed for a same-origin resource (auth API, + * reverse-proxied socket, etc.) so a misconfigured `NEXT_PUBLIC_*` env var + * baked into the client bundle at build time can't pin requests to the wrong host. + */ +export function getBrowserOrigin(): string | null { + return typeof window !== 'undefined' ? window.location.origin : null +} /** * Returns the socket server URL for server-side internal API calls. @@ -114,10 +170,25 @@ export function getSocketServerUrl(): string { /** * Returns the socket server URL for client-side Socket.IO connections. - * Reads from NEXT_PUBLIC_SOCKET_URL with a localhost fallback for development. + * + * Resolution order: + * 1. `NEXT_PUBLIC_SOCKET_URL` if explicitly set (subdomain, separate host:port) + * 2. In the browser when the page is served from a non-localhost origin, the + * page's own origin — assumes the reverse proxy routes `/socket.io` to the + * realtime service. This avoids shipping a hardcoded `localhost:3002` to + * self-hosters behind nginx/Cloudflare. + * 3. `http://localhost:3002` for local development and SSR. */ export function getSocketUrl(): string { - return getEnv('NEXT_PUBLIC_SOCKET_URL') || DEFAULT_SOCKET_URL + const explicit = getEnv('NEXT_PUBLIC_SOCKET_URL')?.trim() + if (explicit) return explicit + + const browserOrigin = getBrowserOrigin() + if (browserOrigin && !LOCALHOST_HOSTNAMES.has(new URL(browserOrigin).hostname)) { + return browserOrigin + } + + return DEFAULT_SOCKET_URL } /** diff --git a/bun.lock b/bun.lock index f70b8c4e926..f8b62029912 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "simstudio", @@ -348,9 +349,9 @@ "name": "@sim/db", "version": "0.1.0", "dependencies": { + "@sim/utils": "workspace:*", "drizzle-orm": "^0.45.2", "postgres": "^3.4.5", - "uuid": "^11.1.0", "zod": "4.3.6", }, "devDependencies": { @@ -396,6 +397,9 @@ "packages/testing": { "name": "@sim/testing", "version": "0.1.0", + "dependencies": { + "@sim/utils": "workspace:*", + }, "devDependencies": { "@sim/tsconfig": "workspace:*", "typescript": "^5.7.3", diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 335fb0c33ab..2de960ad43f 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -23,7 +23,7 @@ services: - SIM_AGENT_API_URL=${SIM_AGENT_API_URL} - OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434} - SOCKET_SERVER_URL=${SOCKET_SERVER_URL:-http://realtime:3002} - - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-http://localhost:3002} + - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-} depends_on: db: condition: service_healthy diff --git a/docker-compose.ollama.yml b/docker-compose.ollama.yml index 9d4f072bfe6..43f5cdcbff8 100644 --- a/docker-compose.ollama.yml +++ b/docker-compose.ollama.yml @@ -21,7 +21,7 @@ services: - COPILOT_API_KEY=${COPILOT_API_KEY} - SIM_AGENT_API_URL=${SIM_AGENT_API_URL} - OLLAMA_URL=http://ollama:11434 - - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-http://localhost:3002} + - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-} depends_on: db: condition: service_healthy diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index cbb667b1c55..67266fbc780 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -13,6 +13,10 @@ services: - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-simstudio} - BETTER_AUTH_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} + # TRUSTED_ORIGINS: comma-separated public origins to trust for auth in + # addition to NEXT_PUBLIC_APP_URL. Use when serving from multiple domains + # (apex + www, alias hostnames, reverse-proxy IPs). Empty by default. + - TRUSTED_ORIGINS=${TRUSTED_ORIGINS:-} - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET} - ENCRYPTION_KEY=${ENCRYPTION_KEY} - API_ENCRYPTION_KEY=${API_ENCRYPTION_KEY:-} @@ -22,7 +26,11 @@ services: - SIM_AGENT_API_URL=${SIM_AGENT_API_URL} - OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434} - SOCKET_SERVER_URL=${SOCKET_SERVER_URL:-http://realtime:3002} - - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-http://localhost:3002} + # NEXT_PUBLIC_SOCKET_URL is read by the browser. Leaving it unset lets the + # client default to the page's own origin (assumes the reverse proxy routes + # /socket.io). Set it explicitly only when the realtime service is on a + # different host:port from the app (e.g. wss://socket.example.com). + - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-} - ADMISSION_GATE_MAX_INFLIGHT=${ADMISSION_GATE_MAX_INFLIGHT:-500} depends_on: db: diff --git a/docker/db.Dockerfile b/docker/db.Dockerfile index bcf5096d1da..bc7240962a3 100644 --- a/docker/db.Dockerfile +++ b/docker/db.Dockerfile @@ -11,9 +11,10 @@ WORKDIR /app # Copy only package files needed for migrations (these change less frequently) COPY package.json bun.lock turbo.json ./ -RUN mkdir -p packages/db packages/tsconfig +RUN mkdir -p packages/db packages/tsconfig packages/utils COPY packages/db/package.json ./packages/db/package.json COPY packages/tsconfig/package.json ./packages/tsconfig/package.json +COPY packages/utils/package.json ./packages/utils/package.json # Install dependencies with cache mount for faster builds RUN --mount=type=cache,id=bun-cache,target=/root/.bun/install/cache \ @@ -41,6 +42,9 @@ COPY --chown=nextjs:nodejs packages/db/drizzle.config.ts ./packages/db/drizzle.c # Copy tsconfig package (needed for workspace symlink resolution) COPY --chown=nextjs:nodejs packages/tsconfig ./packages/tsconfig +# Copy utils package (needed by db scripts that import @sim/utils) +COPY --chown=nextjs:nodejs packages/utils ./packages/utils + # Copy database package source code (changes most frequently - placed last) COPY --chown=nextjs:nodejs packages/db ./packages/db diff --git a/helm/sim/examples/values-aws.yaml b/helm/sim/examples/values-aws.yaml index 3310bcd0ce8..4e79130c5db 100644 --- a/helm/sim/examples/values-aws.yaml +++ b/helm/sim/examples/values-aws.yaml @@ -84,7 +84,6 @@ realtime: env: NEXT_PUBLIC_APP_URL: "https://simstudio.acme.com" BETTER_AUTH_URL: "https://simstudio.acme.com" - NEXT_PUBLIC_SOCKET_URL: "https://simstudio-ws.acme.com" BETTER_AUTH_SECRET: "your-secure-production-auth-secret-here" ALLOWED_ORIGINS: "https://simstudio.acme.com" NODE_ENV: "production" diff --git a/helm/sim/examples/values-azure.yaml b/helm/sim/examples/values-azure.yaml index 0926eb31aad..b8afaba6d6f 100644 --- a/helm/sim/examples/values-azure.yaml +++ b/helm/sim/examples/values-azure.yaml @@ -93,7 +93,6 @@ realtime: env: NEXT_PUBLIC_APP_URL: "https://simstudio.acme.com" BETTER_AUTH_URL: "https://simstudio.acme.com" - NEXT_PUBLIC_SOCKET_URL: "https://simstudio-ws.acme.com" BETTER_AUTH_SECRET: "your-secure-production-auth-secret-here" ALLOWED_ORIGINS: "https://simstudio.acme.com" NODE_ENV: "production" diff --git a/helm/sim/examples/values-development.yaml b/helm/sim/examples/values-development.yaml index 8ce0b25218a..500195d16d0 100644 --- a/helm/sim/examples/values-development.yaml +++ b/helm/sim/examples/values-development.yaml @@ -53,7 +53,6 @@ realtime: env: NEXT_PUBLIC_APP_URL: "http://localhost:3000" BETTER_AUTH_URL: "http://localhost:3000" - NEXT_PUBLIC_SOCKET_URL: "http://localhost:3002" BETTER_AUTH_SECRET: "dev-32-char-auth-secret-not-secure-dev" ALLOWED_ORIGINS: "http://localhost:3000" diff --git a/helm/sim/examples/values-existing-secret.yaml b/helm/sim/examples/values-existing-secret.yaml index 4f686256cf1..0e0dbc5f2b1 100644 --- a/helm/sim/examples/values-existing-secret.yaml +++ b/helm/sim/examples/values-existing-secret.yaml @@ -23,7 +23,6 @@ realtime: env: NEXT_PUBLIC_APP_URL: "https://sim.example.com" BETTER_AUTH_URL: "https://sim.example.com" - NEXT_PUBLIC_SOCKET_URL: "wss://sim-ws.example.com" ALLOWED_ORIGINS: "https://sim.example.com" NODE_ENV: "production" diff --git a/helm/sim/examples/values-external-db.yaml b/helm/sim/examples/values-external-db.yaml index 920129d4cc4..d651072b975 100644 --- a/helm/sim/examples/values-external-db.yaml +++ b/helm/sim/examples/values-external-db.yaml @@ -55,7 +55,6 @@ realtime: env: NEXT_PUBLIC_APP_URL: "https://simstudio.acme.com" BETTER_AUTH_URL: "https://simstudio.acme.com" - NEXT_PUBLIC_SOCKET_URL: "https://simstudio-ws.acme.com" BETTER_AUTH_SECRET: "" # Must match main app secret - set via --set flag ALLOWED_ORIGINS: "https://simstudio.acme.com" NODE_ENV: "production" diff --git a/helm/sim/examples/values-external-secrets.yaml b/helm/sim/examples/values-external-secrets.yaml index 5aa36bc3ac3..9c4ec21067c 100644 --- a/helm/sim/examples/values-external-secrets.yaml +++ b/helm/sim/examples/values-external-secrets.yaml @@ -37,7 +37,6 @@ realtime: env: NEXT_PUBLIC_APP_URL: "https://sim.example.com" BETTER_AUTH_URL: "https://sim.example.com" - NEXT_PUBLIC_SOCKET_URL: "wss://sim-ws.example.com" ALLOWED_ORIGINS: "https://sim.example.com" NODE_ENV: "production" diff --git a/helm/sim/examples/values-gcp.yaml b/helm/sim/examples/values-gcp.yaml index f0b5e66b58d..07a2bd4e9dc 100644 --- a/helm/sim/examples/values-gcp.yaml +++ b/helm/sim/examples/values-gcp.yaml @@ -70,7 +70,6 @@ realtime: env: NEXT_PUBLIC_APP_URL: "https://simstudio.acme.com" BETTER_AUTH_URL: "https://simstudio.acme.com" - NEXT_PUBLIC_SOCKET_URL: "https://simstudio-ws.acme.com" BETTER_AUTH_SECRET: "your-secure-production-auth-secret-here" ALLOWED_ORIGINS: "https://simstudio.acme.com" NODE_ENV: "production" diff --git a/helm/sim/examples/values-production.yaml b/helm/sim/examples/values-production.yaml index 81893fd25ae..2986cf3ff87 100644 --- a/helm/sim/examples/values-production.yaml +++ b/helm/sim/examples/values-production.yaml @@ -64,7 +64,6 @@ realtime: env: NEXT_PUBLIC_APP_URL: "https://sim.acme.ai" BETTER_AUTH_URL: "https://sim.acme.ai" - NEXT_PUBLIC_SOCKET_URL: "https://sim-ws.acme.ai" BETTER_AUTH_SECRET: "your-production-auth-secret-here" ALLOWED_ORIGINS: "https://sim.acme.ai" diff --git a/helm/sim/examples/values-whitelabeled.yaml b/helm/sim/examples/values-whitelabeled.yaml index 7b4b54b4960..831558150c5 100644 --- a/helm/sim/examples/values-whitelabeled.yaml +++ b/helm/sim/examples/values-whitelabeled.yaml @@ -47,7 +47,6 @@ realtime: env: NEXT_PUBLIC_APP_URL: "https://sim.acme.ai" BETTER_AUTH_URL: "https://sim.acme.ai" - NEXT_PUBLIC_SOCKET_URL: "https://sim-ws.acme.ai" BETTER_AUTH_SECRET: "your-production-auth-secret-here" ALLOWED_ORIGINS: "https://sim.acme.ai" diff --git a/helm/sim/values.schema.json b/helm/sim/values.schema.json index 615db3939fd..4624c0154e7 100644 --- a/helm/sim/values.schema.json +++ b/helm/sim/values.schema.json @@ -144,8 +144,19 @@ }, "NEXT_PUBLIC_SOCKET_URL": { "type": "string", - "format": "uri", - "description": "Public socket URL" + "anyOf": [ + { + "format": "uri" + }, + { + "const": "" + } + ], + "description": "Public socket URL; leave empty to default to the page origin (assumes reverse proxy routes /socket.io)" + }, + "TRUSTED_ORIGINS": { + "type": "string", + "description": "Comma-separated additional public origins to trust for auth (e.g. 'https://app.example.com,https://www.example.com'). Merged into Better Auth trustedOrigins." }, "NODE_ENV": { "type": "string", @@ -382,11 +393,6 @@ "format": "uri", "description": "Authentication service URL" }, - "NEXT_PUBLIC_SOCKET_URL": { - "type": "string", - "format": "uri", - "description": "Public socket URL" - }, "ALLOWED_ORIGINS": { "type": "string", "description": "CORS allowed origins" diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml index f2ec7964dfb..477122f218c 100644 --- a/helm/sim/values.yaml +++ b/helm/sim/values.yaml @@ -68,13 +68,20 @@ app: # Environment variables env: - # Application URLs + # Application URLs — set NEXT_PUBLIC_APP_URL to your public origin (e.g. https://sim.example.com). + # Leaving these at localhost in a production deployment will break sign-up/sign-in for browsers, + # because the auth client and Better Auth `trustedOrigins` use these values. NEXT_PUBLIC_APP_URL: "http://localhost:3000" BETTER_AUTH_URL: "http://localhost:3000" INTERNAL_API_BASE_URL: "" # Optional server-side internal base URL for /api self-calls (include http:// or https://); falls back to NEXT_PUBLIC_APP_URL when empty + # TRUSTED_ORIGINS: comma-separated extra public origins to trust for auth (e.g. apex+www, alias hostnames). + # Merged into Better Auth `trustedOrigins` alongside NEXT_PUBLIC_APP_URL. Leave empty when serving from a single origin. + TRUSTED_ORIGINS: "" # SOCKET_SERVER_URL: Auto-detected when realtime.enabled=true (uses internal service) - # Only set this if using an external WebSocket service with realtime.enabled=false - NEXT_PUBLIC_SOCKET_URL: "http://localhost:3002" # Public WebSocket URL for browsers + # NEXT_PUBLIC_SOCKET_URL: public WebSocket URL for browsers. Leave empty to default to the + # page's own origin (assumes the ingress/reverse proxy routes /socket.io to the realtime service). + # Set explicitly only when realtime is on a separate host:port from the app (e.g. wss://socket.example.com). + NEXT_PUBLIC_SOCKET_URL: "" # Node environment NODE_ENV: "production" @@ -352,16 +359,17 @@ realtime: # Environment variables env: - # Application URLs + # Application URLs — must mirror the public origin used by the main app. NEXT_PUBLIC_APP_URL: "http://localhost:3000" BETTER_AUTH_URL: "http://localhost:3000" - NEXT_PUBLIC_SOCKET_URL: "http://localhost:3002" - + # Authentication secret (REQUIRED for production) # Must match the BETTER_AUTH_SECRET value from the main app configuration BETTER_AUTH_SECRET: "" # REQUIRED - set via --set flag or external secret manager - - # Cross-Origin Resource Sharing (CORS) allowed origins + + # Cross-Origin Resource Sharing (CORS) allowed origins for the realtime service. + # Set to the same public origin(s) as the main app (comma-separated). The realtime + # service rejects WebSocket upgrades from origins not in this list. ALLOWED_ORIGINS: "http://localhost:3000" # Node environment diff --git a/packages/db/package.json b/packages/db/package.json index 88d1bcb02dd..c4e794ce07c 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -30,9 +30,9 @@ "format:check": "biome format ." }, "dependencies": { + "@sim/utils": "workspace:*", "drizzle-orm": "^0.45.2", "postgres": "^3.4.5", - "uuid": "^11.1.0", "zod": "4.3.6" }, "devDependencies": { diff --git a/packages/db/scripts/migrate-block-api-keys-to-byok.ts b/packages/db/scripts/migrate-block-api-keys-to-byok.ts index 88ee98e2204..25b3fb3df32 100644 --- a/packages/db/scripts/migrate-block-api-keys-to-byok.ts +++ b/packages/db/scripts/migrate-block-api-keys-to-byok.ts @@ -22,11 +22,11 @@ import { createCipheriv, createDecipheriv, randomBytes } from 'crypto' import { appendFileSync, readFileSync, writeFileSync } from 'fs' import { resolve } from 'path' +import { generateId } from '@sim/utils/id' import { eq, sql } from 'drizzle-orm' import { index, json, jsonb, pgTable, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' -import { v4 as uuidv4 } from 'uuid' // ---------- CLI ---------- const DRY_RUN = process.argv.includes('--dry-run') @@ -573,7 +573,7 @@ async function processWorkspace( const result = await db .insert(workspaceBYOKKeys) .values({ - id: uuidv4(), + id: generateId(), workspaceId, providerId, encryptedApiKey: encrypted, diff --git a/packages/db/scripts/migrate-deployment-versions.ts b/packages/db/scripts/migrate-deployment-versions.ts index 30677c88b02..98685135e06 100644 --- a/packages/db/scripts/migrate-deployment-versions.ts +++ b/packages/db/scripts/migrate-deployment-versions.ts @@ -2,11 +2,12 @@ // This script is intentionally self-contained for execution in the migrations image. // Do not import from the main app code; duplicate minimal schema and DB setup here. +// Workspace-internal packages (`@sim/*`) are permitted since they ship in the migrations image. +import { generateId } from '@sim/utils/id' import { sql } from 'drizzle-orm' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' -import { v4 as uuidv4 } from 'uuid' // ---------- Minimal env helpers ---------- function getEnv(name: string): string | undefined { @@ -293,7 +294,7 @@ async function migrateWorkflows() { if (state) { deploymentVersions.push({ - id: uuidv4(), + id: generateId(), workflowId: wf.id, version: 1, state, diff --git a/packages/db/scripts/register-sso-provider.ts b/packages/db/scripts/register-sso-provider.ts index 19052ece762..293d6f545cc 100644 --- a/packages/db/scripts/register-sso-provider.ts +++ b/packages/db/scripts/register-sso-provider.ts @@ -33,10 +33,10 @@ * SSO_SAML_WANT_ASSERTIONS_SIGNED=true (optional, defaults to false) */ +import { generateId } from '@sim/utils/id' import { eq } from 'drizzle-orm' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' -import { v4 as uuidv4 } from 'uuid' import { ssoProvider, user } from '../schema' interface SSOMapping { @@ -557,7 +557,7 @@ async function registerSSOProvider(): Promise { } const providerData: SSOProviderData = { - id: uuidv4(), + id: generateId(), issuer: ssoConfig.issuer, domain: ssoConfig.domain, userId: adminUser.id, diff --git a/packages/db/scripts/seed-stress-test-users.ts b/packages/db/scripts/seed-stress-test-users.ts index b7ade8144b9..dcab3170e20 100644 --- a/packages/db/scripts/seed-stress-test-users.ts +++ b/packages/db/scripts/seed-stress-test-users.ts @@ -6,6 +6,7 @@ * cd packages/db && bun run scripts/seed-stress-test-users.ts */ +import { generateId } from '@sim/utils/id' import { eq, type InferInsertModel } from 'drizzle-orm' import { db, userTableDefinitions, userTableRows } from '../index' @@ -152,7 +153,7 @@ async function main() { .where(eq(userTableDefinitions.id, tableId)) } else { // Create table - tableId = `tbl_${crypto.randomUUID().replace(/-/g, '')}` + tableId = `tbl_${generateId().replace(/-/g, '')}` const now = new Date() const tableSchema = { @@ -195,7 +196,7 @@ async function main() { for (let j = i; j < endIdx; j++) { batch.push({ - id: `row_${crypto.randomUUID().replace(/-/g, '')}`, + id: `row_${generateId().replace(/-/g, '')}`, tableId, workspaceId: WORKSPACE_ID, data: generateUserRow(j), diff --git a/packages/testing/package.json b/packages/testing/package.json index e309809577a..74227adb0c8 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -56,5 +56,7 @@ "typescript": "^5.7.3", "vitest": "^3.0.8" }, - "dependencies": {} + "dependencies": { + "@sim/utils": "workspace:*" + } } diff --git a/packages/testing/src/factories/table.factory.ts b/packages/testing/src/factories/table.factory.ts index e6102d94c4e..020787735ec 100644 --- a/packages/testing/src/factories/table.factory.ts +++ b/packages/testing/src/factories/table.factory.ts @@ -1,4 +1,6 @@ -import { customAlphabet, nanoid } from 'nanoid' +import { generateShortId } from '@sim/utils/id' + +const COLUMN_SUFFIX_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789_' export type TableColumnType = 'string' | 'number' | 'boolean' | 'date' | 'json' @@ -32,14 +34,12 @@ export interface TableRowFactoryOptions { updatedAt?: string } -const createTableColumnSuffix = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789_', 6) - /** * Creates a table column fixture with sensible defaults. */ export function createTableColumn(options: TableColumnFactoryOptions = {}): TableColumnFixture { return { - name: options.name ?? `column_${createTableColumnSuffix()}`, + name: options.name ?? `column_${generateShortId(6, COLUMN_SUFFIX_ALPHABET)}`, type: options.type ?? 'string', required: options.required, unique: options.unique, @@ -53,7 +53,7 @@ export function createTableRow(options: TableRowFactoryOptions = {}): TableRowFi const timestamp = new Date().toISOString() return { - id: options.id ?? `row_${nanoid(8)}`, + id: options.id ?? `row_${generateShortId(8)}`, data: options.data ?? {}, position: options.position ?? 0, createdAt: options.createdAt ?? timestamp, diff --git a/packages/utils/src/id.test.ts b/packages/utils/src/id.test.ts index 7c95de6083f..262115b3bb0 100644 --- a/packages/utils/src/id.test.ts +++ b/packages/utils/src/id.test.ts @@ -38,6 +38,17 @@ describe('generateShortId', () => { const ids = new Set(Array.from({ length: 100 }, () => generateShortId())) expect(ids.size).toBe(100) }) + + it('supports a custom alphabet', () => { + const alphabet = 'abcdef0123456789' + const id = generateShortId(32, alphabet) + expect(id).toHaveLength(32) + expect(id).toMatch(/^[a-f0-9]+$/) + }) + + it('throws for an alphabet shorter than 2 characters', () => { + expect(() => generateShortId(8, 'a')).toThrow() + }) }) describe('isValidUuid', () => { diff --git a/packages/utils/src/id.ts b/packages/utils/src/id.ts index a0010e8789a..333a029916b 100644 --- a/packages/utils/src/id.ts +++ b/packages/utils/src/id.ts @@ -39,15 +39,29 @@ const URL_SAFE_ALPHABET = 'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghj * contexts including non-secure (HTTP) browsers. * * @param size - Length of the generated ID (default: 21) - * @returns A URL-safe random string + * @param alphabet - Optional custom alphabet (replaces nanoid's `customAlphabet`). + * Length must be in [2, 256]. + * @returns A random string drawn from the alphabet */ -export function generateShortId(size = 21): string { - const bytes = new Uint8Array(size) - crypto.getRandomValues(bytes) +export function generateShortId(size = 21, alphabet: string = URL_SAFE_ALPHABET): string { + const alphabetLength = alphabet.length + if (alphabetLength < 2 || alphabetLength > 256) { + throw new Error('generateShortId alphabet length must be between 2 and 256') + } + + const mask = (2 << (31 - Math.clz32((alphabetLength - 1) | 1))) - 1 + const step = Math.ceil((1.6 * mask * size) / alphabetLength) let id = '' - for (let i = 0; i < size; i++) { - id += URL_SAFE_ALPHABET[bytes[i] & 63] + while (id.length < size) { + const bytes = new Uint8Array(step) + crypto.getRandomValues(bytes) + for (let i = 0; i < step && id.length < size; i++) { + const index = bytes[i] & mask + if (index < alphabetLength) { + id += alphabet[index] + } + } } return id }