The audit log. It records what happened by subscribing to the transactional outbox rather than by being called — which is what makes an audited action impossible to commit without its audit row, and impossible to audit without committing.
Install
npm install @apograph/activity-server @apograph/activity-adminRegister
// apps/server/src/plugins.ts
import { ActivityPlugin } from '@apograph/activity-server';
ActivityPlugin();// apps/admin/src/plugins.ts
import { ActivityPlugin } from '@apograph/activity-admin';
ActivityPlugin();No configuration, no environment variables. It owns activity_events and ships
its migrations.
A generic sink
kind and meta are open — text and jsonb. Each emitting plugin owns its
own event kinds: identity emits authentication and token events, users emits
the member lifecycle, content emits the publish lifecycle, media emits asset
and folder changes. That is what keeps this package decoupled from any one
domain.
Since 0.4.0 the sign-in row also records how somebody signed in and through
which provider, and single sign-on contributes three kinds of its own —
user.sso_linked, user.sso_provisioned and user.sso_role_mapped. They are
deliberately not folded into the sign-in row: “somebody signed in” cannot
answer “where did this account come from?”, which is the first question anyone
reviewing an SSO deployment asks.
A new producer needs a mapper here
An event kind with no entry in FACET_MAPPERS is not an error anywhere. The
dispatcher finds no subscriber, stamps the row dispatched_at, and the action
is simply never audited — silently.
The check is one query: compare select distinct kind from outbox_events
against AUDITED_EVENT_KINDS. This has been missed three times upstream — API
tokens, entry publishes, and the whole media library.
Reading it
The admin’s Activity Log drives GET /api/activity, filtered by workspace,
actor, kind and date range. See the endpoint
reference for the query parameters, and
insights and activity for what the page
does with them.
Dead letters
An outbox event no subscriber accepted after fifteen attempts is parked, and the
subscriber it was most often bound for is this plugin’s — so a parked row is,
more often than not, an audit row that was never written. GET /api/activity/dead-letters lists them (gated on activity:read, the same key
as the log, because “what is missing from the trail” is the same question as
“what is in it”), and the Activity page shows a notice whenever the total is
non-zero.
It reports and does not repair. Replaying a parked row means clearing its
attempts, which is an operator’s action against a fixed cause rather than a
button. See domain events
for the query and the retry ladder behind it.