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 ); `, }, { // P10 : archivage automatique des sessions terminées (rétention configurable, défaut 30 j). // `archived_at` NULL = active. Soft-archive : JAMAIS de DELETE (resume/fork restent intacts). // Sémantique distincte de hidden_sessions (#9) : `hidden` est manuel et indexé par // claude_session_id ; `archived` est automatique par ancienneté, indexé par `id`, et ne // s'applique qu'aux sessions managées en DB. Les deux filtres sont indépendants et cumulables. id: 10, sql: ` ALTER TABLE sessions ADD COLUMN archived_at TEXT; CREATE INDEX idx_sessions_archived_at ON sessions(archived_at); `, }, { // P12 : connexions aux services git distants (Gitea/GitLab/GitHub). Les SECRETS (PAT, app // password, tokens OAuth) sont chiffrés par SecretBox AVANT insertion (colonnes *_encrypted) : // la base ne contient jamais de secret en clair. `ssh_key_path`/`oauth_*` sont posés dès // maintenant (schéma stable) mais exploités en P12b/P12c. `test_result` = dernier diagnostic. id: 11, sql: ` CREATE TABLE git_credentials ( id TEXT PRIMARY KEY, label TEXT NOT NULL, service TEXT NOT NULL CHECK (service IN ('gitea','gitlab','github')), base_url TEXT, auth_type TEXT NOT NULL CHECK (auth_type IN ('pat','app_password','ssh_key','oauth')), username TEXT, secret_encrypted TEXT, ssh_key_path TEXT, oauth_access_encrypted TEXT, oauth_refresh_encrypted TEXT, oauth_expires_at TEXT, created_at TEXT NOT NULL, last_tested_at TEXT, test_result TEXT ); CREATE INDEX idx_git_credentials_service ON git_credentials(service); `, }, { // P12 : un repo enregistré peut provenir d'un clone : on retient sa source (remote_url), // le service détecté et le credential utilisé. PAS de FK sur credential_id (cohérent avec // sessions.group_id #8) : la suppression d'un credential nullifie ce champ côté manager. id: 12, sql: ` ALTER TABLE repos ADD COLUMN remote_url TEXT; ALTER TABLE repos ADD COLUMN git_service TEXT; ALTER TABLE repos ADD COLUMN credential_id TEXT; `, }, ]; 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); } // ---- Archivage des sessions terminées (par id, soft-archive, P10) ---- /** Archive une session managée (idempotent : ne touche pas une session déjà archivée). */ export function archiveSession(db: Db, id: string, archivedAt: string): void { db.prepare('UPDATE sessions SET archived_at = ? WHERE id = ? AND archived_at IS NULL').run(archivedAt, id); } /** Dés-archive une session (idempotent). La session reste reprenable/forkable (inchangé). */ export function unarchiveSession(db: Db, id: string): void { db.prepare('UPDATE sessions SET archived_at = NULL WHERE id = ?').run(id); } /** true si une session managée avec cet id existe en DB. */ export function sessionExists(db: Db, id: string): boolean { return db.prepare('SELECT 1 FROM sessions WHERE id = ?').get(id) != null; } /** true si la session existe en DB et est archivée. */ export function isSessionArchived(db: Db, id: string): boolean { const row = db.prepare('SELECT archived_at FROM sessions WHERE id = ?').get(id) as { archived_at: string | null } | undefined; return row != null && row.archived_at != null; } /** * Archive en masse les sessions terminées plus anciennes que `cutoffIso` (comparaison lexicographique * sur ended_at, format ISO UTC). Retourne les `id` archivés (pour émettre les events + auditer). * SELECT puis UPDATE dans une transaction → la liste retournée correspond exactement aux lignes mutées. * Les sessions vivantes (ended_at NULL) et déjà archivées sont exclues. */ export function archiveExpiredSessions(db: Db, archivedAt: string, cutoffIso: string): string[] { db.exec('BEGIN'); try { const rows = db .prepare('SELECT id FROM sessions WHERE ended_at IS NOT NULL AND ended_at < ? AND archived_at IS NULL') .all(cutoffIso) as Array<{ id: string }>; const stmt = db.prepare('UPDATE sessions SET archived_at = ? WHERE id = ?'); for (const r of rows) stmt.run(archivedAt, r.id); db.exec('COMMIT'); return rows.map((r) => r.id); } catch (err) { db.exec('ROLLBACK'); throw err; } }