Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Activity plugin

The audit log — reading it, retrying parked events, and adding a new event kind.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

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-admin

Register

// 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_DAYS prunes 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":"…"}'
RoutePermissionX-Workspace-Id
GET /api/activityactivity:readno — filter with workspaceId
GET /api/activity/exportactivity:readno
GET /api/activity/entries/:entryIdcontent:readrequired
GET /api/activity/dead-lettersactivity:readno
POST /api/activity/dead-letters/:id/retryactivity:manageno

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 paramNotes
qFree 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
kindComma-separated kinds, matched with IN
subjectType, subjectIdOne subject’s history, e.g. user / <id>
actorIdA user id
actorTypeuser or api_token
actorEmailCase-insensitive substring
workspaceIdOne workspace; rows with no workspace are excluded
from, toISO 8601, inclusive
filterA JSON filter tree, AND-ed with the rest
page, pageSizeDefault 1 and 25; pageSize ≤ 100
sort, orderat 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-Count is the uncapped count and X-Export-Truncated: true says 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:

Example language
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), attempts to 0, backoff cleared, lastError kept.
  • Drains the outbox before it answers.
  • Writes an outbox.event_retried audit 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:manage is 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.

SourceExamples
Identityuser.signed_in, user.sign_in_failed, token.used, user.sso_linked, user.sso_provisioned, user.sso_role_mapped
Users, workspacesuser.role_changed, workspace.member_added
Contententry.created, entry.updated, entry.published, entry.restored, entry.purged
Mediamedia.asset.uploaded, media.folder.renamed
Saved viewssaved_view.created, saved_view.updated, saved_view.deleted
Transfertransfer.content.exported, transfer.content.imported
Protectionreview.requested, review.approved, entry.publish_bypassed, protection.rule_changed
Activityoutbox.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 file

Delivered 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.