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 fbc4db74..0e70d9cf 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", @@ -106,6 +108,12 @@ function quickstartDefaultsFromEnv(): { 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( @@ -114,8 +122,7 @@ function quickstartDefaultsFromEnv(): { ); 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, @@ -127,6 +134,15 @@ function quickstartDefaultsFromEnv(): { .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; @@ -164,7 +180,7 @@ function quickstartDefaultsFromEnv(): { 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..49d0c4ab 100644 --- a/doc/DOCKER.md +++ b/doc/DOCKER.md @@ -42,6 +42,32 @@ Optional overrides: PAPERCLIP_PORT=3200 PAPERCLIP_DATA_DIR=./data/pc docker compose -f docker-compose.quickstart.yml up --build ``` +If you change host port or use a non-local domain, set `PAPERCLIP_PUBLIC_URL` to the external URL you will use in browser/auth flows. + +## 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`). + +Set `PAPERCLIP_ALLOWED_HOSTNAMES` explicitly only when you need additional hostnames beyond the public URL host (for example Tailscale/LAN aliases or multiple private hostnames). + ## Claude + Codex Local Adapters in Docker The image pre-installs: diff --git a/docker-compose.quickstart.yml b/docker-compose.quickstart.yml index 4193c2f9..82006606 100644 --- a/docker-compose.quickstart.yml +++ b/docker-compose.quickstart.yml @@ -12,7 +12,7 @@ services: ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}" PAPERCLIP_DEPLOYMENT_MODE: "authenticated" PAPERCLIP_DEPLOYMENT_EXPOSURE: "private" - PAPERCLIP_ALLOWED_HOSTNAMES: "${PAPERCLIP_ALLOWED_HOSTNAMES:-localhost}" + PAPERCLIP_PUBLIC_URL: "${PAPERCLIP_PUBLIC_URL:-http://localhost:3100}" BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET:?BETTER_AUTH_SECRET must be set}" volumes: - "${PAPERCLIP_DATA_DIR:-./data/docker-paperclip}:/paperclip" diff --git a/docker-compose.yml b/docker-compose.yml index 039bb6b1..94f4291f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,7 +25,7 @@ services: SERVE_UI: "true" PAPERCLIP_DEPLOYMENT_MODE: "authenticated" PAPERCLIP_DEPLOYMENT_EXPOSURE: "private" - PAPERCLIP_ALLOWED_HOSTNAMES: "${PAPERCLIP_ALLOWED_HOSTNAMES:-localhost}" + PAPERCLIP_PUBLIC_URL: "${PAPERCLIP_PUBLIC_URL:-http://localhost:3100}" BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET:?BETTER_AUTH_SECRET must be set}" volumes: - paperclip-data:/paperclip diff --git a/server/src/auth/better-auth.ts b/server/src/auth/better-auth.ts index db117a00..5c80fad7 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 createBetterAuthInstance(db: Db, config: Config): BetterAuthInstance { +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") { + 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, trustedOrigins?: string[]): 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 effectiveTrustedOrigins = trustedOrigins ?? deriveAuthTrustedOrigins(config); const authConfig = { baseURL: baseUrl, secret, + trustedOrigins: effectiveTrustedOrigins, 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 125d3021..e78a6479 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,7 +423,25 @@ if (config.deploymentMode === "authenticated") { "authenticated mode requires BETTER_AUTH_SECRET (or PAPERCLIP_AGENT_JWT_SECRET) to be set", ); } - const auth = createBetterAuthInstance(db as any, config); + 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, effectiveTrustedOrigins); betterAuthHandler = createBetterAuthHandler(auth); resolveSession = (req) => resolveBetterAuthSession(auth, req); resolveSessionFromHeaders = (headers) => resolveBetterAuthSessionFromHeaders(auth, headers);