/** * @file Session snapshots: `POST /api/snapshots` freezes a session — its row, * agents, events (oldest first), the SessionOverview stats and the token cost — * into one immutable `session_snapshots` row read back by an unguessable token * (`GET /api/snapshots/:token`). File paths can be redacted at capture, so * nothing redacted is ever stored. Writes are same-origin only. * @author Nguyễn Ngọc Trí Vĩ */ const crypto = require("node:crypto"); const os = require("node:os"); const { Router } = require("express"); const { db, stmts } = require("../db"); const { sameOriginGuard } = require("../lib/same-origin"); const { calculateCost, attachAgentCosts } = require("./pricing"); const { sessionStats } = require("./sessions"); const { bySessionWhere } = require("../lib/scope"); const router = Router(); /** Keys whose string value is a path on this machine — replaced whole. */ const PATH_KEYS = new Set([ "cwd", "transcript_path", "agent_transcript_path", "file_path", "filePath", "notebook_path", "path", ]); const REDACTED = "[redacted]"; /** 24 random bytes, base64url. */ const TOKEN_RE = /^[A-Za-z0-9_-]{32}$/; function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** * A redactor for one session: path-valued keys become `[redacted]`, and the * session's cwd / the home dir inside any other string (a Bash command, a * summary) become `` / `~`. */ function pathRedactor(cwd) { const swaps = [ [cwd, ""], [os.homedir(), "~"], ] .filter(([p]) => typeof p === "string" && p.length > 1) .map(([p, to]) => [new RegExp(`${escapeRe(p)}(?![\\w.-])`, "g"), to]); const str = (s) => swaps.reduce((acc, [re, to]) => acc.replace(re, to), s); const walk = (v, key) => { if (typeof v === "string") return PATH_KEYS.has(key) ? REDACTED : str(v); if (Array.isArray(v)) return v.map((x) => walk(x)); if (v && typeof v === "object") { const out = {}; for (const [k, x] of Object.entries(v)) out[k] = walk(x, k); return out; } return v; }; // A column holding JSON text (events.data, *.metadata) is redacted as JSON. const json = (text) => { if (typeof text !== "string") return text; try { return JSON.stringify(walk(JSON.parse(text))); } catch { return str(text); } }; return { walk, json }; } function capture(sessionId, redactPaths) { const session = stmts.getSession.get(sessionId); if (!session) return null; const agents = attachAgentCosts(stmts.listAgentsBySession.all(sessionId)); const events = stmts.listEventsBySession.all(sessionId).reverse(); const cost = calculateCost( stmts.getTokensBySession.all(sessionId), stmts.listPricing.all(), session.started_at ? String(session.started_at).slice(0, 10) : undefined ); const payload = { version: 1, captured_at: new Date().toISOString(), redacted: { file_paths: redactPaths }, session, agents, events, stats: sessionStats(sessionId), cost, }; if (!redactPaths) return payload; const r = pathRedactor(session.cwd); const row = (x) => { const out = r.walk(x); if ("data" in x) out.data = r.json(x.data); if ("metadata" in x) out.metadata = r.json(x.metadata); return out; }; return { ...payload, session: row(session), agents: agents.map(row), events: events.map(row), }; } // GET /api/snapshots — every snapshot, newest first, without its payload; a // server-mode member gets only the snapshots of their own sessions. router.get("/", (req, res) => { const own = bySessionWhere(req); const snapshots = db .prepare( `SELECT id, token, session_id, created_at, json_extract(payload, '$.session.name') AS session_name, json_extract(payload, '$.redacted.file_paths') AS redacted, json_array_length(payload, '$.events') AS event_count, length(payload) AS bytes FROM session_snapshots WHERE ${own.where} ORDER BY id DESC` ) .all(...own.params) .map((s) => ({ ...s, redacted: s.redacted === 1 })); res.json({ snapshots }); }); // POST /api/snapshots {session_id, redact?: {file_paths?: boolean}} router.post("/", sameOriginGuard, (req, res) => { const sessionId = req.body?.session_id; if (typeof sessionId !== "string" || !sessionId) { return res .status(400) .json({ error: { code: "INVALID_INPUT", message: "session_id is required" } }); } const payload = capture(sessionId, req.body?.redact?.file_paths === true); if (!payload) { return res.status(404).json({ error: { code: "NOT_FOUND", message: "Session not found" } }); } const token = crypto.randomBytes(24).toString("base64url"); const { lastInsertRowid } = db .prepare("INSERT INTO session_snapshots (token, session_id, payload) VALUES (?, ?, ?)") .run(token, sessionId, JSON.stringify(payload)); const row = db .prepare("SELECT id, token, session_id, created_at FROM session_snapshots WHERE id = ?") .get(lastInsertRowid); res.status(201).json({ snapshot: row }); }); // GET /api/snapshots/:token — the frozen payload, sent as stored. router.get("/:token", (req, res) => { const token = req.params.token; const row = TOKEN_RE.test(token) ? db .prepare( "SELECT id, token, session_id, created_at, payload FROM session_snapshots WHERE token = ?" ) .get(token) : null; if (!row) { return res.status(404).json({ error: { code: "NOT_FOUND", message: "Snapshot not found" } }); } const { payload, ...meta } = row; res.type("application/json").send(`{"snapshot":${JSON.stringify(meta)},"payload":${payload}}`); }); // DELETE /api/snapshots/:id router.delete("/:id", sameOriginGuard, (req, res) => { const id = Number(req.params.id); const { changes } = Number.isInteger(id) ? db.prepare("DELETE FROM session_snapshots WHERE id = ?").run(id) : { changes: 0 }; if (!changes) { return res.status(404).json({ error: { code: "NOT_FOUND", message: "Snapshot not found" } }); } res.json({ ok: true }); }); module.exports = router; module.exports.pathRedactor = pathRedactor;