From 3ec96fdb7327dc0dce10320cee19ef48d28ccb8c Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 11:12:34 -0500 Subject: [PATCH 1/3] fix: complete authenticated docker onboard smoke --- Dockerfile.onboard-smoke | 2 +- doc/RELEASING.md | 2 ++ scripts/docker-onboard-smoke.sh | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Dockerfile.onboard-smoke b/Dockerfile.onboard-smoke index 7b13756b..ffc9a61e 100644 --- a/Dockerfile.onboard-smoke +++ b/Dockerfile.onboard-smoke @@ -37,4 +37,4 @@ WORKDIR /home/paperclip/workspace EXPOSE 3100 USER paperclip -CMD ["bash", "-lc", "set -euo pipefail; mkdir -p \"$PAPERCLIP_HOME\"; npx --yes \"paperclipai@${PAPERCLIPAI_VERSION}\" onboard --yes --data-dir \"$PAPERCLIP_HOME\""] +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\""] diff --git a/doc/RELEASING.md b/doc/RELEASING.md index 1f9b7fae..a97a232f 100644 --- a/doc/RELEASING.md +++ b/doc/RELEASING.md @@ -126,6 +126,8 @@ This is the best existing fit when you want: - a dedicated host port - 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. + If you want to exercise onboarding from a fresh local checkout rather than npm, use: ```bash diff --git a/scripts/docker-onboard-smoke.sh b/scripts/docker-onboard-smoke.sh index 2da125de..2477383f 100755 --- a/scripts/docker-onboard-smoke.sh +++ b/scripts/docker-onboard-smoke.sh @@ -9,6 +9,7 @@ DATA_DIR="${DATA_DIR:-$REPO_ROOT/data/docker-onboard-smoke}" HOST_UID="${HOST_UID:-$(id -u)}" PAPERCLIP_DEPLOYMENT_MODE="${PAPERCLIP_DEPLOYMENT_MODE:-authenticated}" PAPERCLIP_DEPLOYMENT_EXPOSURE="${PAPERCLIP_DEPLOYMENT_EXPOSURE:-private}" +BETTER_AUTH_SECRET="${BETTER_AUTH_SECRET:-paperclip-onboard-smoke-secret}" DOCKER_TTY_ARGS=() if [[ -t 0 && -t 1 ]]; then @@ -38,5 +39,6 @@ docker run --rm \ -e PORT=3100 \ -e PAPERCLIP_DEPLOYMENT_MODE="$PAPERCLIP_DEPLOYMENT_MODE" \ -e PAPERCLIP_DEPLOYMENT_EXPOSURE="$PAPERCLIP_DEPLOYMENT_EXPOSURE" \ + -e BETTER_AUTH_SECRET="$BETTER_AUTH_SECRET" \ -v "$DATA_DIR:/paperclip" \ "$IMAGE_NAME" From 8360b2e3e3764f1fb65e839f09b880761d5518ac Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 11:26:58 -0500 Subject: [PATCH 2/3] fix: complete authenticated onboarding startup --- Dockerfile.onboard-smoke | 2 +- cli/src/commands/auth-bootstrap-ceo.ts | 8 +- cli/src/commands/doctor.ts | 2 + cli/src/commands/onboard.ts | 16 +- cli/src/commands/run.ts | 50 +- cli/src/config/env.ts | 4 + doc/RELEASING.md | 2 +- scripts/docker-onboard-smoke.sh | 2 - server/src/index.ts | 1108 ++++++++++++------------ 9 files changed, 652 insertions(+), 542 deletions(-) diff --git a/Dockerfile.onboard-smoke b/Dockerfile.onboard-smoke index ffc9a61e..7b13756b 100644 --- a/Dockerfile.onboard-smoke +++ b/Dockerfile.onboard-smoke @@ -37,4 +37,4 @@ WORKDIR /home/paperclip/workspace EXPOSE 3100 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\""] diff --git a/cli/src/commands/auth-bootstrap-ceo.ts b/cli/src/commands/auth-bootstrap-ceo.ts index a844c447..ec539396 100644 --- a/cli/src/commands/auth-bootstrap-ceo.ts +++ b/cli/src/commands/auth-bootstrap-ceo.ts @@ -3,6 +3,7 @@ import * as p from "@clack/prompts"; import pc from "picocolors"; import { and, eq, gt, isNull } from "drizzle-orm"; import { createDb, instanceUserRoles, invites } from "@paperclipai/db"; +import { loadPaperclipEnvFile } from "../config/env.js"; import { readConfig, resolveConfigPath } from "../config/store.js"; function hashToken(token: string) { @@ -13,7 +14,8 @@ function createInviteToken() { 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); if (process.env.DATABASE_URL) return process.env.DATABASE_URL; if (config?.database.mode === "postgres" && config.database.connectionString) { @@ -49,8 +51,10 @@ export async function bootstrapCeoInvite(opts: { force?: boolean; expiresHours?: number; baseUrl?: string; + dbUrl?: string; }) { const configPath = resolveConfigPath(opts.config); + loadPaperclipEnvFile(configPath); const config = readConfig(configPath); if (!config) { p.log.error(`No config found at ${configPath}. Run ${pc.cyan("paperclip onboard")} first.`); @@ -62,7 +66,7 @@ export async function bootstrapCeoInvite(opts: { return; } - const dbUrl = resolveDbUrl(configPath); + const dbUrl = resolveDbUrl(configPath, opts.dbUrl); if (!dbUrl) { p.log.error( "Could not resolve database connection for bootstrap.", diff --git a/cli/src/commands/doctor.ts b/cli/src/commands/doctor.ts index f6ec1f4f..ab99b012 100644 --- a/cli/src/commands/doctor.ts +++ b/cli/src/commands/doctor.ts @@ -14,6 +14,7 @@ import { storageCheck, type CheckResult, } from "../checks/index.js"; +import { loadPaperclipEnvFile } from "../config/env.js"; import { printPaperclipCliBanner } from "../utils/banner.js"; const STATUS_ICON = { @@ -31,6 +32,7 @@ export async function doctor(opts: { p.intro(pc.bgCyan(pc.black(" paperclip doctor "))); const configPath = resolveConfigPath(opts.config); + loadPaperclipEnvFile(configPath); const results: CheckResult[] = []; // 1. Config check (must pass before others) diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index e3f17001..523484f3 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -229,6 +229,10 @@ function quickstartDefaultsFromEnv(): { return { defaults, usedEnvKeys, ignoredEnvKeys }; } +function canCreateBootstrapInviteImmediately(config: Pick): boolean { + return config.server.deploymentMode === "authenticated" && config.database.mode !== "embedded-postgres"; +} + export async function onboard(opts: OnboardOptions): Promise { printPaperclipCliBanner(); p.intro(pc.bgCyan(pc.black(" paperclipai onboard "))); @@ -450,7 +454,7 @@ export async function onboard(opts: OnboardOptions): Promise { "Next commands", ); - if (server.deploymentMode === "authenticated") { + if (canCreateBootstrapInviteImmediately({ database, server })) { p.log.step("Generating bootstrap CEO invite"); await bootstrapCeoInvite({ config: configPath }); } @@ -473,5 +477,15 @@ export async function onboard(opts: OnboardOptions): Promise { 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!"); } diff --git a/cli/src/commands/run.ts b/cli/src/commands/run.ts index 6e061b2e..a6606745 100644 --- a/cli/src/commands/run.ts +++ b/cli/src/commands/run.ts @@ -3,9 +3,13 @@ import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import * as p from "@clack/prompts"; import pc from "picocolors"; +import { bootstrapCeoInvite } from "./auth-bootstrap-ceo.js"; import { onboard } from "./onboard.js"; import { doctor } from "./doctor.js"; +import { loadPaperclipEnvFile } from "../config/env.js"; import { configExists, resolveConfigPath } from "../config/store.js"; +import type { PaperclipConfig } from "../config/schema.js"; +import { readConfig } from "../config/store.js"; import { describeLocalInstancePaths, resolvePaperclipHomeDir, @@ -19,6 +23,13 @@ interface RunOptions { yes?: boolean; } +interface StartedServer { + apiUrl: string; + databaseUrl: string; + host: string; + listenPort: number; +} + export async function runCommand(opts: RunOptions): Promise { const instanceId = resolvePaperclipInstanceId(opts.instance); process.env.PAPERCLIP_INSTANCE_ID = instanceId; @@ -31,6 +42,7 @@ export async function runCommand(opts: RunOptions): Promise { const configPath = resolveConfigPath(opts.config); process.env.PAPERCLIP_CONFIG = configPath; + loadPaperclipEnvFile(configPath); p.intro(pc.bgCyan(pc.black(" paperclipai run "))); p.log.message(pc.dim(`Home: ${paths.homeDir}`)); @@ -60,8 +72,23 @@ export async function runCommand(opts: RunOptions): Promise { 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..."); - 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 { @@ -101,19 +128,20 @@ function maybeEnableUiDevMiddleware(entrypoint: string): void { } } -async function importServerEntry(): Promise { +async function importServerEntry(): Promise { // Dev mode: try local workspace path (monorepo with tsx) const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); const devEntry = path.resolve(projectRoot, "server/src/index.ts"); if (fs.existsSync(devEntry)) { maybeEnableUiDevMiddleware(devEntry); - await import(pathToFileURL(devEntry).href); - return; + const mod = await import(pathToFileURL(devEntry).href); + return await startServerFromModule(mod, devEntry); } // Production mode: import the published @paperclipai/server package try { - await import("@paperclipai/server"); + const mod = await import("@paperclipai/server"); + return await startServerFromModule(mod, "@paperclipai/server"); } catch (err) { const missingSpecifier = getMissingModuleSpecifier(err); const missingServerEntrypoint = !missingSpecifier || missingSpecifier === "@paperclipai/server"; @@ -130,3 +158,15 @@ async function importServerEntry(): Promise { ); } } + +function shouldGenerateBootstrapInviteAfterStart(config: PaperclipConfig): boolean { + return config.server.deploymentMode === "authenticated" && config.database.mode === "embedded-postgres"; +} + +async function startServerFromModule(mod: unknown, label: string): Promise { + const startServer = (mod as { startServer?: () => Promise }).startServer; + if (typeof startServer !== "function") { + throw new Error(`Paperclip server entrypoint did not export startServer(): ${label}`); + } + return await startServer(); +} diff --git a/cli/src/config/env.ts b/cli/src/config/env.ts index 908907ba..0ca4bcc1 100644 --- a/cli/src/config/env.ts +++ b/cli/src/config/env.ts @@ -36,6 +36,10 @@ export function resolveAgentJwtEnvFile(configPath?: string): string { return resolveEnvFilePath(configPath); } +export function loadPaperclipEnvFile(configPath?: string): void { + loadAgentJwtEnvFile(resolveEnvFilePath(configPath)); +} + export function loadAgentJwtEnvFile(filePath = resolveEnvFilePath()): void { if (loadedEnvFiles.has(filePath)) return; diff --git a/doc/RELEASING.md b/doc/RELEASING.md index a97a232f..bd082807 100644 --- a/doc/RELEASING.md +++ b/doc/RELEASING.md @@ -126,7 +126,7 @@ This is the best existing fit when you want: - a dedicated host port - 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: diff --git a/scripts/docker-onboard-smoke.sh b/scripts/docker-onboard-smoke.sh index 2477383f..2da125de 100755 --- a/scripts/docker-onboard-smoke.sh +++ b/scripts/docker-onboard-smoke.sh @@ -9,7 +9,6 @@ DATA_DIR="${DATA_DIR:-$REPO_ROOT/data/docker-onboard-smoke}" HOST_UID="${HOST_UID:-$(id -u)}" PAPERCLIP_DEPLOYMENT_MODE="${PAPERCLIP_DEPLOYMENT_MODE:-authenticated}" PAPERCLIP_DEPLOYMENT_EXPOSURE="${PAPERCLIP_DEPLOYMENT_EXPOSURE:-private}" -BETTER_AUTH_SECRET="${BETTER_AUTH_SECRET:-paperclip-onboard-smoke-secret}" DOCKER_TTY_ARGS=() if [[ -t 0 && -t 1 ]]; then @@ -39,6 +38,5 @@ docker run --rm \ -e PORT=3100 \ -e PAPERCLIP_DEPLOYMENT_MODE="$PAPERCLIP_DEPLOYMENT_MODE" \ -e PAPERCLIP_DEPLOYMENT_EXPOSURE="$PAPERCLIP_DEPLOYMENT_EXPOSURE" \ - -e BETTER_AUTH_SECRET="$BETTER_AUTH_SECRET" \ -v "$DATA_DIR:/paperclip" \ "$IMAGE_NAME" diff --git a/server/src/index.ts b/server/src/index.ts index e78a6479..71992ce2 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -4,6 +4,7 @@ import { createServer } from "node:http"; import { resolve } from "node:path"; import { createInterface } from "node:readline/promises"; import { stdin, stdout } from "node:process"; +import { pathToFileURL } from "node:url"; import type { Request as ExpressRequest, RequestHandler } from "express"; import { and, eq } from "drizzle-orm"; import { @@ -56,75 +57,99 @@ type EmbeddedPostgresCtor = new (opts: { onError?: (message: unknown) => void; }) => EmbeddedPostgresInstance; -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; + +export interface StartedServer { + server: ReturnType; + host: string; + listenPort: number; + apiUrl: string; + databaseUrl: string; } -type MigrationSummary = - | "skipped" - | "already applied" - | "applied (empty database)" - | "applied (pending migrations)" - | "pending migrations skipped"; - -function formatPendingMigrationSummary(migrations: string[]): string { - if (migrations.length === 0) return "none"; - return migrations.length > 3 - ? `${migrations.slice(0, 3).join(", ")} (+${migrations.length - 3} more)` - : migrations.join(", "); -} - -async function promptApplyMigrations(migrations: string[]): Promise { - if (process.env.PAPERCLIP_MIGRATION_PROMPT === "never") return false; - if (process.env.PAPERCLIP_MIGRATION_AUTO_APPLY === "true") return true; - if (!stdin.isTTY || !stdout.isTTY) return true; - - const prompt = createInterface({ input: stdin, output: stdout }); - try { - const answer = (await prompt.question( - `Apply pending migrations (${formatPendingMigrationSummary(migrations)}) now? (y/N): `, - )).trim().toLowerCase(); - return answer === "y" || answer === "yes"; - } finally { - prompt.close(); +export async function startServer(): Promise { + const config = loadConfig(); + if (process.env.PAPERCLIP_SECRETS_PROVIDER === undefined) { + process.env.PAPERCLIP_SECRETS_PROVIDER = config.secretsProvider; } -} - -type EnsureMigrationsOptions = { - autoApply?: boolean; -}; - -async function ensureMigrations( - connectionString: string, - label: string, - opts?: EnsureMigrationsOptions, -): Promise { - const autoApply = opts?.autoApply === true; - let state = await inspectMigrations(connectionString); - if (state.status === "needsMigrations" && state.reason === "pending-migrations") { - const repair = await reconcilePendingMigrationHistory(connectionString); - if (repair.repairedMigrations.length > 0) { - logger.warn( - { repairedMigrations: repair.repairedMigrations }, - `${label} had drifted migration history; repaired migration journal entries from existing schema state.`, - ); - state = await inspectMigrations(connectionString); - if (state.status === "upToDate") return "already applied"; + 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" + | "already applied" + | "applied (empty database)" + | "applied (pending migrations)" + | "pending migrations skipped"; + + function formatPendingMigrationSummary(migrations: string[]): string { + if (migrations.length === 0) return "none"; + return migrations.length > 3 + ? `${migrations.slice(0, 3).join(", ")} (+${migrations.length - 3} more)` + : migrations.join(", "); + } + + async function promptApplyMigrations(migrations: string[]): Promise { + if (process.env.PAPERCLIP_MIGRATION_PROMPT === "never") return false; + if (process.env.PAPERCLIP_MIGRATION_AUTO_APPLY === "true") return true; + if (!stdin.isTTY || !stdout.isTTY) return true; + + const prompt = createInterface({ input: stdin, output: stdout }); + try { + const answer = (await prompt.question( + `Apply pending migrations (${formatPendingMigrationSummary(migrations)}) now? (y/N): `, + )).trim().toLowerCase(); + return answer === "y" || answer === "yes"; + } finally { + prompt.close(); } } - if (state.status === "upToDate") return "already applied"; - if (state.status === "needsMigrations" && state.reason === "no-migration-journal-non-empty-db") { - logger.warn( - { tableCount: state.tableCount }, - `${label} has existing tables but no migration journal. Run migrations manually to sync schema.`, - ); + + type EnsureMigrationsOptions = { + autoApply?: boolean; + }; + + async function ensureMigrations( + connectionString: string, + label: string, + opts?: EnsureMigrationsOptions, + ): Promise { + const autoApply = opts?.autoApply === true; + let state = await inspectMigrations(connectionString); + if (state.status === "needsMigrations" && state.reason === "pending-migrations") { + const repair = await reconcilePendingMigrationHistory(connectionString); + if (repair.repairedMigrations.length > 0) { + logger.warn( + { repairedMigrations: repair.repairedMigrations }, + `${label} had drifted migration history; repaired migration journal entries from existing schema state.`, + ); + state = await inspectMigrations(connectionString); + if (state.status === "upToDate") return "already applied"; + } + } + if (state.status === "upToDate") return "already applied"; + if (state.status === "needsMigrations" && state.reason === "no-migration-journal-non-empty-db") { + logger.warn( + { tableCount: state.tableCount }, + `${label} has existing tables but no migration journal. Run migrations manually to sync schema.`, + ); + const apply = autoApply ? true : await promptApplyMigrations(state.pendingMigrations); + if (!apply) { + logger.warn( + { pendingMigrations: state.pendingMigrations }, + `${label} has pending migrations; continuing without applying. Run pnpm db:migrate to apply before startup.`, + ); + return "pending migrations skipped"; + } + + logger.info({ pendingMigrations: state.pendingMigrations }, `Applying ${state.pendingMigrations.length} pending migrations for ${label}`); + await applyPendingMigrations(connectionString); + return "applied (pending migrations)"; + } + const apply = autoApply ? true : await promptApplyMigrations(state.pendingMigrations); if (!apply) { logger.warn( @@ -133,499 +158,522 @@ async function ensureMigrations( ); return "pending migrations skipped"; } - + logger.info({ pendingMigrations: state.pendingMigrations }, `Applying ${state.pendingMigrations.length} pending migrations for ${label}`); await applyPendingMigrations(connectionString); return "applied (pending migrations)"; } - - const apply = autoApply ? true : await promptApplyMigrations(state.pendingMigrations); - if (!apply) { - logger.warn( - { pendingMigrations: state.pendingMigrations }, - `${label} has pending migrations; continuing without applying. Run pnpm db:migrate to apply before startup.`, - ); - return "pending migrations skipped"; + + function isLoopbackHost(host: string): boolean { + const normalized = host.trim().toLowerCase(); + return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1"; } - - logger.info({ pendingMigrations: state.pendingMigrations }, `Applying ${state.pendingMigrations.length} pending migrations for ${label}`); - await applyPendingMigrations(connectionString); - return "applied (pending migrations)"; -} - -function isLoopbackHost(host: string): boolean { - const normalized = host.trim().toLowerCase(); - return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1"; -} - -const LOCAL_BOARD_USER_ID = "local-board"; -const LOCAL_BOARD_USER_EMAIL = "local@paperclip.local"; -const LOCAL_BOARD_USER_NAME = "Board"; - -async function ensureLocalTrustedBoardPrincipal(db: any): Promise { - const now = new Date(); - const existingUser = await db - .select({ id: authUsers.id }) - .from(authUsers) - .where(eq(authUsers.id, LOCAL_BOARD_USER_ID)) - .then((rows: Array<{ id: string }>) => rows[0] ?? null); - - if (!existingUser) { - await db.insert(authUsers).values({ - id: LOCAL_BOARD_USER_ID, - name: LOCAL_BOARD_USER_NAME, - email: LOCAL_BOARD_USER_EMAIL, - emailVerified: true, - image: null, - createdAt: now, - updatedAt: now, - }); - } - - const role = await db - .select({ id: instanceUserRoles.id }) - .from(instanceUserRoles) - .where(and(eq(instanceUserRoles.userId, LOCAL_BOARD_USER_ID), eq(instanceUserRoles.role, "instance_admin"))) - .then((rows: Array<{ id: string }>) => rows[0] ?? null); - if (!role) { - await db.insert(instanceUserRoles).values({ - userId: LOCAL_BOARD_USER_ID, - role: "instance_admin", - }); - } - - const companyRows = await db.select({ id: companies.id }).from(companies); - for (const company of companyRows) { - const membership = await db - .select({ id: companyMemberships.id }) - .from(companyMemberships) - .where( - and( - eq(companyMemberships.companyId, company.id), - eq(companyMemberships.principalType, "user"), - eq(companyMemberships.principalId, LOCAL_BOARD_USER_ID), - ), - ) + + const LOCAL_BOARD_USER_ID = "local-board"; + const LOCAL_BOARD_USER_EMAIL = "local@paperclip.local"; + const LOCAL_BOARD_USER_NAME = "Board"; + + async function ensureLocalTrustedBoardPrincipal(db: any): Promise { + const now = new Date(); + const existingUser = await db + .select({ id: authUsers.id }) + .from(authUsers) + .where(eq(authUsers.id, LOCAL_BOARD_USER_ID)) .then((rows: Array<{ id: string }>) => rows[0] ?? null); - if (membership) continue; - await db.insert(companyMemberships).values({ - companyId: company.id, - principalType: "user", - principalId: LOCAL_BOARD_USER_ID, - status: "active", - membershipRole: "owner", - }); - } -} - -let db; -let embeddedPostgres: EmbeddedPostgresInstance | null = null; -let embeddedPostgresStartedByThisProcess = false; -let migrationSummary: MigrationSummary = "skipped"; -let activeDatabaseConnectionString: string; -let startupDbInfo: - | { mode: "external-postgres"; connectionString: string } - | { mode: "embedded-postgres"; dataDir: string; port: number }; -if (config.databaseUrl) { - migrationSummary = await ensureMigrations(config.databaseUrl, "PostgreSQL"); - - db = createDb(config.databaseUrl); - logger.info("Using external PostgreSQL via DATABASE_URL/config"); - activeDatabaseConnectionString = config.databaseUrl; - startupDbInfo = { mode: "external-postgres", connectionString: config.databaseUrl }; -} else { - const moduleName = "embedded-postgres"; - let EmbeddedPostgres: EmbeddedPostgresCtor; - try { - const mod = await import(moduleName); - EmbeddedPostgres = mod.default as EmbeddedPostgresCtor; - } catch { - throw new Error( - "Embedded PostgreSQL mode requires dependency `embedded-postgres`. Reinstall dependencies (without omitting required packages), or set DATABASE_URL for external Postgres.", - ); - } - - const dataDir = resolve(config.embeddedPostgresDataDir); - const configuredPort = config.embeddedPostgresPort; - let port = configuredPort; - const embeddedPostgresLogBuffer: string[] = []; - const EMBEDDED_POSTGRES_LOG_BUFFER_LIMIT = 120; - const verboseEmbeddedPostgresLogs = process.env.PAPERCLIP_EMBEDDED_POSTGRES_VERBOSE === "true"; - const appendEmbeddedPostgresLog = (message: unknown) => { - const text = typeof message === "string" ? message : message instanceof Error ? message.message : String(message ?? ""); - for (const lineRaw of text.split(/\r?\n/)) { - const line = lineRaw.trim(); - if (!line) continue; - embeddedPostgresLogBuffer.push(line); - if (embeddedPostgresLogBuffer.length > EMBEDDED_POSTGRES_LOG_BUFFER_LIMIT) { - embeddedPostgresLogBuffer.splice(0, embeddedPostgresLogBuffer.length - EMBEDDED_POSTGRES_LOG_BUFFER_LIMIT); - } - if (verboseEmbeddedPostgresLogs) { - logger.info({ embeddedPostgresLog: line }, "embedded-postgres"); - } + + if (!existingUser) { + await db.insert(authUsers).values({ + id: LOCAL_BOARD_USER_ID, + name: LOCAL_BOARD_USER_NAME, + email: LOCAL_BOARD_USER_EMAIL, + emailVerified: true, + image: null, + createdAt: now, + updatedAt: now, + }); } - }; - const logEmbeddedPostgresFailure = (phase: "initialise" | "start", err: unknown) => { - if (embeddedPostgresLogBuffer.length > 0) { - logger.error( - { - phase, - recentLogs: embeddedPostgresLogBuffer, - err, - }, - "Embedded PostgreSQL failed; showing buffered startup logs", + + const role = await db + .select({ id: instanceUserRoles.id }) + .from(instanceUserRoles) + .where(and(eq(instanceUserRoles.userId, LOCAL_BOARD_USER_ID), eq(instanceUserRoles.role, "instance_admin"))) + .then((rows: Array<{ id: string }>) => rows[0] ?? null); + if (!role) { + await db.insert(instanceUserRoles).values({ + userId: LOCAL_BOARD_USER_ID, + role: "instance_admin", + }); + } + + const companyRows = await db.select({ id: companies.id }).from(companies); + for (const company of companyRows) { + const membership = await db + .select({ id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, company.id), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, LOCAL_BOARD_USER_ID), + ), + ) + .then((rows: Array<{ id: string }>) => rows[0] ?? null); + if (membership) continue; + await db.insert(companyMemberships).values({ + companyId: company.id, + principalType: "user", + principalId: LOCAL_BOARD_USER_ID, + status: "active", + membershipRole: "owner", + }); + } + } + + let db; + let embeddedPostgres: EmbeddedPostgresInstance | null = null; + let embeddedPostgresStartedByThisProcess = false; + let migrationSummary: MigrationSummary = "skipped"; + let activeDatabaseConnectionString: string; + let startupDbInfo: + | { mode: "external-postgres"; connectionString: string } + | { mode: "embedded-postgres"; dataDir: string; port: number }; + if (config.databaseUrl) { + migrationSummary = await ensureMigrations(config.databaseUrl, "PostgreSQL"); + + db = createDb(config.databaseUrl); + logger.info("Using external PostgreSQL via DATABASE_URL/config"); + activeDatabaseConnectionString = config.databaseUrl; + startupDbInfo = { mode: "external-postgres", connectionString: config.databaseUrl }; + } else { + const moduleName = "embedded-postgres"; + let EmbeddedPostgres: EmbeddedPostgresCtor; + try { + const mod = await import(moduleName); + EmbeddedPostgres = mod.default as EmbeddedPostgresCtor; + } catch { + throw new Error( + "Embedded PostgreSQL mode requires dependency `embedded-postgres`. Reinstall dependencies (without omitting required packages), or set DATABASE_URL for external Postgres.", ); } - }; - - if (config.databaseMode === "postgres") { - logger.warn("Database mode is postgres but no connection string was set; falling back to embedded PostgreSQL"); - } - - const clusterVersionFile = resolve(dataDir, "PG_VERSION"); - const clusterAlreadyInitialized = existsSync(clusterVersionFile); - const postmasterPidFile = resolve(dataDir, "postmaster.pid"); - const isPidRunning = (pid: number): boolean => { - try { - process.kill(pid, 0); - return true; - } catch { - return false; + + const dataDir = resolve(config.embeddedPostgresDataDir); + const configuredPort = config.embeddedPostgresPort; + let port = configuredPort; + const embeddedPostgresLogBuffer: string[] = []; + const EMBEDDED_POSTGRES_LOG_BUFFER_LIMIT = 120; + const verboseEmbeddedPostgresLogs = process.env.PAPERCLIP_EMBEDDED_POSTGRES_VERBOSE === "true"; + const appendEmbeddedPostgresLog = (message: unknown) => { + const text = typeof message === "string" ? message : message instanceof Error ? message.message : String(message ?? ""); + for (const lineRaw of text.split(/\r?\n/)) { + const line = lineRaw.trim(); + if (!line) continue; + embeddedPostgresLogBuffer.push(line); + if (embeddedPostgresLogBuffer.length > EMBEDDED_POSTGRES_LOG_BUFFER_LIMIT) { + embeddedPostgresLogBuffer.splice(0, embeddedPostgresLogBuffer.length - EMBEDDED_POSTGRES_LOG_BUFFER_LIMIT); + } + if (verboseEmbeddedPostgresLogs) { + logger.info({ embeddedPostgresLog: line }, "embedded-postgres"); + } + } + }; + const logEmbeddedPostgresFailure = (phase: "initialise" | "start", err: unknown) => { + if (embeddedPostgresLogBuffer.length > 0) { + logger.error( + { + phase, + recentLogs: embeddedPostgresLogBuffer, + err, + }, + "Embedded PostgreSQL failed; showing buffered startup logs", + ); + } + }; + + if (config.databaseMode === "postgres") { + logger.warn("Database mode is postgres but no connection string was set; falling back to embedded PostgreSQL"); } - }; - - const getRunningPid = (): number | null => { - if (!existsSync(postmasterPidFile)) return null; - try { - const pidLine = readFileSync(postmasterPidFile, "utf8").split("\n")[0]?.trim(); - const pid = Number(pidLine); - if (!Number.isInteger(pid) || pid <= 0) return null; - if (!isPidRunning(pid)) return null; - return pid; - } catch { - return null; - } - }; - - const runningPid = getRunningPid(); - if (runningPid) { - logger.warn(`Embedded PostgreSQL already running; reusing existing process (pid=${runningPid}, port=${port})`); - } else { - const detectedPort = await detectPort(configuredPort); - if (detectedPort !== configuredPort) { - logger.warn(`Embedded PostgreSQL port is in use; using next free port (requestedPort=${configuredPort}, selectedPort=${detectedPort})`); - } - port = detectedPort; - logger.info(`Using embedded PostgreSQL because no DATABASE_URL set (dataDir=${dataDir}, port=${port})`); - embeddedPostgres = new EmbeddedPostgres({ - databaseDir: dataDir, - user: "paperclip", - password: "paperclip", - port, - persistent: true, - onLog: appendEmbeddedPostgresLog, - onError: appendEmbeddedPostgresLog, - }); - - if (!clusterAlreadyInitialized) { + + const clusterVersionFile = resolve(dataDir, "PG_VERSION"); + const clusterAlreadyInitialized = existsSync(clusterVersionFile); + const postmasterPidFile = resolve(dataDir, "postmaster.pid"); + const isPidRunning = (pid: number): boolean => { try { - await embeddedPostgres.initialise(); + process.kill(pid, 0); + return true; + } catch { + return false; + } + }; + + const getRunningPid = (): number | null => { + if (!existsSync(postmasterPidFile)) return null; + try { + const pidLine = readFileSync(postmasterPidFile, "utf8").split("\n")[0]?.trim(); + const pid = Number(pidLine); + if (!Number.isInteger(pid) || pid <= 0) return null; + if (!isPidRunning(pid)) return null; + return pid; + } catch { + return null; + } + }; + + const runningPid = getRunningPid(); + if (runningPid) { + logger.warn(`Embedded PostgreSQL already running; reusing existing process (pid=${runningPid}, port=${port})`); + } else { + const detectedPort = await detectPort(configuredPort); + if (detectedPort !== configuredPort) { + logger.warn(`Embedded PostgreSQL port is in use; using next free port (requestedPort=${configuredPort}, selectedPort=${detectedPort})`); + } + port = detectedPort; + logger.info(`Using embedded PostgreSQL because no DATABASE_URL set (dataDir=${dataDir}, port=${port})`); + embeddedPostgres = new EmbeddedPostgres({ + databaseDir: dataDir, + user: "paperclip", + password: "paperclip", + port, + persistent: true, + onLog: appendEmbeddedPostgresLog, + onError: appendEmbeddedPostgresLog, + }); + + if (!clusterAlreadyInitialized) { + try { + await embeddedPostgres.initialise(); + } catch (err) { + logEmbeddedPostgresFailure("initialise", err); + throw err; + } + } else { + logger.info(`Embedded PostgreSQL cluster already exists (${clusterVersionFile}); skipping init`); + } + + if (existsSync(postmasterPidFile)) { + logger.warn("Removing stale embedded PostgreSQL lock file"); + rmSync(postmasterPidFile, { force: true }); + } + try { + await embeddedPostgres.start(); } catch (err) { - logEmbeddedPostgresFailure("initialise", err); + logEmbeddedPostgresFailure("start", err); throw err; } - } else { - logger.info(`Embedded PostgreSQL cluster already exists (${clusterVersionFile}); skipping init`); + embeddedPostgresStartedByThisProcess = true; } - - if (existsSync(postmasterPidFile)) { - logger.warn("Removing stale embedded PostgreSQL lock file"); - rmSync(postmasterPidFile, { force: true }); + + const embeddedAdminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; + const dbStatus = await ensurePostgresDatabase(embeddedAdminConnectionString, "paperclip"); + if (dbStatus === "created") { + logger.info("Created embedded PostgreSQL database: paperclip"); } - try { - await embeddedPostgres.start(); - } catch (err) { - logEmbeddedPostgresFailure("start", err); - throw err; + + const embeddedConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; + const shouldAutoApplyFirstRunMigrations = !clusterAlreadyInitialized || dbStatus === "created"; + if (shouldAutoApplyFirstRunMigrations) { + logger.info("Detected first-run embedded PostgreSQL setup; applying pending migrations automatically"); } - embeddedPostgresStartedByThisProcess = true; + migrationSummary = await ensureMigrations(embeddedConnectionString, "Embedded PostgreSQL", { + autoApply: shouldAutoApplyFirstRunMigrations, + }); + + db = createDb(embeddedConnectionString); + logger.info("Embedded PostgreSQL ready"); + activeDatabaseConnectionString = embeddedConnectionString; + startupDbInfo = { mode: "embedded-postgres", dataDir, port }; } - - const embeddedAdminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; - const dbStatus = await ensurePostgresDatabase(embeddedAdminConnectionString, "paperclip"); - if (dbStatus === "created") { - logger.info("Created embedded PostgreSQL database: paperclip"); - } - - const embeddedConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; - const shouldAutoApplyFirstRunMigrations = !clusterAlreadyInitialized || dbStatus === "created"; - if (shouldAutoApplyFirstRunMigrations) { - logger.info("Detected first-run embedded PostgreSQL setup; applying pending migrations automatically"); - } - migrationSummary = await ensureMigrations(embeddedConnectionString, "Embedded PostgreSQL", { - autoApply: shouldAutoApplyFirstRunMigrations, - }); - - db = createDb(embeddedConnectionString); - logger.info("Embedded PostgreSQL ready"); - activeDatabaseConnectionString = embeddedConnectionString; - startupDbInfo = { mode: "embedded-postgres", dataDir, port }; -} - -if (config.deploymentMode === "local_trusted" && !isLoopbackHost(config.host)) { - throw new Error( - `local_trusted mode requires loopback host binding (received: ${config.host}). ` + - "Use authenticated mode for non-loopback deployments.", - ); -} - -if (config.deploymentMode === "local_trusted" && config.deploymentExposure !== "private") { - throw new Error("local_trusted mode only supports private exposure"); -} - -if (config.deploymentMode === "authenticated") { - if (config.authBaseUrlMode === "explicit" && !config.authPublicBaseUrl) { - throw new Error("auth.baseUrlMode=explicit requires auth.publicBaseUrl"); - } - if (config.deploymentExposure === "public") { - if (config.authBaseUrlMode !== "explicit") { - throw new Error("authenticated public exposure requires auth.baseUrlMode=explicit"); - } - if (!config.authPublicBaseUrl) { - throw new Error("authenticated public exposure requires auth.publicBaseUrl"); - } - } -} - -let authReady = config.deploymentMode === "local_trusted"; -let betterAuthHandler: RequestHandler | undefined; -let resolveSession: - | ((req: ExpressRequest) => Promise) - | undefined; -let resolveSessionFromHeaders: - | ((headers: Headers) => Promise) - | undefined; -if (config.deploymentMode === "local_trusted") { - await ensureLocalTrustedBoardPrincipal(db as any); -} -if (config.deploymentMode === "authenticated") { - const { - createBetterAuthHandler, - createBetterAuthInstance, - deriveAuthTrustedOrigins, - resolveBetterAuthSession, - resolveBetterAuthSessionFromHeaders, - } = await import("./auth/better-auth.js"); - const betterAuthSecret = - process.env.BETTER_AUTH_SECRET?.trim() ?? process.env.PAPERCLIP_AGENT_JWT_SECRET?.trim(); - if (!betterAuthSecret) { + + if (config.deploymentMode === "local_trusted" && !isLoopbackHost(config.host)) { throw new Error( - "authenticated mode requires BETTER_AUTH_SECRET (or PAPERCLIP_AGENT_JWT_SECRET) to be set", + `local_trusted mode requires loopback host binding (received: ${config.host}). ` + + "Use authenticated mode for non-loopback deployments.", ); } - 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); - resolveSession = (req) => resolveBetterAuthSession(auth, req); - resolveSessionFromHeaders = (headers) => resolveBetterAuthSessionFromHeaders(auth, headers); - await initializeBoardClaimChallenge(db as any, { deploymentMode: config.deploymentMode }); - authReady = true; -} - -const uiMode = config.uiDevMiddleware ? "vite-dev" : config.serveUi ? "static" : "none"; -const storageService = createStorageServiceFromConfig(config); -const app = await createApp(db as any, { - uiMode, - storageService, - deploymentMode: config.deploymentMode, - deploymentExposure: config.deploymentExposure, - allowedHostnames: config.allowedHostnames, - bindHost: config.host, - authReady, - companyDeletionEnabled: config.companyDeletionEnabled, - betterAuthHandler, - resolveSession, -}); -const server = createServer(app as unknown as Parameters[0]); -const listenPort = await detectPort(config.port); - -if (listenPort !== config.port) { - logger.warn(`Requested port is busy; using next free port (requestedPort=${config.port}, selectedPort=${listenPort})`); -} - -const runtimeListenHost = config.host; -const runtimeApiHost = - runtimeListenHost === "0.0.0.0" || runtimeListenHost === "::" - ? "localhost" - : runtimeListenHost; -process.env.PAPERCLIP_LISTEN_HOST = runtimeListenHost; -process.env.PAPERCLIP_LISTEN_PORT = String(listenPort); -process.env.PAPERCLIP_API_URL = `http://${runtimeApiHost}:${listenPort}`; - -setupLiveEventsWebSocketServer(server, db as any, { - deploymentMode: config.deploymentMode, - resolveSessionFromHeaders, -}); - -if (config.heartbeatSchedulerEnabled) { - const heartbeat = heartbeatService(db as any); - - // Reap orphaned runs at startup (no threshold -- runningProcesses is empty) - void heartbeat.reapOrphanedRuns().catch((err) => { - logger.error({ err }, "startup reap of orphaned heartbeat runs failed"); - }); - - setInterval(() => { - void heartbeat - .tickTimers(new Date()) - .then((result) => { - if (result.enqueued > 0) { - logger.info({ ...result }, "heartbeat timer tick enqueued runs"); - } - }) - .catch((err) => { - logger.error({ err }, "heartbeat timer tick failed"); - }); - - // Periodically reap orphaned runs (5-min staleness threshold) - void heartbeat - .reapOrphanedRuns({ staleThresholdMs: 5 * 60 * 1000 }) - .catch((err) => { - logger.error({ err }, "periodic reap of orphaned heartbeat runs failed"); - }); - }, config.heartbeatSchedulerIntervalMs); -} - -if (config.databaseBackupEnabled) { - const backupIntervalMs = config.databaseBackupIntervalMinutes * 60 * 1000; - let backupInFlight = false; - - const runScheduledBackup = async () => { - if (backupInFlight) { - logger.warn("Skipping scheduled database backup because a previous backup is still running"); - return; - } - - backupInFlight = true; - try { - const result = await runDatabaseBackup({ - connectionString: activeDatabaseConnectionString, - backupDir: config.databaseBackupDir, - retentionDays: config.databaseBackupRetentionDays, - filenamePrefix: "paperclip", - }); - logger.info( - { - backupFile: result.backupFile, - sizeBytes: result.sizeBytes, - prunedCount: result.prunedCount, - backupDir: config.databaseBackupDir, - retentionDays: config.databaseBackupRetentionDays, - }, - `Automatic database backup complete: ${formatDatabaseBackupResult(result)}`, - ); - } catch (err) { - logger.error({ err, backupDir: config.databaseBackupDir }, "Automatic database backup failed"); - } finally { - backupInFlight = false; - } - }; - - logger.info( - { - intervalMinutes: config.databaseBackupIntervalMinutes, - retentionDays: config.databaseBackupRetentionDays, - backupDir: config.databaseBackupDir, - }, - "Automatic database backups enabled", - ); - setInterval(() => { - void runScheduledBackup(); - }, backupIntervalMs); -} - -server.listen(listenPort, config.host, () => { - logger.info(`Server listening on ${config.host}:${listenPort}`); - 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 url = `http://${openHost}:${listenPort}`; - void import("open") - .then((mod) => mod.default(url)) - .then(() => { - logger.info(`Opened browser at ${url}`); - }) - .catch((err) => { - logger.warn({ err, url }, "Failed to open browser on startup"); - }); + + if (config.deploymentMode === "local_trusted" && config.deploymentExposure !== "private") { + throw new Error("local_trusted mode only supports private exposure"); } - printStartupBanner({ - host: config.host, + + if (config.deploymentMode === "authenticated") { + if (config.authBaseUrlMode === "explicit" && !config.authPublicBaseUrl) { + throw new Error("auth.baseUrlMode=explicit requires auth.publicBaseUrl"); + } + if (config.deploymentExposure === "public") { + if (config.authBaseUrlMode !== "explicit") { + throw new Error("authenticated public exposure requires auth.baseUrlMode=explicit"); + } + if (!config.authPublicBaseUrl) { + throw new Error("authenticated public exposure requires auth.publicBaseUrl"); + } + } + } + + let authReady = config.deploymentMode === "local_trusted"; + let betterAuthHandler: RequestHandler | undefined; + let resolveSession: + | ((req: ExpressRequest) => Promise) + | undefined; + let resolveSessionFromHeaders: + | ((headers: Headers) => Promise) + | undefined; + if (config.deploymentMode === "local_trusted") { + await ensureLocalTrustedBoardPrincipal(db as any); + } + if (config.deploymentMode === "authenticated") { + const { + createBetterAuthHandler, + createBetterAuthInstance, + deriveAuthTrustedOrigins, + resolveBetterAuthSession, + resolveBetterAuthSessionFromHeaders, + } = await import("./auth/better-auth.js"); + const betterAuthSecret = + process.env.BETTER_AUTH_SECRET?.trim() ?? process.env.PAPERCLIP_AGENT_JWT_SECRET?.trim(); + if (!betterAuthSecret) { + throw new Error( + "authenticated mode requires BETTER_AUTH_SECRET (or PAPERCLIP_AGENT_JWT_SECRET) to be set", + ); + } + 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); + resolveSession = (req) => resolveBetterAuthSession(auth, req); + resolveSessionFromHeaders = (headers) => resolveBetterAuthSessionFromHeaders(auth, headers); + await initializeBoardClaimChallenge(db as any, { deploymentMode: config.deploymentMode }); + authReady = true; + } + + const uiMode = config.uiDevMiddleware ? "vite-dev" : config.serveUi ? "static" : "none"; + const storageService = createStorageServiceFromConfig(config); + const app = await createApp(db as any, { + uiMode, + storageService, deploymentMode: config.deploymentMode, deploymentExposure: config.deploymentExposure, + allowedHostnames: config.allowedHostnames, + bindHost: config.host, authReady, - requestedPort: config.port, - listenPort, - uiMode, - db: startupDbInfo, - migrationSummary, - heartbeatSchedulerEnabled: config.heartbeatSchedulerEnabled, - heartbeatSchedulerIntervalMs: config.heartbeatSchedulerIntervalMs, - databaseBackupEnabled: config.databaseBackupEnabled, - databaseBackupIntervalMinutes: config.databaseBackupIntervalMinutes, - databaseBackupRetentionDays: config.databaseBackupRetentionDays, - databaseBackupDir: config.databaseBackupDir, + companyDeletionEnabled: config.companyDeletionEnabled, + betterAuthHandler, + resolveSession, }); - - const boardClaimUrl = getBoardClaimWarningUrl(config.host, listenPort); - if (boardClaimUrl) { - const red = "\x1b[41m\x1b[30m"; - const yellow = "\x1b[33m"; - const reset = "\x1b[0m"; - console.log( - [ - `${red} BOARD CLAIM REQUIRED ${reset}`, - `${yellow}This instance was previously local_trusted and still has local-board as the only admin.${reset}`, - `${yellow}Sign in with a real user and open this one-time URL to claim ownership:${reset}`, - `${yellow}${boardClaimUrl}${reset}`, - `${yellow}If you are connecting over Tailscale, replace the host in this URL with your Tailscale IP/MagicDNS name.${reset}`, - ].join("\n"), - ); + const server = createServer(app as unknown as Parameters[0]); + const listenPort = await detectPort(config.port); + + if (listenPort !== config.port) { + logger.warn(`Requested port is busy; using next free port (requestedPort=${config.port}, selectedPort=${listenPort})`); } -}); - -if (embeddedPostgres && embeddedPostgresStartedByThisProcess) { - const shutdown = async (signal: "SIGINT" | "SIGTERM") => { - logger.info({ signal }, "Stopping embedded PostgreSQL"); - try { - await embeddedPostgres?.stop(); - } catch (err) { - logger.error({ err }, "Failed to stop embedded PostgreSQL cleanly"); - } finally { - process.exit(0); - } - }; - - process.once("SIGINT", () => { - void shutdown("SIGINT"); + + const runtimeListenHost = config.host; + const runtimeApiHost = + runtimeListenHost === "0.0.0.0" || runtimeListenHost === "::" + ? "localhost" + : runtimeListenHost; + process.env.PAPERCLIP_LISTEN_HOST = runtimeListenHost; + process.env.PAPERCLIP_LISTEN_PORT = String(listenPort); + process.env.PAPERCLIP_API_URL = `http://${runtimeApiHost}:${listenPort}`; + + setupLiveEventsWebSocketServer(server, db as any, { + deploymentMode: config.deploymentMode, + resolveSessionFromHeaders, }); - process.once("SIGTERM", () => { - void shutdown("SIGTERM"); + + if (config.heartbeatSchedulerEnabled) { + const heartbeat = heartbeatService(db as any); + + // Reap orphaned runs at startup (no threshold -- runningProcesses is empty) + void heartbeat.reapOrphanedRuns().catch((err) => { + logger.error({ err }, "startup reap of orphaned heartbeat runs failed"); + }); + + setInterval(() => { + void heartbeat + .tickTimers(new Date()) + .then((result) => { + if (result.enqueued > 0) { + logger.info({ ...result }, "heartbeat timer tick enqueued runs"); + } + }) + .catch((err) => { + logger.error({ err }, "heartbeat timer tick failed"); + }); + + // Periodically reap orphaned runs (5-min staleness threshold) + void heartbeat + .reapOrphanedRuns({ staleThresholdMs: 5 * 60 * 1000 }) + .catch((err) => { + logger.error({ err }, "periodic reap of orphaned heartbeat runs failed"); + }); + }, config.heartbeatSchedulerIntervalMs); + } + + if (config.databaseBackupEnabled) { + const backupIntervalMs = config.databaseBackupIntervalMinutes * 60 * 1000; + let backupInFlight = false; + + const runScheduledBackup = async () => { + if (backupInFlight) { + logger.warn("Skipping scheduled database backup because a previous backup is still running"); + return; + } + + backupInFlight = true; + try { + const result = await runDatabaseBackup({ + connectionString: activeDatabaseConnectionString, + backupDir: config.databaseBackupDir, + retentionDays: config.databaseBackupRetentionDays, + filenamePrefix: "paperclip", + }); + logger.info( + { + backupFile: result.backupFile, + sizeBytes: result.sizeBytes, + prunedCount: result.prunedCount, + backupDir: config.databaseBackupDir, + retentionDays: config.databaseBackupRetentionDays, + }, + `Automatic database backup complete: ${formatDatabaseBackupResult(result)}`, + ); + } catch (err) { + logger.error({ err, backupDir: config.databaseBackupDir }, "Automatic database backup failed"); + } finally { + backupInFlight = false; + } + }; + + logger.info( + { + intervalMinutes: config.databaseBackupIntervalMinutes, + retentionDays: config.databaseBackupRetentionDays, + backupDir: config.databaseBackupDir, + }, + "Automatic database backups enabled", + ); + setInterval(() => { + void runScheduledBackup(); + }, backupIntervalMs); + } + + await new Promise((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}`); + 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 url = `http://${openHost}:${listenPort}`; + void import("open") + .then((mod) => mod.default(url)) + .then(() => { + logger.info(`Opened browser at ${url}`); + }) + .catch((err) => { + logger.warn({ err, url }, "Failed to open browser on startup"); + }); + } + printStartupBanner({ + host: config.host, + deploymentMode: config.deploymentMode, + deploymentExposure: config.deploymentExposure, + authReady, + requestedPort: config.port, + listenPort, + uiMode, + db: startupDbInfo, + migrationSummary, + heartbeatSchedulerEnabled: config.heartbeatSchedulerEnabled, + heartbeatSchedulerIntervalMs: config.heartbeatSchedulerIntervalMs, + databaseBackupEnabled: config.databaseBackupEnabled, + databaseBackupIntervalMinutes: config.databaseBackupIntervalMinutes, + databaseBackupRetentionDays: config.databaseBackupRetentionDays, + databaseBackupDir: config.databaseBackupDir, + }); + + const boardClaimUrl = getBoardClaimWarningUrl(config.host, listenPort); + if (boardClaimUrl) { + const red = "\x1b[41m\x1b[30m"; + const yellow = "\x1b[33m"; + const reset = "\x1b[0m"; + console.log( + [ + `${red} BOARD CLAIM REQUIRED ${reset}`, + `${yellow}This instance was previously local_trusted and still has local-board as the only admin.${reset}`, + `${yellow}Sign in with a real user and open this one-time URL to claim ownership:${reset}`, + `${yellow}${boardClaimUrl}${reset}`, + `${yellow}If you are connecting over Tailscale, replace the host in this URL with your Tailscale IP/MagicDNS name.${reset}`, + ].join("\n"), + ); + } + + resolveListen(); + }); + }); + + if (embeddedPostgres && embeddedPostgresStartedByThisProcess) { + const shutdown = async (signal: "SIGINT" | "SIGTERM") => { + logger.info({ signal }, "Stopping embedded PostgreSQL"); + try { + await embeddedPostgres?.stop(); + } catch (err) { + logger.error({ err }, "Failed to stop embedded PostgreSQL cleanly"); + } finally { + process.exit(0); + } + }; + + process.once("SIGINT", () => { + void shutdown("SIGINT"); + }); + process.once("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); }); } From 23dec980e26f932e45b7f95a806dd911845725dc Mon Sep 17 00:00:00 2001 From: lockfile-bot Date: Mon, 9 Mar 2026 16:41:30 +0000 Subject: [PATCH 3/3] chore(lockfile): refresh pnpm-lock.yaml --- pnpm-lock.yaml | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9536ff75..d1dd1ddc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@changesets/cli': specifier: ^2.30.0 version: 2.30.0(@types/node@25.2.3) + '@playwright/test': + specifier: ^1.58.2 + version: 1.58.2 esbuild: specifier: ^0.27.3 version: 0.27.3 @@ -1671,6 +1674,11 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + '@radix-ui/colors@3.0.0': resolution: {integrity: sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==} @@ -3987,6 +3995,11 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4773,6 +4786,16 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -7369,6 +7392,10 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + '@radix-ui/colors@3.0.0': {} '@radix-ui/number@1.1.1': {} @@ -9847,6 +9874,9 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -10898,6 +10928,14 @@ snapshots: mlly: 1.8.1 pathe: 2.0.3 + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + points-on-curve@0.2.0: {} points-on-path@0.2.1: