feature: add workspace history of actions (History page + audit_events)

✅ merged · #271 · open-legal-products/mike ← jmooves/mike · opened 1mo ago by jmooves · merged 23d ago by willchen96 · +2,599-19 across 28 files · ↗ on GitHub

From the PR description

User Experience

Currently no audit history exists within Mike. This feature enhancement adds an audit trail of user actions with a top-level History page and adds a sidebar entry below Workflows.

What it does

  • History page (/history): User · Created · Title (linked to the chat/review) · Status · Type · Application · Model, with search, a type filter, a date range, CSV export and pagination.
  • Recorded actions: chat turns (model used; cancelled status when a stream is aborted) with per-artifact rows mined from the persisted stream events (generated documents, edits, applied workflows), document uploads, tabular review creation and generation runs, and data exports.
  • Visibility: each user sees their own actions plus activity in projects they own or that are shared with them and reuses the existing shared_with model, no new permission concept.

Design notes

  • audit_events is append-only, added to schema.sql (fresh installs) and as a dated migration (existing deployments). Accessed only via the service-role backend.
  • The recorder (lib/audit.ts) is fire-and-forget: it can never throw or block a user-facing path - failures log and are swallowed.
  • Artifact rows come from the already-persisted assistant events, so the tool layer needed no changes.

Testing

  • npx vitest run green on this branch; tsc clean on both apps; next build includes /history.
  • Validated end-to-end on a self-hosted deployment: a chat turn, review creation and a chats export all appeared in /audit with correct fields; the type filter and CSV export verified.

🤖 Generated with Claude Code and human reviewed. :)

Our analysis

Add workspace audit history — read the full analysis →

Think the analysis missed something the PR description covers?

Commits in this PR (10)

SHA Subject Author Date
2bf6f9f8 feat: workspace history of actions (History page + audit_events) JJ 2026-07-28 ↗ GitHub
commit body
Adds an append-only audit trail of user actions with a top-level History
page (sidebar entry): User, Created, Title (linked), Status, Type,
Application and Model columns, with search, type filter, date range, CSV
export and pagination.

Backend: audit_events table (schema.sql + dated migration), a
fire-and-forget recorder (lib/audit.ts) that can never throw or block a
user-facing path, and call sites for chat turns (model, cancelled status,
plus per-artifact rows for generated/edited documents and applied
workflows mined from the persisted stream events), document uploads,
tabular review creation/generation and data exports. GET /audit lists
events (visibility: own actions plus activity in projects owned by or
shared with the caller); GET /audit/export streams the same view as CSV.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ec34667f fix(db): lock down audit_events to the backend (revoke + RLS + grant order) Amal 2026-08-01 ↗ GitHub
commit body
WHY THIS MATTERS
This repo's threat model explicitly includes direct PostgREST access with the
public anon key - which is why schema.sql revokes anon/authenticated on every
backend-owned table. audit_events shipped with neither a revoke nor RLS, so on a
hosted Supabase deployment its default privileges leave the whole table readable
AND writable from the browser: any visitor could dump every user's email, chat
titles and prompt excerpts, or forge/delete audit rows - which defeats the whole
point of an append-only audit trail.

WHAT IS RLS / PostgREST default access
Supabase exposes every table in schema `public` over PostgREST. Whether the
browser `anon`/`authenticated` roles can touch a table is governed by two
things: (1) SQL table GRANTs (managed Supabase's default ACLs grant these roles
broad privileges on new tables), and (2) Row-Level Security. With RLS disabled
and the default grants in place, the table is wide open. The repo's convention
is defense-in-depth: `revoke all ... from anon, authenticated` removes the
grant, and `enable row level security` (with no policies) means even if a grant
slips back the rows are invisible. service_role bypasses RLS, so the backend
path is unaffected.

HOW IT WORKS
- schema.sql: audit_events now has `revoke all ... from anon, authenticated` in
  the revoke block and `enable row level security`, matching every sibling
  table.
- Grant ordering (F4): `grant ... on all tables in schema public to
  service_role` only covers tables that already exist when it runs. The table
  was defined *after* that block, so a fresh plain-Postgres install created it
  with no service_role privileges and the backend's inserts failed
  permission-denied - silently, because recordAudit swallows errors. The DDL is
  moved above the grant block so the blanket grant covers it.
