import { z } from "zod"; import { AUTH_BASE_URL_MODES, BIND_MODES, DEPLOYMENT_EXPOSURES, DEPLOYMENT_MODES, SECRET_PROVIDERS, STORAGE_PROVIDERS, } from "./constants.js"; import { validateConfiguredBindMode } from "./network-bind.js"; export const configMetaSchema = z.object({ version: z.literal(1), updatedAt: z.string(), source: z.enum(["onboard", "configure", "doctor"]), }).passthrough(); export const llmConfigSchema = z.object({ provider: z.enum(["claude", "openai"]), apiKey: z.string().optional(), }).passthrough(); export const databaseBackupConfigSchema = z.object({ enabled: z.boolean().default(true), intervalMinutes: z.number().int().min(1).max(7 * 24 * 60).default(60), retentionDays: z.number().int().min(1).max(3650).default(7), dir: z.string().default("~/.paperclip/instances/default/data/backups"), }).passthrough(); export const databaseConfigSchema = z.object({ mode: z.enum(["embedded-postgres", "postgres"]).default("embedded-postgres"), connectionString: z.string().optional(), embeddedPostgresDataDir: z.string().default("~/.paperclip/instances/default/db"), embeddedPostgresPort: z.number().int().min(1).max(65535).default(54329), backup: databaseBackupConfigSchema.default({ enabled: true, intervalMinutes: 60, retentionDays: 7, dir: "~/.paperclip/instances/default/data/backups", }), }).passthrough(); export const loggingConfigSchema = z.object({ mode: z.enum(["file", "cloud"]), logDir: z.string().default("~/.paperclip/instances/default/logs"), }).passthrough(); export const serverConfigSchema = z.object({ deploymentMode: z.enum(DEPLOYMENT_MODES).default("local_trusted"), exposure: z.enum(DEPLOYMENT_EXPOSURES).default("private"), bind: z.enum(BIND_MODES).optional(), customBindHost: z.string().optional(), host: z.string().default("127.0.0.1"), port: z.number().int().min(1).max(65535).default(3100), allowedHostnames: z.array(z.string().min(1)).default([]), serveUi: z.boolean().default(true), }).passthrough(); export const authConfigSchema = z.object({ baseUrlMode: z.enum(AUTH_BASE_URL_MODES).default("auto"), publicBaseUrl: z.string().url().optional(), disableSignUp: z.boolean().default(false), }).passthrough(); export const storageLocalDiskConfigSchema = z.object({ baseDir: z.string().default("~/.paperclip/instances/default/data/storage"), }).passthrough(); export const storageS3ConfigSchema = z.object({ bucket: z.string().min(1).default("paperclip"), region: z.string().min(1).default("us-east-1"), endpoint: z.string().optional(), prefix: z.string().default(""), forcePathStyle: z.boolean().default(false), }).passthrough(); export const storageConfigSchema = z.object({ provider: z.enum(STORAGE_PROVIDERS).default("local_disk"), localDisk: storageLocalDiskConfigSchema.default({ baseDir: "~/.paperclip/instances/default/data/storage", }), s3: storageS3ConfigSchema.default({ bucket: "paperclip", region: "us-east-1", prefix: "", forcePathStyle: false, }), }).passthrough(); export const secretsLocalEncryptedConfigSchema = z.object({ keyFilePath: z.string().default("~/.paperclip/instances/default/secrets/master.key"), }).passthrough(); export const secretsConfigSchema = z.object({ provider: z.enum(SECRET_PROVIDERS).default("local_encrypted"), strictMode: z.boolean().default(false), localEncrypted: secretsLocalEncryptedConfigSchema.default({ keyFilePath: "~/.paperclip/instances/default/secrets/master.key", }), }).passthrough(); export const telemetryConfigSchema = z.object({ enabled: z.boolean().default(true), }).passthrough().prefault({}); export const updatesConfigSchema = z.object({ checkEnabled: z.boolean().default(true), }).passthrough().prefault({}); export const paperclipConfigSchema = z .object({ $meta: configMetaSchema, llm: llmConfigSchema.optional(), database: databaseConfigSchema, logging: loggingConfigSchema, server: serverConfigSchema, telemetry: telemetryConfigSchema, updates: updatesConfigSchema.optional(), auth: authConfigSchema.default({ baseUrlMode: "auto", disableSignUp: false, }), storage: storageConfigSchema.default({ provider: "local_disk", localDisk: { baseDir: "~/.paperclip/instances/default/data/storage", }, s3: { bucket: "paperclip", region: "us-east-1", prefix: "", forcePathStyle: false, }, }), secrets: secretsConfigSchema.default({ provider: "local_encrypted", strictMode: false, localEncrypted: { keyFilePath: "~/.paperclip/instances/default/secrets/master.key", }, }), }) .passthrough() .superRefine((value, ctx) => { if (value.server.deploymentMode === "local_trusted" && value.server.exposure !== "private") { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "server.exposure must be private when deploymentMode is local_trusted", path: ["server", "exposure"], }); } for (const message of validateConfiguredBindMode({ deploymentMode: value.server.deploymentMode, deploymentExposure: value.server.exposure, bind: value.server.bind, host: value.server.host, customBindHost: value.server.customBindHost, })) { ctx.addIssue({ code: z.ZodIssueCode.custom, message, path: message.includes("customBindHost") ? ["server", "customBindHost"] : ["server", "bind"], }); } if (value.auth.baseUrlMode === "explicit" && !value.auth.publicBaseUrl) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "auth.publicBaseUrl is required when auth.baseUrlMode is explicit", path: ["auth", "publicBaseUrl"], }); } if (value.server.exposure === "public" && value.auth.baseUrlMode !== "explicit") { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "auth.baseUrlMode must be explicit when deploymentMode=authenticated and exposure=public", path: ["auth", "baseUrlMode"], }); } if (value.server.exposure === "public" && !value.auth.publicBaseUrl) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "auth.publicBaseUrl is required when deploymentMode=authenticated and exposure=public", path: ["auth", "publicBaseUrl"], }); } }); export type PaperclipConfig = z.infer; export type LlmConfig = z.infer; export type DatabaseConfig = z.infer; export type LoggingConfig = z.infer; export type ServerConfig = z.infer; export type StorageConfig = z.infer; export type StorageLocalDiskConfig = z.infer; export type StorageS3Config = z.infer; export type SecretsConfig = z.infer; export type SecretsLocalEncryptedConfig = z.infer; export type AuthConfig = z.infer; export type TelemetryConfig = z.infer; export type UpdatesConfig = z.infer; export type ConfigMeta = z.infer; export type DatabaseBackupConfig = z.infer; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function unwrapConfigSchema(schema: z.ZodTypeAny): z.ZodTypeAny { let current: z.ZodTypeAny = schema; while (true) { if (current instanceof z.ZodOptional) { current = current.unwrap() as z.ZodTypeAny; continue; } if (current instanceof z.ZodDefault) { current = current.unwrap() as z.ZodTypeAny; continue; } return current; } } function mergeUnknownConfigKeys( source: Record, update: Record, schema: z.ZodTypeAny, ): Record { const objectSchema = unwrapConfigSchema(schema); if (!(objectSchema instanceof z.ZodObject)) return { ...update }; const shape = objectSchema.shape as Record; const merged = { ...update }; for (const [key, sourceValue] of Object.entries(source)) { const childSchema = shape[key]; if (childSchema === undefined) { merged[key] = sourceValue; continue; } const updateValue = update[key]; if (isRecord(sourceValue) && isRecord(updateValue)) { merged[key] = mergeUnknownConfigKeys(sourceValue, updateValue, childSchema); } } return merged; } /** * Applies a config update while retaining extension keys from the parsed source. * Known optional keys that are absent from the update stay absent, so callers can * intentionally clear values such as llm.apiKey or auth.publicBaseUrl. */ export function mergePaperclipConfig( source: PaperclipConfig, update: PaperclipConfig, ): PaperclipConfig { return mergeUnknownConfigKeys( source as Record, update as Record, paperclipConfigSchema, ) as PaperclipConfig; } export type ConfigKeyWarning = { path: string; suggestion: string; }; function editDistance(left: string, right: string): number { const previous = Array.from({ length: right.length + 1 }, (_, index) => index); for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) { const current = [leftIndex]; for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) { const substitutionCost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1; current[rightIndex] = Math.min( current[rightIndex - 1] + 1, previous[rightIndex] + 1, previous[rightIndex - 1] + substitutionCost, ); } previous.splice(0, previous.length, ...current); } return previous[right.length]; } function nearMatch(key: string, candidates: string[]): string | null { const normalizedKey = key.toLowerCase(); let best: { candidate: string; distance: number } | null = null; for (const candidate of candidates) { const normalizedCandidate = candidate.toLowerCase(); if (normalizedKey === normalizedCandidate && key !== candidate) return candidate; const distance = editDistance(normalizedKey, normalizedCandidate); const threshold = Math.max(normalizedKey.length, normalizedCandidate.length) >= 8 ? 2 : 1; if (distance > threshold || (best && distance >= best.distance)) continue; best = { candidate, distance }; } return best?.candidate ?? null; } function collectConfigKeyWarnings( value: Record, schema: z.ZodTypeAny, prefix: string, warnings: ConfigKeyWarning[], ): void { const objectSchema = unwrapConfigSchema(schema); if (!(objectSchema instanceof z.ZodObject)) return; const shape = objectSchema.shape as Record; const knownKeys = Object.keys(shape); for (const [key, childValue] of Object.entries(value)) { const childSchema = shape[key]; const childPath = prefix ? `${prefix}.${key}` : key; if (childSchema === undefined) { const suggestion = nearMatch(key, knownKeys); if (suggestion) { warnings.push({ path: childPath, suggestion: prefix ? `${prefix}.${suggestion}` : suggestion, }); } continue; } if (isRecord(childValue)) { collectConfigKeyWarnings(childValue, childSchema, childPath, warnings); } } } /** Returns likely misspellings among retained extension keys without modifying them. */ export function findPaperclipConfigKeyWarnings(config: unknown): ConfigKeyWarning[] { if (!isRecord(config)) return []; const warnings: ConfigKeyWarning[] = []; collectConfigKeyWarnings(config, paperclipConfigSchema, "", warnings); return warnings; }