From 86486c08e035ce98a5eaa452e2f9820d9ba01f21 Mon Sep 17 00:00:00 2001 From: Vsevolod Avramov Date: Tue, 7 Apr 2026 18:38:03 +0300 Subject: [PATCH] Refactor setup config storage into sections --- .env.example | 3 + drizzle/0003_breezy_config_sections.sql | 8 + drizzle/meta/_journal.json | 9 +- src/dashboard-server.ts | 11 + src/db/schema.ts | 9 + src/db/setup-store.ts | 286 ++++++++++++++++-------- 6 files changed, 231 insertions(+), 95 deletions(-) create mode 100644 drizzle/0003_breezy_config_sections.sql diff --git a/.env.example b/.env.example index 4afd11c..7a0971a 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,7 @@ SLACK_BOT_TOKEN=xoxb-... SLACK_APP_TOKEN=xapp-... SLACK_SIGNING_SECRET=... SLACK_COMMAND_NAME=gooseherd +# SLACK_CONFIG_OVERRIDE_FROM_ENV=false # SLACK_ALLOWED_CHANNELS=C123,C456 # ── GitHub (required — use EITHER PAT or GitHub App) ── @@ -23,6 +24,7 @@ GITHUB_TOKEN=ghp_... # GITHUB_APP_ID= # GITHUB_APP_PRIVATE_KEY= # GITHUB_APP_INSTALLATION_ID= +# GITHUB_CONFIG_OVERRIDE_FROM_ENV=false GITHUB_DEFAULT_OWNER= # ── Repo allowlist (comma-separated) ── @@ -32,6 +34,7 @@ REPO_ALLOWLIST= OPENROUTER_API_KEY= # ANTHROPIC_API_KEY= # OPENAI_API_KEY= +# LLM_CONFIG_OVERRIDE_FROM_ENV=false # CODEX_API_KEY= # OpenAI Codex CLI # CURSOR_API_KEY= # Cursor Agent CLI diff --git a/drizzle/0003_breezy_config_sections.sql b/drizzle/0003_breezy_config_sections.sql new file mode 100644 index 0000000..8d7587f --- /dev/null +++ b/drizzle/0003_breezy_config_sections.sql @@ -0,0 +1,8 @@ +CREATE TABLE "config_sections" ( + "section" text PRIMARY KEY NOT NULL, + "config" jsonb DEFAULT '{}'::jsonb NOT NULL, + "secrets_enc" bytea, + "override_from_env" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 5446600..f62e791 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1773181213088, "tag": "0002_dark_hercules", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1775570000000, + "tag": "0003_breezy_config_sections", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/src/dashboard-server.ts b/src/dashboard-server.ts index 1345730..df63cc9 100644 --- a/src/dashboard-server.ts +++ b/src/dashboard-server.ts @@ -550,6 +550,11 @@ export function startDashboardServer( pipelineFile: config.pipelineFile, slackConnected: Boolean(config.slackBotToken), githubAuthMode, + configOverrides: { + githubFromEnv: parseOverrideFlag(process.env.GITHUB_CONFIG_OVERRIDE_FROM_ENV), + slackFromEnv: parseOverrideFlag(process.env.SLACK_CONFIG_OVERRIDE_FROM_ENV), + llmFromEnv: parseOverrideFlag(process.env.LLM_CONFIG_OVERRIDE_FROM_ENV), + }, features: { observer: config.observerEnabled, sandbox: config.sandboxEnabled, @@ -1195,3 +1200,9 @@ export function startDashboardServer( }); }); } + +function parseOverrideFlag(value: string | undefined): boolean { + if (value === undefined) return false; + const normalized = value.trim().toLowerCase(); + return normalized === "1" || normalized === "true" || normalized === "yes"; +} diff --git a/src/db/schema.ts b/src/db/schema.ts index fa51bb0..96bab25 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -264,3 +264,12 @@ export const setup = pgTable("setup", { createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }); + +export const configSections = pgTable("config_sections", { + section: text("section").primaryKey(), + config: jsonb("config").notNull().$type>().default({}), + secretsEnc: bytea("secrets_enc"), + overrideFromEnv: boolean("override_from_env").notNull().default(false), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); diff --git a/src/db/setup-store.ts b/src/db/setup-store.ts index f5e1a36..7742773 100644 --- a/src/db/setup-store.ts +++ b/src/db/setup-store.ts @@ -10,7 +10,7 @@ import { open, readFile, mkdir } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import type { Database } from "./index.js"; -import { setup } from "./schema.js"; +import { configSections, setup } from "./schema.js"; import { encrypt, decrypt, generateEncryptionKey } from "./encryption.js"; const scryptAsync = promisify(scrypt); @@ -49,6 +49,13 @@ export interface SlackSetupConfig { } const SCRYPT_KEYLEN = 64; +type ConfigSectionName = "github" | "llm" | "slack"; + +interface StoredConfigSection { + config: Record; + secrets: Record; + overrideFromEnv: boolean; +} export class SetupStore { private readonly db: Database; @@ -57,6 +64,8 @@ export class SetupStore { // In-memory cache to avoid 2 DB queries per HTTP request private cachedComplete: boolean | undefined; private cachedPasswordHash: string | undefined | null; // null = queried, not set + private legacyConfigMigrationDone = false; + private legacyConfigMigrationPromise: Promise | undefined; constructor(db: Database, encryptionKey?: string) { this.db = db; @@ -73,17 +82,28 @@ export class SetupStore { /** Get current setup status for the wizard UI. */ async getStatus(): Promise { + await this.ensureLegacyConfigsMigrated(); const rows = await this.db.select().from(setup).where(eq(setup.id, 1)); const row = rows[0]; + const sections = await this.db + .select({ section: configSections.section }) + .from(configSections); + const sectionNames = new Set(sections.map((entry) => entry.section)); if (!row) { - return { complete: false, hasPassword: false, hasGithub: false, hasLlm: false, hasSlack: false }; + return { + complete: false, + hasPassword: false, + hasGithub: sectionNames.has("github"), + hasLlm: sectionNames.has("llm"), + hasSlack: sectionNames.has("slack"), + }; } return { complete: row.completedAt != null, hasPassword: row.passwordHash != null, - hasGithub: row.githubTokenEnc != null || row.githubAppKeyEnc != null, - hasLlm: row.llmApiKeyEnc != null, - hasSlack: row.slackBotTokenEnc != null, + hasGithub: sectionNames.has("github"), + hasLlm: sectionNames.has("llm"), + hasSlack: sectionNames.has("slack"), }; } @@ -124,81 +144,48 @@ export class SetupStore { /** Save GitHub configuration (encrypted). */ async saveGitHub(config: GitHubSetupConfig): Promise { - const key = await this.getEncryptionKey(); - const githubConfig: Record = { authMode: config.authMode, defaultOwner: config.defaultOwner, repos: config.repos, }; - - let githubTokenEnc: Buffer | null = null; - let githubAppKeyEnc: Buffer | null = null; + const githubSecrets: Record = {}; if (config.authMode === "pat" && config.token) { - githubTokenEnc = encrypt(config.token, key); + githubSecrets.token = config.token; } else if (config.authMode === "app") { githubConfig.appId = config.appId; githubConfig.installationId = config.installationId; if (config.privateKey) { - githubAppKeyEnc = encrypt(config.privateKey, key); + githubSecrets.privateKey = config.privateKey; } } - await this.db - .insert(setup) - .values({ - id: 1, - githubConfig, - githubTokenEnc, - githubAppKeyEnc, - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: setup.id, - set: { githubConfig, githubTokenEnc, githubAppKeyEnc, updatedAt: new Date() }, - }); + await this.upsertConfigSection("github", githubConfig, githubSecrets); } /** Save LLM configuration (encrypted). */ async saveLLM(config: LLMSetupConfig): Promise { - const key = await this.getEncryptionKey(); - const llmConfig: Record = { provider: config.provider, defaultModel: config.defaultModel, }; - - const llmApiKeyEnc = encrypt(config.apiKey, key); - - await this.db - .insert(setup) - .values({ id: 1, llmConfig, llmApiKeyEnc, updatedAt: new Date() }) - .onConflictDoUpdate({ - target: setup.id, - set: { llmConfig, llmApiKeyEnc, updatedAt: new Date() }, - }); + await this.upsertConfigSection("llm", llmConfig, { apiKey: config.apiKey }); } /** Save Slack configuration (encrypted). */ async saveSlack(config: SlackSetupConfig): Promise { - const key = await this.getEncryptionKey(); - const slackConfig: Record = { - signingSecret: config.signingSecret, commandName: config.commandName, }; - - const slackBotTokenEnc = encrypt(config.botToken, key); - const slackAppTokenEnc = encrypt(config.appToken, key); - - await this.db - .insert(setup) - .values({ id: 1, slackConfig, slackBotTokenEnc, slackAppTokenEnc, updatedAt: new Date() }) - .onConflictDoUpdate({ - target: setup.id, - set: { slackConfig, slackBotTokenEnc, slackAppTokenEnc, updatedAt: new Date() }, - }); + const slackSecrets: Record = { + botToken: config.botToken, + appToken: config.appToken, + }; + if (config.signingSecret) { + slackSecrets.signingSecret = config.signingSecret; + } + await this.upsertConfigSection("slack", slackConfig, slackSecrets); } /** Mark setup as complete. Throws if password hasn't been set. */ @@ -229,65 +216,158 @@ export class SetupStore { async applyToEnv(): Promise { const row = await this.getSetupRow(); if (!row || !row.completedAt) return; + await this.ensureLegacyConfigsMigrated(); + + const github = await this.readConfigSection("github"); + if (github && !this.shouldUseEnvOverride("github", github.overrideFromEnv)) { + const authMode = String(github.config.authMode ?? ""); + const token = stringOrUndefined(github.secrets.token); + const privateKey = stringOrUndefined(github.secrets.privateKey); + setEnvValue("GITHUB_DEFAULT_OWNER", stringOrUndefined(github.config.defaultOwner)); + const repos = Array.isArray(github.config.repos) ? github.config.repos.filter((entry): entry is string => typeof entry === "string") : []; + setEnvValue("REPO_ALLOWLIST", repos.length > 0 ? repos.join(",") : undefined); + if (authMode === "app") { + setEnvValue("GITHUB_TOKEN", undefined); + setEnvValue("GITHUB_APP_ID", stringOrUndefined(github.config.appId)); + setEnvValue("GITHUB_APP_INSTALLATION_ID", stringOrUndefined(github.config.installationId)); + setEnvValue("GITHUB_APP_PRIVATE_KEY", privateKey); + } else { + setEnvValue("GITHUB_TOKEN", token); + setEnvValue("GITHUB_APP_ID", undefined); + setEnvValue("GITHUB_APP_INSTALLATION_ID", undefined); + setEnvValue("GITHUB_APP_PRIVATE_KEY", undefined); + } + } - const key = await this.getEncryptionKey(); + const llm = await this.readConfigSection("llm"); + if (llm && !this.shouldUseEnvOverride("llm", llm.overrideFromEnv)) { + const provider = stringOrUndefined(llm.config.provider) ?? "openrouter"; + const apiKey = stringOrUndefined(llm.secrets.apiKey); + setEnvValue("ANTHROPIC_API_KEY", provider === "anthropic" ? apiKey : undefined); + setEnvValue("OPENAI_API_KEY", provider === "openai" ? apiKey : undefined); + setEnvValue("OPENROUTER_API_KEY", provider === "openrouter" ? apiKey : undefined); + setEnvValue("DEFAULT_LLM_MODEL", stringOrUndefined(llm.config.defaultModel)); + } - // GitHub PAT - if (row.githubTokenEnc) { - const token = decrypt(row.githubTokenEnc, key); - process.env.GITHUB_TOKEN ??= token; + const slack = await this.readConfigSection("slack"); + if (slack && !this.shouldUseEnvOverride("slack", slack.overrideFromEnv)) { + setEnvValue("SLACK_BOT_TOKEN", stringOrUndefined(slack.secrets.botToken)); + setEnvValue("SLACK_APP_TOKEN", stringOrUndefined(slack.secrets.appToken)); + setEnvValue("SLACK_SIGNING_SECRET", stringOrUndefined(slack.secrets.signingSecret)); + setEnvValue("SLACK_COMMAND_NAME", stringOrUndefined(slack.config.commandName)); } + } - // GitHub App - if (row.githubAppKeyEnc) { - const privateKey = decrypt(row.githubAppKeyEnc, key); - process.env.GITHUB_APP_PRIVATE_KEY ??= privateKey; + private async ensureLegacyConfigsMigrated(): Promise { + if (this.legacyConfigMigrationDone) return; + if (this.legacyConfigMigrationPromise) { + await this.legacyConfigMigrationPromise; + return; } - const ghConfig = row.githubConfig as Record | null; - if (ghConfig) { - if (ghConfig.appId) process.env.GITHUB_APP_ID ??= String(ghConfig.appId); - if (ghConfig.installationId) process.env.GITHUB_APP_INSTALLATION_ID ??= String(ghConfig.installationId); - if (ghConfig.defaultOwner) process.env.GITHUB_DEFAULT_OWNER ??= String(ghConfig.defaultOwner); - if (Array.isArray(ghConfig.repos) && ghConfig.repos.length > 0) { - process.env.REPO_ALLOWLIST ??= (ghConfig.repos as string[]).join(","); - } + this.legacyConfigMigrationPromise = this.migrateLegacyConfigs(); + try { + await this.legacyConfigMigrationPromise; + this.legacyConfigMigrationDone = true; + } finally { + this.legacyConfigMigrationPromise = undefined; } + } - // LLM - if (row.llmApiKeyEnc) { - const apiKey = decrypt(row.llmApiKeyEnc, key); - const llmConfig = row.llmConfig as Record | null; - const provider = llmConfig?.provider as string | undefined; + private async migrateLegacyConfigs(): Promise { + const row = await this.getSetupRow(); + if (!row) return; - if (provider === "anthropic") { - process.env.ANTHROPIC_API_KEY ??= apiKey; - } else if (provider === "openai") { - process.env.OPENAI_API_KEY ??= apiKey; - } else { - // Default: openrouter - process.env.OPENROUTER_API_KEY ??= apiKey; - } + const existingRows = await this.db + .select({ section: configSections.section }) + .from(configSections); + const existing = new Set(existingRows.map((entry) => entry.section)); + const key = await this.getEncryptionKey(); - if (llmConfig?.defaultModel) { - process.env.DEFAULT_LLM_MODEL ??= String(llmConfig.defaultModel); - } + if (!existing.has("github") && (row.githubConfig || row.githubTokenEnc || row.githubAppKeyEnc)) { + const githubConfig = (row.githubConfig as Record | null) ?? {}; + const githubSecrets: Record = {}; + if (row.githubTokenEnc) githubSecrets.token = decrypt(row.githubTokenEnc, key); + if (row.githubAppKeyEnc) githubSecrets.privateKey = decrypt(row.githubAppKeyEnc, key); + await this.upsertConfigSection("github", githubConfig, githubSecrets); } - // Slack - if (row.slackBotTokenEnc) { - process.env.SLACK_BOT_TOKEN ??= decrypt(row.slackBotTokenEnc, key); - } - if (row.slackAppTokenEnc) { - process.env.SLACK_APP_TOKEN ??= decrypt(row.slackAppTokenEnc, key); + if (!existing.has("llm") && (row.llmConfig || row.llmApiKeyEnc)) { + const llmConfig = (row.llmConfig as Record | null) ?? {}; + const llmSecrets: Record = {}; + if (row.llmApiKeyEnc) llmSecrets.apiKey = decrypt(row.llmApiKeyEnc, key); + await this.upsertConfigSection("llm", llmConfig, llmSecrets); } - const slackConfig = row.slackConfig as Record | null; - if (slackConfig) { - if (slackConfig.signingSecret) process.env.SLACK_SIGNING_SECRET ??= String(slackConfig.signingSecret); - if (slackConfig.commandName) process.env.SLACK_COMMAND_NAME ??= String(slackConfig.commandName); + + if (!existing.has("slack") && (row.slackConfig || row.slackBotTokenEnc || row.slackAppTokenEnc)) { + const legacySlackConfig = (row.slackConfig as Record | null) ?? {}; + const slackConfig: Record = { + commandName: legacySlackConfig.commandName, + }; + const slackSecrets: Record = {}; + if (row.slackBotTokenEnc) slackSecrets.botToken = decrypt(row.slackBotTokenEnc, key); + if (row.slackAppTokenEnc) slackSecrets.appToken = decrypt(row.slackAppTokenEnc, key); + if (legacySlackConfig.signingSecret) slackSecrets.signingSecret = String(legacySlackConfig.signingSecret); + await this.upsertConfigSection("slack", slackConfig, slackSecrets); } } + private async upsertConfigSection( + section: ConfigSectionName, + config: Record, + secrets: Record, + ): Promise { + const key = await this.getEncryptionKey(); + const hasSecrets = Object.keys(secrets).length > 0; + const secretsEnc = hasSecrets ? encrypt(JSON.stringify(secrets), key) : null; + await this.db + .insert(configSections) + .values({ + section, + config, + secretsEnc, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: configSections.section, + set: { + config, + secretsEnc, + updatedAt: new Date(), + }, + }); + } + + private async readConfigSection(section: ConfigSectionName): Promise { + const rows = await this.db + .select() + .from(configSections) + .where(eq(configSections.section, section)); + const row = rows[0]; + if (!row) return undefined; + + const key = row.secretsEnc ? await this.getEncryptionKey() : undefined; + const secrets = row.secretsEnc && key + ? JSON.parse(decrypt(row.secretsEnc, key)) as Record + : {}; + + return { + config: row.config ?? {}, + secrets, + overrideFromEnv: row.overrideFromEnv, + }; + } + + private shouldUseEnvOverride(section: ConfigSectionName, sectionOverrideFromEnv: boolean): boolean { + if (sectionOverrideFromEnv) return true; + const varName = section === "github" + ? "GITHUB_CONFIG_OVERRIDE_FROM_ENV" + : section === "slack" + ? "SLACK_CONFIG_OVERRIDE_FROM_ENV" + : "LLM_CONFIG_OVERRIDE_FROM_ENV"; + return parseOverrideFlag(process.env[varName]); + } + // ── Encryption key management ── /** @@ -344,6 +424,24 @@ export class SetupStore { } } +function setEnvValue(name: string, value: string | undefined): void { + if (value === undefined || value === "") { + delete process.env[name]; + return; + } + process.env[name] = value; +} + +function stringOrUndefined(value: unknown): string | undefined { + return typeof value === "string" && value.trim() !== "" ? value : undefined; +} + +function parseOverrideFlag(value: string | undefined): boolean { + if (value === undefined) return false; + const normalized = value.trim().toLowerCase(); + return normalized === "1" || normalized === "true" || normalized === "yes"; +} + /** Password hashing using async scrypt (N=16384, r=8, p=1, keylen=64). */ async function hashPassword(password: string): Promise { const salt = randomBytes(16);