/* What the hub keeps per app under `${HUB_DATA}/apps//`: baseline.json the desired config, as TEXT (some configs are not JSON) overrides.json the fields that stay local to each server, and their values history/ the config a server had before the hub changed it (50 files kept) Every write is a temp file and a rename, mode 0600: these files hold configs. */ import { createHash } from 'node:crypto' import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs' import path from 'node:path' export const KEEP_HISTORY = 50 /* A folder name for an app: letters, digits and . _ - only, with a short hash when anything had to change. */ export function safeName(name: string): string { const cleaned = name.replace(/[^A-Za-z0-9._-]/g, '_').replace(/\.{2,}/g, '_').replace(/^\.+/, '_').slice(0, 80) if (cleaned === name && cleaned !== '') return cleaned return `${cleaned === '' ? '_' : cleaned}-${createHash('sha256').update(name).digest('hex').slice(0, 6)}` } export const appDir = (dataDir: string, app: string): string => path.join(dataDir, 'apps', safeName(app)) export function writeAtomic(file: string, text: string): void { mkdirSync(path.dirname(file), { recursive: true }) const temp = `${file}.${process.pid}.tmp` try { writeFileSync(temp, text, { mode: 0o600 }) renameSync(temp, file) } catch (e) { rmSync(temp, { force: true }) throw e } } export interface Baseline { app: string text: string importedFrom?: string updatedAt: string updatedBy: string } export interface Overrides { /** Dot paths (`a.b.0.c`) of the fields that are not synchronised. */ paths: string[] /** server -> path -> value */ values: Record> } export const EMPTY_OVERRIDES: Overrides = { paths: [], values: {} } function readJson(file: string): unknown { if (!existsSync(file)) return null try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return null } } export function readBaseline(dataDir: string, app: string): Baseline | null { const data = readJson(path.join(appDir(dataDir, app), 'baseline.json')) as Partial | null if (data === null || typeof data.text !== 'string') return null return { app, text: data.text, ...(typeof data.importedFrom === 'string' ? { importedFrom: data.importedFrom } : {}), updatedAt: String(data.updatedAt ?? ''), updatedBy: String(data.updatedBy ?? ''), } } export function writeBaseline(dataDir: string, baseline: Baseline): void { writeAtomic(path.join(appDir(dataDir, baseline.app), 'baseline.json'), JSON.stringify(baseline, null, 2) + '\n') } export function readOverrides(dataDir: string, app: string): Overrides { const data = readJson(path.join(appDir(dataDir, app), 'overrides.json')) as Partial | null if (data === null || !Array.isArray(data.paths)) return { paths: [], values: {} } const values = data.values !== null && typeof data.values === 'object' ? data.values : {} return { paths: data.paths.filter((p): p is string => typeof p === 'string'), values } } export function writeOverrides(dataDir: string, app: string, overrides: Overrides): void { writeAtomic(path.join(appDir(dataDir, app), 'overrides.json'), JSON.stringify(overrides, null, 2) + '\n') } export type HistoryKind = 'before-uninstall' | 'before-apply' | 'before-set' | 'before-save' | 'before-lists' export interface HistoryEntry { /** The file name; the only handle `readHistory` accepts. */ id: string at: string /** A server id, or `baseline` for an earlier text of the baseline. */ server: string kind: string size: number hash: string } const HISTORY_FILE = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2}\.\d{3}Z)__(.+)__(before-[a-z-]+)\.txt$/ const historyDir = (dataDir: string, app: string): string => path.join(appDir(dataDir, app), 'history') export const listHistory = (dataDir: string, app: string): HistoryEntry[] => listHistoryIn(historyDir(dataDir, app)) /* Newest first. Never the text. `dir` is a history folder. */ export function listHistoryIn(dir: string): HistoryEntry[] { if (!existsSync(dir)) return [] const entries: HistoryEntry[] = [] for (const name of readdirSync(dir)) { const m = HISTORY_FILE.exec(name) if (m === null) continue try { const text = readFileSync(path.join(dir, name), 'utf8') entries.push({ id: name, at: `${m[1]}T${m[2]}:${m[3]}:${m[4]}`, server: m[5], kind: m[6], size: Buffer.byteLength(text), hash: createHash('sha256').update(text).digest('hex').slice(0, 16), }) } catch { // A file that vanished while listing is skipped. } } return entries.sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0)) } /* The text of one entry. `id` has to be a name the listing returns, so nothing outside the folder can be read. */ export const readHistory = (dataDir: string, app: string, id: string): string | null => readHistoryIn(historyDir(dataDir, app), id) export function readHistoryIn(dir: string, id: string): string | null { const known = listHistoryIn(dir).find((e) => e.id === id) return known === undefined ? null : readFileSync(path.join(dir, known.id), 'utf8') } /* The apps that have a baseline, by the name stored in the file. */ export function listBaselineApps(dataDir: string): string[] { const root = path.join(dataDir, 'apps') if (!existsSync(root)) return [] const apps: string[] = [] for (const dir of readdirSync(root)) { try { if (!statSync(path.join(root, dir)).isDirectory()) continue const data = readJson(path.join(root, dir, 'baseline.json')) as Partial | null if (data !== null && typeof data.app === 'string' && typeof data.text === 'string') apps.push(data.app) } catch { // Not an app folder. } } return apps.sort((a, b) => a.localeCompare(b)) } /* Saves a server's config as it was before the hub changed it. The name starts with the time, so sorting the names sorts the files by age; the oldest beyond KEEP_HISTORY are deleted. */ export function writeHistory( dataDir: string, app: string, server: string, text: string, kind: HistoryKind, now: number, ): string { return writeHistoryIn(historyDir(dataDir, app), server, text, kind, now) } export function writeHistoryIn(dir: string, server: string, text: string, kind: HistoryKind, now: number): string { const stamp = new Date(now).toISOString().replace(/:/g, '-') const file = path.join(dir, `${stamp}__${safeName(server)}__${kind}.txt`) writeAtomic(file, text) const files = readdirSync(dir).filter((f) => f.endsWith('.txt')).sort() for (const old of files.slice(0, Math.max(0, files.length - KEEP_HISTORY))) rmSync(path.join(dir, old), { force: true }) return file } /* ---- Settings and blocking lists (stage 7): `${HUB_DATA}/settings/` and `${HUB_DATA}/blocking/` ---- */ export const settingsDir = (dataDir: string): string => path.join(dataDir, 'settings') export const settingsHistoryDir = (dataDir: string): string => path.join(settingsDir(dataDir), 'history') export const blockingDir = (dataDir: string): string => path.join(dataDir, 'blocking') export interface SettingsBaseline { /** The settings as `settings/get` gave them, secrets included (the file is 0600). */ settings: Record importedFrom?: string updatedAt: string updatedBy: string } /* Extra local fields, on top of the built-in ones. Values are read live from the servers, not stored. */ export interface SettingsOverrides { paths: string[] } export function readSettingsBaseline(dataDir: string): SettingsBaseline | null { const data = readJson(path.join(settingsDir(dataDir), 'baseline.json')) as Partial | null if (data === null || data.settings === null || typeof data.settings !== 'object' || Array.isArray(data.settings)) return null return { settings: data.settings, ...(typeof data.importedFrom === 'string' ? { importedFrom: data.importedFrom } : {}), updatedAt: String(data.updatedAt ?? ''), updatedBy: String(data.updatedBy ?? ''), } } export const writeSettingsBaseline = (dataDir: string, baseline: SettingsBaseline): void => writeAtomic(path.join(settingsDir(dataDir), 'baseline.json'), JSON.stringify(baseline, null, 2) + '\n') export function readSettingsOverrides(dataDir: string): SettingsOverrides { const data = readJson(path.join(settingsDir(dataDir), 'overrides.json')) as Partial | null return { paths: Array.isArray(data?.paths) ? data.paths.filter((p): p is string => typeof p === 'string') : [] } } export const writeSettingsOverrides = (dataDir: string, overrides: SettingsOverrides): void => writeAtomic(path.join(settingsDir(dataDir), 'overrides.json'), JSON.stringify(overrides, null, 2) + '\n') export interface ListsBaseline { blocked: string[] allowed: string[] importedFrom?: string updatedAt: string updatedBy: string } export function readListsBaseline(dataDir: string): ListsBaseline | null { const data = readJson(path.join(blockingDir(dataDir), 'baseline.json')) as Partial | null if (data === null || !Array.isArray(data.blocked) || !Array.isArray(data.allowed)) return null const strings = (v: unknown[]): string[] => v.filter((x): x is string => typeof x === 'string') return { blocked: strings(data.blocked), allowed: strings(data.allowed), ...(typeof data.importedFrom === 'string' ? { importedFrom: data.importedFrom } : {}), updatedAt: String(data.updatedAt ?? ''), updatedBy: String(data.updatedBy ?? ''), } } export const writeListsBaseline = (dataDir: string, baseline: ListsBaseline): void => writeAtomic(path.join(blockingDir(dataDir), 'baseline.json'), JSON.stringify(baseline, null, 2) + '\n')