- migration 20260728: adds the same revoke + RLS, plus an explicit
  `grant select, insert, update, delete ... to service_role` so a fresh apply
  works even where service_role has no default ACL for new tables. The old
  comment ("no RLS policies needed, like other app tables") inverted the
  convention and is corrected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3d4e0d52 fix(privacy): purge audit_events on account deletion and include them in export Amal 2026-08-01 ↗ GitHub
commit body
WHY THIS MATTERS
audit_events stores personal data: the user's id, email, chat/document titles
and prompt excerpts. Account deletion erased chats, projects, documents and
workflows but left the audit rows behind forever - a GDPR "right to erasure"
gap, and the rows also became orphans pointing at chats/projects that no longer
exist. Separately, GET /user/export (the user's own copy of their data) omitted
audit rows, so the export was incomplete.

WHAT IS "erasure completeness"
When a user deletes their account, every table keyed by their identity must be
swept - not just the primary feature tables. Any table carrying user_id (or
their email, titles, excerpts) is in scope. Audit trails are easy to overlook
precisely because they're written by a fire-and-forget side path, but they hold
some of the most sensitive text in the system (prompt excerpts).

HOW IT WORKS
- deleteUserAccountData: adds `audit_events.delete().eq("user_id", userId)` to
  the batched deletion set, alongside workflows/projects/etc. Keyed by user_id so
  it removes exactly the departing user's rows.
- buildUserAccountExport: adds an `audit_events` section (the user's own rows,
  ordered by created_at) so the export mirrors what deletion removes.
- Test: the account-deletion fixture gains audit rows for two users and asserts
  only the other user's row survives (a1/a2 purged, a-other kept).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
484dadcf fix(audit): harden the /audit route - CSV escaping, export limiter/MFA, input bounds Amal 2026-08-01 ↗ GitHub
commit body
WHY THIS MATTERS
The history route had four independent weaknesses: CSV export could smuggle a
formula into a victim's spreadsheet, /audit/export lacked the export limiter and
MFA gate that every other data export has, an out-of-range ?page= crashed with a
500, and a malformed from/to date crashed with a 500. Titles are attacker-
controllable across shared projects, so these are reachable by another user.

WHAT IS CSV FORMULA INJECTION
Excel/Google Sheets evaluate any cell whose text begins with =, +, -, @, a tab
or a carriage return as a *formula* when the file is opened. A chat titled
=HYPERLINK("http://evil","invoice") therefore executes on export in the
victim's spreadsheet - data exfiltration / phishing with no macro prompt. The
fix (OWASP's recommendation) prefixes a single quote to any value starting with
a trigger char, forcing the value to be treated as literal text. The quote-
trigger regex also gains \r so a leading carriage return is both escaped and
quoted.

HOW IT WORKS
- csvCell (F3): prefixes ' when the value matches /^[=+\-@\t\r]/, and the
  CSV-quote test now includes \r.
- Export limiter + MFA (F5): app.ts adds app.get("/audit/export",
  exportLimiter) (10/hr) and the route gains requireMfaIfEnrolled, matching
  /user/export. A 2000-row export that can include other users' emails no longer
  runs under only the general limiter and plain auth.
- Page clamp (F7): parseQuery clamps page into [1, 100000]. Previously
  ?page=99999999999999 produced a ~5e15 OFFSET that PostgREST rejected as a 500;
  the clamp keeps the offset inside Postgres' integer range.
- Date validation (F8): from/to must match ^\d{4}-\d{2}-\d{2}$ (they come from
  <input type="date">). parseQuery now returns a discriminated result and the
  handlers reply 400 on bad input instead of building "...ZT23:59:59.999Z" and
  500ing.

Tests pin csvCell escaping for every trigger char, page clamping/flooring, date
rejection/acceptance, and (with queryEvents/accessibleProjectIds exported)
visibility scoping: own-events OR accessible-project events for owned+shared
projects, own-only when none, and owned/shared id de-duplication.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
97478819 fix(audit): mine doc_replicated copies and audit project document uploads Amal 2026-08-01 ↗ GitHub
commit body
WHY THIS MATTERS
The history feature under-recorded reality in two ways. Replicated documents
were attributed to the wrong file and lost their id, and project document
uploads were never recorded at all - so the audit trail silently disagreed with
what the user actually did.

WHAT IS EVENT MINING
recordChatTurn derives one audit row per artifact a chat turn produced by
walking the persisted assistant-event stream (doc_created, doc_edited,
doc_replicated, workflow_applied). Each event type stores its payload
differently, so the miner has to read each shape correctly.

HOW IT WORKS
- doc_replicated (F6): per chat/streaming.ts, this event's top-level `filename`
  is the SOURCE document and there is no top-level document_id - the produced
  copies live in a `copies: [{ new_filename, document_id, version_id }]` array,
  and one event can produce several. The miner previously read the top-level
  fields, so it logged one row titled with the source filename and a null id.
  It now iterates `copies`, emitting one document.generated row per copy with
  that copy's new_filename and document_id.
- Project uploads (F6): POST /projects/:id/documents calls the project-scoped
  handleDocumentUpload in projects.ts, a duplicate of the instrumented one in
  documents.ts, and it had no recordAudit call - so project uploads never
  appeared in history. It now records a document.uploaded event
  (surface: "project", the project id, the new document id) on success,
  fire-and-forget like the sibling handler.

Tests exercise the miner directly: a mixed turn (created/edited/workflow) maps
to the right actions, a doc_replicated with two copies yields exactly two
document.generated rows carrying the copies' filenames/ids (and never the source
filename), and an empty-copies event yields only the chat.message row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3cba69ae fix(history): add an error state and cancel out-of-order loads on the History page Amal 2026-08-01 ↗ GitHub
commit body
WHY THIS MATTERS
Two UX/correctness bugs. When the fetch failed the catch cleared the list, so a
backend outage rendered the "No history yet" empty state - telling the user they
have no history when the truth is the request failed. And rapid filter changes
raced: because responses can arrive out of order, a slow earlier request could
land after a faster later one and overwrite it with stale rows, and a "Load
more" issued mid-filter-change appended a page from the old filter (wrong rows
and duplicate React key={e.id} warnings).

