From ab6ec999c52fe395f8148ce122982b3b628a0ff7 Mon Sep 17 00:00:00 2001 From: zvictor Date: Thu, 5 Mar 2026 17:55:34 -0300 Subject: [PATCH] centralize URLs into single canonical URL var --- cli/src/commands/auth-bootstrap-ceo.ts | 6 ++++ cli/src/commands/env.ts | 41 ++++++++++++++++++++++++++ cli/src/commands/onboard.ts | 22 ++++++++++++-- doc/DOCKER.md | 22 ++++++++++++++ server/src/auth/better-auth.ts | 25 ++++++++++++++++ server/src/config.ts | 21 ++++++++++++- server/src/index.ts | 19 ++++++++++++ 7 files changed, 152 insertions(+), 4 deletions(-) diff --git a/cli/src/commands/auth-bootstrap-ceo.ts b/cli/src/commands/auth-bootstrap-ceo.ts index 63490f2d..a844c447 100644 --- a/cli/src/commands/auth-bootstrap-ceo.ts +++ b/cli/src/commands/auth-bootstrap-ceo.ts @@ -28,6 +28,12 @@ function resolveDbUrl(configPath?: string) { function resolveBaseUrl(configPath?: string, explicitBaseUrl?: string) { if (explicitBaseUrl) return explicitBaseUrl.replace(/\/+$/, ""); + const fromEnv = + process.env.PAPERCLIP_PUBLIC_URL ?? + process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ?? + process.env.BETTER_AUTH_URL ?? + process.env.BETTER_AUTH_BASE_URL; + if (fromEnv?.trim()) return fromEnv.trim().replace(/\/+$/, ""); const config = readConfig(configPath); if (config?.auth.baseUrlMode === "explicit" && config.auth.publicBaseUrl) { return config.auth.publicBaseUrl.replace(/\/+$/, ""); diff --git a/cli/src/commands/env.ts b/cli/src/commands/env.ts index b8eb83a2..2a584785 100644 --- a/cli/src/commands/env.ts +++ b/cli/src/commands/env.ts @@ -118,6 +118,29 @@ function collectDeploymentEnvRows(config: PaperclipConfig | null, configPath: st const dbUrl = process.env.DATABASE_URL ?? config?.database?.connectionString ?? ""; const databaseMode = config?.database?.mode ?? "embedded-postgres"; const dbUrlSource: EnvSource = process.env.DATABASE_URL ? "env" : config?.database?.connectionString ? "config" : "missing"; + const publicUrl = + process.env.PAPERCLIP_PUBLIC_URL ?? + process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ?? + process.env.BETTER_AUTH_URL ?? + process.env.BETTER_AUTH_BASE_URL ?? + config?.auth?.publicBaseUrl ?? + ""; + const publicUrlSource: EnvSource = + process.env.PAPERCLIP_PUBLIC_URL + ? "env" + : process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL || process.env.BETTER_AUTH_URL || process.env.BETTER_AUTH_BASE_URL + ? "env" + : config?.auth?.publicBaseUrl + ? "config" + : "missing"; + let trustedOriginsDefault = ""; + if (publicUrl) { + try { + trustedOriginsDefault = new URL(publicUrl).origin; + } catch { + trustedOriginsDefault = ""; + } + } const heartbeatInterval = process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS ?? DEFAULT_HEARTBEAT_SCHEDULER_INTERVAL_MS; const heartbeatEnabled = process.env.HEARTBEAT_SCHEDULER_ENABLED ?? "true"; @@ -192,6 +215,24 @@ function collectDeploymentEnvRows(config: PaperclipConfig | null, configPath: st required: false, note: "HTTP listen port", }, + { + key: "PAPERCLIP_PUBLIC_URL", + value: publicUrl, + source: publicUrlSource, + required: false, + note: "Canonical public URL for auth/callback/invite origin wiring", + }, + { + key: "BETTER_AUTH_TRUSTED_ORIGINS", + value: process.env.BETTER_AUTH_TRUSTED_ORIGINS ?? trustedOriginsDefault, + source: process.env.BETTER_AUTH_TRUSTED_ORIGINS + ? "env" + : trustedOriginsDefault + ? "default" + : "missing", + required: false, + note: "Comma-separated auth origin allowlist (auto-derived from PAPERCLIP_PUBLIC_URL when possible)", + }, { key: "PAPERCLIP_AGENT_JWT_TTL_SECONDS", value: process.env.PAPERCLIP_AGENT_JWT_TTL_SECONDS ?? DEFAULT_AGENT_JWT_TTL_SECONDS, diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index 9fee5f84..9be97107 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -46,6 +46,7 @@ type OnboardOptions = { type OnboardDefaults = Pick; const ONBOARD_ENV_KEYS = [ + "PAPERCLIP_PUBLIC_URL", "DATABASE_URL", "PAPERCLIP_DB_BACKUP_ENABLED", "PAPERCLIP_DB_BACKUP_INTERVAL_MINUTES", @@ -60,6 +61,7 @@ const ONBOARD_ENV_KEYS = [ "PAPERCLIP_AUTH_BASE_URL_MODE", "PAPERCLIP_AUTH_PUBLIC_BASE_URL", "BETTER_AUTH_URL", + "BETTER_AUTH_BASE_URL", "PAPERCLIP_STORAGE_PROVIDER", "PAPERCLIP_STORAGE_LOCAL_DIR", "PAPERCLIP_STORAGE_S3_BUCKET", @@ -99,6 +101,12 @@ function quickstartDefaultsFromEnv(): { defaults: OnboardDefaults; usedEnvKeys: const defaultStorage = defaultStorageConfig(); const defaultSecrets = defaultSecretsConfig(); const databaseUrl = process.env.DATABASE_URL?.trim() || undefined; + const publicUrl = + process.env.PAPERCLIP_PUBLIC_URL?.trim() || + process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL?.trim() || + process.env.BETTER_AUTH_URL?.trim() || + process.env.BETTER_AUTH_BASE_URL?.trim() || + undefined; const deploymentMode = parseEnumFromEnv(process.env.PAPERCLIP_DEPLOYMENT_MODE, DEPLOYMENT_MODES) ?? "local_trusted"; const deploymentExposureFromEnv = parseEnumFromEnv( @@ -107,8 +115,7 @@ function quickstartDefaultsFromEnv(): { defaults: OnboardDefaults; usedEnvKeys: ); const deploymentExposure = deploymentMode === "local_trusted" ? "private" : (deploymentExposureFromEnv ?? "private"); - const authPublicBaseUrl = - (process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ?? process.env.BETTER_AUTH_URL)?.trim() || undefined; + const authPublicBaseUrl = publicUrl; const authBaseUrlModeFromEnv = parseEnumFromEnv( process.env.PAPERCLIP_AUTH_BASE_URL_MODE, AUTH_BASE_URL_MODES, @@ -120,6 +127,15 @@ function quickstartDefaultsFromEnv(): { defaults: OnboardDefaults; usedEnvKeys: .map((value) => value.trim().toLowerCase()) .filter((value) => value.length > 0) : []; + const hostnameFromPublicUrl = publicUrl + ? (() => { + try { + return new URL(publicUrl).hostname.trim().toLowerCase(); + } catch { + return null; + } + })() + : null; const storageProvider = parseEnumFromEnv(process.env.PAPERCLIP_STORAGE_PROVIDER, STORAGE_PROVIDERS) ?? defaultStorage.provider; @@ -157,7 +173,7 @@ function quickstartDefaultsFromEnv(): { defaults: OnboardDefaults; usedEnvKeys: exposure: deploymentExposure, host: process.env.HOST ?? "127.0.0.1", port: Number(process.env.PORT) || 3100, - allowedHostnames: Array.from(new Set(allowedHostnamesFromEnv)), + allowedHostnames: Array.from(new Set([...allowedHostnamesFromEnv, ...(hostnameFromPublicUrl ? [hostnameFromPublicUrl] : [])])), serveUi: parseBooleanFromEnv(process.env.SERVE_UI) ?? true, }, auth: { diff --git a/doc/DOCKER.md b/doc/DOCKER.md index 033812f3..b7686984 100644 --- a/doc/DOCKER.md +++ b/doc/DOCKER.md @@ -42,6 +42,28 @@ Optional overrides: PAPERCLIP_PORT=3200 PAPERCLIP_DATA_DIR=./data/pc docker compose -f docker-compose.quickstart.yml up --build ``` +## Authenticated Compose (Single Public URL) + +For authenticated deployments, set one canonical public URL and let Paperclip derive auth/callback defaults: + +```yaml +services: + paperclip: + environment: + PAPERCLIP_DEPLOYMENT_MODE: authenticated + PAPERCLIP_DEPLOYMENT_EXPOSURE: private + PAPERCLIP_PUBLIC_URL: https://desk.koker.net +``` + +`PAPERCLIP_PUBLIC_URL` is used as the primary source for: + +- auth public base URL +- Better Auth base URL defaults +- bootstrap invite URL defaults +- hostname allowlist defaults (hostname extracted from URL) + +Granular overrides remain available if needed (`PAPERCLIP_AUTH_PUBLIC_BASE_URL`, `BETTER_AUTH_URL`, `BETTER_AUTH_TRUSTED_ORIGINS`, `PAPERCLIP_ALLOWED_HOSTNAMES`). + ## Claude + Codex Local Adapters in Docker The image pre-installs: diff --git a/server/src/auth/better-auth.ts b/server/src/auth/better-auth.ts index db117a00..c234cb49 100644 --- a/server/src/auth/better-auth.ts +++ b/server/src/auth/better-auth.ts @@ -42,13 +42,38 @@ function headersFromExpressRequest(req: Request): Headers { return headersFromNodeHeaders(req.headers); } +export function deriveAuthTrustedOrigins(config: Config): string[] { + const baseUrl = config.authBaseUrlMode === "explicit" ? config.authPublicBaseUrl : undefined; + const trustedOrigins = new Set(); + + if (baseUrl) { + try { + trustedOrigins.add(new URL(baseUrl).origin); + } catch { + // Better Auth will surface invalid base URL separately. + } + } + if (config.deploymentMode === "authenticated" && config.deploymentExposure === "private") { + for (const hostname of config.allowedHostnames) { + const trimmed = hostname.trim().toLowerCase(); + if (!trimmed) continue; + trustedOrigins.add(`https://${trimmed}`); + trustedOrigins.add(`http://${trimmed}`); + } + } + + return Array.from(trustedOrigins); +} + export function createBetterAuthInstance(db: Db, config: Config): BetterAuthInstance { const baseUrl = config.authBaseUrlMode === "explicit" ? config.authPublicBaseUrl : undefined; const secret = process.env.BETTER_AUTH_SECRET ?? process.env.PAPERCLIP_AGENT_JWT_SECRET ?? "paperclip-dev-secret"; + const trustedOrigins = deriveAuthTrustedOrigins(config); const authConfig = { baseURL: baseUrl, secret, + trustedOrigins, database: drizzleAdapter(db, { provider: "pg", schema: { diff --git a/server/src/config.ts b/server/src/config.ts index 01a37588..5aa0c31e 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -130,9 +130,12 @@ export function loadConfig(): Config { AUTH_BASE_URL_MODES.includes(authBaseUrlModeFromEnvRaw as AuthBaseUrlMode) ? (authBaseUrlModeFromEnvRaw as AuthBaseUrlMode) : null; + const publicUrlFromEnv = process.env.PAPERCLIP_PUBLIC_URL; const authPublicBaseUrlRaw = process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ?? process.env.BETTER_AUTH_URL ?? + process.env.BETTER_AUTH_BASE_URL ?? + publicUrlFromEnv ?? fileConfig?.auth?.publicBaseUrl; const authPublicBaseUrl = authPublicBaseUrlRaw?.trim() || undefined; const authBaseUrlMode: AuthBaseUrlMode = @@ -146,8 +149,24 @@ export function loadConfig(): Config { .map((value) => value.trim().toLowerCase()) .filter((value) => value.length > 0) : null; + const publicUrlHostname = authPublicBaseUrl + ? (() => { + try { + return new URL(authPublicBaseUrl).hostname.trim().toLowerCase(); + } catch { + return null; + } + })() + : null; const allowedHostnames = Array.from( - new Set((allowedHostnamesFromEnv ?? fileConfig?.server.allowedHostnames ?? []).map((value) => value.trim().toLowerCase()).filter(Boolean)), + new Set( + [ + ...(allowedHostnamesFromEnv ?? fileConfig?.server.allowedHostnames ?? []), + ...(publicUrlHostname ? [publicUrlHostname] : []), + ] + .map((value) => value.trim().toLowerCase()) + .filter(Boolean), + ), ); const companyDeletionEnvRaw = process.env.PAPERCLIP_ENABLE_COMPANY_DELETION; const companyDeletionEnabled = diff --git a/server/src/index.ts b/server/src/index.ts index ada5743f..052f671c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -412,6 +412,7 @@ if (config.deploymentMode === "authenticated") { const { createBetterAuthHandler, createBetterAuthInstance, + deriveAuthTrustedOrigins, resolveBetterAuthSession, resolveBetterAuthSessionFromHeaders, } = await import("./auth/better-auth.js"); @@ -422,6 +423,24 @@ if (config.deploymentMode === "authenticated") { "authenticated mode requires BETTER_AUTH_SECRET (or PAPERCLIP_AGENT_JWT_SECRET) to be set", ); } + const derivedTrustedOrigins = deriveAuthTrustedOrigins(config); + const envTrustedOrigins = (process.env.BETTER_AUTH_TRUSTED_ORIGINS ?? "") + .split(",") + .map((value) => value.trim()) + .filter((value) => value.length > 0); + const effectiveTrustedOrigins = Array.from(new Set([...derivedTrustedOrigins, ...envTrustedOrigins])); + logger.info( + { + authBaseUrlMode: config.authBaseUrlMode, + authPublicBaseUrl: config.authPublicBaseUrl ?? null, + trustedOrigins: effectiveTrustedOrigins, + trustedOriginsSource: { + derived: derivedTrustedOrigins.length, + env: envTrustedOrigins.length, + }, + }, + "Authenticated mode auth origin configuration", + ); const auth = createBetterAuthInstance(db as any, config); betterAuthHandler = createBetterAuthHandler(auth); resolveSession = (req) => resolveBetterAuthSession(auth, req);