fix(config): defer runtime validation until saved Cloud configuration loads

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-30 11:36:25 -05:00
1 parent 4bb9e10296
commit d8f920ce7e
5 files changed
+71 -24

No files matched your search

+3 -1
View File
@@ -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.
@@ -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);
@@ -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<null>(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;
});
+17 -6
View File
@@ -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 };
}
+15 -13
View File
@@ -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