// Per-bot "let BotFights answer for me" configuration — an operator-supplied // LLM API key (Anthropic or OpenAI) stored locally so the server itself can // answer fight challenges for a poll-mode bot, instead of the operator // running their own external bot script. // // Storage pattern deliberately mirrors Archipelago's own node-level pattern // for the exact same class of secret (system.settings.set "claude_api_key" // in core/archipelago/src/api/rpc/system/handlers.rs): a single 0600 file // per secret, under this app's own data volume, GET never returns the raw // value — only whether one is configured and which provider. // // This is a human operator opting in via the app's own UI for their own // bot — never something an AI agent following the unified prompt is asked // for (see BOTFIGHTS.md "What playing never requires... your model-provider // API keys"). Different trust boundary entirely: a person configuring their // own node-local bot, not a third party asking an autonomous agent for // credentials mid-conversation. import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, chmodSync } from 'fs' import { join, dirname } from 'path' import { fileURLToPath } from 'url' const __dirname = dirname(fileURLToPath(import.meta.url)) const configDir = join(__dirname, '..', '..', 'data', 'ai-keys') export type LlmProvider = 'anthropic' | 'openai' export interface AiBotConfig { provider: LlmProvider apiKey: string } function configPath(botId: string): string { // botId is always a nanoid from this app's own registration flow (never // user-supplied path input), but guard against traversal regardless. if (botId.includes('/') || botId.includes('..')) { throw new Error('Invalid bot ID') } return join(configDir, `${botId}.json`) } export function setAiBotConfig(botId: string, config: AiBotConfig): void { if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true }) const path = configPath(botId) writeFileSync(path, JSON.stringify(config), { mode: 0o600 }) chmodSync(path, 0o600) // belt-and-suspenders: writeFileSync's mode is subject to umask } export function getAiBotConfig(botId: string): AiBotConfig | null { const path = configPath(botId) if (!existsSync(path)) return null try { return JSON.parse(readFileSync(path, 'utf-8')) as AiBotConfig } catch { return null } } export function hasAiBotConfig(botId: string): boolean { return existsSync(configPath(botId)) } export function deleteAiBotConfig(botId: string): void { const path = configPath(botId) if (existsSync(path)) unlinkSync(path) }