Reference

Activity Log

Trigger-driven audit of every mutation on opted-in tables.

Every write to an opted-in table is captured by the log_activity() Postgres AFTER trigger — actor, source, before/after snapshot, and a per-column diff for updates. The log is visible at /app/activity to team owners only (activity.view permission).

What gets captured

Each row in activity_log:

  • team_id — team the change belongs to (never null; rows that can't be attributed are dropped by the trigger)
  • actor_id — user who initiated the change. Null when the write came from a service-role path that bypassed authUser (treat as "system wrote this — investigate")
  • actor_source'api' (UI or external), 'chat' (AI assistant tool call), or 'system' (trigger saw no x-actor-source header)
  • source_ref — context-specific pointer, e.g. the chat ID when actor_source = 'chat'
  • table_name, row_id, action — what changed
  • before, after, changed — full row snapshots and per-column diff

How it knows who did what

authUser() / authUserOnly() return a Supabase client built by createAuditedClient({ actorId, teamId, source: 'api' }). That client attaches three headers to every PostgREST request:

HeaderValue
x-actor-idThe authenticated user's UUID
x-team-idActive team (fallback when row has no team_id column)
x-actor-sourceapi, chat, or system
x-actor-refOptional reference, e.g. <chatId> for chat

The trigger reads current_setting('request.headers') to populate actor_id, actor_source, and source_ref. Any write not routed through createAuditedClient (e.g. a direct serverSupabaseServiceRole call) produces a row with actor_id = null / actor_source = 'system' — that's your signal that someone bypassed authUser.

Chat endpoints override the default: server/api/chats/[id].post.ts builds its own client with createAuditedClient({ source: 'chat', sourceRef: chatId }), so every tool-driven write is tagged with the chat that caused it.

Opting a new table in

One line in a migration:

select enable_activity_log('tasks');

To exclude sensitive columns from the before/after snapshots:

select enable_activity_log('api_keys', exclude_cols => array['key_hash']);

enable_activity_log is idempotent — it drops any existing trigger on the target table first, so you can re-run it safely.

No-op updates (same row upserted with identical values) are detected by the trigger and suppressed. Excluded columns never reach activity_log.before / after / changed.

Add a UI filter entry

The /app/activity page exposes a table filter dropdown populated from the tableItems array in app/components/activity/List.vue. Whenever you opt a new table in, add an entry there:

const tableItems = [
  // ...
  { label: "Tasks", value: "tasks", icon: "i-lucide-check-square" },
];

Without this entry, the log still captures writes to the table, but the filter dropdown won't let owners narrow down to it.

Global tables

Tables without a team_id column (e.g. profiles) are intentionally not opted in by default — attributing a profile name change to one of N teams would be misleading. The heavy, noisy chats and chat_messages tables are also excluded: the useful audit trail is captured on the target table of each tool call, tagged with actor_source='chat'.

Using the log

  • UI/app/activity shows every row for the current team. Owners only (enforced by RLS + the activity.view permission + the requirePermission middleware on the page).
  • AI chatactivity_log is registered in tablePermissions as read-only for owners. The LLM can answer "what changed in the last hour?" style questions for owners and is blocked for admins/members.
  • SQL — the before/after/changed columns are heavy JSON. Don't select * — project specific keys (before->>'title') or aggregate.