Merge pull request #99 from zvictor/canonical-url

feat: Canonical Public URL for Authenticated Deployments (`PAPERCLIP_PUBLIC_URL`)
This commit is contained in:
Dotta
2026-03-06 12:41:58 -06:00
committed by GitHub
9 changed files with 160 additions and 8 deletions

View File

@@ -28,6 +28,12 @@ function resolveDbUrl(configPath?: string) {
function resolveBaseUrl(configPath?: string, explicitBaseUrl?: string) { function resolveBaseUrl(configPath?: string, explicitBaseUrl?: string) {
if (explicitBaseUrl) return explicitBaseUrl.replace(/\/+$/, ""); 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); const config = readConfig(configPath);
if (config?.auth.baseUrlMode === "explicit" && config.auth.publicBaseUrl) { if (config?.auth.baseUrlMode === "explicit" && config.auth.publicBaseUrl) {
return config.auth.publicBaseUrl.replace(/\/+$/, ""); return config.auth.publicBaseUrl.replace(/\/+$/, "");

View File

@@ -118,6 +118,29 @@ function collectDeploymentEnvRows(config: PaperclipConfig | null, configPath: st
const dbUrl = process.env.DATABASE_URL ?? config?.database?.connectionString ?? ""; const dbUrl = process.env.DATABASE_URL ?? config?.database?.connectionString ?? "";
const databaseMode = config?.database?.mode ?? "embedded-postgres"; const databaseMode = config?.database?.mode ?? "embedded-postgres";
const dbUrlSource: EnvSource = process.env.DATABASE_URL ? "env" : config?.database?.connectionString ? "config" : "missing"; 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 heartbeatInterval = process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS ?? DEFAULT_HEARTBEAT_SCHEDULER_INTERVAL_MS;
const heartbeatEnabled = process.env.HEARTBEAT_SCHEDULER_ENABLED ?? "true"; const heartbeatEnabled = process.env.HEARTBEAT_SCHEDULER_ENABLED ?? "true";
@@ -192,6 +215,24 @@ function collectDeploymentEnvRows(config: PaperclipConfig | null, configPath: st
required: false, required: false,
note: "HTTP listen port", 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", key: "PAPERCLIP_AGENT_JWT_TTL_SECONDS",
value: process.env.PAPERCLIP_AGENT_JWT_TTL_SECONDS ?? DEFAULT_AGENT_JWT_TTL_SECONDS, value: process.env.PAPERCLIP_AGENT_JWT_TTL_SECONDS ?? DEFAULT_AGENT_JWT_TTL_SECONDS,

View File

@@ -46,6 +46,7 @@ type OnboardOptions = {
type OnboardDefaults = Pick<PaperclipConfig, "database" | "logging" | "server" | "auth" | "storage" | "secrets">; type OnboardDefaults = Pick<PaperclipConfig, "database" | "logging" | "server" | "auth" | "storage" | "secrets">;
const ONBOARD_ENV_KEYS = [ const ONBOARD_ENV_KEYS = [
"PAPERCLIP_PUBLIC_URL",
"DATABASE_URL", "DATABASE_URL",
"PAPERCLIP_DB_BACKUP_ENABLED", "PAPERCLIP_DB_BACKUP_ENABLED",
"PAPERCLIP_DB_BACKUP_INTERVAL_MINUTES", "PAPERCLIP_DB_BACKUP_INTERVAL_MINUTES",
@@ -60,6 +61,7 @@ const ONBOARD_ENV_KEYS = [
"PAPERCLIP_AUTH_BASE_URL_MODE", "PAPERCLIP_AUTH_BASE_URL_MODE",
"PAPERCLIP_AUTH_PUBLIC_BASE_URL", "PAPERCLIP_AUTH_PUBLIC_BASE_URL",
"BETTER_AUTH_URL", "BETTER_AUTH_URL",
"BETTER_AUTH_BASE_URL",
"PAPERCLIP_STORAGE_PROVIDER", "PAPERCLIP_STORAGE_PROVIDER",
"PAPERCLIP_STORAGE_LOCAL_DIR", "PAPERCLIP_STORAGE_LOCAL_DIR",
"PAPERCLIP_STORAGE_S3_BUCKET", "PAPERCLIP_STORAGE_S3_BUCKET",
@@ -106,6 +108,12 @@ function quickstartDefaultsFromEnv(): {
const defaultStorage = defaultStorageConfig(); const defaultStorage = defaultStorageConfig();
const defaultSecrets = defaultSecretsConfig(); const defaultSecrets = defaultSecretsConfig();
const databaseUrl = process.env.DATABASE_URL?.trim() || undefined; 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 = const deploymentMode =
parseEnumFromEnv<DeploymentMode>(process.env.PAPERCLIP_DEPLOYMENT_MODE, DEPLOYMENT_MODES) ?? "local_trusted"; parseEnumFromEnv<DeploymentMode>(process.env.PAPERCLIP_DEPLOYMENT_MODE, DEPLOYMENT_MODES) ?? "local_trusted";
const deploymentExposureFromEnv = parseEnumFromEnv<DeploymentExposure>( const deploymentExposureFromEnv = parseEnumFromEnv<DeploymentExposure>(
@@ -114,8 +122,7 @@ function quickstartDefaultsFromEnv(): {
); );
const deploymentExposure = const deploymentExposure =
deploymentMode === "local_trusted" ? "private" : (deploymentExposureFromEnv ?? "private"); deploymentMode === "local_trusted" ? "private" : (deploymentExposureFromEnv ?? "private");
const authPublicBaseUrl = const authPublicBaseUrl = publicUrl;
(process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ?? process.env.BETTER_AUTH_URL)?.trim() || undefined;
const authBaseUrlModeFromEnv = parseEnumFromEnv<AuthBaseUrlMode>( const authBaseUrlModeFromEnv = parseEnumFromEnv<AuthBaseUrlMode>(
process.env.PAPERCLIP_AUTH_BASE_URL_MODE, process.env.PAPERCLIP_AUTH_BASE_URL_MODE,
AUTH_BASE_URL_MODES, AUTH_BASE_URL_MODES,
@@ -127,6 +134,15 @@ function quickstartDefaultsFromEnv(): {
.map((value) => value.trim().toLowerCase()) .map((value) => value.trim().toLowerCase())
.filter((value) => value.length > 0) .filter((value) => value.length > 0)
: []; : [];
const hostnameFromPublicUrl = publicUrl
? (() => {
try {
return new URL(publicUrl).hostname.trim().toLowerCase();
} catch {
return null;
}
})()
: null;
const storageProvider = const storageProvider =
parseEnumFromEnv<StorageProvider>(process.env.PAPERCLIP_STORAGE_PROVIDER, STORAGE_PROVIDERS) ?? parseEnumFromEnv<StorageProvider>(process.env.PAPERCLIP_STORAGE_PROVIDER, STORAGE_PROVIDERS) ??
defaultStorage.provider; defaultStorage.provider;
@@ -164,7 +180,7 @@ function quickstartDefaultsFromEnv(): {
exposure: deploymentExposure, exposure: deploymentExposure,
host: process.env.HOST ?? "127.0.0.1", host: process.env.HOST ?? "127.0.0.1",
port: Number(process.env.PORT) || 3100, 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, serveUi: parseBooleanFromEnv(process.env.SERVE_UI) ?? true,
}, },
auth: { auth: {

View File

@@ -42,6 +42,32 @@ Optional overrides:
PAPERCLIP_PORT=3200 PAPERCLIP_DATA_DIR=./data/pc docker compose -f docker-compose.quickstart.yml up --build 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 ## Claude + Codex Local Adapters in Docker
The image pre-installs: The image pre-installs:

View File

@@ -12,7 +12,7 @@ services:
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}" ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
PAPERCLIP_DEPLOYMENT_MODE: "authenticated" PAPERCLIP_DEPLOYMENT_MODE: "authenticated"
PAPERCLIP_DEPLOYMENT_EXPOSURE: "private" 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}" BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET:?BETTER_AUTH_SECRET must be set}"
volumes: volumes:
- "${PAPERCLIP_DATA_DIR:-./data/docker-paperclip}:/paperclip" - "${PAPERCLIP_DATA_DIR:-./data/docker-paperclip}:/paperclip"

View File

@@ -25,7 +25,7 @@ services:
SERVE_UI: "true" SERVE_UI: "true"
PAPERCLIP_DEPLOYMENT_MODE: "authenticated" PAPERCLIP_DEPLOYMENT_MODE: "authenticated"
PAPERCLIP_DEPLOYMENT_EXPOSURE: "private" 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}" BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET:?BETTER_AUTH_SECRET must be set}"
volumes: volumes:
- paperclip-data:/paperclip - paperclip-data:/paperclip

View File

@@ -42,13 +42,38 @@ function headersFromExpressRequest(req: Request): Headers {
return headersFromNodeHeaders(req.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<string>();
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 baseUrl = config.authBaseUrlMode === "explicit" ? config.authPublicBaseUrl : undefined;
const secret = process.env.BETTER_AUTH_SECRET ?? process.env.PAPERCLIP_AGENT_JWT_SECRET ?? "paperclip-dev-secret"; const secret = process.env.BETTER_AUTH_SECRET ?? process.env.PAPERCLIP_AGENT_JWT_SECRET ?? "paperclip-dev-secret";
const effectiveTrustedOrigins = trustedOrigins ?? deriveAuthTrustedOrigins(config);
const authConfig = { const authConfig = {
baseURL: baseUrl, baseURL: baseUrl,
secret, secret,
trustedOrigins: effectiveTrustedOrigins,
database: drizzleAdapter(db, { database: drizzleAdapter(db, {
provider: "pg", provider: "pg",
schema: { schema: {

View File

@@ -130,9 +130,12 @@ export function loadConfig(): Config {
AUTH_BASE_URL_MODES.includes(authBaseUrlModeFromEnvRaw as AuthBaseUrlMode) AUTH_BASE_URL_MODES.includes(authBaseUrlModeFromEnvRaw as AuthBaseUrlMode)
? (authBaseUrlModeFromEnvRaw as AuthBaseUrlMode) ? (authBaseUrlModeFromEnvRaw as AuthBaseUrlMode)
: null; : null;
const publicUrlFromEnv = process.env.PAPERCLIP_PUBLIC_URL;
const authPublicBaseUrlRaw = const authPublicBaseUrlRaw =
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ?? process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ??
process.env.BETTER_AUTH_URL ?? process.env.BETTER_AUTH_URL ??
process.env.BETTER_AUTH_BASE_URL ??
publicUrlFromEnv ??
fileConfig?.auth?.publicBaseUrl; fileConfig?.auth?.publicBaseUrl;
const authPublicBaseUrl = authPublicBaseUrlRaw?.trim() || undefined; const authPublicBaseUrl = authPublicBaseUrlRaw?.trim() || undefined;
const authBaseUrlMode: AuthBaseUrlMode = const authBaseUrlMode: AuthBaseUrlMode =
@@ -146,8 +149,24 @@ export function loadConfig(): Config {
.map((value) => value.trim().toLowerCase()) .map((value) => value.trim().toLowerCase())
.filter((value) => value.length > 0) .filter((value) => value.length > 0)
: null; : null;
const publicUrlHostname = authPublicBaseUrl
? (() => {
try {
return new URL(authPublicBaseUrl).hostname.trim().toLowerCase();
} catch {
return null;
}
})()
: null;
const allowedHostnames = Array.from( 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 companyDeletionEnvRaw = process.env.PAPERCLIP_ENABLE_COMPANY_DELETION;
const companyDeletionEnabled = const companyDeletionEnabled =

View File

@@ -412,6 +412,7 @@ if (config.deploymentMode === "authenticated") {
const { const {
createBetterAuthHandler, createBetterAuthHandler,
createBetterAuthInstance, createBetterAuthInstance,
deriveAuthTrustedOrigins,
resolveBetterAuthSession, resolveBetterAuthSession,
resolveBetterAuthSessionFromHeaders, resolveBetterAuthSessionFromHeaders,
} = await import("./auth/better-auth.js"); } = 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", "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); betterAuthHandler = createBetterAuthHandler(auth);
resolveSession = (req) => resolveBetterAuthSession(auth, req); resolveSession = (req) => resolveBetterAuthSession(auth, req);
resolveSessionFromHeaders = (headers) => resolveBetterAuthSessionFromHeaders(auth, headers); resolveSessionFromHeaders = (headers) => resolveBetterAuthSessionFromHeaders(auth, headers);