mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 22:24:16 +01:00
fix(config): defer runtime validation until saved Cloud configuration loads
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
4bb9e10296
commit
d8f920ce7e
5 files changed
+71
-24
No files matched your search
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user