feat(snapshots): shareable read-only session snapshots (#9)#173
feat(snapshots): shareable read-only session snapshots (#9)#173LeoLin990405 wants to merge 1 commit into
Conversation
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 hoangsonww#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.
There was a problem hiding this comment.
Code Review
This pull request introduces a new feature for creating and sharing read-only session snapshots, including a new modal for capture, a management list, and a public viewer route. The implementation covers backend API endpoints, database schema updates for snapshots and audit logs, and i18n support. My review identified a potential issue with date parsing in the snapshot viewer, a missing i18n key for the session fallback title, and a need for better state management in the snapshots list component to handle unmounting.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| 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)), |
There was a problem hiding this comment.
If capturedAt is invalid or missing, Date.parse(capturedAt) will return NaN. This propagates and results in age_seconds being NaN, which can cause the frontend to display broken values like Captured NaN days ago.
We should parse the date defensively and fallback to 0 if the parsing fails.
| 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)), | |
| const capturedAt = payload.captured_at || row.created_at; | |
| const parsedCapturedAt = Date.parse(capturedAt); | |
| const ageSeconds = Number.isNaN(parsedCapturedAt) ? 0 : Math.max(0, Math.round((Date.now() - parsedCapturedAt) / 1000)); | |
| let redactions = []; | |
| try { | |
| redactions = JSON.parse(row.redactions || "[]"); | |
| } catch { | |
| redactions = []; | |
| } | |
| const publicSnapshot = { | |
| token: row.token, | |
| title: row.title ?? null, | |
| captured_at: capturedAt, | |
| age_seconds: ageSeconds, |
| <h1 className="text-xl font-semibold text-gray-100 truncate"> | ||
| {snapshot.title || session.name || `Session ${session.id.slice(0, 8)}`} | ||
| </h1> |
There was a problem hiding this comment.
The fallback session name Session is hardcoded in English. To support full internationalization (i18n) as implemented in the rest of this PR, we should use the translation key viewer.session from the snapshots namespace.
| <h1 className="text-xl font-semibold text-gray-100 truncate"> | |
| {snapshot.title || session.name || `Session ${session.id.slice(0, 8)}`} | |
| </h1> | |
| <h1 className="text-xl font-semibold text-gray-100 truncate"> | |
| {snapshot.title || session.name || `${t("viewer.session")} ${session.id.slice(0, 8)}`} | |
| </h1> |
| 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); | ||
| } | ||
| }, []); |
There was a problem hiding this comment.
The load function performs an asynchronous API call but does not handle component unmounting. If the component unmounts while the request is pending, calling setSnapshots or setLoading will trigger state updates on an unmounted component.
Consider using a useRef to track the component's mounted status and guard these state updates. Remember to import useRef from 'react'.
const isMounted = useRef(true);
useEffect(() => {
return () => {
isMounted.current = false;
};
}, []);
const load = useCallback(async () => {
setLoading(true);
try {
const items = await api.snapshots.list();
if (isMounted.current) {
setSnapshots(items);
}
} catch (err) {
console.error("Failed to load snapshots:", err);
} finally {
if (isMounted.current) {
setLoading(false);
}
}
}, []);
Summary
Implements shareable read-only session snapshots (issue #9): capture a session's current
{ session, agents, events, workflows }into an immutable payload addressed by an unguessable token, so a specific session state can be shared read-only — without granting dashboard access or hand-exporting data. Closes #9.Changes
Backend (
/api/snapshots, read-only, local-first)file_paths(sessioncwd/transcript_path+ top-level path keys inside eachevent.data),event_data,agent_tasks,event_summaries. Internal session/agentmetadatablobs are never captured — they can carry secrets/paths and aren't meaningful in a shared view.410and never the payload, via a shared status helper that fails closed on a corruptexpires_at.expires_in_hoursis bounded to avoidDateoverflow.snapshot_audit.truncatedflag) and the management list is limited, so one huge session can't produce an unbounded blob. Token =crypto.randomBytes(24)(192-bit), lookups fully parameterized.snapshots,snapshot_audit) are additive (CREATE TABLE IF NOT EXISTS) and, likealert_rules, survive Clear Data.Frontend
/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.Type of Change
How to Test
npm run test:server— all pass (366), incl.server/__tests__/snapshots.test.js(21 tests).cd client && npm run build— cleantsc -b && vite build.npm run dev, open a session → Create snapshot (try redaction toggles + an expiry) → copy the link → open/snapshot/<token>in a private window (read-only). Then Snapshots in the sidebar → revoke/delete; revisiting a revoked/expired link shows the unavailable state.curl -s -XPOST localhost:4820/api/snapshots -H 'content-type: application/json' -d '{"session_id":"<id>","redactions":["file_paths","event_data"],"expires_in_hours":24}'thenGET /api/snapshots/<token>.Security notes
The capture/redaction/enforcement path was hardened against an adversarial review: tokens are CSPRNG and unguessable; redaction is applied at capture (and
metadatais never captured); expiry/revoke are enforced server-side and fail closed; payload/list sizes are bounded; token lookups are parameterized; list/audit return metadata only (never the payload). One intentional property given the app's no-auth local-first model: whoever holds a share token can also revoke/delete that snapshot (same trust level as every other endpoint) — happy to gate management behind a separate control if you'd prefer.Checklist
npm test) — see notenpm run format:check)/optionsendpoint + i18n)