Activity Log
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 bypassedauthUser(treat as "system wrote this — investigate")actor_source—'api'(UI or external),'chat'(AI assistant tool call), or'system'(trigger saw nox-actor-sourceheader)source_ref— context-specific pointer, e.g. the chat ID whenactor_source = 'chat'table_name,row_id,action— what changedbefore,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:
| Header | Value |
|---|---|
x-actor-id | The authenticated user's UUID |
x-team-id | Active team (fallback when row has no team_id column) |
x-actor-source | api, chat, or system |
x-actor-ref | Optional 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/activityshows every row for the current team. Owners only (enforced by RLS + theactivity.viewpermission + therequirePermissionmiddleware on the page). - AI chat —
activity_logis registered intablePermissionsas 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/changedcolumns are heavy JSON. Don'tselect *— project specific keys (before->>'title') or aggregate.