import { DatabaseSync } from 'node:sqlite'; import { chmodSync, existsSync } from 'node:fs'; const MIGRATIONS: Array<{ id: number; sql: string }> = [ { id: 1, sql: ` CREATE TABLE settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE auth_tokens ( id TEXT PRIMARY KEY, label TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL, last_used_at TEXT, revoked_at TEXT ); CREATE TABLE sessions ( id TEXT PRIMARY KEY, cwd TEXT NOT NULL, command TEXT NOT NULL, title TEXT, created_at TEXT NOT NULL, ended_at TEXT, exit_code INTEGER ); `, }, { // P2 — découverte & reprise : corrélation avec les sessions Claude sur disque. id: 2, sql: ` ALTER TABLE sessions ADD COLUMN claude_session_id TEXT; ALTER TABLE sessions ADD COLUMN resumed_from TEXT; CREATE INDEX idx_sessions_claude_session_id ON sessions(claude_session_id); `, }, { // P3 — repos enregistrés. Les worktrees sont dérivés à la volée de git (non persistés). id: 3, sql: ` CREATE TABLE repos ( id TEXT PRIMARY KEY, path TEXT NOT NULL UNIQUE, label TEXT NOT NULL, default_branch TEXT, post_create_hooks TEXT NOT NULL DEFAULT '[]', pre_trust INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL ); `, }, { // P4 — abonnements Web Push. Liés au token d'auth (token_id) ; clés VAPID en settings. id: 4, sql: ` CREATE TABLE push_subscriptions ( id TEXT PRIMARY KEY, token_id TEXT NOT NULL, endpoint TEXT NOT NULL UNIQUE, p256dh TEXT NOT NULL, auth TEXT NOT NULL, user_agent TEXT, created_at TEXT NOT NULL, last_ok_at TEXT ); CREATE INDEX idx_push_subs_token ON push_subscriptions(token_id); `, }, { // P5 — groupes de travail. Un groupe = collection de repos (many-to-many). // Worktrees/sessions NON persistés ici : servis par WorktreeManager/PtyManager // et filtrés côté client par repoId. CASCADE s'appuie sur PRAGMA foreign_keys = ON (cf. openDb). id: 5, sql: ` CREATE TABLE groups ( id TEXT PRIMARY KEY, label TEXT NOT NULL, description TEXT, color TEXT, position INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE group_repos ( group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE, repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, position INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, PRIMARY KEY (group_id, repo_id) ); CREATE INDEX idx_group_repos_repo ON group_repos(repo_id); `, }, { // Découverte auto : un repo masqué reste en DB (exclu du dashboard) pour qu'un // re-scan ne le ressuscite pas. hidden=1 = masqué. id: 6, sql: `ALTER TABLE repos ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0;`, }, { // Journal d'audit (conformité entreprise : GDPR/SOX/ISO 27001). Trace les mutations // sensibles (tokens, réglages, secrets, abonnements push, groupes). Ne contient JAMAIS // de secret en clair — `details` est un JSON de métadonnées non sensibles. id: 7, sql: ` CREATE TABLE audit_logs ( id TEXT PRIMARY KEY, ts TEXT NOT NULL, actor TEXT NOT NULL, action TEXT NOT NULL, resource_id TEXT, details TEXT, result TEXT NOT NULL ); CREATE INDEX idx_audit_ts ON audit_logs(ts); `, }, { // P6 — session de groupe multi-repo. Une session peut couvrir plusieurs répertoires (--add-dir) // et appartenir à un groupe. `added_dirs` : JSON array de chemins absolus (NULL si mono-repo). // `group_id` : pas de FK (ALTER ADD COLUMN sqlite n'en pose pas) ; nettoyé à la suppression du groupe. id: 8, sql: ` ALTER TABLE sessions ADD COLUMN added_dirs TEXT; ALTER TABLE sessions ADD COLUMN group_id TEXT; CREATE INDEX idx_sessions_group_id ON sessions(group_id); `, }, { // Masquage des sessions Claude découvertes (lancées en CLI hors Arboretum) qui polluent la liste. // Calqué sur repos.hidden (#6) : la session masquée reste connue (resume/fork possibles) mais est // exclue de la liste par défaut, et un re-scan ne la ressuscite pas. Clé = claudeSessionId (stable, // partagé entre une découverte et sa reprise managée). id: 9, sql: ` CREATE TABLE hidden_sessions ( claude_session_id TEXT PRIMARY KEY, hidden_at TEXT NOT NULL ); `, }, ]; export type Db = DatabaseSync; export function openDb(path: string): Db { const db = new DatabaseSync(path); db.exec('PRAGMA journal_mode = WAL'); db.exec('PRAGMA foreign_keys = ON'); hardenDbPermissions(path); migrate(db); return db; } /** * Restreint la base (et ses fichiers WAL/SHM) à 0o600 — proprio uniquement. La base contient des * secrets (server_secret, clé privée VAPID, hashs de tokens) : elle ne doit jamais être lisible par * d'autres utilisateurs du système. Best-effort : ignoré sur les FS sans permissions POSIX. */ function hardenDbPermissions(path: string): void { if (path === ':memory:') return; for (const p of [path, `${path}-wal`, `${path}-shm`]) { try { if (existsSync(p)) chmodSync(p, 0o600); } catch { /* FS sans permissions POSIX (Windows / montage) : ignoré */ } } } function migrate(db: DatabaseSync): void { db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (id INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)'); const applied = new Set( (db.prepare('SELECT id FROM schema_migrations').all() as Array<{ id: number }>).map((r) => r.id), ); for (const m of MIGRATIONS) { if (applied.has(m.id)) continue; db.exec('BEGIN'); try { db.exec(m.sql); db.prepare('INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)').run(m.id, new Date().toISOString()); db.exec('COMMIT'); } catch (err) { db.exec('ROLLBACK'); throw err; } } } export function getSetting(db: Db, key: string): string | null { const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined; return row?.value ?? null; } export function setSetting(db: Db, key: string, value: string): void { db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, value); } // ---- Sessions masquées (par claudeSessionId) ---- /** Ensemble des claudeSessionId masqués par l'utilisateur. */ export function listHiddenSessionIds(db: Db): Set { const rows = db.prepare('SELECT claude_session_id FROM hidden_sessions').all() as Array<{ claude_session_id: string }>; return new Set(rows.map((r) => r.claude_session_id)); } /** Masque une session (idempotent). */ export function hideSession(db: Db, claudeSessionId: string): void { db.prepare( 'INSERT INTO hidden_sessions (claude_session_id, hidden_at) VALUES (?, ?) ON CONFLICT(claude_session_id) DO NOTHING', ).run(claudeSessionId, new Date().toISOString()); } /** Ré-affiche une session masquée (idempotent). */ export function unhideSession(db: Db, claudeSessionId: string): void { db.prepare('DELETE FROM hidden_sessions WHERE claude_session_id = ?').run(claudeSessionId); }