Skip to content

feat(snapshots): shareable read-only session snapshots (#9)#173

Open
LeoLin990405 wants to merge 1 commit into
hoangsonww:masterfrom
LeoLin990405:feat/session-snapshots
Open

feat(snapshots): shareable read-only session snapshots (#9)#173
LeoLin990405 wants to merge 1 commit into
hoangsonww:masterfrom
LeoLin990405:feat/session-snapshots

Conversation

@LeoLin990405

Copy link
Copy Markdown
Contributor

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)

  • Immutable capture — payload is frozen at create time, stored as JSON; a re-read can never drift or mutate live data.
  • Redaction applied at capture (persisted blob is already clean, so it can't be un-redacted later): opt-in file_paths (session cwd/transcript_path + top-level path keys inside each 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 — revoked/expired tokens return 410 and never the payload, 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 in snapshot_audit.
  • Bounded — captured events are capped (with a truncated flag) 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.
  • New tables (snapshots, snapshot_audit) are additive (CREATE TABLE IF NOT EXISTS) and, like alert_rules, survive Clear Data.

Frontend

  • Public 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.

Type of Change

  • Bug fix
  • New feature (non-breaking change that adds functionality)
  • Breaking change
  • Refactor
  • Documentation update
  • Infrastructure / CI / DevOps
  • Dependency update

How to Test

  1. npm run test:server — all pass (366), incl. server/__tests__/snapshots.test.js (21 tests).
  2. cd client && npm run build — clean tsc -b && vite build.
  3. Manual: 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.
  4. API: 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}' then GET /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 metadata is 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

  • I have read the contributing guidelines
  • My code follows the project's coding standards
  • I have added/updated tests that prove my feature works
  • All new and existing tests pass (npm test) — see note
  • Code is formatted (npm run format:check)
  • I have updated documentation where necessary (self-describing /options endpoint + i18n)

Note on tests: npm run test:server is green (366/366) and cd client && npm run build is clean. I regenerated the Session detail screen-snapshot baseline for the new Share button (verified the diff is exactly that). npm run test:client otherwise shows only the pre-existing Dashboard/Tabby failures that reproduce on a clean master under Node 26 (jsdom 27 leaves localStorage undefined) — unrelated to this PR; CI runs Node 22 where the client suite passes.

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.
@LeoLin990405 LeoLin990405 requested a review from hoangsonww as a code owner June 18, 2026 04:02

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +272 to +284
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)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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,

Comment on lines +144 to +146
<h1 className="text-xl font-semibold text-gray-100 truncate">
{snapshot.title || session.name || `Session ${session.id.slice(0, 8)}`}
</h1>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
<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>

Comment on lines +43 to +53
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);
}
}, []);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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);
      }
    }
  }, []);

@hoangsonww hoangsonww self-assigned this Jun 19, 2026
@hoangsonww hoangsonww added bug Something isn't working documentation Improvements or additions to documentation enhancement New feature or request help wanted Extra attention is needed good first issue Good for newcomers question Further information is requested labels Jun 19, 2026
@hoangsonww hoangsonww moved this from Backlog to In review in Claude Code AI Agents Monitor Project Board Jun 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working documentation Improvements or additions to documentation enhancement New feature or request good first issue Good for newcomers help wanted Extra attention is needed question Further information is requested

Projects

Development

Successfully merging this pull request may close these issues.

Feature: Shareable Read-Only Session Snapshots

2 participants