WHAT IS AN OUT-OF-ORDER RESPONSE RACE
The browser does not guarantee that fetches resolve in the order they were
started. If you fire request A (filter=all) then request B (filter=chat), B may
resolve first and A second, leaving the UI showing "all" results under a "chat"
filter. The standard fix is an AbortController: each new load aborts the
previous in-flight request, and the handler ignores any response whose signal
was aborted so a superseded request can neither overwrite fresher state nor
surface a false error.

HOW IT WORKS
- getAuditHistory (mikeApi) gains an optional AbortSignal, threaded into the
  underlying fetch via RequestInit.
- HistoryTable keeps the live controller in a ref. Each load() aborts the
  previous controller, starts a new one, and passes its signal down. Responses
  are dropped when controller.signal.aborted; the aborted-request rejection
  (AbortError) is swallowed rather than treated as an error. The effect's
  cleanup aborts on unmount.
- A dedicated error state renders "Couldn't load your history - Try again"
  (with a retry button) instead of the misleading empty state; the genuine
  empty state only shows when there was no error.
- Nit (F12): drops a leaked private-fork "(Clue custom)" comment from
  HistorySkeuoIcon.

Frontend tsc is clean and the existing suite passes; the History page has no
pre-existing component test to extend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
e12b5cf9 Merge remote-tracking branch 'upstream/main' into upstream-pr/audit-history JJ 2026-08-04 ↗ GitHub
# Conflicts:
#	backend/src/app.ts
d3f41718 feat: refine workspace history experience willchen96 2026-08-13 ↗ GitHub
bebbc713 Merge main into upstream-pr/audit-history willchen96 2026-08-13 ↗ GitHub
4ddcd5a7 fix: clear history CI and security checks willchen96 2026-08-13 ↗ GitHub

Capture this PR into my fork

Download a Markdown prompt that tells Claude how to port every commit in this PR into your working tree. Run it via claude -p < capture-pull-271.md from inside the repo you want the changes in.

⬇ Download capture-pull-271.md