fix: complete authenticated onboarding startup

This commit is contained in:
Dotta
2026-03-09 11:26:58 -05:00
parent 3ec96fdb73
commit 8360b2e3e3
9 changed files with 652 additions and 542 deletions

View File

@@ -37,4 +37,4 @@ WORKDIR /home/paperclip/workspace
EXPOSE 3100 EXPOSE 3100
USER paperclip USER paperclip
CMD ["bash", "-lc", "set -euo pipefail; mkdir -p \"$PAPERCLIP_HOME\"; npx --yes \"paperclipai@${PAPERCLIPAI_VERSION}\" onboard --yes --data-dir \"$PAPERCLIP_HOME\" & app_pid=$!; cleanup() { if kill -0 \"$app_pid\" >/dev/null 2>&1; then kill \"$app_pid\" >/dev/null 2>&1 || true; fi; }; trap cleanup EXIT INT TERM; ready=0; for _ in $(seq 1 60); do if curl -fsS \"http://127.0.0.1:${PORT}/api/health\" >/dev/null 2>&1; then ready=1; break; fi; sleep 1; done; if [ \"$ready\" -eq 1 ]; then echo; echo \"==> Creating bootstrap CEO invite after server startup\"; npx --yes \"paperclipai@${PAPERCLIPAI_VERSION}\" auth bootstrap-ceo --data-dir \"$PAPERCLIP_HOME\" || true; else echo; echo \"==> Warning: server did not become healthy within 60s; skipping bootstrap invite\"; fi; wait \"$app_pid\""] CMD ["bash", "-lc", "set -euo pipefail; mkdir -p \"$PAPERCLIP_HOME\"; npx --yes \"paperclipai@${PAPERCLIPAI_VERSION}\" onboard --yes --data-dir \"$PAPERCLIP_HOME\""]

View File

