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 |