/** * Backup-artifact manifest — a single `manifest.json` file embedded at the * root of every encrypted backup envelope (alongside the actual data). The * manifest makes artifacts self-describing: an operator can hand a backup * file to a different celilo install and the restore code can validate * schemaVersion compatibility + dispatch to the right restore path * without needing the source celilo's DB. * * The DB's `backups` table stays as the queryable index ("show me my * backups") but is no longer authoritative — the artifact itself is. * * Schema versioning: * - Bump the major when the file layout changes (e.g. renaming `data/`). * - Bump the minor for additive fields. * - Restore refuses an artifact whose major doesn't match. */ import { hostname } from 'node:os'; import { z } from 'zod'; /** Current envelope-schema version. Bump the major on breaking changes. */ export const MANIFEST_SCHEMA_VERSION = '1.0' as const; export const BackupManifestSchema = z.object({ schemaVersion: z.string().regex(/^\d+\.\d+$/), createdAt: z.string(), // ISO 8601 celiloVersion: z.string(), hostname: z.string(), kind: z.enum(['system', 'module']), /** Only present when kind='module'. */ moduleId: z.string().optional(), /** Only present when kind='module'. */ moduleVersion: z.string().optional(), /** * Schema version of the on_backup hook's own data shape. Reported back * by the module's hook output (`outputs.schema_version`) and threaded * through to on_restore so the hook can migrate its own data. Distinct * from `schemaVersion` (which versions the envelope, not the data). */ dataSchemaVersion: z.string().optional(), }); export type BackupManifest = z.infer; interface BuildOptions { kind: 'system' | 'module'; moduleId?: string; moduleVersion?: string; dataSchemaVersion?: string; /** * Override the timestamp / hostname / celiloVersion for tests. Production * callers leave these at their defaults. */ now?: Date; hostnameOverride?: string; celiloVersion?: string; } /** * Construct a manifest object. Pure function — no I/O, no DB reads. The * caller decides where to serialize it (`writeManifest()` below). */ export function buildManifest(options: BuildOptions): BackupManifest { const manifest: BackupManifest = { schemaVersion: MANIFEST_SCHEMA_VERSION, createdAt: (options.now ?? new Date()).toISOString(), celiloVersion: options.celiloVersion ?? getCeliloVersion(), hostname: options.hostnameOverride ?? hostname(), kind: options.kind, }; if (options.moduleId) manifest.moduleId = options.moduleId; if (options.moduleVersion) manifest.moduleVersion = options.moduleVersion; if (options.dataSchemaVersion) manifest.dataSchemaVersion = options.dataSchemaVersion; return manifest; } let cachedCeliloVersion: string | null = null; function getCeliloVersion(): string { if (cachedCeliloVersion !== null) return cachedCeliloVersion; try { // Read our own package.json. Walking up from __dirname keeps this // robust to the test directory layout (tests run from repo root). const { readFileSync } = require('node:fs'); const { dirname, join } = require('node:path'); let dir: string = __dirname; for (let i = 0; i < 8; i++) { try { const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf-8')); if (pkg.name === '@celilo/cli' || pkg.name === 'celilo') { cachedCeliloVersion = String(pkg.version); return cachedCeliloVersion; } } catch { /* package.json doesn't exist in this dir or isn't ours */ } const parent = dirname(dir); if (parent === dir) break; dir = parent; } } catch { /* fall through to unknown */ } cachedCeliloVersion = 'unknown'; return cachedCeliloVersion; } /** * Parse + validate a manifest from a JSON string. Throws ManifestParseError * with an actionable message on failure. */ export function parseManifest(json: string): BackupManifest { let raw: unknown; try { raw = JSON.parse(json); } catch (err) { const detail = err instanceof Error ? err.message : String(err); throw new ManifestParseError( `Backup artifact manifest.json is not valid JSON: ${detail}. The artifact may be corrupted or produced by an incompatible celilo version.`, ); } const parsed = BackupManifestSchema.safeParse(raw); if (!parsed.success) { throw new ManifestParseError( `Backup artifact manifest.json is malformed: ${parsed.error.message}. The artifact may be corrupted or produced by an incompatible celilo version.`, ); } return parsed.data; } /** * Verify that a backup artifact's schemaVersion is compatible with this * celilo build. Throws with an operator-readable message on mismatch. * * Today the only check is "major version matches." A v1.x restorer will * load v1.0, v1.1, ... but refuse v2.x. */ export function assertCompatibleSchema(manifest: BackupManifest): void { const expectedMajor = MANIFEST_SCHEMA_VERSION.split('.')[0]; const actualMajor = manifest.schemaVersion.split('.')[0]; if (expectedMajor !== actualMajor) { throw new IncompatibleManifestError( `Cannot restore backup: artifact was created with envelope schema ${manifest.schemaVersion}, this celilo supports ${MANIFEST_SCHEMA_VERSION}. Use a celilo build whose major version matches the artifact (or re-create the backup with this celilo).`, manifest, ); } } export class ManifestParseError extends Error { constructor(message: string) { super(message); this.name = 'ManifestParseError'; } } export class IncompatibleManifestError extends Error { constructor( message: string, public readonly manifest: BackupManifest, ) { super(message); this.name = 'IncompatibleManifestError'; } }