The audit log. It subscribes to the transactional outbox instead of being called, so an audited action cannot commit without its audit row.
Install
npm install @orthacms/activity-server @orthacms/activity-adminRegister
// apps/server/src/plugins.ts
import { ActivityPlugin } from '@orthacms/activity-server';
// after DatabasePlugin, IdentityPlugin and WorkspacesPlugin
ActivityPlugin();// apps/admin/src/plugins.ts
import { ActivityPlugin } from '@orthacms/activity-admin';
ActivityPlugin();No options, no environment variables. It owns activity_events and ships its
migrations. Since 0.13.0, 0002_subject_label adds a subject_label column and
backfills it from existing rows; run orthacms migrate after upgrading.
- Nothing prunes
activity_events. Sign-ins, failed attempts and token uses (at most one row per token per minute) are all kept. Plan for growth. OUTBOX_RETENTION_DAYSprunes delivered outbox rows, not this table.
Calling the routes
Admin routes: they take the session cookie, not an API token.
curl -c cookies.txt https://cms.example.com/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@example.com","password":"…"}'| Route | Permission | X-Workspace-Id |
|---|---|---|
GET /api/activity | activity:read | no — filter with workspaceId |
GET /api/activity/export | activity:read | no |
GET /api/activity/entries/:entryId | content:read | required |
GET /api/activity/dead-letters | activity:read | no |
POST /api/activity/dead-letters/:id/retry | activity:manage | no |
Reading the log
curl -G https://cms.example.com/api/activity \
-b cookies.txt \
--data-urlencode 'q=spring launch' \
--data-urlencode 'kind=entry.published,entry.publish_bypassed' \
--data-urlencode 'workspaceId=3f7a…' \
--data-urlencode 'from=2026-09-01T00:00:00Z' \
--data-urlencode 'pageSize=50'{
"items": [
{
"id": "0b8e…",
"kind": "entry.published",
"subjectType": "content_entry",
"subjectId": "9c4b…",
"subjectLabel": "Spring launch",
"actorId": "4d1c…",
"actorType": "api_token",
"actorEmail": null,
"workspaceId": "3f7a…",
"meta": { "contentType": "article", "title": "Spring launch" },
"at": "2026-09-30T08:41:02.117Z"
}
],
"total": 1,
"page": 1,
"pageSize": 50
}| Query param | Notes |
|---|---|
q | Free text (≤ 255): a case-insensitive substring over the actor email, subjectLabel, the subject id and type, and the kind — spaces also match the kind’s _. meta is never searched |
kind | Comma-separated kinds, matched with IN |
subjectType, subjectId | One subject’s history, e.g. user / <id> |
actorId | A user id |
actorType | user or api_token |
actorEmail | Case-insensitive substring |
workspaceId | One workspace; rows with no workspace are excluded |
from, to | ISO 8601, inclusive |
filter | A JSON filter tree, AND-ed with the rest |
page, pageSize | Default 1 and 25; pageSize ≤ 100 |
sort, order | at or kind; asc or desc (default at, desc) |
subjectLabel is the subject’s name at the time of the event — lifted from
the kind’s own meta (title, name, label, email, key or slug, in that
order) when the row is written, and null when there is none. It is what q
finds a record by.
actorType tells a person from a token: a write over /api/v1, GraphQL or MCP
records the token, not “System”. A copilot change applied for somebody records
that person, with meta.via saying how.
Exporting
GET /api/activity/export takes the same q, filters and sort as the list,
without paging, and answers a CSV download of every column (at, kind,
actor_type, actor_id, actor_email, subject_type, subject_id,
subject_label, workspace_id, event_id, meta as JSON):
curl -G https://cms.example.com/api/activity/export \
-b cookies.txt \
--data-urlencode 'q=published' \
--data-urlencode 'from=2026-10-01T00:00:00Z' \
-o activity.csv -D -
# X-Total-Count: 312
# X-Export-Truncated: false- At most 10,000 rows.
X-Total-Countis the uncapped count andX-Export-Truncated: truesays the file is not the whole answer. - A cell a spreadsheet would read as a formula is prefixed with
'. no-store; writes nothing, not even an audit row.
In the admin
The Activity page is one search box (Search people, records, actions, sent
as q), a time range (Last 7 days by default; ?range=today, 7d, 30d,
90d or all), a filter button, and a When · Event · Workspace table —
the actor is in the event’s sentence. Clicking a row opens a panel beside the
table: the sentence, the exact time, a before/after table of what the event
recorded, the actor and subject, the event id and the raw metadata. Export
CSV in the top bar downloads the current view. Every filter lives in the URL.
One entry’s history
The rows between the versions of the entry editor’s History timeline —
shown under All and Activity. Since 0.12.0 the admin contributes them
through content’s ENTRY_TIMELINE_SLOT instead of a tab of its own; an old
…/activity link opens History filtered to activity. Gated on content:read,
so editors can use it:
curl 'https://cms.example.com/api/activity/entries/9c4b…?page=1&pageSize=25' \
-b cookies.txt -H 'X-Workspace-Id: 3f7a…'It fails closed: an id from another workspace reads as an empty page.
Dead letters
An outbox event no subscriber accepted after 15 attempts (about half an hour of backoff) is parked. Most parked rows are audit rows that were never written.
curl 'https://cms.example.com/api/activity/dead-letters?limit=50&since=2026-09-29T00:00:00Z' \
-b cookies.txt{
"total": 1,
"items": [
{
"id": "6f3a…",
"kind": "media.asset.uploaded",
"aggregateType": "media_asset",
"aggregateId": "a1b2…",
"occurredAt": "2026-09-29T22:10:04.550Z",
"attempts": 15,
"lastError": "relation \"activity_events\" does not exist"
}
]
}limit is 1–200 (default 50), newest first. The Activity page shows a notice
whenever total is non-zero, and so does a card under Home’s Needs your
attention for anyone holding activity:read.
Retrying a parked event
Read lastError, fix the cause, then retry one row. In the admin, Review and
retry on the notice lists the parked events with a Retry per row and
Retry all:
curl -X POST https://cms.example.com/api/activity/dead-letters/6f3a…/retry \
-b cookies.txt const response = await fetch('/api/activity/dead-letters/' + id + '/retry', {
method: 'POST',
credentials: 'include'
});
if (response.status === 409) {
// still being retried on its own schedule — nothing to un-park
} {
"id": "6f3a…",
"kind": "media.asset.uploaded",
"aggregateType": "media_asset",
"aggregateId": "a1b2…",
"occurredAt": "2026-09-29T22:10:04.550Z",
"attempts": 0,
"nextAttemptAt": null,
"lastError": "relation \"activity_events\" does not exist"
}- Resets the row in place: same event id (subscribers deduplicate on it),
attemptsto 0, backoff cleared,lastErrorkept. - Drains the outbox before it answers.
- Writes an
outbox.event_retriedaudit event with the stuck event’s kind and last error. - 404 — unknown id or already delivered (deliberately the same answer).
- 409 — the row has not given up yet:
{
"statusCode": 409,
"message": "Event 6f3a… has not given up yet (4 of 15 attempts spent); it is still being retried on its own schedule.",
"error": "Conflict"
}- No bulk route. The admin’s Retry all retries the rows its dialog shows,
one request at a time through this route, and stops at the first refusal other
than a 404 or 409.
activity:manageis admin-only, and no API token scope carries it.
See domain events for the retry ladder.
Event kinds
kind and meta are open (text and jsonb); each plugin owns its own kinds.
| Source | Examples |
|---|---|
| Identity | user.signed_in, user.sign_in_failed, token.used, user.sso_linked, user.sso_provisioned, user.sso_role_mapped |
| Users, workspaces | user.role_changed, workspace.member_added |
| Content | entry.created, entry.updated, entry.published, entry.restored, entry.purged |
| Media | media.asset.uploaded, media.folder.renamed |
| Saved views | saved_view.created, saved_view.updated, saved_view.deleted |
| Transfer | transfer.content.exported, transfer.content.imported |
| Protection | review.requested, review.approved, entry.publish_bypassed, protection.rule_changed |
| Activity | outbox.event_retried |
Segments, alarms and the copilot contribute kinds too.
A new event kind needs a mapper here
A kind with no entry in FACET_MAPPERS (audit-event-mapping.ts) is never
audited, and nothing errors: the dispatcher finds no subscriber and marks the row
delivered. Check after adding a producer:
select distinct kind from outbox_events;
-- compare with AUDITED_EVENT_KINDS in the same fileDelivered rows are pruned after OUTBOX_RETENTION_DAYS, so the query sees that
window only. The admin also needs a label in ACTIVITY_KINDS
(@orthacms/activity-admin), or it prints the raw dotted kind.
Copilot tool
activity_recent — a read-only page of the same log, offered only to holders of
activity:read. Copilot only; not exposed over MCP.