1/**
2 * Backup and Restore
3 *
4 * Reads kernel models directly (allowed for extensions). Knows Node, User, Note,
5 * Contribution, AIChat. That's enough to export and import everything.
6 *
7 * Three modes:
8 * exportLand() - Full backup. Every node, user, note, contribution. JSON file.
9 * snapshotLand() - Lightweight. Tree structure + metadata only. No content, no history.
10 * importLand() - Restore from export. Validates checksum, preserves all metadata.
11 */
12
13import crypto from "crypto";
14import fs from "fs";
15import path from "path";
16import Node from "../../seed/models/node.js";
17import User from "../../seed/models/user.js";
18import Note from "../../seed/models/note.js";
19import Contribution from "../../seed/models/contribution.js";
20import log from "../../seed/log.js";
21
22let Chat = null;
23try { Chat = (await import("../../seed/models/chat.js")).default; } catch (err) { log.debug("Backup", "Chat model not available:", err.message); }
24
25let seedVersion = "unknown";
26try { seedVersion = (await import("../../seed/version.js")).SEED_VERSION; } catch (err) { log.debug("Backup", "Could not load seed version:", err.message); }
27
28let getLandConfigValue = () => null;
29try {
30 const mod = await import("../../seed/landConfig.js");
31 getLandConfigValue = mod.getLandConfigValue;
32} catch (err) { log.debug("Backup", "Could not load landConfig:", err.message); }
33
34function normalizeDoc(doc) {
35 if (doc.metadata instanceof Map) {
36 doc.metadata = Object.fromEntries(doc.metadata);
37 }
38 return doc;
39}
40
41/**
42 * Full land export.
43 */
44export async function exportLand(opts = {}) {
45 const timestamp = new Date().toISOString();
46 log.info("Backup", `Starting full export at ${timestamp}`);
47
48 const nodes = await Node.find({}).lean();
49 const users = await User.find({}).select("-password").lean();
50 const notes = await Note.find({}).lean();
51
52 const retentionDays = parseInt(getLandConfigValue("contributionRetentionDays") || "365", 10);
53 const cutoff = retentionDays > 0
54 ? new Date(Date.now() - retentionDays * 86400000)
55 : new Date(0);
56 const contributions = await Contribution.find({ date: { $gte: cutoff } }).lean();
57
58 const data = {
59 _backup: {
60 version: 1,
61 seedVersion,
62 timestamp,
63 landName: getLandConfigValue("LAND_NAME") || "Unknown",
64 },
65 nodes: nodes.map(normalizeDoc),
66 users: users.map(normalizeDoc),
67 notes,
68 contributions,
69 };
70
71 const serialized = JSON.stringify(data);
72 data._backup.checksum = crypto.createHash("sha256").update(serialized).digest("hex");
73 data._backup.sizeBytes = Buffer.byteLength(serialized, "utf8");
74
75 if (opts.outputPath) {
76 const dir = path.dirname(opts.outputPath);
77 if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
78 fs.writeFileSync(opts.outputPath, JSON.stringify(data, null, 2));
79 log.info("Backup", `Full export: ${opts.outputPath} (${nodes.length} nodes, ${users.length} users, ${notes.length} notes)`);
80 return { outputPath: opts.outputPath, nodes: nodes.length, users: users.length, notes: notes.length, contributions: contributions.length };
81 }
82
83 log.info("Backup", `Full export complete (${nodes.length} nodes, ${users.length} users, ${notes.length} notes)`);
84 return data;
85}
86
87/**
88 * Lightweight snapshot. Structure + metadata only.
89 */
90export async function snapshotLand(opts = {}) {
91 const timestamp = new Date().toISOString();
92
93 const nodes = await Node.find({}).select("_id name type status parent children rootOwner contributors systemRole metadata visibility dateCreated").lean();
94 const users = await User.find({}).select("-password").lean();
95
96 const data = {
97 _backup: {
98 version: 1,
99 seedVersion,
100 timestamp,
101 type: "snapshot",
102 landName: getLandConfigValue("LAND_NAME") || "Unknown",
103 },
104 nodes: nodes.map(normalizeDoc),
105 users: users.map(normalizeDoc),
106 };
107
108 const serialized = JSON.stringify(data);
109 data._backup.checksum = crypto.createHash("sha256").update(serialized).digest("hex");
110
111 if (opts.outputPath) {
112 const dir = path.dirname(opts.outputPath);
113 if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
114 fs.writeFileSync(opts.outputPath, JSON.stringify(data, null, 2));
115 log.info("Backup", `Snapshot: ${opts.outputPath} (${nodes.length} nodes)`);
116 return { outputPath: opts.outputPath, nodes: nodes.length, users: users.length };
117 }
118
119 return data;
120}
121
122/**
123 * Restore a land from an export.
124 */
125export async function importLand(input) {
126 let data;
127 if (typeof input === "string") {
128 // Path traversal guard: resolve to absolute and verify it's within the backup directory
129 const backupPath = path.resolve(getLandConfigValue("backupPath") || "./backups");
130 const resolved = path.resolve(backupPath, path.basename(input));
131 if (!resolved.startsWith(backupPath)) {
132 throw new Error("Invalid backup path: must be within the backup directory");
133 }
134 if (!fs.existsSync(resolved)) throw new Error(`Backup file not found: ${path.basename(input)}`);
135 const raw = fs.readFileSync(resolved, "utf8");
136 data = JSON.parse(raw);
137 } else {
138 data = input;
139 }
140
141 if (!data?._backup?.version) {
142 throw new Error("Invalid backup: missing _backup header");
143 }
144
145 // Validate checksum
146 const savedChecksum = data._backup.checksum;
147 if (savedChecksum) {
148 const copy = JSON.parse(JSON.stringify(data));
149 delete copy._backup.checksum;
150 delete copy._backup.sizeBytes;
151 const computed = crypto.createHash("sha256").update(JSON.stringify(copy)).digest("hex");
152 if (computed !== savedChecksum) {
153 throw new Error(`Checksum mismatch: backup may be corrupted`);
154 }
155 }
156
157 log.info("Backup", `Restoring from ${data._backup.type || "full"} backup (seed ${data._backup.seedVersion}, ${data._backup.timestamp})`);
158
159 // Drop existing data
160 const mongoose = (await import("mongoose")).default;
161 const db = mongoose.connection.db;
162 for (const col of ["nodes", "users", "notes", "contributions"]) {
163 try { await db.collection(col).deleteMany({}); } catch (err) { log.debug("Backup", `Failed to clear collection ${col}:`, err.message); }
164 }
165
166 const report = { nodes: 0, users: 0, notes: 0, contributions: 0 };
167
168 if (data.nodes?.length > 0) {
169 const docs = data.nodes.map(n => {
170 if (n.metadata && typeof n.metadata === "object" && !(n.metadata instanceof Map)) {
171 n.metadata = new Map(Object.entries(n.metadata));
172 }
173 return n;
174 });
175 await Node.insertMany(docs, { ordered: false });
176 report.nodes = docs.length;
177 }
178
179 if (data.users?.length > 0) {
180 const docs = data.users.map(u => {
181 if (u.metadata && typeof u.metadata === "object" && !(u.metadata instanceof Map)) {
182 u.metadata = new Map(Object.entries(u.metadata));
183 }
184 return u;
185 });
186 await User.insertMany(docs, { ordered: false });
187 report.users = docs.length;
188 }
189
190 if (data.notes?.length > 0) {
191 await Note.insertMany(data.notes, { ordered: false });
192 report.notes = data.notes.length;
193 }
194
195 if (data.contributions?.length > 0) {
196 await Contribution.insertMany(data.contributions, { ordered: false });
197 report.contributions = data.contributions.length;
198 }
199
200 log.info("Backup",
201 `Restore complete: ${report.nodes} nodes, ${report.users} users, ${report.notes} notes, ${report.contributions} contributions`
202 );
203
204 return report;
205}
206
207/**
208 * List available backups in the backup directory.
209 */
210export function listBackups(backupPath = "./backups") {
211 if (!fs.existsSync(backupPath)) return [];
212 return fs.readdirSync(backupPath)
213 .filter(f => f.endsWith(".json"))
214 .map(f => {
215 const full = path.join(backupPath, f);
216 const stat = fs.statSync(full);
217 return { file: f, path: full, size: stat.size, modified: stat.mtime.toISOString() };
218 })
219 .sort((a, b) => b.modified.localeCompare(a.modified));
220}
221
1import express from "express";
2import { exportLand, snapshotLand, importLand, listBackups } from "./core.js";
3import { getLandConfigValue } from "../../seed/landConfig.js";
4import { sendOk, sendError, ERR } from "../../seed/protocol.js";
5import log from "../../seed/log.js";
6import path from "path";
7
8const router = express.Router();
9
10// POST /backup/full - trigger full backup
11router.post("/backup/full", async (req, res) => {
12 try {
13 const user = await (await import("../../seed/models/user.js")).default.findById(req.userId).select("isAdmin").lean();
14 if (!user?.isAdmin) return sendError(res, 403, ERR.FORBIDDEN, "Admin required");
15
16 const backupPath = getLandConfigValue("backupPath") || "./backups";
17 const filename = `full-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
18 const outputPath = path.join(backupPath, filename);
19
20 const result = await exportLand({ outputPath });
21 sendOk(res, result);
22 } catch (err) {
23 sendError(res, 500, ERR.INTERNAL, err.message);
24 }
25});
26
27// POST /backup/snapshot - trigger snapshot
28router.post("/backup/snapshot", async (req, res) => {
29 try {
30 const user = await (await import("../../seed/models/user.js")).default.findById(req.userId).select("isAdmin").lean();
31 if (!user?.isAdmin) return sendError(res, 403, ERR.FORBIDDEN, "Admin required");
32
33 const backupPath = getLandConfigValue("backupPath") || "./backups";
34 const filename = `snapshot-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
35 const outputPath = path.join(backupPath, filename);
36
37 const result = await snapshotLand({ outputPath });
38 sendOk(res, result);
39 } catch (err) {
40 sendError(res, 500, ERR.INTERNAL, err.message);
41 }
42});
43
44// POST /backup/restore - restore from backup
45router.post("/backup/restore", async (req, res) => {
46 try {
47 const user = await (await import("../../seed/models/user.js")).default.findById(req.userId).select("isAdmin").lean();
48 if (!user?.isAdmin) return sendError(res, 403, ERR.FORBIDDEN, "Admin required");
49
50 const { file } = req.body;
51 if (!file) return sendError(res, 400, ERR.INVALID_INPUT, "file is required");
52
53 const result = await importLand(file);
54 sendOk(res, result);
55 } catch (err) {
56 sendError(res, 500, ERR.INTERNAL, err.message);
57 }
58});
59
60// GET /backup/list - list available backups
61router.get("/backup/list", async (req, res) => {
62 try {
63 const user = await (await import("../../seed/models/user.js")).default.findById(req.userId).select("isAdmin").lean();
64 if (!user?.isAdmin) return sendError(res, 403, ERR.FORBIDDEN, "Admin required");
65
66 const backupPath = getLandConfigValue("backupPath") || "./backups";
67 const backups = listBackups(backupPath);
68 sendOk(res, { backups });
69 } catch (err) {
70 sendError(res, 500, ERR.INTERNAL, err.message);
71 }
72});
73
74export async function init(core) {
75 // Start automatic snapshot job if configured
76 const interval = parseInt(getLandConfigValue("backupInterval") || "0", 10);
77 if (interval > 0) {
78 const backupPath = getLandConfigValue("backupPath") || "./backups";
79 const timer = setInterval(async () => {
80 try {
81 const filename = `snapshot-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
82 const outputPath = path.join(backupPath, filename);
83 await snapshotLand({ outputPath });
84 } catch (err) {
85 log.error("Backup", "Automatic snapshot failed:", err.message);
86 }
87 }, interval);
88 if (timer.unref) timer.unref();
89 log.verbose("Backup", `Automatic snapshots every ${Math.round(interval / 3600000)}h to ${backupPath}`);
90 }
91
92 // Listen for afterRestore to reinitialize after a restore
93 core.hooks.register("afterBoot", async () => {
94 const restoreInfo = getLandConfigValue("_restoredFrom");
95 if (!restoreInfo) return;
96
97 log.info("Backup", `Post-restore boot detected (restored at ${restoreInfo.restoredAt})`);
98
99 // Rebuild indexes
100 try {
101 const { ensureIndexes } = await import("../../seed/tree/indexes.js");
102 await ensureIndexes();
103 } catch (err) { log.debug("Backup", "Post-restore index rebuild failed:", err.message); }
104
105 // Integrity check
106 try {
107 const { checkIntegrity } = await import("../../seed/tree/integrityCheck.js");
108 await checkIntegrity({ repair: true });
109 } catch (err) { log.debug("Backup", "Post-restore integrity check failed:", err.message); }
110
111 // Invalidate ancestor cache
112 try {
113 const { invalidateAll } = await import("../../seed/tree/ancestorCache.js");
114 invalidateAll();
115 } catch (err) { log.debug("Backup", "Post-restore ancestor cache invalidation failed:", err.message); }
116
117 // Fire afterRestore for other extensions
118 await core.hooks.run("afterRestore", { restoreInfo });
119
120 // Clear the flag
121 const { setLandConfigValue } = await import("../../seed/landConfig.js");
122 await setLandConfigValue("_restoredFrom", null);
123 }, "backup");
124
125 return {
126 router,
127 exports: { exportLand, snapshotLand, importLand, listBackups },
128 };
129}
130
1export default {
2 name: "backup",
3 version: "1.0.1",
4 builtFor: "TreeOS",
5 description:
6 "Two backup modes. Full backup serializes every document in the database to a " +
7 "single JSON file: nodes, users, notes, contributions, chats, LLM connections, " +
8 "canopy peers, extension models. Snapshot backup captures only structure and " +
9 "metadata, omitting note content and chat history, producing a smaller file " +
10 "suitable for frequent automated runs." +
11 "\n\n" +
12 "Restore reads a backup file, validates its format, drops existing collections, " +
13 "and rebuilds the database. After restore the extension triggers index rebuilds, " +
14 "tree integrity checks, ancestor cache invalidation, and fires afterRestore so " +
15 "other extensions can reinitialize. The restore flag is cleared automatically " +
16 "after post-restore boot completes." +
17 "\n\n" +
18 "Automatic snapshots run on a configurable interval when backupInterval is set " +
19 "in land config (milliseconds). Output directory is configurable via backupPath " +
20 "(defaults to ./backups). All operations are admin-only.",
21
22 needs: {
23 models: ["Node", "User", "Note", "Contribution"],
24 },
25 optional: {
26 models: ["Chat"],
27 },
28 provides: {
29 routes: false,
30 hooks: { fires: ["afterRestore"], listens: ["afterBoot"] },
31 cli: [
32 { command: "backup", scope: ["home"], description: "Full backup (all data)", method: "POST", endpoint: "/backup/full", bodyMap: { output: 0 } },
33 { command: "backup-snapshot", scope: ["home"], description: "Snapshot (structure + metadata only)", method: "POST", endpoint: "/backup/snapshot", bodyMap: { output: 0 } },
34 { command: "backup-restore", scope: ["home"], description: "Restore from backup file", method: "POST", endpoint: "/backup/restore", bodyMap: { file: 0 } },
35 { command: "backup-list", scope: ["home"], description: "List available backups", method: "GET", endpoint: "/backup/list" },
36 ],
37 env: [],
38 },
39};
40
Loading comments...