From d8f920ce7e780ebc92ea4b49263d18f157aebce7 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 30 Oct 2026 11:35:27 -0500 Subject: [PATCH] fix(config): defer runtime validation until saved Cloud configuration loads Co-Authored-By: Paperclip --- doc/cloud-runtime-configuration.md | 4 ++- .../__tests__/cloud-runtime-config.test.ts | 28 +++++++++++++++++++ .../server-startup-feedback-export.test.ts | 12 +++++--- server/src/config.ts | 23 +++++++++++---- server/src/index.ts | 28 ++++++++++--------- 5 files changed, 71 insertions(+), 24 deletions(-) diff --git a/doc/cloud-runtime-configuration.md b/doc/cloud-runtime-configuration.md index 0f7ce7ed446..c6a87f2507b 100644 --- a/doc/cloud-runtime-configuration.md +++ b/doc/cloud-runtime-configuration.md @@ -17,7 +17,9 @@ existing validation at application startup. `DATABASE_MIGRATION_URL`. Cloud requires a PostgreSQL URL and never falls back to embedded PostgreSQL. Credentials, trust keys, provider configuration and deployment wiring needed before the database connection remain supplied by - the environment or the existing secret system. + the environment or the existing secret system. This phase parses only the + database connection record. Runtime mode, host binding and authentication + checks wait until the saved snapshot has loaded. 2. After schema migration and database connection, startup reads the private runtime configuration row. An existing invalid row or a database error stops startup. Neither condition selects a legacy file or an old process snapshot. diff --git a/server/src/__tests__/cloud-runtime-config.test.ts b/server/src/__tests__/cloud-runtime-config.test.ts index 93f1d7900eb..c7078f7cddc 100644 --- a/server/src/__tests__/cloud-runtime-config.test.ts +++ b/server/src/__tests__/cloud-runtime-config.test.ts @@ -56,6 +56,7 @@ beforeEach(() => { vi.stubEnv("PAPERCLIP_CONFIG", configPath); vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "test-token"); vi.stubEnv("PAPERCLIP_MANAGED_CONFIG", undefined); + vi.stubEnv("DATABASE_MIGRATION_URL", undefined); vi.stubEnv("PAPERCLIP_DEPLOYMENT_MODE", undefined); vi.stubEnv("PAPERCLIP_AUTH_PUBLIC_BASE_URL", undefined); vi.stubEnv("PAPERCLIP_AUTH_BASE_URL_MODE", undefined); @@ -115,6 +116,20 @@ describe("Cloud configuration file boundary", () => { expect(() => loadCloudBootstrapConfig()).toThrow(/must use PostgreSQL/); }); + it.each(["", " "])("treats an empty migration URL as absent (%j)", (migrationUrl) => { + vi.stubEnv("DATABASE_URL", "postgres://bootstrap:password@127.0.0.1/runtime"); + vi.stubEnv("DATABASE_MIGRATION_URL", migrationUrl); + expect(loadCloudBootstrapConfig().databaseMigrationUrl).toBeUndefined(); + }); + + it("normalizes and validates an explicit migration URL", () => { + vi.stubEnv("DATABASE_URL", "postgres://bootstrap:password@127.0.0.1/runtime"); + vi.stubEnv("DATABASE_MIGRATION_URL", " postgres://migration:password@127.0.0.1/runtime "); + expect(loadCloudBootstrapConfig().databaseMigrationUrl).toBe("postgres://migration:password@127.0.0.1/runtime"); + vi.stubEnv("DATABASE_MIGRATION_URL", "https://wrong.example.test"); + expect(() => loadCloudBootstrapConfig()).toThrow("DATABASE_MIGRATION_URL must use PostgreSQL"); + }); + it("preserves local config file behavior without managed signals", () => { vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", undefined); fs.writeFileSync(configPath, JSON.stringify(legacyConfig())); @@ -158,6 +173,19 @@ describePostgres("Cloud runtime configuration in PostgreSQL", () => { expect(loadConfig()).toMatchObject({ databaseMode: "postgres", authBaseUrlMode: "explicit", secretsStrictMode: true, telemetryEnabled: true }); }); + it("loads the saved authenticated mode before validating an environment host bind", async () => { + fs.writeFileSync(configPath, JSON.stringify(legacyConfig())); + vi.stubEnv("HOST", "0.0.0.0"); + vi.stubEnv("PAPERCLIP_DEPLOYMENT_MODE", undefined); + expect(loadCloudBootstrapConfig()).toEqual({ + databaseMode: "postgres", + databaseUrl: database.connectionString, + databaseMigrationUrl: process.env.DATABASE_MIGRATION_URL, + }); + await initializeCloudRuntimeConfig(first); + expect(loadConfig()).toMatchObject({ deploymentMode: "authenticated", host: "0.0.0.0", port: 3210 }); + }); + it("imports existing choices without credentials and restores them from a second client and new home", async () => { fs.writeFileSync(configPath, JSON.stringify(legacyConfig())); const initial = await initializeCloudRuntimeConfig(first); diff --git a/server/src/__tests__/server-startup-feedback-export.test.ts b/server/src/__tests__/server-startup-feedback-export.test.ts index 77d61af1bcc..cc276572807 100644 --- a/server/src/__tests__/server-startup-feedback-export.test.ts +++ b/server/src/__tests__/server-startup-feedback-export.test.ts @@ -601,7 +601,7 @@ describe("startServer feedback export wiring", () => { process.env.PAPERCLIP_AGENT_JWT_SECRET = "0123456789abcdef0123456789abcdef"; loadConfigMock.mockReturnValue(buildTestConfig()); cloudRuntimeStartupMocks.initialize.mockReset().mockResolvedValue(null); - cloudRuntimeStartupMocks.bootstrap.mockReturnValue(buildTestConfig()); + cloudRuntimeStartupMocks.bootstrap.mockReturnValue({ databaseMode: "postgres", databaseUrl: buildTestConfig().databaseUrl, databaseMigrationUrl: undefined }); resolveHeartbeatSchedulingSuppressionMock.mockReturnValue({ suppressed: false, reason: null, @@ -613,10 +613,13 @@ describe("startServer feedback export wiring", () => { it("awaits saved Cloud configuration before telemetry, authentication and app construction", async () => { vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "startup-test-token"); + vi.stubEnv("HOST", "0.0.0.0"); + vi.stubEnv("PAPERCLIP_DEPLOYMENT_MODE", undefined); + cloudRuntimeStartupMocks.bootstrap.mockReturnValue({ databaseMode: "postgres", databaseUrl: buildTestConfig().databaseUrl, databaseMigrationUrl: undefined }); let release: (() => void) | undefined; cloudRuntimeStartupMocks.initialize.mockImplementationOnce(() => new Promise(resolve => { release = () => { - loadConfigMock.mockReturnValue(buildTestConfig({ telemetryEnabled: false, authDisableSignUp: true, port: 4321 })); + loadConfigMock.mockReturnValue(buildTestConfig({ telemetryEnabled: false, authDisableSignUp: true, port: 4321, host: "0.0.0.0", bind: "all" })); resolve(null); }; })); @@ -627,6 +630,7 @@ describe("startServer feedback export wiring", () => { expect(cloudRuntimeStartupMocks.telemetry).not.toHaveBeenCalled(); expect(createBetterAuthInstanceMock).not.toHaveBeenCalled(); expect(createAppMock).not.toHaveBeenCalled(); + expect(loadConfigMock).not.toHaveBeenCalled(); release!(); const started = await starting; await settleStartupHousekeeping(); @@ -1567,7 +1571,7 @@ describe("startServer authenticated auth origin setup", () => { process.env.PAPERCLIP_DECISION_SIGNING_SECRET = "fedcba9876543210fedcba9876543210"; loadConfigMock.mockReturnValue(buildTestConfig()); cloudRuntimeStartupMocks.initialize.mockReset().mockResolvedValue(null); - cloudRuntimeStartupMocks.bootstrap.mockReturnValue(buildTestConfig()); + cloudRuntimeStartupMocks.bootstrap.mockReturnValue({ databaseMode: "postgres", databaseUrl: buildTestConfig().databaseUrl, databaseMigrationUrl: undefined }); createBetterAuthInstanceMock.mockReturnValue({}); deriveAuthTrustedOriginsMock.mockReturnValue([]); process.env.BETTER_AUTH_SECRET = "test-secret"; @@ -1630,7 +1634,7 @@ describe("startServer PAPERCLIP_API_URL handling", () => { process.env.PAPERCLIP_DECISION_SIGNING_SECRET = "fedcba9876543210fedcba9876543210"; loadConfigMock.mockReturnValue(buildTestConfig()); cloudRuntimeStartupMocks.initialize.mockReset().mockResolvedValue(null); - cloudRuntimeStartupMocks.bootstrap.mockReturnValue(buildTestConfig()); + cloudRuntimeStartupMocks.bootstrap.mockReturnValue({ databaseMode: "postgres", databaseUrl: buildTestConfig().databaseUrl, databaseMigrationUrl: undefined }); process.env.BETTER_AUTH_SECRET = "test-secret"; delete process.env.PAPERCLIP_API_URL; }); diff --git a/server/src/config.ts b/server/src/config.ts index 133f28aff75..e5af468eedb 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -1,5 +1,6 @@ import { readConfigFile, resolveDeploymentMode } from "./config-file.js"; import { isCloudManagedInstance } from "./services/cloud-instance.js"; +import { StartupRefusalError } from "./startup-refusals.js"; import type { PaperclipConfig } from "@paperclipai/shared"; import { parseChatWebhookPublicBaseUrl } from "./chat-webhook-public-url.js"; import { execFileSync } from "node:child_process"; @@ -464,12 +465,22 @@ export function loadConfig(fileConfig: PaperclipConfig | null = readConfigFile() }; } -export function loadCloudBootstrapConfig(): Config { +export type CloudBootstrapConfig = { + databaseMode: "postgres"; + databaseUrl: string; + databaseMigrationUrl: string | undefined; +}; + +export function loadCloudBootstrapConfig(): CloudBootstrapConfig { if (!isCloudManagedInstance()) throw new Error("Cloud bootstrap configuration requires a managed instance"); const url = process.env.DATABASE_URL?.trim(); - if (!url) throw new Error("Cloud bootstrap requires DATABASE_URL from the environment"); - let parsed: URL; - try { parsed = new URL(url); } catch { throw new Error("Cloud bootstrap DATABASE_URL must use PostgreSQL"); } - if (!["postgres:", "postgresql:"].includes(parsed.protocol)) throw new Error("Cloud bootstrap DATABASE_URL must use PostgreSQL"); - return { ...loadConfig(null), databaseMode: "postgres", databaseUrl: url }; + if (!url) throw new StartupRefusalError("database-contract-unmet", "Cloud bootstrap requires DATABASE_URL from the environment"); + const migrationUrl = process.env.DATABASE_MIGRATION_URL?.trim() || undefined; + for (const [name, value] of [["DATABASE_URL", url], ["DATABASE_MIGRATION_URL", migrationUrl]]) { + if (!value) continue; + let parsed: URL; + try { parsed = new URL(value); } catch { throw new Error(`Cloud bootstrap ${name} must use PostgreSQL`); } + if (!["postgres:", "postgresql:"].includes(parsed.protocol)) throw new Error(`Cloud bootstrap ${name} must use PostgreSQL`); + } + return { databaseMode: "postgres", databaseUrl: url, databaseMigrationUrl: migrationUrl }; } diff --git a/server/src/index.ts b/server/src/index.ts index b08485754f4..44e4a77ca1b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -105,7 +105,7 @@ import { import detectPort from "detect-port"; import { createApp } from "./app.js"; import { initializeCloudRuntimeConfig } from "./services/cloud-runtime-config.js"; -import { loadCloudBootstrapConfig, loadConfig } from "./config.js"; +import { loadCloudBootstrapConfig, loadConfig, type Config } from "./config.js"; import { logger } from "./middleware/logger.js"; import { createIncompleteTaskWatchdogScanReporter } from "./services/task-watchdog-diagnostics.js"; import { setStartupRecoveryPhase } from "./startup-recovery-state.js"; @@ -293,8 +293,8 @@ async function startServerWithDatabaseTeardown( // sentry.ts. await sentryReady; ensureDecisionSigningSecret(); - let config = isCloudManagedInstance() ? loadCloudBootstrapConfig() : loadConfig(); - if (!isCloudManagedInstance()) initializeRuntimeConfigConsumers(config); + const startupConfig = isCloudManagedInstance() ? loadCloudBootstrapConfig() : loadConfig(); + if ("deploymentMode" in startupConfig) initializeRuntimeConfigConsumers(startupConfig); type MigrationSummary = | "skipped" @@ -396,7 +396,7 @@ async function startServerWithDatabaseTeardown( } } - function assertCloudDatabaseContract(): void { + function assertCloudDatabaseContract(config: Config): void { if (config.deploymentMode !== "authenticated" || config.deploymentExposure !== "public") { return; } @@ -489,17 +489,19 @@ async function startServerWithDatabaseTeardown( let startupDbInfo: | { mode: "external-postgres"; connectionString: string } | { mode: "embedded-postgres"; dataDir: string; port: number }; - assertCloudDatabaseContract(); - if (config.databaseUrl) { - const migrationUrl = config.databaseMigrationUrl ?? config.databaseUrl; + if ("deploymentMode" in startupConfig) assertCloudDatabaseContract(startupConfig); + if (startupConfig.databaseUrl) { + const migrationUrl = startupConfig.databaseMigrationUrl ?? startupConfig.databaseUrl; migrationSummary = await ensureMigrations(migrationUrl, "PostgreSQL"); - - db = createDb(config.databaseUrl); - pluginMigrationDb = config.databaseMigrationUrl ? createDb(config.databaseMigrationUrl) : db; + + db = createDb(startupConfig.databaseUrl); + pluginMigrationDb = startupConfig.databaseMigrationUrl ? createDb(startupConfig.databaseMigrationUrl) : db; logger.info("Using external PostgreSQL via DATABASE_URL/config"); - activeDatabaseConnectionString = config.databaseUrl; - startupDbInfo = { mode: "external-postgres", connectionString: config.databaseUrl }; + activeDatabaseConnectionString = startupConfig.databaseUrl; + startupDbInfo = { mode: "external-postgres", connectionString: startupConfig.databaseUrl }; } else { + if (!("deploymentMode" in startupConfig)) throw new Error("Cloud bootstrap requires an external PostgreSQL connection"); + const config = startupConfig; const moduleName = "embedded-postgres"; let EmbeddedPostgres: EmbeddedPostgresCtor; try { @@ -720,7 +722,7 @@ async function startServerWithDatabaseTeardown( await initializeAdapterSettings(db as any); await initializeCloudRuntimeConfig(db as any); - if (isCloudManagedInstance()) config = loadConfig(); + let config = "deploymentMode" in startupConfig ? startupConfig : loadConfig(); // A claimed warm-pool stack may restart while its provider environment still // names the pool host. Restore the signed, durable identity before Better