@@ -3,6 +3,7 @@ import * as p from "@clack/prompts";
import pc from "picocolors"; import pc from "picocolors";
import { and, eq, gt, isNull } from "drizzle-orm"; import { and, eq, gt, isNull } from "drizzle-orm";
import { createDb, instanceUserRoles, invites } from "@paperclipai/db"; import { createDb, instanceUserRoles, invites } from "@paperclipai/db";
import { loadPaperclipEnvFile } from "../config/env.js";
import { readConfig, resolveConfigPath } from "../config/store.js"; import { readConfig, resolveConfigPath } from "../config/store.js";
function hashToken(token: string) { function hashToken(token: string) {
@@ -13,7 +14,8 @@ function createInviteToken() {
return `pcp_bootstrap_${randomBytes(24).toString("hex")}`; return `pcp_bootstrap_${randomBytes(24).toString("hex")}`;
} }
function resolveDbUrl(configPath?: string) { function resolveDbUrl(configPath?: string, explicitDbUrl?: string) {
if (explicitDbUrl) return explicitDbUrl;
const config = readConfig(configPath); const config = readConfig(configPath);
if (process.env.DATABASE_URL) return process.env.DATABASE_URL; if (process.env.DATABASE_URL) return process.env.DATABASE_URL;
if (config?.database.mode === "postgres" && config.database.connectionString) { if (config?.database.mode === "postgres" && config.database.connectionString) {
@@ -49,8 +51,10 @@ export async function bootstrapCeoInvite(opts: {
force?: boolean; force?: boolean;
expiresHours?: number; expiresHours?: number;
baseUrl?: string; baseUrl?: string;
dbUrl?: string;
}) { }) {
const configPath = resolveConfigPath(opts.config); const configPath = resolveConfigPath(opts.config);
loadPaperclipEnvFile(configPath);
const config = readConfig(configPath); const config = readConfig(configPath);
if (!config) { if (!config) {
p.log.error(`No config found at ${configPath}. Run ${pc.cyan("paperclip onboard")} first.`); p.log.error(`No config found at ${configPath}. Run ${pc.cyan("paperclip onboard")} first.`);
@@ -62,7 +66,7 @@ export async function bootstrapCeoInvite(opts: {
return; return;
} }
const dbUrl = resolveDbUrl(configPath); const dbUrl = resolveDbUrl(configPath, opts.dbUrl);
if (!dbUrl) { if (!dbUrl) {
p.log.error( p.log.error(
"Could not resolve database connection for bootstrap.", "Could not resolve database connection for bootstrap.",

View File

@@ -14,6 +14,7 @@ import {
storageCheck, storageCheck,
type CheckResult, type CheckResult,
} from "../checks/index.js"; } from "../checks/index.js";
import { loadPaperclipEnvFile } from "../config/env.js";
import { printPaperclipCliBanner } from "../utils/banner.js"; import { printPaperclipCliBanner } from "../utils/banner.js";
const STATUS_ICON = { const STATUS_ICON = {
@@ -31,6 +32,7 @@ export async function doctor(opts: {
p.intro(pc.bgCyan(pc.black(" paperclip doctor "))); p.intro(pc.bgCyan(pc.black(" paperclip doctor ")));
const configPath = resolveConfigPath(opts.config); const configPath = resolveConfigPath(opts.config);
loadPaperclipEnvFile(configPath);
const results: CheckResult[] = []; const results: CheckResult[] = [];
// 1. Config check (must pass before others) // 1. Config check (must pass before others)

View File

@@ -229,6 +229,10 @@ function quickstartDefaultsFromEnv(): {
return { defaults, usedEnvKeys, ignoredEnvKeys }; return { defaults, usedEnvKeys, ignoredEnvKeys };
} }
function canCreateBootstrapInviteImmediately(config: Pick<PaperclipConfig, "database" | "server">): boolean {
return config.server.deploymentMode === "authenticated" && config.database.mode !== "embedded-postgres";
}
export async function onboard(opts: OnboardOptions): Promise<void> { export async function onboard(opts: OnboardOptions): Promise<void> {
printPaperclipCliBanner(); printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclipai onboard "))); p.intro(pc.bgCyan(pc.black(" paperclipai onboard ")));
@@ -450,7 +454,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
"Next commands", "Next commands",
); );
if (server.deploymentMode === "authenticated") { if (canCreateBootstrapInviteImmediately({ database, server })) {
p.log.step("Generating bootstrap CEO invite"); p.log.step("Generating bootstrap CEO invite");
await bootstrapCeoInvite({ config: configPath }); await bootstrapCeoInvite({ config: configPath });
} }
@@ -473,5 +477,15 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
return; return;
} }
if (server.deploymentMode === "authenticated" && database.mode === "embedded-postgres") {
p.log.info(
[
"Bootstrap CEO invite will be created after the server starts.",
`Next: ${pc.cyan("paperclipai run")}`,
`Then: ${pc.cyan("paperclipai auth bootstrap-ceo")}`,
].join("\n"),
);
}
p.outro("You're all set!"); p.outro("You're all set!");
} }

View File

@@ -3,9 +3,13 @@ import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url"; import { fileURLToPath, pathToFileURL } from "node:url";
import * as p from "@clack/prompts"; import * as p from "@clack/prompts";
import pc from "picocolors"; import pc from "picocolors";
import { bootstrapCeoInvite } from "./auth-bootstrap-ceo.js";
import { onboard } from "./onboard.js"; import { onboard } from "./onboard.js";
import { doctor } from "./doctor.js"; import { doctor } from "./doctor.js";
import { loadPaperclipEnvFile } from "../config/env.js";
import { configExists, resolveConfigPath } from "../config/store.js"; import { configExists, resolveConfigPath } from "../config/store.js";
import type { PaperclipConfig } from "../config/schema.js";
import { readConfig } from "../config/store.js";
import { import {
describeLocalInstancePaths, describeLocalInstancePaths,
resolvePaperclipHomeDir, resolvePaperclipHomeDir,
@@ -19,6 +23,13 @@ interface RunOptions {
yes?: boolean; yes?: boolean;
} }
interface StartedServer {
apiUrl: string;
databaseUrl: string;
host: string;
listenPort: number;
}
export async function runCommand(opts: RunOptions): Promise<void> { export async function runCommand(opts: RunOptions): Promise<void> {
const instanceId = resolvePaperclipInstanceId(opts.instance); const instanceId = resolvePaperclipInstanceId(opts.instance);
process.env.PAPERCLIP_INSTANCE_ID = instanceId; process.env.PAPERCLIP_INSTANCE_ID = instanceId;
@@ -31,6 +42,7 @@ export async function runCommand(opts: RunOptions): Promise<void> {
const configPath = resolveConfigPath(opts.config); const configPath = resolveConfigPath(opts.config);
process.env.PAPERCLIP_CONFIG = configPath; process.env.PAPERCLIP_CONFIG = configPath;
loadPaperclipEnvFile(configPath);
p.intro(pc.bgCyan(pc.black(" paperclipai run "))); p.intro(pc.bgCyan(pc.black(" paperclipai run ")));
p.log.message(pc.dim(`Home: ${paths.homeDir}`)); p.log.message(pc.dim(`Home: ${paths.homeDir}`));
@@ -60,8 +72,23 @@ export async function runCommand(opts: RunOptions): Promise<void> {
process.exit(1); process.exit(1);
} }
const config = readConfig(configPath);
if (!config) {
p.log.error(`No config found at ${configPath}.`);
process.exit(1);
}
p.log.step("Starting Paperclip server..."); p.log.step("Starting Paperclip server...");
await importServerEntry(); const startedServer = await importServerEntry();
if (shouldGenerateBootstrapInviteAfterStart(config)) {
p.log.step("Generating bootstrap CEO invite");
await bootstrapCeoInvite({
config: configPath,
dbUrl: startedServer.databaseUrl,
baseUrl: startedServer.apiUrl.replace(/\/api$/, ""),
});
}
} }
function formatError(err: unknown): string { function formatError(err: unknown): string {
@@ -101,19 +128,20 @@ function maybeEnableUiDevMiddleware(entrypoint: string): void {
} }
} }
async function importServerEntry(): Promise<void> { async function importServerEntry(): Promise<StartedServer> {
// Dev mode: try local workspace path (monorepo with tsx) // Dev mode: try local workspace path (monorepo with tsx)
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const devEntry = path.resolve(projectRoot, "server/src/index.ts"); const devEntry = path.resolve(projectRoot, "server/src/index.ts");
if (fs.existsSync(devEntry)) { if (fs.existsSync(devEntry)) {
maybeEnableUiDevMiddleware(devEntry); maybeEnableUiDevMiddleware(devEntry);
await import(pathToFileURL(devEntry).href); const mod = await import(pathToFileURL(devEntry).href);
return; return await startServerFromModule(mod, devEntry);
} }
// Production mode: import the published @paperclipai/server package // Production mode: import the published @paperclipai/server package
try { try {
await import("@paperclipai/server"); const mod = await import("@paperclipai/server");
return await startServerFromModule(mod, "@paperclipai/server");
} catch (err) { } catch (err) {
const missingSpecifier = getMissingModuleSpecifier(err); const missingSpecifier = getMissingModuleSpecifier(err);
const missingServerEntrypoint = !missingSpecifier || missingSpecifier === "@paperclipai/server"; const missingServerEntrypoint = !missingSpecifier || missingSpecifier === "@paperclipai/server";
@@ -130,3 +158,15 @@ async function importServerEntry(): Promise<void> {
); );
} }
} }
function shouldGenerateBootstrapInviteAfterStart(config: PaperclipConfig): boolean {
return config.server.deploymentMode === "authenticated" && config.database.mode === "embedded-postgres";
}
async function startServerFromModule(mod: unknown, label: string): Promise<StartedServer> {
const startServer = (mod as { startServer?: () => Promise<StartedServer> }).startServer;
if (typeof startServer !== "function") {
throw new Error(`Paperclip server entrypoint did not export startServer(): ${label}`);
}
return await startServer();
}

View File

@@ -36,6 +36,10 @@ export function resolveAgentJwtEnvFile(configPath?: string): string {
return resolveEnvFilePath(configPath); return resolveEnvFilePath(configPath);
} }
export function loadPaperclipEnvFile(configPath?: string): void {
loadAgentJwtEnvFile(resolveEnvFilePath(configPath));
}
export function loadAgentJwtEnvFile(filePath = resolveEnvFilePath()): void { export function loadAgentJwtEnvFile(filePath = resolveEnvFilePath()): void {
if (loadedEnvFiles.has(filePath)) return; if (loadedEnvFiles.has(filePath)) return;

View File

@@ -126,7 +126,7 @@ This is the best existing fit when you want:
- a dedicated host port - a dedicated host port
- an end-to-end `npx paperclipai ... onboard` check - an end-to-end `npx paperclipai ... onboard` check
In authenticated/private mode, this smoke path also injects a smoke-only `BETTER_AUTH_SECRET` by default and prints the bootstrap CEO invite after the server becomes healthy. In authenticated/private mode, the expected result is a full authenticated onboarding flow, including printing the bootstrap CEO invite once startup completes.
If you want to exercise onboarding from a fresh local checkout rather than npm, use: If you want to exercise onboarding from a fresh local checkout rather than npm, use:

View File

@@ -9,7 +9,6 @@ DATA_DIR="${DATA_DIR:-$REPO_ROOT/data/docker-onboard-smoke}"
HOST_UID="${HOST_UID:-$(id -u)}" HOST_UID="${HOST_UID:-$(id -u)}"
PAPERCLIP_DEPLOYMENT_MODE="${PAPERCLIP_DEPLOYMENT_MODE:-authenticated}" PAPERCLIP_DEPLOYMENT_MODE="${PAPERCLIP_DEPLOYMENT_MODE:-authenticated}"
PAPERCLIP_DEPLOYMENT_EXPOSURE="${PAPERCLIP_DEPLOYMENT_EXPOSURE:-private}" PAPERCLIP_DEPLOYMENT_EXPOSURE="${PAPERCLIP_DEPLOYMENT_EXPOSURE:-private}"
BETTER_AUTH_SECRET="${BETTER_AUTH_SECRET:-paperclip-onboard-smoke-secret}"
DOCKER_TTY_ARGS=() DOCKER_TTY_ARGS=()
if [[ -t 0 && -t 1 ]]; then if [[ -t 0 && -t 1 ]]; then
@@ -39,6 +38,5 @@ docker run --rm \
-e PORT=3100 \ -e PORT=3100 \
-e PAPERCLIP_DEPLOYMENT_MODE="$PAPERCLIP_DEPLOYMENT_MODE" \ -e PAPERCLIP_DEPLOYMENT_MODE="$PAPERCLIP_DEPLOYMENT_MODE" \
-e PAPERCLIP_DEPLOYMENT_EXPOSURE="$PAPERCLIP_DEPLOYMENT_EXPOSURE" \ -e PAPERCLIP_DEPLOYMENT_EXPOSURE="$PAPERCLIP_DEPLOYMENT_EXPOSURE" \
-e BETTER_AUTH_SECRET="$BETTER_AUTH_SECRET" \
-v "$DATA_DIR:/paperclip" \ -v "$DATA_DIR:/paperclip" \
"$IMAGE_NAME" "$IMAGE_NAME"

View File

@@ -4,6 +4,7 @@ import { createServer } from "node:http";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { createInterface } from "node:readline/promises"; import { createInterface } from "node:readline/promises";
import { stdin, stdout } from "node:process"; import { stdin, stdout } from "node:process";
import { pathToFileURL } from "node:url";
import type { Request as ExpressRequest, RequestHandler } from "express"; import type { Request as ExpressRequest, RequestHandler } from "express";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { import {
@@ -56,32 +57,42 @@ type EmbeddedPostgresCtor = new (opts: {
onError?: (message: unknown) => void; onError?: (message: unknown) => void;
}) => EmbeddedPostgresInstance; }) => EmbeddedPostgresInstance;
const config = loadConfig();
if (process.env.PAPERCLIP_SECRETS_PROVIDER === undefined) { export interface StartedServer {
process.env.PAPERCLIP_SECRETS_PROVIDER = config.secretsProvider; server: ReturnType<typeof createServer>;
} host: string;
if (process.env.PAPERCLIP_SECRETS_STRICT_MODE === undefined) { listenPort: number;
process.env.PAPERCLIP_SECRETS_STRICT_MODE = config.secretsStrictMode ? "true" : "false"; apiUrl: string;
} databaseUrl: string;
if (process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE === undefined) {
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = config.secretsMasterKeyFilePath;
} }
type MigrationSummary = export async function startServer(): Promise<StartedServer> {
const config = loadConfig();
if (process.env.PAPERCLIP_SECRETS_PROVIDER === undefined) {
process.env.PAPERCLIP_SECRETS_PROVIDER = config.secretsProvider;
}
if (process.env.PAPERCLIP_SECRETS_STRICT_MODE === undefined) {
process.env.PAPERCLIP_SECRETS_STRICT_MODE = config.secretsStrictMode ? "true" : "false";
}
if (process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE === undefined) {
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = config.secretsMasterKeyFilePath;
}
type MigrationSummary =
| "skipped" | "skipped"
| "already applied" | "already applied"
| "applied (empty database)" | "applied (empty database)"
| "applied (pending migrations)" | "applied (pending migrations)"
| "pending migrations skipped"; | "pending migrations skipped";
function formatPendingMigrationSummary(migrations: string[]): string { function formatPendingMigrationSummary(migrations: string[]): string {
if (migrations.length === 0) return "none"; if (migrations.length === 0) return "none";
return migrations.length > 3 return migrations.length > 3
? `${migrations.slice(0, 3).join(", ")} (+${migrations.length - 3} more)` ? `${migrations.slice(0, 3).join(", ")} (+${migrations.length - 3} more)`
: migrations.join(", "); : migrations.join(", ");
} }
async function promptApplyMigrations(migrations: string[]): Promise<boolean> { async function promptApplyMigrations(migrations: string[]): Promise<boolean> {
if (process.env.PAPERCLIP_MIGRATION_PROMPT === "never") return false; if (process.env.PAPERCLIP_MIGRATION_PROMPT === "never") return false;
if (process.env.PAPERCLIP_MIGRATION_AUTO_APPLY === "true") return true; if (process.env.PAPERCLIP_MIGRATION_AUTO_APPLY === "true") return true;
if (!stdin.isTTY || !stdout.isTTY) return true; if (!stdin.isTTY || !stdout.isTTY) return true;
@@ -95,17 +106,17 @@ async function promptApplyMigrations(migrations: string[]): Promise<boolean> {
} finally { } finally {
prompt.close(); prompt.close();
} }
} }
type EnsureMigrationsOptions = { type EnsureMigrationsOptions = {
autoApply?: boolean; autoApply?: boolean;
}; };
async function ensureMigrations( async function ensureMigrations(
connectionString: string, connectionString: string,
label: string, label: string,
opts?: EnsureMigrationsOptions, opts?: EnsureMigrationsOptions,
): Promise<MigrationSummary> { ): Promise<MigrationSummary> {
const autoApply = opts?.autoApply === true; const autoApply = opts?.autoApply === true;
let state = await inspectMigrations(connectionString); let state = await inspectMigrations(connectionString);
if (state.status === "needsMigrations" && state.reason === "pending-migrations") { if (state.status === "needsMigrations" && state.reason === "pending-migrations") {
@@ -151,18 +162,18 @@ async function ensureMigrations(
logger.info({ pendingMigrations: state.pendingMigrations }, `Applying ${state.pendingMigrations.length} pending migrations for ${label}`); logger.info({ pendingMigrations: state.pendingMigrations }, `Applying ${state.pendingMigrations.length} pending migrations for ${label}`);
await applyPendingMigrations(connectionString); await applyPendingMigrations(connectionString);
return "applied (pending migrations)"; return "applied (pending migrations)";
} }
function isLoopbackHost(host: string): boolean { function isLoopbackHost(host: string): boolean {
const normalized = host.trim().toLowerCase(); const normalized = host.trim().toLowerCase();
return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1"; return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1";
} }
const LOCAL_BOARD_USER_ID = "local-board"; const LOCAL_BOARD_USER_ID = "local-board";
const LOCAL_BOARD_USER_EMAIL = "local@paperclip.local"; const LOCAL_BOARD_USER_EMAIL = "local@paperclip.local";
const LOCAL_BOARD_USER_NAME = "Board"; const LOCAL_BOARD_USER_NAME = "Board";
async function ensureLocalTrustedBoardPrincipal(db: any): Promise<void> { async function ensureLocalTrustedBoardPrincipal(db: any): Promise<void> {
const now = new Date(); const now = new Date();
const existingUser = await db const existingUser = await db
.select({ id: authUsers.id }) .select({ id: authUsers.id })
@@ -216,24 +227,24 @@ async function ensureLocalTrustedBoardPrincipal(db: any): Promise<void> {
membershipRole: "owner", membershipRole: "owner",
}); });
} }
} }
let db; let db;
let embeddedPostgres: EmbeddedPostgresInstance | null = null; let embeddedPostgres: EmbeddedPostgresInstance | null = null;
let embeddedPostgresStartedByThisProcess = false; let embeddedPostgresStartedByThisProcess = false;
let migrationSummary: MigrationSummary = "skipped"; let migrationSummary: MigrationSummary = "skipped";
let activeDatabaseConnectionString: string; let activeDatabaseConnectionString: string;
let startupDbInfo: let startupDbInfo:
| { mode: "external-postgres"; connectionString: string } | { mode: "external-postgres"; connectionString: string }
| { mode: "embedded-postgres"; dataDir: string; port: number }; | { mode: "embedded-postgres"; dataDir: string; port: number };
if (config.databaseUrl) { if (config.databaseUrl) {
migrationSummary = await ensureMigrations(config.databaseUrl, "PostgreSQL"); migrationSummary = await ensureMigrations(config.databaseUrl, "PostgreSQL");
db = createDb(config.databaseUrl); db = createDb(config.databaseUrl);
logger.info("Using external PostgreSQL via DATABASE_URL/config"); logger.info("Using external PostgreSQL via DATABASE_URL/config");
activeDatabaseConnectionString = config.databaseUrl; activeDatabaseConnectionString = config.databaseUrl;
startupDbInfo = { mode: "external-postgres", connectionString: config.databaseUrl }; startupDbInfo = { mode: "external-postgres", connectionString: config.databaseUrl };
} else { } else {
const moduleName = "embedded-postgres"; const moduleName = "embedded-postgres";
let EmbeddedPostgres: EmbeddedPostgresCtor; let EmbeddedPostgres: EmbeddedPostgresCtor;
try { try {
@@ -370,20 +381,20 @@ if (config.databaseUrl) {
logger.info("Embedded PostgreSQL ready"); logger.info("Embedded PostgreSQL ready");
activeDatabaseConnectionString = embeddedConnectionString; activeDatabaseConnectionString = embeddedConnectionString;
startupDbInfo = { mode: "embedded-postgres", dataDir, port }; startupDbInfo = { mode: "embedded-postgres", dataDir, port };
} }
if (config.deploymentMode === "local_trusted" && !isLoopbackHost(config.host)) { if (config.deploymentMode === "local_trusted" && !isLoopbackHost(config.host)) {
throw new Error( throw new Error(
`local_trusted mode requires loopback host binding (received: ${config.host}). ` + `local_trusted mode requires loopback host binding (received: ${config.host}). ` +
"Use authenticated mode for non-loopback deployments.", "Use authenticated mode for non-loopback deployments.",
); );
} }
if (config.deploymentMode === "local_trusted" && config.deploymentExposure !== "private") { if (config.deploymentMode === "local_trusted" && config.deploymentExposure !== "private") {
throw new Error("local_trusted mode only supports private exposure"); throw new Error("local_trusted mode only supports private exposure");
} }
if (config.deploymentMode === "authenticated") { if (config.deploymentMode === "authenticated") {
if (config.authBaseUrlMode === "explicit" && !config.authPublicBaseUrl) { if (config.authBaseUrlMode === "explicit" && !config.authPublicBaseUrl) {
throw new Error("auth.baseUrlMode=explicit requires auth.publicBaseUrl"); throw new Error("auth.baseUrlMode=explicit requires auth.publicBaseUrl");
} }
@@ -395,20 +406,20 @@ if (config.deploymentMode === "authenticated") {
throw new Error("authenticated public exposure requires auth.publicBaseUrl"); throw new Error("authenticated public exposure requires auth.publicBaseUrl");
} }
} }
} }
let authReady = config.deploymentMode === "local_trusted"; let authReady = config.deploymentMode === "local_trusted";
let betterAuthHandler: RequestHandler | undefined; let betterAuthHandler: RequestHandler | undefined;
let resolveSession: let resolveSession:
| ((req: ExpressRequest) => Promise<BetterAuthSessionResult | null>) | ((req: ExpressRequest) => Promise<BetterAuthSessionResult | null>)
| undefined; | undefined;
let resolveSessionFromHeaders: let resolveSessionFromHeaders:
| ((headers: Headers) => Promise<BetterAuthSessionResult | null>) | ((headers: Headers) => Promise<BetterAuthSessionResult | null>)
| undefined; | undefined;
if (config.deploymentMode === "local_trusted") { if (config.deploymentMode === "local_trusted") {
await ensureLocalTrustedBoardPrincipal(db as any); await ensureLocalTrustedBoardPrincipal(db as any);
} }
if (config.deploymentMode === "authenticated") { if (config.deploymentMode === "authenticated") {
const { const {
createBetterAuthHandler, createBetterAuthHandler,
createBetterAuthInstance, createBetterAuthInstance,
@@ -447,11 +458,11 @@ if (config.deploymentMode === "authenticated") {
resolveSessionFromHeaders = (headers) => resolveBetterAuthSessionFromHeaders(auth, headers); resolveSessionFromHeaders = (headers) => resolveBetterAuthSessionFromHeaders(auth, headers);
await initializeBoardClaimChallenge(db as any, { deploymentMode: config.deploymentMode }); await initializeBoardClaimChallenge(db as any, { deploymentMode: config.deploymentMode });
authReady = true; authReady = true;
} }
const uiMode = config.uiDevMiddleware ? "vite-dev" : config.serveUi ? "static" : "none"; const uiMode = config.uiDevMiddleware ? "vite-dev" : config.serveUi ? "static" : "none";
const storageService = createStorageServiceFromConfig(config); const storageService = createStorageServiceFromConfig(config);
const app = await createApp(db as any, { const app = await createApp(db as any, {
uiMode, uiMode,
storageService, storageService,
deploymentMode: config.deploymentMode, deploymentMode: config.deploymentMode,
@@ -462,29 +473,29 @@ const app = await createApp(db as any, {
companyDeletionEnabled: config.companyDeletionEnabled, companyDeletionEnabled: config.companyDeletionEnabled,
betterAuthHandler, betterAuthHandler,
resolveSession, resolveSession,
}); });
const server = createServer(app as unknown as Parameters<typeof createServer>[0]); const server = createServer(app as unknown as Parameters<typeof createServer>[0]);
const listenPort = await detectPort(config.port); const listenPort = await detectPort(config.port);
if (listenPort !== config.port) { if (listenPort !== config.port) {
logger.warn(`Requested port is busy; using next free port (requestedPort=${config.port}, selectedPort=${listenPort})`); logger.warn(`Requested port is busy; using next free port (requestedPort=${config.port}, selectedPort=${listenPort})`);
} }
const runtimeListenHost = config.host; const runtimeListenHost = config.host;
const runtimeApiHost = const runtimeApiHost =
runtimeListenHost === "0.0.0.0" || runtimeListenHost === "::" runtimeListenHost === "0.0.0.0" || runtimeListenHost === "::"
? "localhost" ? "localhost"
: runtimeListenHost; : runtimeListenHost;
process.env.PAPERCLIP_LISTEN_HOST = runtimeListenHost; process.env.PAPERCLIP_LISTEN_HOST = runtimeListenHost;
process.env.PAPERCLIP_LISTEN_PORT = String(listenPort); process.env.PAPERCLIP_LISTEN_PORT = String(listenPort);
process.env.PAPERCLIP_API_URL = `http://${runtimeApiHost}:${listenPort}`; process.env.PAPERCLIP_API_URL = `http://${runtimeApiHost}:${listenPort}`;
setupLiveEventsWebSocketServer(server, db as any, { setupLiveEventsWebSocketServer(server, db as any, {
deploymentMode: config.deploymentMode, deploymentMode: config.deploymentMode,
resolveSessionFromHeaders, resolveSessionFromHeaders,
}); });
if (config.heartbeatSchedulerEnabled) { if (config.heartbeatSchedulerEnabled) {
const heartbeat = heartbeatService(db as any); const heartbeat = heartbeatService(db as any);
// Reap orphaned runs at startup (no threshold -- runningProcesses is empty) // Reap orphaned runs at startup (no threshold -- runningProcesses is empty)
@@ -511,9 +522,9 @@ if (config.heartbeatSchedulerEnabled) {
logger.error({ err }, "periodic reap of orphaned heartbeat runs failed"); logger.error({ err }, "periodic reap of orphaned heartbeat runs failed");
}); });
}, config.heartbeatSchedulerIntervalMs); }, config.heartbeatSchedulerIntervalMs);
} }
if (config.databaseBackupEnabled) { if (config.databaseBackupEnabled) {
const backupIntervalMs = config.databaseBackupIntervalMinutes * 60 * 1000; const backupIntervalMs = config.databaseBackupIntervalMinutes * 60 * 1000;
let backupInFlight = false; let backupInFlight = false;
@@ -559,9 +570,17 @@ if (config.databaseBackupEnabled) {
setInterval(() => { setInterval(() => {
void runScheduledBackup(); void runScheduledBackup();
}, backupIntervalMs); }, backupIntervalMs);
} }
server.listen(listenPort, config.host, () => { await new Promise<void>((resolveListen, rejectListen) => {
const onError = (err: Error) => {
server.off("error", onError);
rejectListen(err);
};
server.once("error", onError);
server.listen(listenPort, config.host, () => {
server.off("error", onError);
logger.info(`Server listening on ${config.host}:${listenPort}`); logger.info(`Server listening on ${config.host}:${listenPort}`);
if (process.env.PAPERCLIP_OPEN_ON_LISTEN === "true") { if (process.env.PAPERCLIP_OPEN_ON_LISTEN === "true") {
const openHost = config.host === "0.0.0.0" || config.host === "::" ? "127.0.0.1" : config.host; const openHost = config.host === "0.0.0.0" || config.host === "::" ? "127.0.0.1" : config.host;
@@ -608,9 +627,12 @@ server.listen(listenPort, config.host, () => {
].join("\n"), ].join("\n"),
); );
} }
});
if (embeddedPostgres && embeddedPostgresStartedByThisProcess) { resolveListen();
});
});
if (embeddedPostgres && embeddedPostgresStartedByThisProcess) {
const shutdown = async (signal: "SIGINT" | "SIGTERM") => { const shutdown = async (signal: "SIGINT" | "SIGTERM") => {
logger.info({ signal }, "Stopping embedded PostgreSQL"); logger.info({ signal }, "Stopping embedded PostgreSQL");
try { try {
@@ -628,4 +650,30 @@ if (embeddedPostgres && embeddedPostgresStartedByThisProcess) {
process.once("SIGTERM", () => { process.once("SIGTERM", () => {
void shutdown("SIGTERM"); void shutdown("SIGTERM");
}); });
}
return {
server,
host: config.host,
listenPort,
apiUrl: process.env.PAPERCLIP_API_URL ?? `http://${runtimeApiHost}:${listenPort}`,
databaseUrl: activeDatabaseConnectionString,
};
}
function isMainModule(metaUrl: string): boolean {
const entry = process.argv[1];
if (!entry) return false;
try {
return pathToFileURL(resolve(entry)).href === metaUrl;
} catch {
return false;
}
}
if (isMainModule(import.meta.url)) {
void startServer().catch((err) => {
logger.error({ err }, "Paperclip server failed to start");
process.exit(1);
});
} }