// Réglages exposés à l'UI (onglet Réglages). Frontière de sécurité CENTRALE : la table `settings` // contient aussi des SECRETS (server_secret, vapid_private). Ces routes n'exposent QUE des champs // non sensibles et n'écrivent QUE des clés explicitement allow-listées — jamais les secrets. import type { FastifyInstance } from 'fastify'; import type { ServerInfo, SettingsResponse, UpdateSettingsRequest } from '@arboretum/shared'; import type { Config } from '../config.js'; import { type Db, setSetting } from '../db/index.js'; import type { PushService } from '../core/push-service.js'; import type { SettingsBus } from '../core/settings-bus.js'; import { recordAudit } from '../core/audit-log.js'; import { diagnoseClaudeBin } from '../core/claude-launcher.js'; import { SCAN_INTERVAL_KEY, SCAN_ROOTS_KEY, normalizeScanIntervalMin, normalizeScanRoots, readScanIntervalMin, readScanRoots, } from '../core/scan-settings.js'; import { CLAUDE_BIN_PATH_KEY, CLAUDE_HOME_KEY, normalizeClaudeBinPath, normalizeClaudeHome, readClaudeBinPath, readClaudeHome, } from '../core/claude-settings.js'; import { PURGE_DAYS_KEY, RETENTION_DAYS_KEY, normalizePurgeDays, normalizeRetentionDays, readPurgeDays, readRetentionDays, } from '../core/retention-settings.js'; // Réglages modifiables via l'API (allow-list stricte). Les secrets ne figurent JAMAIS ici. export function registerSettingsRoutes( app: FastifyInstance, db: Db, config: Config, serverVersion: string, push: PushService, settingsBus: SettingsBus, ): void { const serverInfo = (): ServerInfo => ({ version: serverVersion, port: config.port, bind: config.bind, allowedOrigins: config.allowedOrigins, dataDir: config.dataDir, vapidPublicKey: push.publicKey() || null, vapidContact: config.vapidContact, claudeHome: config.claudeHome, // Diagnostic recalculé à chaque GET : reflète l'état réel (override exécutable ? `which claude` ?). claudeBin: diagnoseClaudeBin(readClaudeBinPath(db)), }); const snapshot = (): SettingsResponse => ({ settings: { scanRoots: readScanRoots(db), scanIntervalMin: readScanIntervalMin(db), claudeBinPath: readClaudeBinPath(db), claudeHome: readClaudeHome(db), retentionDays: readRetentionDays(db), purgeDays: readPurgeDays(db), }, server: serverInfo(), }); app.get('/api/v1/settings', async (): Promise => snapshot()); app.patch('/api/v1/settings', async (req, reply) => { const body = (req.body as Partial | null) ?? {}; if ('scanRoots' in body) { const roots = normalizeScanRoots(body.scanRoots); if (!roots) { return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'scanRoots must be an array of ≤16 absolute, normalized paths (never "/")' } }); } setSetting(db, SCAN_ROOTS_KEY, JSON.stringify(roots)); } if ('scanIntervalMin' in body) { const interval = normalizeScanIntervalMin(body.scanIntervalMin); if (interval === null) { return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'scanIntervalMin must be an integer between 0 and 1440' } }); } setSetting(db, SCAN_INTERVAL_KEY, String(interval)); } if ('claudeBinPath' in body) { const value = normalizeClaudeBinPath(body.claudeBinPath); if (value === null) { return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'claudeBinPath must be an absolute path to an executable file (or "" to reset)' } }); } setSetting(db, CLAUDE_BIN_PATH_KEY, value); } if ('claudeHome' in body) { const value = normalizeClaudeHome(body.claudeHome); if (value === null) { return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'claudeHome must be an absolute path to an existing directory (or "" to reset)' } }); } setSetting(db, CLAUDE_HOME_KEY, value); } if ('retentionDays' in body) { const value = normalizeRetentionDays(body.retentionDays); if (value === null) { return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'retentionDays must be 0 (never) or an integer between 1 and 3650' } }); } setSetting(db, RETENTION_DAYS_KEY, String(value)); } if ('purgeDays' in body) { const value = normalizePurgeDays(body.purgeDays); if (value === null) { return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'purgeDays must be 0 (disabled) or an integer between 1 and 3650' } }); } setSetting(db, PURGE_DAYS_KEY, String(value)); } recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'settings.update', details: { keys: Object.keys(body) }, }); const snap = snapshot(); // P11 — diffuse le nouvel état non sensible à tous les clients abonnés au topic 'settings'. settingsBus.emit('settings_update', snap.settings); return reply.send(snap); }); }