From 5603714429ec21b74b021672cf9ab86599f6dc6f Mon Sep 17 00:00:00 2001 From: Zhongyue Lin <101193087+LeoLin990405@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:02:15 +0800 Subject: [PATCH] feat(snapshots): shareable read-only session snapshots (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture a session's current {session, agents, events, workflows} into an immutable JSON payload addressed by an unguessable 192-bit token, so a specific session state can be shared read-only without granting dashboard access. Addresses #9. Backend (/api/snapshots, read-only, local-first): - Immutable capture: the payload is frozen at create time, so a re-read can never drift or mutate live data. - Redaction applied AT CAPTURE (persisted blob is already clean): opt-in file_paths (session cwd/transcript_path + top-level cwd/path keys inside event.data), event_data, agent_tasks, event_summaries. Internal session/agent `metadata` blobs are NEVER captured (they can carry secrets/paths and aren't meaningful in a shared view). - Expiration + revocation enforced server-side (410, never the payload), computed via a shared status helper that fails closed on a corrupt expires_at. expires_in_hours is bounded to avoid Date overflow. - Audit log: every create / access / revoke / denied access recorded. - Captured events are bounded (cap + truncated flag) and the management list is limited, so one huge session can't produce an unbounded blob. - New tables (snapshots, snapshot_audit) are additive and, like alert_rules, survive Clear Data. Token lookups are fully parameterized. Frontend: - Public read-only Snapshot Viewer at /snapshot/:token (rendered outside the dashboard layout — no nav/websocket): read-only banner, captured-age watermark, session/agents/events, and a clean "no longer available" state for revoked/expired/missing tokens. - One-click Create snapshot from the session detail page (title + redaction checkboxes + optional expiry) with a copyable share link. - Snapshots management page (list, copy link, revoke, delete) + full en/zh/vi i18n. Tests: server/__tests__/snapshots.test.js (21) covers capture, redaction-at- capture, server-side expiry/revoke enforcement, audit accumulation, delete, and the security follow-ups (metadata never exposed, event.data path scrub, oversized-expiry rejection, fail-closed corrupt expiry). Regenerated the Session-detail screen snapshot baseline for the new Share button. --- client/src/App.tsx | 6 + client/src/components/CreateSnapshotModal.tsx | 356 ++++++++++++++ client/src/components/Sidebar.tsx | 2 + client/src/i18n/index.ts | 7 + client/src/i18n/locales/en/nav.json | 1 + client/src/i18n/locales/en/snapshots.json | 84 ++++ client/src/i18n/locales/vi/nav.json | 1 + client/src/i18n/locales/vi/snapshots.json | 84 ++++ client/src/i18n/locales/zh/nav.json | 1 + client/src/i18n/locales/zh/snapshots.json | 84 ++++ client/src/lib/api.ts | 38 ++ client/src/lib/types.ts | 54 +++ client/src/pages/SessionDetail.tsx | 28 +- client/src/pages/SnapshotViewer.tsx | 268 +++++++++++ client/src/pages/Snapshots.tsx | 237 ++++++++++ .../screens.snapshot.test.tsx.snap | 89 ++-- server/__tests__/snapshots.test.js | 439 ++++++++++++++++++ server/db.js | 32 ++ server/index.js | 2 + server/lib/snapshots.js | 156 +++++++ server/routes/snapshots.js | 335 +++++++++++++ 21 files changed, 2274 insertions(+), 30 deletions(-) create mode 100644 client/src/components/CreateSnapshotModal.tsx create mode 100644 client/src/i18n/locales/en/snapshots.json create mode 100644 client/src/i18n/locales/vi/snapshots.json create mode 100644 client/src/i18n/locales/zh/snapshots.json create mode 100644 client/src/pages/SnapshotViewer.tsx create mode 100644 client/src/pages/Snapshots.tsx create mode 100644 server/__tests__/snapshots.test.js create mode 100644 server/lib/snapshots.js create mode 100644 server/routes/snapshots.js diff --git a/client/src/App.tsx b/client/src/App.tsx index ffb481e9..a268481b 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -17,6 +17,8 @@ import { Workflows } from "./pages/Workflows"; import { Settings } from "./pages/Settings"; import { CcConfig } from "./pages/CcConfig"; import { Run } from "./pages/Run"; +import { Snapshots } from "./pages/Snapshots"; +import { SnapshotViewer } from "./pages/SnapshotViewer"; import { NotFound } from "./pages/NotFound"; import { useWebSocket } from "./hooks/useWebSocket"; import { useNotifications } from "./hooks/useNotifications"; @@ -34,6 +36,9 @@ export default function App() { return ( + {/* Public, read-only snapshot viewer — intentionally OUTSIDE the Layout + wrapper so it renders with no sidebar / nav / live websocket. */} + } /> }> } /> } /> @@ -44,6 +49,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/client/src/components/CreateSnapshotModal.tsx b/client/src/components/CreateSnapshotModal.tsx new file mode 100644 index 00000000..6f5833e2 --- /dev/null +++ b/client/src/components/CreateSnapshotModal.tsx @@ -0,0 +1,356 @@ +/** + * @file CreateSnapshotModal.tsx + * @description Modal for capturing a session as a shareable read-only snapshot. + * Lets the author set a title, choose redactions (fetched lazily from + * /api/snapshots/options when the modal opens), and pick a link expiry. On + * submit it POSTs to /api/snapshots and surfaces the resulting shareable link + * with a copy button. Mirrors the app's existing modal patterns (Escape / + * click-outside cancel, surface-1 card, btn-primary actions). + * @author Son Nguyen + */ + +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Camera, Check, Copy, ExternalLink, X } from "lucide-react"; +import { api } from "../lib/api"; +import type { RedactionKey, RedactionOption } from "../lib/types"; + +type ExpiryChoice = "never" | "1h" | "24h" | "7d"; + +const EXPIRY_HOURS: Record = { + never: undefined, + "1h": 1, + "24h": 24, + "7d": 24 * 7, +}; + +const EXPIRY_ORDER: ExpiryChoice[] = ["never", "1h", "24h", "7d"]; + +function snapshotUrl(token: string): string { + const origin = typeof window !== "undefined" ? window.location.origin : ""; + return `${origin}/snapshot/${token}`; +} + +interface CreateSnapshotModalProps { + open: boolean; + sessionId: string; + onClose: () => void; + /** Called after a snapshot is successfully created so callers can refresh lists. */ + onCreated?: () => void; +} + +export function CreateSnapshotModal({ + open, + sessionId, + onClose, + onCreated, +}: CreateSnapshotModalProps) { + const { t } = useTranslation("snapshots"); + const [title, setTitle] = useState(""); + const [options, setOptions] = useState([]); + const [selected, setSelected] = useState>(() => new Set()); + const [expiry, setExpiry] = useState("never"); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [createdToken, setCreatedToken] = useState(null); + const [copied, setCopied] = useState(false); + + // Reset transient state and lazily fetch redaction options each time the + // modal opens. Fetching here (not on mount) keeps the parent page's render + // path free of /api/snapshots calls. + useEffect(() => { + if (!open) return; + setTitle(""); + setSelected(new Set()); + setExpiry("never"); + setSubmitting(false); + setError(null); + setCreatedToken(null); + setCopied(false); + + let cancelled = false; + // Defensive: test mocks may not include the snapshots namespace. + if (api.snapshots && typeof api.snapshots.options === "function") { + api.snapshots + .options() + .then((opts) => { + if (!cancelled) setOptions(opts); + }) + .catch(() => { + if (!cancelled) setOptions([]); + }); + } + return () => { + cancelled = true; + }; + }, [open]); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open, onClose]); + + if (!open) return null; + + const toggleRedaction = (key: RedactionKey) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + const handleSubmit = async () => { + if (submitting) return; + setSubmitting(true); + setError(null); + try { + const redactions = Array.from(selected); + const snapshot = await api.snapshots.create({ + session_id: sessionId, + title: title.trim() || undefined, + redactions: redactions.length > 0 ? redactions : undefined, + expires_in_hours: EXPIRY_HOURS[expiry], + }); + setCreatedToken(snapshot.token); + onCreated?.(); + } catch (err) { + setError(err instanceof Error ? err.message : t("modal.error")); + } finally { + setSubmitting(false); + } + }; + + const handleCopy = async () => { + if (!createdToken) return; + try { + await navigator.clipboard.writeText(snapshotUrl(createdToken)); + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + } catch { + /* clipboard unavailable — ignore */ + } + }; + + const reset = () => { + setCreatedToken(null); + setCopied(false); + setTitle(""); + setSelected(new Set()); + setExpiry("never"); + setError(null); + }; + + const link = createdToken ? snapshotUrl(createdToken) : ""; + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+ +
+
+

{t("modal.title")}

+

+ {createdToken ? t("modal.successDesc") : t("modal.subtitle")} +

+
+ +
+ + {/* Body */} +
+ {createdToken ? ( +
+
+ + {t("modal.successTitle")} +
+ +
+ e.currentTarget.select()} + className="input w-full font-mono text-xs" + /> + +
+ + {t("viewer.readOnly")} + + +
+ ) : ( + <> + {/* Title */} +
+ + setTitle(e.target.value)} + placeholder={t("modal.titlePlaceholder")} + className="input w-full" + /> +
+ + {/* Redactions */} +
+ +

+ {t("modal.redactionsHint")} +

+ {options.length === 0 ? ( +

+ {t("modal.noRedactionOptions")} +

+ ) : ( +
+ {options.map((opt) => { + const checked = selected.has(opt.key); + return ( + + ); + })} +
+ )} +
+ + {/* Expiry */} +
+ + +
+ + {error && ( +

+ {error} +

+ )} + + )} +
+ + {/* Footer */} +
+ {createdToken ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+
+ ); +} diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index b3330340..00bd0152 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -17,6 +17,7 @@ import { Workflow, Boxes, Play, + Camera, Settings, Wifi, WifiOff, @@ -51,6 +52,7 @@ const NAV_KEYS = [ { to: "/workflows", icon: Workflow, key: "nav:workflows" }, { to: "/cc-config", icon: Boxes, key: "nav:ccConfig" }, { to: "/run", icon: Play, key: "nav:run" }, + { to: "/snapshots", icon: Camera, key: "nav:snapshots" }, { to: "/settings", icon: Settings, key: "nav:settings" }, ] as const; diff --git a/client/src/i18n/index.ts b/client/src/i18n/index.ts index 3129e0d7..1d4ddc3c 100644 --- a/client/src/i18n/index.ts +++ b/client/src/i18n/index.ts @@ -50,6 +50,9 @@ import run_vi from "./locales/vi/run.json"; import alerts_en from "./locales/en/alerts.json"; import alerts_zh from "./locales/zh/alerts.json"; import alerts_vi from "./locales/vi/alerts.json"; +import snapshots_en from "./locales/en/snapshots.json"; +import snapshots_zh from "./locales/zh/snapshots.json"; +import snapshots_vi from "./locales/vi/snapshots.json"; i18n .use(LanguageDetector) @@ -71,6 +74,7 @@ i18n ccConfig: ccConfig_en, run: run_en, alerts: alerts_en, + snapshots: snapshots_en, }, zh: { common: common_zh, @@ -87,6 +91,7 @@ i18n ccConfig: ccConfig_zh, run: run_zh, alerts: alerts_zh, + snapshots: snapshots_zh, }, vi: { common: common_vi, @@ -103,6 +108,7 @@ i18n ccConfig: ccConfig_vi, run: run_vi, alerts: alerts_vi, + snapshots: snapshots_vi, }, }, supportedLngs: ["en", "zh", "vi"], @@ -123,6 +129,7 @@ i18n "ccConfig", "run", "alerts", + "snapshots", ], defaultNS: "common", interpolation: { escapeValue: false }, diff --git a/client/src/i18n/locales/en/nav.json b/client/src/i18n/locales/en/nav.json index 8f89b09c..6602e237 100644 --- a/client/src/i18n/locales/en/nav.json +++ b/client/src/i18n/locales/en/nav.json @@ -10,6 +10,7 @@ "alerts": "Alerts", "ccConfig": "Claude Config", "run": "Run Claude", + "snapshots": "Snapshots", "settings": "Settings", "language": "Language", "github": "GitHub", diff --git a/client/src/i18n/locales/en/snapshots.json b/client/src/i18n/locales/en/snapshots.json new file mode 100644 index 00000000..e97709b1 --- /dev/null +++ b/client/src/i18n/locales/en/snapshots.json @@ -0,0 +1,84 @@ +{ + "title": "Snapshots", + "subtitle": "Shareable read-only snapshots of sessions", + "create": "Create snapshot", + "share": "Share", + "refresh": "Refresh", + "empty": "No snapshots yet", + "emptyDesc": "Create a snapshot from a session to share a read-only view that doesn't change as the session continues.", + "table": { + "title": "Title", + "session": "Session", + "created": "Created", + "status": "Status", + "views": "Views", + "actions": "Actions" + }, + "untitled": "Untitled snapshot", + "viewSession": "View session", + "copyLink": "Copy link", + "copied": "Copied", + "revoke": "Revoke", + "delete": "Delete", + "status": { + "active": "Active", + "expired": "Expired", + "revoked": "Revoked" + }, + "confirmRevoke": { + "title": "Revoke this snapshot?", + "message": "Anyone with the link will immediately lose access. This cannot be undone, but the snapshot record is kept.", + "confirm": "Revoke" + }, + "confirmDelete": { + "title": "Delete this snapshot?", + "message": "The snapshot and its share link are permanently removed. This cannot be undone.", + "confirm": "Delete" + }, + "modal": { + "title": "Create snapshot", + "subtitle": "Capture this session as a read-only page you can share by link.", + "titleLabel": "Title", + "titlePlaceholder": "Optional — defaults to the session name", + "redactions": "Redact data", + "redactionsHint": "Hide sensitive data from the shared view. Redacted fields are stripped at capture time.", + "noRedactionOptions": "No redaction options available.", + "expiry": "Link expiry", + "expiryOptions": { + "never": "Never expires", + "1h": "1 hour", + "24h": "24 hours", + "7d": "7 days" + }, + "cancel": "Cancel", + "submit": "Create snapshot", + "submitting": "Creating…", + "createAnother": "Create another", + "done": "Done", + "successTitle": "Snapshot ready", + "successDesc": "Share this read-only link. It won't change as the session continues.", + "shareLink": "Share link", + "error": "Could not create the snapshot. Please try again." + }, + "viewer": { + "readOnly": "Read-only snapshot", + "readOnlyDesc": "This is a captured, shareable view of a session. It does not update live and has no controls.", + "captured": "Snapshot · captured {{age}}", + "capturedAt": "Captured {{datetime}}", + "redactedNote": "Some data was redacted by the author:", + "session": "Session", + "agents": "Agents", + "events": "Recent events", + "noAgents": "No agents in this snapshot.", + "noEvents": "No events in this snapshot.", + "unavailableTitle": "This snapshot is no longer available", + "unavailableDesc": "The link may have been revoked, expired, or never existed.", + "loading": "Loading snapshot…" + }, + "redactions": { + "file_paths": "File paths", + "event_data": "Event payloads", + "agent_tasks": "Agent tasks", + "event_summaries": "Event summaries" + } +} diff --git a/client/src/i18n/locales/vi/nav.json b/client/src/i18n/locales/vi/nav.json index 49fe0a1e..5c4fa26c 100644 --- a/client/src/i18n/locales/vi/nav.json +++ b/client/src/i18n/locales/vi/nav.json @@ -10,6 +10,7 @@ "alerts": "Cảnh báo", "ccConfig": "Cấu hình Claude", "run": "Chạy Claude", + "snapshots": "Ảnh chụp", "settings": "Cài đặt", "language": "Ngôn ngữ", "github": "GitHub", diff --git a/client/src/i18n/locales/vi/snapshots.json b/client/src/i18n/locales/vi/snapshots.json new file mode 100644 index 00000000..1043f50f --- /dev/null +++ b/client/src/i18n/locales/vi/snapshots.json @@ -0,0 +1,84 @@ +{ + "title": "Ảnh chụp", + "subtitle": "Ảnh chụp phiên chỉ đọc có thể chia sẻ", + "create": "Tạo ảnh chụp", + "share": "Chia sẻ", + "refresh": "Làm mới", + "empty": "Chưa có ảnh chụp nào", + "emptyDesc": "Tạo ảnh chụp từ một phiên để chia sẻ một chế độ xem chỉ đọc không thay đổi khi phiên tiếp tục.", + "table": { + "title": "Tiêu đề", + "session": "Phiên", + "created": "Tạo lúc", + "status": "Trạng thái", + "views": "Lượt xem", + "actions": "Hành động" + }, + "untitled": "Ảnh chụp không tiêu đề", + "viewSession": "Xem phiên", + "copyLink": "Sao chép liên kết", + "copied": "Đã sao chép", + "revoke": "Thu hồi", + "delete": "Xóa", + "status": { + "active": "Đang hoạt động", + "expired": "Đã hết hạn", + "revoked": "Đã thu hồi" + }, + "confirmRevoke": { + "title": "Thu hồi ảnh chụp này?", + "message": "Bất kỳ ai có liên kết sẽ mất quyền truy cập ngay lập tức. Không thể hoàn tác, nhưng bản ghi ảnh chụp vẫn được giữ lại.", + "confirm": "Thu hồi" + }, + "confirmDelete": { + "title": "Xóa ảnh chụp này?", + "message": "Ảnh chụp và liên kết chia sẻ của nó sẽ bị xóa vĩnh viễn. Không thể hoàn tác.", + "confirm": "Xóa" + }, + "modal": { + "title": "Tạo ảnh chụp", + "subtitle": "Chụp lại phiên này thành một trang chỉ đọc mà bạn có thể chia sẻ bằng liên kết.", + "titleLabel": "Tiêu đề", + "titlePlaceholder": "Tùy chọn — mặc định dùng tên phiên", + "redactions": "Ẩn dữ liệu", + "redactionsHint": "Ẩn dữ liệu nhạy cảm khỏi chế độ xem được chia sẻ. Các trường bị ẩn được loại bỏ tại thời điểm chụp.", + "noRedactionOptions": "Không có tùy chọn ẩn dữ liệu nào.", + "expiry": "Hết hạn liên kết", + "expiryOptions": { + "never": "Không bao giờ hết hạn", + "1h": "1 giờ", + "24h": "24 giờ", + "7d": "7 ngày" + }, + "cancel": "Hủy", + "submit": "Tạo ảnh chụp", + "submitting": "Đang tạo…", + "createAnother": "Tạo cái khác", + "done": "Xong", + "successTitle": "Ảnh chụp đã sẵn sàng", + "successDesc": "Chia sẻ liên kết chỉ đọc này. Nó sẽ không thay đổi khi phiên tiếp tục.", + "shareLink": "Liên kết chia sẻ", + "error": "Không thể tạo ảnh chụp. Vui lòng thử lại." + }, + "viewer": { + "readOnly": "Ảnh chụp chỉ đọc", + "readOnlyDesc": "Đây là chế độ xem đã chụp, có thể chia sẻ của một phiên. Nó không cập nhật trực tiếp và không có nút điều khiển.", + "captured": "Ảnh chụp · đã chụp {{age}}", + "capturedAt": "Đã chụp {{datetime}}", + "redactedNote": "Một số dữ liệu đã được tác giả ẩn đi:", + "session": "Phiên", + "agents": "Tác nhân", + "events": "Sự kiện gần đây", + "noAgents": "Không có tác nhân nào trong ảnh chụp này.", + "noEvents": "Không có sự kiện nào trong ảnh chụp này.", + "unavailableTitle": "Ảnh chụp này không còn khả dụng", + "unavailableDesc": "Liên kết có thể đã bị thu hồi, hết hạn hoặc chưa từng tồn tại.", + "loading": "Đang tải ảnh chụp…" + }, + "redactions": { + "file_paths": "Đường dẫn tệp", + "event_data": "Dữ liệu sự kiện", + "agent_tasks": "Nhiệm vụ tác nhân", + "event_summaries": "Tóm tắt sự kiện" + } +} diff --git a/client/src/i18n/locales/zh/nav.json b/client/src/i18n/locales/zh/nav.json index bbd0e565..38dc83bb 100644 --- a/client/src/i18n/locales/zh/nav.json +++ b/client/src/i18n/locales/zh/nav.json @@ -10,6 +10,7 @@ "alerts": "警报", "ccConfig": "Claude 配置", "run": "运行 Claude", + "snapshots": "快照", "settings": "设置", "language": "语言", "github": "GitHub", diff --git a/client/src/i18n/locales/zh/snapshots.json b/client/src/i18n/locales/zh/snapshots.json new file mode 100644 index 00000000..09c5807b --- /dev/null +++ b/client/src/i18n/locales/zh/snapshots.json @@ -0,0 +1,84 @@ +{ + "title": "快照", + "subtitle": "可分享的会话只读快照", + "create": "创建快照", + "share": "分享", + "refresh": "刷新", + "empty": "暂无快照", + "emptyDesc": "从会话创建快照,即可分享一个不会随会话继续而变化的只读视图。", + "table": { + "title": "标题", + "session": "会话", + "created": "创建时间", + "status": "状态", + "views": "浏览次数", + "actions": "操作" + }, + "untitled": "未命名快照", + "viewSession": "查看会话", + "copyLink": "复制链接", + "copied": "已复制", + "revoke": "撤销", + "delete": "删除", + "status": { + "active": "有效", + "expired": "已过期", + "revoked": "已撤销" + }, + "confirmRevoke": { + "title": "撤销此快照?", + "message": "持有链接的任何人将立即失去访问权限。此操作无法撤销,但快照记录会被保留。", + "confirm": "撤销" + }, + "confirmDelete": { + "title": "删除此快照?", + "message": "快照及其分享链接将被永久删除。此操作无法撤销。", + "confirm": "删除" + }, + "modal": { + "title": "创建快照", + "subtitle": "将此会话捕获为可通过链接分享的只读页面。", + "titleLabel": "标题", + "titlePlaceholder": "可选 — 默认使用会话名称", + "redactions": "脱敏数据", + "redactionsHint": "在分享视图中隐藏敏感数据。脱敏字段会在捕获时被剔除。", + "noRedactionOptions": "暂无可用的脱敏选项。", + "expiry": "链接有效期", + "expiryOptions": { + "never": "永不过期", + "1h": "1 小时", + "24h": "24 小时", + "7d": "7 天" + }, + "cancel": "取消", + "submit": "创建快照", + "submitting": "正在创建…", + "createAnother": "再创建一个", + "done": "完成", + "successTitle": "快照已就绪", + "successDesc": "分享此只读链接。它不会随会话继续而变化。", + "shareLink": "分享链接", + "error": "无法创建快照,请重试。" + }, + "viewer": { + "readOnly": "只读快照", + "readOnlyDesc": "这是会话的已捕获、可分享视图。它不会实时更新,也没有任何控件。", + "captured": "快照 · 捕获于 {{age}}", + "capturedAt": "捕获于 {{datetime}}", + "redactedNote": "作者对部分数据进行了脱敏:", + "session": "会话", + "agents": "智能体", + "events": "近期事件", + "noAgents": "此快照中没有智能体。", + "noEvents": "此快照中没有事件。", + "unavailableTitle": "此快照已不可用", + "unavailableDesc": "该链接可能已被撤销、已过期或从未存在。", + "loading": "正在加载快照…" + }, + "redactions": { + "file_paths": "文件路径", + "event_data": "事件载荷", + "agent_tasks": "智能体任务", + "event_summaries": "事件摘要" + } +} diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 5858ebdc..0a24d109 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -12,9 +12,15 @@ import type { CostResult, DashboardEvent, ModelPricing, + PublicSnapshot, + RedactionKey, + RedactionOption, Session, SessionDrillIn, SessionStats, + SnapshotAuditEntry, + SnapshotMeta, + SnapshotPayload, Stats, TranscriptListResult, TranscriptResult, @@ -464,6 +470,38 @@ export const api = { ); }, }, + + // Shareable read-only session snapshots (issue: session-snapshots). Every + // server response is enveloped; we unwrap here so callers get the bare value. + snapshots: { + create: (body: { + session_id: string; + title?: string; + redactions?: RedactionKey[]; + expires_in_hours?: number; + }) => + request<{ snapshot: SnapshotMeta }>("/snapshots", { + method: "POST", + body: JSON.stringify(body), + }).then((r) => r.snapshot), + list: () => request<{ snapshots: SnapshotMeta[] }>("/snapshots").then((r) => r.snapshots), + options: () => + request<{ redactions: RedactionOption[] }>("/snapshots/options").then((r) => r.redactions), + get: (token: string) => + request<{ snapshot: PublicSnapshot; payload: SnapshotPayload }>( + `/snapshots/${encodeURIComponent(token)}` + ), + revoke: (token: string) => + request<{ snapshot: SnapshotMeta }>(`/snapshots/${encodeURIComponent(token)}/revoke`, { + method: "POST", + }).then((r) => r.snapshot), + remove: (token: string) => + request<{ ok: true }>(`/snapshots/${encodeURIComponent(token)}`, { method: "DELETE" }), + audit: (token: string) => + request<{ audit: SnapshotAuditEntry[] }>( + `/snapshots/${encodeURIComponent(token)}/audit` + ).then((r) => r.audit), + }, }; function requestBackupsHelper(params?: { scope?: "user" | "project"; type?: CcArtifactType }) { diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index e21437f7..edee8be2 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -665,6 +665,60 @@ export interface WorkflowRunDetail { events: DashboardEvent[]; } +// ── Session snapshots (shareable read-only views) ── + +/** Categories of data a snapshot author can redact before sharing. */ +export type RedactionKey = "file_paths" | "event_data" | "agent_tasks" | "event_summaries"; + +/** Lifecycle of a shared snapshot from the author's point of view. */ +export type SnapshotStatus = "active" | "expired" | "revoked"; + +/** Author-facing snapshot record returned by the management endpoints. */ +export interface SnapshotMeta { + token: string; + session_id: string; + title: string | null; + created_at: string; + expires_at: string | null; + revoked_at: string | null; + view_count: number; + redactions: RedactionKey[]; + status: SnapshotStatus; +} + +/** The captured-at-time payload embedded in a snapshot. Reuses the live types. */ +export interface SnapshotPayload { + captured_at: string; + session: Session; + agents: Agent[]; + events: DashboardEvent[]; + workflows: WorkflowRun[]; +} + +/** Trimmed metadata exposed on the public (read-only) viewer route. */ +export interface PublicSnapshot { + token: string; + title: string | null; + captured_at: string; + age_seconds: number; + redactions: RedactionKey[]; + read_only: true; +} + +/** A single audit-log entry for a snapshot's lifecycle / access events. */ +export interface SnapshotAuditEntry { + id: number; + action: "create" | "access" | "revoke" | "access_denied"; + detail: string | null; + created_at: string; +} + +/** A selectable redaction option (key + human label) from the options endpoint. */ +export interface RedactionOption { + key: RedactionKey; + label: string; +} + export const STATUS_CONFIG: Record< EffectiveAgentStatus, { labelKey: string; color: string; bg: string; dot: string } diff --git a/client/src/pages/SessionDetail.tsx b/client/src/pages/SessionDetail.tsx index 2f1b9e06..b6a4fab2 100644 --- a/client/src/pages/SessionDetail.tsx +++ b/client/src/pages/SessionDetail.tsx @@ -9,6 +9,7 @@ import { useTranslation } from "react-i18next"; import { ArrowLeft, Bot, + Camera, Clock, FolderOpen, Cpu, @@ -67,6 +68,7 @@ import type { WorkflowRun, } from "../lib/types"; import { WorkflowRunsPanel } from "../components/workflows/WorkflowRunsPanel"; +import { CreateSnapshotModal } from "../components/CreateSnapshotModal"; type DetailTab = "agents" | "conversation" | "timeline"; @@ -110,6 +112,8 @@ export function SessionDetail() { const [transcriptNotFound, setTranscriptNotFound] = useState(false); const notFoundTimerRef = useRef | null>(null); const [expandedEvents, setExpandedEvents] = useState>(() => new Set()); + // Controls the "Create snapshot / Share" modal. Opened from the header. + const [snapshotModalOpen, setSnapshotModalOpen] = useState(false); function toggleEvent(id: number) { setExpandedEvents((prev) => { @@ -560,11 +564,29 @@ export function SessionDetail() { )} - +
+ + +
+ {id && ( + setSnapshotModalOpen(false)} + /> + )} + {isDashboardRun && ( + */ + +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { Activity, Bot, Clock, Cpu, EyeOff, FolderOpen, Lock, ShieldAlert } from "lucide-react"; +import { api } from "../lib/api"; +import { SessionStatusBadge, AgentStatusBadge } from "../components/StatusBadge"; +import { effectiveSessionStatus, effectiveAgentStatus } from "../lib/types"; +import { Skeleton } from "../components/Skeleton"; +import { + formatDateShort, + formatDateTime, + formatDateTimeFull, + formatModelName, +} from "../lib/format"; +import type { PublicSnapshot, SnapshotPayload, RedactionKey } from "../lib/types"; + +/** Human "captured Nh ago" from a server-supplied age in seconds. */ +function formatAge( + ageSeconds: number, + t: (key: string, opts?: Record) => string +): string { + const sec = Math.max(0, Math.floor(ageSeconds)); + if (sec < 60) return t("common:time.justNow"); + const min = Math.floor(sec / 60); + if (min < 60) return t("common:time.mAgo", { count: min }); + const hr = Math.floor(min / 60); + if (hr < 24) return t("common:time.hAgo", { count: hr }); + const days = Math.floor(hr / 24); + return t("common:time.dAgo", { count: days }); +} + +export function SnapshotViewer() { + const { token } = useParams<{ token: string }>(); + const { t } = useTranslation("snapshots"); + const [snapshot, setSnapshot] = useState(null); + const [payload, setPayload] = useState(null); + const [loading, setLoading] = useState(true); + const [unavailable, setUnavailable] = useState(false); + + useEffect(() => { + if (!token) { + setUnavailable(true); + setLoading(false); + return; + } + let cancelled = false; + setLoading(true); + setUnavailable(false); + api.snapshots + .get(token) + .then((res) => { + if (cancelled) return; + setSnapshot(res.snapshot); + setPayload(res.payload); + }) + .catch(() => { + // 410 (revoked/expired) and 404 (missing) both surface here via the + // shared request helper. Show a single friendly "unavailable" state. + if (!cancelled) setUnavailable(true); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [token]); + + if (loading) { + return ( +
+
+ + +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ +
+
+ ); + } + + if (unavailable || !snapshot || !payload) { + return ( +
+
+
+ +
+

+ {t("viewer.unavailableTitle")} +

+

{t("viewer.unavailableDesc")}

+
+
+ ); + } + + const { session, agents, events } = payload; + const redactions = snapshot.redactions; + + return ( +
+ {/* Read-only banner — prominent, sticky at the top of the page. */} +
+
+ + + +
+

{t("viewer.readOnly")}

+

{t("viewer.readOnlyDesc")}

+
+ + + {t("viewer.captured", { age: formatAge(snapshot.age_seconds, t) })} + +
+
+ +
+ {/* Session summary */} +
+
+

+ {snapshot.title || session.name || `Session ${session.id.slice(0, 8)}`} +

+ +
+
+ + {session.id.slice(0, 16)} + + {session.model && ( + + + {formatModelName(session.model)} + + )} + + + {formatDateTime(session.started_at)} + +
+ {session.cwd && !redactions.includes("file_paths") && ( +
+ + {session.cwd} +
+ )} +
+ + {/* Redaction note */} + {redactions.length > 0 && ( +
+ +
+

{t("viewer.redactedNote")}

+
+ {redactions.map((key: RedactionKey) => ( + + {t(`redactions.${key}`)} + + ))} +
+
+
+ )} + + {/* Agents */} +
+

+ + {t("viewer.agents")} + · {agents.length} +

+ {agents.length === 0 ? ( +

{t("viewer.noAgents")}

+ ) : ( +
+ {agents.map((agent) => ( +
+ +
+

+ {agent.name || agent.subagent_type || agent.id.slice(0, 8)} +

+ {agent.task && !redactions.includes("agent_tasks") && ( +

+ {agent.task} +

+ )} +
+ {agent.subagent_type && ( + + {agent.subagent_type} + + )} +
+ ))} +
+ )} +
+ + {/* Events */} +
+

+ + {t("viewer.events")} + · {events.length} +

+ {events.length === 0 ? ( +

{t("viewer.noEvents")}

+ ) : ( +
+
+ {events.map((event) => ( +
+
+
+ {formatDateShort(event.created_at)} +
+
+ + {event.event_type} + + + {redactions.includes("event_summaries") + ? "" + : (event.summary ?? event.tool_name ?? "")} + + {event.tool_name && ( + + {event.tool_name} + + )} +
+ ))} +
+
+ )} +
+
+
+ ); +} diff --git a/client/src/pages/Snapshots.tsx b/client/src/pages/Snapshots.tsx new file mode 100644 index 00000000..f0a0cc3c --- /dev/null +++ b/client/src/pages/Snapshots.tsx @@ -0,0 +1,237 @@ +/** + * @file Snapshots.tsx + * @description Management list for shareable read-only session snapshots. Shows + * a table of every snapshot (title, source session link, created time, status + * badge, view count) with per-row Copy link / Revoke / Delete actions. Revoke + * and Delete are guarded by a ConfirmModal. Empty state guides the user to + * create a snapshot from a session detail page. + * @author Son Nguyen + */ + +import { useCallback, useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { Camera, Check, Copy, ExternalLink, Eye, RefreshCw, Trash2, XCircle } from "lucide-react"; +import { api } from "../lib/api"; +import { EmptyState } from "../components/EmptyState"; +import { TableRowSkeleton } from "../components/Skeleton"; +import { ConfirmModal } from "../components/ConfirmModal"; +import { formatDateTime } from "../lib/format"; +import type { SnapshotMeta, SnapshotStatus } from "../lib/types"; + +const STATUS_STYLES: Record = { + active: "text-emerald-400 bg-emerald-500/10 border-emerald-500/20", + expired: "text-slate-400 bg-slate-500/10 border-slate-500/20", + revoked: "text-red-400 bg-red-500/10 border-red-500/20", +}; + +function snapshotUrl(token: string): string { + const origin = typeof window !== "undefined" ? window.location.origin : ""; + return `${origin}/snapshot/${token}`; +} + +type PendingAction = { type: "revoke" | "delete"; token: string } | null; + +export function Snapshots() { + const { t } = useTranslation("snapshots"); + const [snapshots, setSnapshots] = useState([]); + const [loading, setLoading] = useState(true); + const [copiedToken, setCopiedToken] = useState(null); + const [pending, setPending] = useState(null); + const [busy, setBusy] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + try { + const items = await api.snapshots.list(); + setSnapshots(items); + } catch (err) { + console.error("Failed to load snapshots:", err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const copyLink = useCallback(async (token: string) => { + try { + await navigator.clipboard.writeText(snapshotUrl(token)); + setCopiedToken(token); + window.setTimeout(() => setCopiedToken((cur) => (cur === token ? null : cur)), 1500); + } catch { + /* clipboard unavailable — ignore */ + } + }, []); + + const confirmAction = useCallback(async () => { + if (!pending) return; + setBusy(true); + try { + if (pending.type === "revoke") { + const updated = await api.snapshots.revoke(pending.token); + setSnapshots((prev) => prev.map((s) => (s.token === updated.token ? updated : s))); + } else { + await api.snapshots.remove(pending.token); + setSnapshots((prev) => prev.filter((s) => s.token !== pending.token)); + } + setPending(null); + } catch (err) { + console.error("Snapshot action failed:", err); + } finally { + setBusy(false); + } + }, [pending]); + + return ( +
+
+
+
+ +
+
+

{t("title")}

+

{t("subtitle")}

+
+
+ +
+ + {!loading && snapshots.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + {loading && snapshots.length === 0 + ? Array.from({ length: 6 }).map((_, i) => ( + + )) + : null} + {snapshots.map((snap) => ( + + + + + + + + + ))} + +
+ {t("table.title")} + + {t("table.session")} + + {t("table.created")} + + {t("table.status")} + + {t("table.views")} + + {t("table.actions")} +
+

+ {snap.title || t("untitled")} +

+

{snap.token.slice(0, 12)}

+
+ + {snap.session_id.slice(0, 12)} + + + + {formatDateTime(snap.created_at)} + + + {t(`status.${snap.status}`)} + + + + + {snap.view_count} + + +
+ + {snap.status === "active" && ( + + )} + +
+
+
+ )} + + setPending(null)} + /> +
+ ); +} diff --git a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap index 1c136e57..ba0722f1 100644 --- a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap +++ b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap @@ -6551,35 +6551,70 @@ exports[`screen snapshots > Session detail 1`] = ` - + + + + + + + +
+ */ + +const { describe, it, before, after } = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("path"); +const os = require("os"); +const http = require("http"); + +// Set up test database BEFORE requiring any server modules +const TEST_DB = path.join(os.tmpdir(), `dashboard-snapshots-test-${Date.now()}-${process.pid}.db`); +process.env.DASHBOARD_DB_PATH = TEST_DB; + +const { createApp, startServer } = require("../index"); +const { db } = require("../db"); + +let server; +let BASE; + +function fetch(urlPath, options = {}) { + return new Promise((resolve, reject) => { + const url = new URL(urlPath, BASE); + const opts = { + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, + method: options.method || "GET", + headers: { "Content-Type": "application/json", ...options.headers }, + }; + + const req = http.request(opts, (res) => { + let body = ""; + res.on("data", (chunk) => (body += chunk)); + res.on("end", () => { + let parsed; + try { + parsed = JSON.parse(body); + } catch { + parsed = body; + } + resolve({ status: res.statusCode, body: parsed, headers: res.headers }); + }); + }); + + req.on("error", reject); + if (options.body) req.write(JSON.stringify(options.body)); + req.end(); + }); +} + +function post(urlPath, body) { + return fetch(urlPath, { method: "POST", body }); +} + +function del(urlPath) { + return fetch(urlPath, { method: "DELETE" }); +} + +/** + * Seed a session plus one agent (with a task) and one event (with data + + * summary) so redaction assertions have something to null out. Returns the + * session id. + */ +function seedSession(suffix) { + const sessionId = `snap-${suffix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + db.prepare( + "INSERT INTO sessions (id, name, status, cwd, model, started_at, updated_at, transcript_path) VALUES (?, ?, 'active', ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now'), ?)" + ).run(sessionId, "Seed session", "/Users/leo/secret-project", "claude-opus-4-8", "/tmp/t.jsonl"); + db.prepare( + "INSERT INTO agents (id, session_id, name, type, status, task, started_at, updated_at) VALUES (?, ?, ?, 'main', 'working', ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now'))" + ).run(`${sessionId}-main`, sessionId, "Main Agent", "Do the secret thing"); + db.prepare( + "INSERT INTO events (session_id, agent_id, event_type, tool_name, summary, data, created_at) VALUES (?, ?, 'PreToolUse', 'Bash', ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))" + ).run( + `${sessionId}`, + `${sessionId}-main`, + "ran a command", + JSON.stringify({ cmd: "ls /secret" }) + ); + return sessionId; +} + +before(async () => { + const app = createApp(); + server = await startServer(app, 0); + const addr = server.address(); + BASE = `http://127.0.0.1:${addr.port}`; +}); + +after(() => { + server?.close(); + try { + db.close(); + } catch { + /* already closed */ + } +}); + +describe("Snapshot options", () => { + it("lists the redaction keys", async () => { + const res = await fetch("/api/snapshots/options"); + assert.equal(res.status, 200); + assert.ok(Array.isArray(res.body.redactions)); + const keys = res.body.redactions.map((r) => r.key); + assert.deepEqual( + keys.sort(), + ["agent_tasks", "event_data", "event_summaries", "file_paths"].sort() + ); + for (const r of res.body.redactions) { + assert.ok(typeof r.label === "string" && r.label.length > 0); + } + }); +}); + +describe("Snapshot creation", () => { + it("creates a snapshot without redactions/expiry — active, has token, full payload", async () => { + const sessionId = seedSession("plain"); + const res = await post("/api/snapshots", { session_id: sessionId, title: "Plain snap" }); + assert.equal(res.status, 201); + const snap = res.body.snapshot; + assert.ok(/^[0-9a-f]{48}$/.test(snap.token), "token is 48 hex chars"); + assert.equal(snap.session_id, sessionId); + assert.equal(snap.title, "Plain snap"); + assert.equal(snap.status, "active"); + assert.equal(snap.view_count, 0); + assert.deepEqual(snap.redactions, []); + assert.equal(snap.revoked_at, null); + assert.equal(snap.expires_at, null); + + // Public read returns an un-redacted payload. + const view = await fetch(`/api/snapshots/${snap.token}`); + assert.equal(view.status, 200); + assert.equal(view.body.snapshot.read_only, true); + assert.ok(view.body.payload.captured_at); + assert.equal(view.body.payload.session.cwd, "/Users/leo/secret-project"); + assert.equal(view.body.payload.session.transcript_path, "/tmp/t.jsonl"); + assert.equal(view.body.payload.agents[0].task, "Do the secret thing"); + assert.deepEqual(view.body.payload.events[0].data, JSON.stringify({ cmd: "ls /secret" })); + assert.equal(view.body.payload.events[0].summary, "ran a command"); + }); + + it("applies redactions AT CAPTURE — redacted fields null, others intact", async () => { + const sessionId = seedSession("redact"); + const res = await post("/api/snapshots", { + session_id: sessionId, + redactions: ["file_paths", "event_data", "agent_tasks", "event_summaries"], + }); + assert.equal(res.status, 201); + const token = res.body.snapshot.token; + assert.deepEqual( + res.body.snapshot.redactions.sort(), + ["agent_tasks", "event_data", "event_summaries", "file_paths"].sort() + ); + + const view = await fetch(`/api/snapshots/${token}`); + assert.equal(view.status, 200); + const p = view.body.payload; + // Redacted + assert.equal(p.session.cwd, null); + assert.equal(p.session.transcript_path, null); + assert.equal(p.events[0].data, null); + assert.equal(p.agents[0].task, null); + assert.equal(p.events[0].summary, null); + // Not redacted — still present + assert.equal(p.session.id, sessionId); + assert.equal(p.events[0].event_type, "PreToolUse"); + assert.equal(p.agents[0].name, "Main Agent"); + }); + + it("redacts only the chosen keys (event_data only)", async () => { + const sessionId = seedSession("partial"); + const res = await post("/api/snapshots", { + session_id: sessionId, + redactions: ["event_data"], + }); + assert.equal(res.status, 201); + const view = await fetch(`/api/snapshots/${res.body.snapshot.token}`); + const p = view.body.payload; + assert.equal(p.events[0].data, null); // redacted + assert.equal(p.session.cwd, "/Users/leo/secret-project"); // not redacted + assert.equal(p.agents[0].task, "Do the secret thing"); // not redacted + assert.equal(p.events[0].summary, "ran a command"); // not redacted + }); + + it("supports an expiry window", async () => { + const sessionId = seedSession("expiry"); + const res = await post("/api/snapshots", { session_id: sessionId, expires_in_hours: 24 }); + assert.equal(res.status, 201); + assert.ok(res.body.snapshot.expires_at); + assert.equal(res.body.snapshot.status, "active"); + assert.ok(Date.parse(res.body.snapshot.expires_at) > Date.now()); + }); + + it("404s for an unknown session", async () => { + const res = await post("/api/snapshots", { session_id: "does-not-exist" }); + assert.equal(res.status, 404); + assert.equal(res.body.error.code, "NOT_FOUND"); + }); + + it("400s for an unknown redaction key", async () => { + const sessionId = seedSession("badkey"); + const res = await post("/api/snapshots", { + session_id: sessionId, + redactions: ["file_paths", "ssn"], + }); + assert.equal(res.status, 400); + assert.equal(res.body.error.code, "INVALID_INPUT"); + assert.match(res.body.error.message, /unknown redaction key/); + }); + + it("400s for a non-positive expiry", async () => { + const sessionId = seedSession("badexpiry"); + const res = await post("/api/snapshots", { session_id: sessionId, expires_in_hours: -5 }); + assert.equal(res.status, 400); + }); + + it("400s without a session_id", async () => { + const res = await post("/api/snapshots", { title: "no session" }); + assert.equal(res.status, 400); + }); +}); + +describe("Snapshot list", () => { + it("returns snapshots newest first", async () => { + const a = seedSession("list-a"); + const b = seedSession("list-b"); + const first = await post("/api/snapshots", { session_id: a }); + const second = await post("/api/snapshots", { session_id: b }); + + const res = await fetch("/api/snapshots"); + assert.equal(res.status, 200); + const tokens = res.body.snapshots.map((s) => s.token); + const iFirst = tokens.indexOf(first.body.snapshot.token); + const iSecond = tokens.indexOf(second.body.snapshot.token); + assert.ok(iFirst >= 0 && iSecond >= 0); + assert.ok(iSecond < iFirst, "newer snapshot appears before older one"); + }); +}); + +describe("Public read + audit", () => { + it("increments view_count and writes create + access audit rows", async () => { + const sessionId = seedSession("audit"); + const created = await post("/api/snapshots", { session_id: sessionId }); + const token = created.body.snapshot.token; + + await fetch(`/api/snapshots/${token}`); + await fetch(`/api/snapshots/${token}`); + + // view_count reflects both reads + const list = await fetch("/api/snapshots"); + const meta = list.body.snapshots.find((s) => s.token === token); + assert.equal(meta.view_count, 2); + + const audit = await fetch(`/api/snapshots/${token}/audit`); + assert.equal(audit.status, 200); + const actions = audit.body.audit.map((a) => a.action); + assert.ok(actions.includes("create")); + assert.equal(actions.filter((a) => a === "access").length, 2); + // newest first + assert.equal(audit.body.audit[0].action, "access"); + }); + + it("404s for an unknown token (no audit row created)", async () => { + const res = await fetch("/api/snapshots/deadbeef"); + assert.equal(res.status, 404); + assert.equal(res.body.error.code, "NOT_FOUND"); + // No snapshot exists, so an audit fetch is also 404. + const audit = await fetch("/api/snapshots/deadbeef/audit"); + assert.equal(audit.status, 404); + }); +}); + +describe("Expiry + revoke enforcement", () => { + it("returns 410 + access_denied(expired) for an expired snapshot", async () => { + const sessionId = seedSession("expired"); + const created = await post("/api/snapshots", { session_id: sessionId, expires_in_hours: 24 }); + const token = created.body.snapshot.token; + + // Force expiry into the past directly in the DB. + db.prepare( + "UPDATE snapshots SET expires_at = strftime('%Y-%m-%dT%H:%M:%fZ','now','-1 hour') WHERE token = ?" + ).run(token); + + const view = await fetch(`/api/snapshots/${token}`); + assert.equal(view.status, 410); + + // status reflects expiry, and the access_denied(expired) audit row is present. + const list = await fetch("/api/snapshots"); + assert.equal(list.body.snapshots.find((s) => s.token === token).status, "expired"); + + const audit = await fetch(`/api/snapshots/${token}/audit`); + const denied = audit.body.audit.find((a) => a.action === "access_denied"); + assert.ok(denied); + assert.equal(denied.detail, "expired"); + }); + + it("returns 410 + access_denied(revoked) after revoke, idempotently", async () => { + const sessionId = seedSession("revoke"); + const created = await post("/api/snapshots", { session_id: sessionId }); + const token = created.body.snapshot.token; + + const r1 = await post(`/api/snapshots/${token}/revoke`); + assert.equal(r1.status, 200); + assert.equal(r1.body.snapshot.status, "revoked"); + assert.ok(r1.body.snapshot.revoked_at); + + // Idempotent: a second revoke keeps the original revoked_at. + const r2 = await post(`/api/snapshots/${token}/revoke`); + assert.equal(r2.status, 200); + assert.equal(r2.body.snapshot.revoked_at, r1.body.snapshot.revoked_at); + + const view = await fetch(`/api/snapshots/${token}`); + assert.equal(view.status, 410); + + const audit = await fetch(`/api/snapshots/${token}/audit`); + const denied = audit.body.audit.find((a) => a.action === "access_denied"); + assert.ok(denied); + assert.equal(denied.detail, "revoked"); + assert.equal(audit.body.audit.filter((a) => a.action === "revoke").length, 2); + }); + + it("404s when revoking an unknown token", async () => { + const res = await post("/api/snapshots/deadbeef/revoke"); + assert.equal(res.status, 404); + }); +}); + +describe("Snapshot delete", () => { + it("removes the snapshot and its audit rows", async () => { + const sessionId = seedSession("delete"); + const created = await post("/api/snapshots", { session_id: sessionId }); + const token = created.body.snapshot.token; + await fetch(`/api/snapshots/${token}`); // create + access audit rows + + const res = await del(`/api/snapshots/${token}`); + assert.equal(res.status, 200); + assert.equal(res.body.ok, true); + + // Gone from the list, the public read, and the audit endpoint. + const list = await fetch("/api/snapshots"); + assert.ok(!list.body.snapshots.some((s) => s.token === token)); + assert.equal((await fetch(`/api/snapshots/${token}`)).status, 404); + assert.equal((await fetch(`/api/snapshots/${token}/audit`)).status, 404); + + // Audit rows are physically gone. + const remaining = db + .prepare("SELECT COUNT(*) AS n FROM snapshot_audit WHERE snapshot_token = ?") + .get(token).n; + assert.equal(remaining, 0); + + const again = await del(`/api/snapshots/${token}`); + assert.equal(again.status, 404); + }); +}); + +describe("Audit log accumulation", () => { + it("accumulates create → access → revoke in order", async () => { + const sessionId = seedSession("accumulate"); + const created = await post("/api/snapshots", { session_id: sessionId }); + const token = created.body.snapshot.token; + + await fetch(`/api/snapshots/${token}`); + await post(`/api/snapshots/${token}/revoke`); + + const audit = await fetch(`/api/snapshots/${token}/audit`); + const actions = audit.body.audit.map((a) => a.action); + assert.ok(actions.includes("create")); + assert.ok(actions.includes("access")); + assert.ok(actions.includes("revoke")); + }); +}); + +describe("Snapshot hardening (review follow-ups)", () => { + it("never exposes session/agent metadata in the public payload", async () => { + const sessionId = `snap-meta-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + db.prepare( + "INSERT INTO sessions (id, name, status, started_at, updated_at, metadata) VALUES (?, 'meta', 'active', strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now'), ?)" + ).run(sessionId, JSON.stringify({ secret_api_key: "sk-LEAKED-123" })); + db.prepare( + "INSERT INTO agents (id, session_id, name, type, status, started_at, updated_at, metadata) VALUES (?, ?, 'Main', 'main', 'working', strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now'), ?)" + ).run(`${sessionId}-main`, sessionId, JSON.stringify({ agent_secret: "TOKEN-XYZ" })); + + // No redactions selected — metadata must STILL be stripped (always-on). + const created = await post("/api/snapshots", { session_id: sessionId }); + assert.equal(created.status, 201); + const view = await fetch(`/api/snapshots/${created.body.snapshot.token}`); + assert.equal(view.status, 200); + assert.equal(view.body.payload.session.metadata, null); + for (const a of view.body.payload.agents) assert.equal(a.metadata, null); + assert.ok(!JSON.stringify(view.body.payload).includes("sk-LEAKED-123")); + assert.ok(!JSON.stringify(view.body.payload).includes("TOKEN-XYZ")); + }); + + it("file_paths redaction also scrubs path keys inside event.data", async () => { + const sessionId = `snap-evpath-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + db.prepare( + "INSERT INTO sessions (id, name, status, started_at, updated_at) VALUES (?, 'evpath', 'active', strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now'))" + ).run(sessionId); + db.prepare( + "INSERT INTO events (session_id, event_type, tool_name, summary, data, created_at) VALUES (?, 'PreToolUse', 'Bash', 's', ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))" + ).run( + sessionId, + JSON.stringify({ cwd: "/Users/leo/secret", transcript_path: "/tmp/t.jsonl", cmd: "ls" }) + ); + + const created = await post("/api/snapshots", { + session_id: sessionId, + redactions: ["file_paths"], + }); + const view = await fetch(`/api/snapshots/${created.body.snapshot.token}`); + const data = JSON.parse(view.body.payload.events[0].data); + assert.ok(!("cwd" in data), "cwd path key stripped from event.data"); + assert.ok(!("transcript_path" in data), "transcript_path stripped from event.data"); + assert.equal(data.cmd, "ls", "non-path keys are preserved"); + }); + + it("rejects an absurdly large expires_in_hours instead of 500ing", async () => { + const sessionId = seedSession("bigexpiry"); + const r = await post("/api/snapshots", { session_id: sessionId, expires_in_hours: 1e16 }); + assert.equal(r.status, 400); + assert.equal(r.body.error.code, "INVALID_INPUT"); + }); + + it("treats a corrupt expires_at as expired (fail-closed)", async () => { + const sessionId = seedSession("corruptexp"); + const created = await post("/api/snapshots", { session_id: sessionId }); + const token = created.body.snapshot.token; + db.prepare("UPDATE snapshots SET expires_at = 'not-a-date' WHERE token = ?").run(token); + const view = await fetch(`/api/snapshots/${token}`); + assert.equal(view.status, 410); + }); +}); diff --git a/server/db.js b/server/db.js index f95875cd..95cfe0d1 100644 --- a/server/db.js +++ b/server/db.js @@ -380,6 +380,38 @@ db.exec(` CREATE INDEX IF NOT EXISTS idx_workflows_session ON workflows(session_id); CREATE INDEX IF NOT EXISTS idx_workflows_status ON workflows(status); + + -- Read-only shareable session snapshots. A snapshot is an IMMUTABLE copy of a + -- session's {session, agents, events, workflows} payload captured at creation + -- (redactions already applied), addressable by an unguessable token. There is + -- intentionally NO FK to sessions: a snapshot is a frozen copy that must + -- outlive a cleared or deleted session. Like alert_rules, snapshots are + -- user-created artifacts and survive Clear Data. + CREATE TABLE IF NOT EXISTS snapshots ( + token TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + title TEXT, + payload TEXT NOT NULL, + redactions TEXT NOT NULL DEFAULT '[]', + view_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + expires_at TEXT, + revoked_at TEXT + ); + + -- Audit trail for snapshot lifecycle: create / access / revoke / access_denied. + -- No FK so an access_denied row can outlive a deleted snapshot if needed; the + -- DELETE route prunes a snapshot's audit rows alongside it. + CREATE TABLE IF NOT EXISTS snapshot_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_token TEXT NOT NULL, + action TEXT NOT NULL, + detail TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ); + + CREATE INDEX IF NOT EXISTS idx_snapshots_created ON snapshots(created_at DESC); + CREATE INDEX IF NOT EXISTS idx_snapshot_audit_token ON snapshot_audit(snapshot_token, created_at DESC); `); // Migrate: link agent rows to a workflow run. Workflow inner-agents are already diff --git a/server/index.js b/server/index.js index 5b819a7b..ccc52d8e 100644 --- a/server/index.js +++ b/server/index.js @@ -53,6 +53,7 @@ const ccConfigRouter = require("./routes/cc-config"); const runRouter = require("./routes/run"); const alertsRouter = require("./routes/alerts"); const webhooksRouter = require("./routes/webhooks"); +const snapshotsRouter = require("./routes/snapshots"); function createApp() { const app = express(); @@ -77,6 +78,7 @@ function createApp() { app.use("/api/run", runRouter); app.use("/api/alerts", alertsRouter); app.use("/api/webhooks", webhooksRouter); + app.use("/api/snapshots", snapshotsRouter); app.get("/api/openapi.json", (_req, res) => { res.json(openApiSpec); }); diff --git a/server/lib/snapshots.js b/server/lib/snapshots.js new file mode 100644 index 00000000..639fa417 --- /dev/null +++ b/server/lib/snapshots.js @@ -0,0 +1,156 @@ +/** + * @file Pure helpers for read-only shareable session snapshots. Redaction is + * applied at CAPTURE time so the persisted blob is already clean — a later + * re-read can never expose data the creator chose to redact. Keeps DB access + * out of this module (callers pass rows in); only `crypto` is required, for the + * unguessable public token. + * @author Son Nguyen + */ + +const crypto = require("crypto"); + +// Allowlist of redaction keys. Each entry pairs the stored key with a +// human-readable label the UI shows in the "what to hide" picker. The order +// here is the order the /options endpoint returns. +const REDACTION_OPTIONS = [ + { + key: "file_paths", + label: "File paths (session cwd/transcript path + event-level cwd/path keys)", + }, + { key: "event_data", label: "Event payloads (raw tool data)" }, + { key: "agent_tasks", label: "Agent task descriptions" }, + { key: "event_summaries", label: "Event summaries" }, +]; + +// Fast membership set for validating incoming redaction keys. +const REDACTION_KEYS = new Set(REDACTION_OPTIONS.map((o) => o.key)); + +// Well-known path-bearing keys inside an event's raw `data` blob (the hook +// payload). `file_paths` strips these top-level keys too; full payload removal +// (nested values, secrets in tool inputs) requires the `event_data` redaction. +const EVENT_DATA_PATH_KEYS = ["cwd", "transcript_path", "file_path", "filePath"]; + +/** + * Best-effort strip of well-known top-level path keys from an event's `data` + * (a JSON string). Returns the original value unchanged when it isn't a JSON + * object string or contains none of the keys. + */ +function scrubPathKeysFromEventData(data) { + if (typeof data !== "string") return data; + let obj; + try { + obj = JSON.parse(data); + } catch { + return data; + } + if (!obj || typeof obj !== "object" || Array.isArray(obj)) return data; + let changed = false; + for (const k of EVENT_DATA_PATH_KEYS) { + if (Object.prototype.hasOwnProperty.call(obj, k)) { + delete obj[k]; + changed = true; + } + } + return changed ? JSON.stringify(obj) : data; +} + +/** + * Return a NEW, deeply-independent copy of `payload` with the requested + * redactions applied. Never mutates the input. `keys` may be any iterable of + * redaction keys; unknown keys are ignored here (the route validates them). + * + * Redactions: + * - file_paths → session.cwd = null, session.transcript_path = null + * - event_data → every event.data = null + * - agent_tasks → every agent.task = null + * - event_summaries → every event.summary = null + */ +function applyRedactions(payload, keys) { + const set = keys instanceof Set ? keys : new Set(keys || []); + // Structured deep clone so callers can persist the result immutably without + // sharing references back into the live capture object. + const copy = JSON.parse(JSON.stringify(payload)); + + if (set.has("file_paths")) { + if (copy.session && typeof copy.session === "object") { + copy.session.cwd = null; + if ("transcript_path" in copy.session) copy.session.transcript_path = null; + } + // Paths also live inside event payloads (the hook stores cwd/transcript_path + // at the top level of event.data) — scrub those too so this option honors + // its name even when the full event_data redaction isn't selected. + if (Array.isArray(copy.events)) { + for (const e of copy.events) e.data = scrubPathKeysFromEventData(e.data); + } + } + if (set.has("event_data") && Array.isArray(copy.events)) { + for (const e of copy.events) e.data = null; + } + if (set.has("event_summaries") && Array.isArray(copy.events)) { + for (const e of copy.events) e.summary = null; + } + if (set.has("agent_tasks") && Array.isArray(copy.agents)) { + for (const a of copy.agents) a.task = null; + } + + return copy; +} + +/** + * Derive the lifecycle status of a snapshot row. Revoked wins over expired + * (an explicit revoke is the stronger signal); expiry is computed against the + * current wall clock so it's enforced server-side on every read. + */ +function computeStatus(row) { + if (row.revoked_at) return "revoked"; + if (row.expires_at) { + const t = Date.parse(row.expires_at); + // Fail CLOSED: an unparseable/expired timestamp is treated as expired so a + // corrupted row can never serve its payload. + if (Number.isNaN(t) || Date.now() > t) return "expired"; + } + return "active"; +} + +/** + * Generate an unguessable public token — 24 random bytes as 48 hex chars. + * This is the snapshot row's primary key and the only handle a viewer needs. + */ +function newToken() { + return crypto.randomBytes(24).toString("hex"); +} + +/** + * Parse a `snapshots` DB row into the SnapshotMeta shape the management UI + * consumes. The immutable `payload` blob is intentionally NOT included here — + * only the public read endpoint hydrates it. + */ +function serializeSnapshot(row) { + let redactions = []; + try { + redactions = JSON.parse(row.redactions || "[]"); + } catch { + /* tolerate a hand-edited bad blob — treat as no redactions */ + } + if (!Array.isArray(redactions)) redactions = []; + return { + token: row.token, + session_id: row.session_id, + title: row.title ?? null, + created_at: row.created_at, + expires_at: row.expires_at ?? null, + revoked_at: row.revoked_at ?? null, + view_count: row.view_count ?? 0, + redactions, + status: computeStatus(row), + }; +} + +module.exports = { + REDACTION_OPTIONS, + REDACTION_KEYS, + applyRedactions, + computeStatus, + newToken, + serializeSnapshot, +}; diff --git a/server/routes/snapshots.js b/server/routes/snapshots.js new file mode 100644 index 00000000..750a6996 --- /dev/null +++ b/server/routes/snapshots.js @@ -0,0 +1,335 @@ +/** + * @file Express router for read-only shareable session snapshots. A snapshot is + * an IMMUTABLE copy of a session's current {session, agents, events, workflows} + * payload, captured the moment it's created and stored as JSON. Redactions are + * applied at capture time so the persisted blob is already clean — a later + * re-read can never leak data the creator chose to hide. Public reads are + * served by token (an unguessable 48-hex string), gated by server-side expiry + * and revoke, and every create/access/revoke/denied attempt is audited. + * @author Son Nguyen + */ + +const { Router } = require("express"); +const { db, stmts } = require("../db"); +const { + REDACTION_OPTIONS, + REDACTION_KEYS, + applyRedactions, + computeStatus, + newToken, + serializeSnapshot, +} = require("../lib/snapshots"); + +const router = Router(); + +// Bound a single captured snapshot so one huge session can't produce a +// multi-megabyte blob that is re-parsed on every public read. +const MAX_SNAPSHOT_EVENTS = 5000; +// 100 years in hours — an upper bound on expires_in_hours that still keeps the +// resulting Date well within range (a larger value overflows toISOString()). +const MAX_EXPIRES_HOURS = 24 * 365 * 100; +// Cap the management list so it can't return an unbounded result set. +const MAX_LIST = 500; + +// Inline prepared statements for snapshot/audit CRUD (events.js convention). +const insertSnapshot = db.prepare( + `INSERT INTO snapshots (token, session_id, title, payload, redactions, expires_at) + VALUES (?, ?, ?, ?, ?, ?)` +); +const getSnapshot = db.prepare("SELECT * FROM snapshots WHERE token = ?"); +const listSnapshots = db.prepare( + "SELECT * FROM snapshots ORDER BY created_at DESC, token DESC LIMIT ?" +); +// Fetch one more than the cap so we can flag truncation without a COUNT. +const captureEventsStmt = db.prepare( + "SELECT * FROM events WHERE session_id = ? ORDER BY created_at ASC, id ASC LIMIT ?" +); +const incrementView = db.prepare( + "UPDATE snapshots SET view_count = view_count + 1 WHERE token = ?" +); +const revokeSnapshot = db.prepare( + "UPDATE snapshots SET revoked_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE token = ? AND revoked_at IS NULL" +); +const deleteSnapshot = db.prepare("DELETE FROM snapshots WHERE token = ?"); + +const insertAudit = db.prepare( + "INSERT INTO snapshot_audit (snapshot_token, action, detail) VALUES (?, ?, ?)" +); +const listAudit = db.prepare( + `SELECT id, action, detail, created_at FROM snapshot_audit + WHERE snapshot_token = ? ORDER BY created_at DESC, id DESC` +); +const deleteAudit = db.prepare("DELETE FROM snapshot_audit WHERE snapshot_token = ?"); + +/** + * Record one audit entry for a snapshot. Best-effort by design: an audit write + * must never break the request it describes, so failures are swallowed. + */ +function audit(token, action, detail = null) { + try { + insertAudit.run(token, action, detail); + } catch (err) { + console.warn("[SNAPSHOTS] audit write failed:", err?.message || err); + } +} + +/** + * Wrap a route handler so any thrown error becomes a structured 500 instead of + * an unhandled rejection / default HTML error page. + */ +function safe(handler) { + return (req, res) => { + try { + return handler(req, res); + } catch (err) { + console.error("[SNAPSHOTS] handler error:", err?.message || err); + return res + .status(500) + .json({ error: { code: "INTERNAL", message: "Internal server error" } }); + } + }; +} + +/** + * Capture a session's current {session, agents, events, workflows} payload — + * mirrors the GET /api/sessions/:id handler exactly so a snapshot reproduces + * what the session detail page shows. Returns null if the session is unknown. + */ +function captureSessionPayload(sessionId) { + const session = stmts.getSession.get(sessionId); + if (!session) return null; + // Internal metadata blobs (hook enrichment + arbitrary user-set values) can + // carry secrets/paths and are not meaningful in a read-only shared view, so + // they are NEVER captured — independent of the redaction choices. + session.metadata = null; + const agents = stmts.listAgentsBySession.all(sessionId).map((a) => ({ ...a, metadata: null })); + // Bound the captured events; flag truncation so the viewer can say so. + const rawEvents = captureEventsStmt.all(sessionId, MAX_SNAPSHOT_EVENTS + 1); + const eventsTruncated = rawEvents.length > MAX_SNAPSHOT_EVENTS; + const events = eventsTruncated ? rawEvents.slice(0, MAX_SNAPSHOT_EVENTS) : rawEvents; + const workflows = stmts.listWorkflowsBySession.all(sessionId).map((w) => { + let phases = []; + let progress = []; + try { + phases = w.phases ? JSON.parse(w.phases) : []; + } catch { + phases = []; + } + try { + progress = w.progress ? JSON.parse(w.progress) : []; + } catch { + progress = []; + } + return { ...w, phases, progress }; + }); + return { session, agents, events, workflows, events_truncated: eventsTruncated }; +} + +// GET /api/snapshots/options — the redaction allowlist for the create UI. +// Registered BEFORE /:token so "options" is never mistaken for a token. +router.get( + "/options", + safe((_req, res) => { + res.json({ redactions: REDACTION_OPTIONS }); + }) +); + +// GET /api/snapshots — management list, newest first. +router.get( + "/", + safe((_req, res) => { + res.json({ snapshots: listSnapshots.all(MAX_LIST).map(serializeSnapshot) }); + }) +); + +// POST /api/snapshots — capture an immutable, optionally-redacted snapshot. +router.post( + "/", + safe((req, res) => { + const { session_id, title, redactions, expires_in_hours } = req.body || {}; + + if (!session_id || typeof session_id !== "string") { + return res + .status(400) + .json({ error: { code: "INVALID_INPUT", message: "session_id is required" } }); + } + + // Validate redactions against the allowlist — reject unknown keys. + let redactionKeys = []; + if (redactions != null) { + if (!Array.isArray(redactions)) { + return res + .status(400) + .json({ error: { code: "INVALID_INPUT", message: "redactions must be an array" } }); + } + for (const key of redactions) { + if (!REDACTION_KEYS.has(key)) { + return res + .status(400) + .json({ error: { code: "INVALID_INPUT", message: `unknown redaction key: ${key}` } }); + } + } + // De-dupe while preserving the allowlist order for a stable stored value. + const requested = new Set(redactions); + redactionKeys = REDACTION_OPTIONS.map((o) => o.key).filter((k) => requested.has(k)); + } + + // Validate expiry — a positive, finite number of hours if given. + let expiresAt = null; + if (expires_in_hours != null) { + if ( + typeof expires_in_hours !== "number" || + !Number.isFinite(expires_in_hours) || + expires_in_hours <= 0 + ) { + return res.status(400).json({ + error: { code: "INVALID_INPUT", message: "expires_in_hours must be a positive number" }, + }); + } + if (expires_in_hours > MAX_EXPIRES_HOURS) { + return res.status(400).json({ + error: { + code: "INVALID_INPUT", + message: `expires_in_hours must be at most ${MAX_EXPIRES_HOURS}`, + }, + }); + } + expiresAt = new Date(Date.now() + expires_in_hours * 3600 * 1000).toISOString(); + } + + if (title != null && typeof title !== "string") { + return res + .status(400) + .json({ error: { code: "INVALID_INPUT", message: "title must be a string" } }); + } + + const captured = captureSessionPayload(session_id); + if (!captured) { + return res.status(404).json({ error: { code: "NOT_FOUND", message: "Session not found" } }); + } + + // Redact at CAPTURE time so the persisted blob is already clean. captured_at + // stamps when this immutable view was taken. + const redactedPayload = applyRedactions(captured, redactionKeys); + redactedPayload.captured_at = new Date().toISOString(); + + const token = newToken(); + insertSnapshot.run( + token, + session_id, + title != null ? title : null, + JSON.stringify(redactedPayload), + JSON.stringify(redactionKeys), + expiresAt + ); + audit(token, "create", redactionKeys.length ? `redacted: ${redactionKeys.join(", ")}` : null); + + res.status(201).json({ snapshot: serializeSnapshot(getSnapshot.get(token)) }); + }) +); + +// GET /api/snapshots/:token — public, read-only view. Enforces revoke + expiry +// server-side and audits every access (and every denied attempt). +router.get( + "/:token", + safe((req, res) => { + const row = getSnapshot.get(req.params.token); + // Unknown token: 404 with NO audit — there's no snapshot to attribute it to. + if (!row) { + return res.status(404).json({ error: { code: "NOT_FOUND", message: "Snapshot not found" } }); + } + + // Enforce revoke + expiry server-side via the shared status helper (which + // fails closed on a corrupt expires_at). The payload is never read below + // unless the snapshot is active. + const status = computeStatus(row); + if (status === "revoked") { + audit(row.token, "access_denied", "revoked"); + return res + .status(410) + .json({ error: { code: "GONE", message: "This snapshot has been revoked" } }); + } + if (status === "expired") { + audit(row.token, "access_denied", "expired"); + return res + .status(410) + .json({ error: { code: "GONE", message: "This snapshot has expired" } }); + } + + let payload; + try { + payload = JSON.parse(row.payload); + } catch { + // A corrupt stored blob is unrecoverable for the viewer. + return res + .status(500) + .json({ error: { code: "INTERNAL", message: "Snapshot payload is corrupt" } }); + } + + incrementView.run(row.token); + audit(row.token, "access", null); + + const capturedAt = payload.captured_at || row.created_at; + let redactions = []; + try { + redactions = JSON.parse(row.redactions || "[]"); + } catch { + redactions = []; + } + + const publicSnapshot = { + token: row.token, + title: row.title ?? null, + captured_at: capturedAt, + age_seconds: Math.max(0, Math.round((Date.now() - Date.parse(capturedAt)) / 1000)), + redactions: Array.isArray(redactions) ? redactions : [], + read_only: true, + }; + + res.json({ snapshot: publicSnapshot, payload }); + }) +); + +// POST /api/snapshots/:token/revoke — idempotent revoke; always audits. +router.post( + "/:token/revoke", + safe((req, res) => { + const row = getSnapshot.get(req.params.token); + if (!row) { + return res.status(404).json({ error: { code: "NOT_FOUND", message: "Snapshot not found" } }); + } + // Only stamp revoked_at the first time (the UPDATE's NULL guard makes this a + // no-op on an already-revoked row), but audit every revoke call. + revokeSnapshot.run(row.token); + audit(row.token, "revoke", null); + res.json({ snapshot: serializeSnapshot(getSnapshot.get(row.token)) }); + }) +); + +// DELETE /api/snapshots/:token — drop the snapshot and its audit trail. +router.delete( + "/:token", + safe((req, res) => { + const row = getSnapshot.get(req.params.token); + if (!row) { + return res.status(404).json({ error: { code: "NOT_FOUND", message: "Snapshot not found" } }); + } + deleteSnapshot.run(row.token); + deleteAudit.run(row.token); + res.json({ ok: true }); + }) +); + +// GET /api/snapshots/:token/audit — the audit trail, newest first. +router.get( + "/:token/audit", + safe((req, res) => { + const row = getSnapshot.get(req.params.token); + if (!row) { + return res.status(404).json({ error: { code: "NOT_FOUND", message: "Snapshot not found" } }); + } + res.json({ audit: listAudit.all(row.token) }); + }) +); + +module.exports = router;