Files
arboretum/packages/server/src/routes/settings.ts
Johan LEROY e8d10b7ec0 feat(p11): temps réel complet (watcher checkout principal + topic settings)
P11-A — branche du checkout principal modifiée hors Arboretum :
- fs-watcher: pin « permanent » non évinçable (pinRepo/unpinRepo, repoPins dans evictIfNeeded)
- worktree-manager: armMainCheckoutWatchers() + arm/désarm sur addRepo/découverte/removeRepo/hidden
- index.ts: armMainCheckoutWatchers() dans runDaemon → git checkout CLI sur le principal → worktree_update <500ms sans watch client

P11-B — réglages en temps réel :
- protocole additif: type SettingsBroadcast (source unique, réutilisé par SettingsResponse), topic 'settings', message settings_update (validés parseClientMessage + gateway)
- core/settings-bus.ts (EventEmitter) ; routes/settings émet après PATCH ; gateway relaie aux abonnés 'settings'
- web: ws-client subscribeSettings + routage ; store settings applySettings/startRealtime ; AppShell abonne globalement ; SettingsView re-sync des drafts scalaires sans écraser une saisie en cours
- tests: protocol (topic settings) + fs-watcher (pinRepo non évincé) ; acceptance-p11.mjs (checkout principal <500ms + settings_update)
2026-06-27 14:08:44 +02:00

127 lines
5.0 KiB
TypeScript

// 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<SettingsResponse> => snapshot());
app.patch('/api/v1/settings', async (req, reply) => {
const body = (req.body as Partial<UpdateSettingsRequest> | 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);
});
}