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

Protection plugin

Protection plugin @orthacms/protection-server@orthacms/protection-admin

Requiring approvals before publication — rules, review requests, the publish guard and its error bodies.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

A rule says entries of one content type, in one workspace, need N approvals before they publish. An approval is one person’s vote on one revision.

  • No stages, no assignment, no new entry status. status stays draft / published.
  • A save writes a new revision, so earlier approvals stop counting — no dismissal logic.
  • With no rule written, publishing behaves exactly as without the plugin.

Install

npm install @orthacms/protection-server @orthacms/protection-admin

Register

// apps/server/src/plugins.ts
import { ProtectionPlugin } from '@orthacms/protection-server';

export function buildPlugins(config: OrthaCmsConfig): ServerPlugin[] {
    return [
        // …DatabasePlugin, IdentityPlugin, WorkspacesPlugin, ContentPlugin…
        ProtectionPlugin() // after content: it fills content's publish-guard port
    ];
}
// apps/admin/src/plugins.ts
import { ProtectionPlugin } from '@orthacms/protection-admin';

export function buildPlugins(): AdminPlugin[] {
    return [
        // …IdentityPlugin(), ShellPlugin(), WorkspacesPlugin(), InsightsPlugin(), ContentPlugin()…
        ProtectionPlugin()
    ];
}

Configuration

None. No options, no environment variables. The rule table is the configuration, and its empty state is “off”. The plugin ships its own migrations (__drizzle_migrations_protection) for three tables: rules, requests, approvals. Deleting a workspace clears all three. Since 0.13.0, migration 0004_review_approvals_by_user adds the index Recently approved reads; run orthacms migrate after upgrading.

Calling the routes

Every route below is an admin route: it takes the session cookie, not an API token, and is scoped by X-Workspace-Id. Writes also pass OriginGuard — send no Origin header from a script, or one listed in ALLOWED_ORIGINS.

# Sign in once and keep the orthacms_session cookie
curl -c cookies.txt https://cms.example.com/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"admin@example.com","password":"…"}'

An unknown type, a type of the wrong kind and a type the workspace was not granted all answer the same 404.

Rules

PUT replaces the rule: an omitted field takes its default, not its previous value.

curl -X PUT https://cms.example.com/api/protection/rules/collection/article \
  -b cookies.txt \
  -H 'X-Workspace-Id: 3f7a…' \
  -H 'Content-Type: application/json' \
  -d '{"enabled":true,"requiredApprovals":2}'
{
    "id": "c1d2…",
    "kind": "collection",
    "slug": "article",
    "enabled": true,
    "requiredApprovals": 2,
    "requireOtherPerson": true,
    "countStaleApprovals": false,
    "adminBypass": true,
    "allowTokenPublish": false,
    "updatedBy": "8a0e…",
    "createdAt": "2026-09-30T08:12:40.101Z",
    "updatedAt": "2026-09-30T08:12:40.101Z"
}
FieldDefaultMeaning
enabledfalseOff behaves exactly like no rule, tokens included
requiredApprovals1Approvals on the current revision that unlock publishing (1–100)
requireOtherPersontrueThe author of the current revision cannot approve it — admins included
countStaleApprovalsfalseCount approvals given on earlier revisions. The weaker choice
adminBypasstrueA protection:manage holder may publish past the rule, with bypass: true
allowTokenPublishfalseOff: an API token cannot publish this type at all
RoutePermission
GET /api/protection/rulesprotection:manage
PUT /api/protection/rules/:kind/:slug — kind is collection or singleprotection:manage
DELETE /api/protection/rules/:kind/:slug — idempotent 204, no grant checkprotection:manage

Asking for review

List who can be asked — other workspace members holding content:approve:

curl https://cms.example.com/api/protection/entries/article/9c4b…/reviewers \
  -b cookies.txt -H 'X-Workspace-Id: 3f7a…'
{ "candidates": [{ "userId": "5e21…", "email": "grace@example.com" }] }

Open the request (or replace the reviewers on the open one — one open request per entry):

curl -X POST https://cms.example.com/api/protection/entries/article/9c4b…/request \
  -b cookies.txt -H 'X-Workspace-Id: 3f7a…' \
  -H 'Content-Type: application/json' \
  -d '{"reviewerIds":["5e21…"]}'
# 201, no body
  • reviewerIds: 1–50 unique user ids. Naming someone never changes whose approval counts.
  • Allowed on an unprotected type too.
  • DELETE …/request withdraws it (the requester or an admin). The row is kept as history.
StatuscodeWhen
422protection.reviewer_not_eligibleA reviewer is not another member who can approve
409protection.nothing_to_reviewThe entry is published and unchanged since — nothing new to review
{
    "statusCode": 409,
    "code": "protection.nothing_to_review",
    "message": "…"
}

Approving

curl -X POST https://cms.example.com/api/protection/entries/article/9c4b…/approve \
  -b cookies.txt -H 'X-Workspace-Id: 3f7a…'
# 201, no body — idempotent
  • Binds the vote to the current revision. The next save leaves it off the head.
  • The body must be empty: a note is a 400.
  • DELETE …/approve withdraws your vote on the head only (204).
  • Approving your own head under requireOtherPerson is 409 protection.self_approval_refused.

Reading an entry’s review

GET /api/protection/entries/:type/:id — content:read, so a contributor sees “1 of 2”.

{
    "protected": true,
    "required": 2,
    "given": 1,
    "stale": 1,
    "blocked": true,
    "bypassable": false,
    "afterSave": { "required": 2, "given": 0, "blocked": true, "bypassable": false },
    "headRevisionId": "b7c0…",
    "headRevisionNumber": 7,
    "headPublished": false,
    "callerWroteHead": true,
    "callerApprovedHead": false,
    "approvals": [
        { "userId": "5e21…", "revisionId": "b7c0…", "revisionNumber": 7, "isStale": false, "createdAt": "…" },
        { "userId": "91aa…", "revisionId": "40d3…", "revisionNumber": 5, "isStale": true, "createdAt": "…" }
    ],
    "request": {
        "id": "e8f1…",
        "requestedBy": "8a0e…",
        "reviewerIds": ["5e21…", "91aa…"],
        "revisionId": "40d3…",
        "createdAt": "…"
    }
}
  • afterSave — the verdict for the version a save would write now (no approvals, you as author). The editor uses it when there are unsaved edits.
  • headPublished — true when the head is the live version. Requesting review is then refused (see above) until the next save.
  • For a create form with no id yet: GET /api/protection/types/:type returns { protected, required, given, blocked, bypassable }.

The publish guard

Protection is the third gate on every publish — admin button, /api/v1 REST, GraphQL and MCP alike:

GateRefuses withOwns
Permissions guard403May this caller publish at all (content:publish)
Publish gate422Is the entry complete
Protection guard409May this person ship it now

Approvals never make an incomplete entry publishable, and neither does a bypass.

Example language
curl -X POST https://cms.example.com/api/v1/content/article/9c4b…/publish \
-H 'Authorization: Bearer orthacms_…' \
-H 'X-Workspace-Id: 3f7a…'

# 409 — a token on a rule with allowTokenPublish: false
{
"statusCode": 409,
"code": "protection.token_refused",
"message": "Publishing \"article\" requires review, and an API token cannot be reviewed. Publish as a person, or allow token publishing on the rule."
}
const response = await fetch(
  'https://cms.example.com/api/v1/content/article/' + id + '/publish',
  {
      method: 'POST',
      headers: {
          Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN,
          'X-Workspace-Id': workspaceId
      }
  }
);
if (response.status === 409) {
  const { code, required, given } = await response.json();
  // code: 'protection.token_refused' | 'protection.insufficient_approvals'
}
mutation {
  publishArticle(id: "9c4b…") {
      id
      status
  }
}

# 200 with an error — the REST status is in extensions.status
# { "errors": [{ "message": "Publishing \"article\" requires review, …",
#     "extensions": { "code": "CONFLICT", "status": 409 } }] }

A person short of approvals gets the counts in the body:

{
    "statusCode": 409,
    "code": "protection.insufficient_approvals",
    "message": "Publishing \"article\" needs 2 approval(s) on the current version; it has 1.",
    "required": 2,
    "given": 1,
    "stale": 1,
    "bypassable": true
}

Bypass

When bypassable is true, an admin publishes past the rule with the ordinary admin publish route plus { "bypass": true }:

curl -X POST https://cms.example.com/api/content/article/9c4b…/publish \
  -b cookies.txt -H 'X-Workspace-Id: 3f7a…' \
  -H 'Content-Type: application/json' \
  -d '{"bypass":true}'
  • Without adminBypass on the rule, or without protection:manage: 403 protection.bypass_refused.
  • An allowed bypass writes entry.publish_bypassed in the same transaction.
  • bypass is ignored when nothing would have refused.
  • Bulk publish never bypasses.
codeStatusFix
protection.insufficient_approvals409Get approvals on the current revision, or bypass
protection.token_refused409Publish as a person, or set allowTokenPublish
protection.bypass_refused403Needs protection:manage and adminBypass: true

Queue, status column and Insights

# Open requests across every type; ?mine=1 = the ones I sent
curl 'https://cms.example.com/api/protection/queue?mine=1&limit=50&offset=0' \
  -b cookies.txt -H 'X-Workspace-Id: 3f7a…'
{
    "items": [
        {
            "id": "e8f1…",
            "contentType": "article",
            "contentTypeLabel": "Article",
            "entryId": "9c4b…",
            "entryTitle": "Spring launch",
            "requestedBy": "8a0e…",
            "reviewerIds": ["5e21…"],
            "required": 2,
            "given": 1,
            "createdAt": "2026-09-26T10:03:11.000Z",
            "headRevisionNumber": 7,
            "liveRevisionNumber": 5,
            "fieldsChanged": 3
        }
    ],
    "total": 1,
    "overdueAfterDays": 3
}
RoutePermissionReturns
GET /api/protection/queue — mine, limit (≤ 100, default 50), offsetcontent:readThe Reviews page’s first two tabs
GET /api/protection/queue/approvedcontent:read{ items, windowDays } — what the caller approved in the last 30 days, at most 50 entries, newest first
GET /api/protection/entries/:typeName/status?ids=a,b,c — ≤ 100 idscontent:read{ byEntry: { [id]: { protected, required, given, stale, requested, blocked } } }
GET /api/insights/protection/reviewscontent:read{ open, overdue, overdueAfterDays, oldestOpen }
  • Since 0.13.0 every queue line carries headRevisionNumber, liveRevisionNumber (null if never published) and fieldsChanged — how many fields the head changed since the live version, counted with content’s own diffSnapshots (null when it cannot be computed).
  • A Recently approved line is one per entry, with approvedAt, approvalCurrent (your approval is on the head and still counts), headPublished, and who asked — the entry’s newest request, open or resolved, or null.
  • oldestOpen names the longest-waiting request (entryTitle, contentTypeLabel, waitingDays), or is null when nothing is open.
  • An entry’s review also carries countsStaleApprovals — the rule’s switch, false when unprotected.

“Waiting on me” is computed client-side from reviewerIds. The overdue threshold (3 days) comes from the server with the rows, so the page and the Insights line agree.

The Reviews page

Three tabs: Waiting on me, My requests and Recently approved. The first two split one request; the third is its own read, made the first time the tab opens. A line reads the record’s title over “Articles · 3 fields changed”, who asked, the reviewers, the approvals as “1 of 2”, how long it has waited (Overdue past three days) and Review. A Recently approved line says where your approval stands now: Published, Changed since or Still counts.

Review opens a side panel, not the record: who asked whom and when, the approvals, Changes since it was last published (content’s own comparison, the same History draws), what approving will do — “A new save asks again.” or “Approvals stay after a new save.” — and Approve, offered exactly when the entry’s Review block would offer it. Open record goes to the editor.

The settings section

Workspace settings → Publishing (“Content types that need a review before they go live.”), renamed from Publishing rules in 0.13.0. A type’s rule is switches — Require review before publishing, The author can’t approve their own changes, Keep approvals across new saves, Admins may publish without review, API tokens may publish — and Approvals needed as 1 / 2 / 3 / Other…. Under them the rule is read back in one sentence: “In short: a record in Articles needs 2 approvals from people other than its author; admins can override.”

The records list also gets a virtual reviewState filter field (awaiting, not_requested; eq and in), so saved views and alarm rules can use it:

{ "and": [{ "field": "reviewState", "op": "eq", "value": "awaiting" }] }

Fixed in 0.7.0

  • Nothing to review on a published, unchanged entry. The review reports headPublished: true and a request is refused with 409 protection.nothing_to_review until the next save.
  • Reviewers stay queued after a publish. Publishing closes the open request. The next edit reopens it for the same requester and reviewers, bound to the new head, when: nothing else is open, it closed by publishing (not by withdrawal), the type has an enabled rule, and at least one reviewer can still approve.

Permissions

KeyHeld byGates
content:approveAdmin, contributorApproving and withdrawing a vote
protection:manageAdminThe rule routes, and bypass
  • There is no protection:read: an entry’s review is readable with content:read.
  • No API token scope grants content:approve. A token names no person.
  • Both keys reach an existing install on the next boot — seedSystemRoles reconciles the catalogue.

Agent tools

ToolSurfacesRequiresInput
protection_review_statusCopilot, MCPcontent:read{ typeName, entryId }
protection_review_diffCopilot, MCPcontent:read{ typeName, entryId } — fields changed since the caller’s last approval
protection_request_reviewCopilot, MCPcontent:update{ typeName, entryId, reviewers: [email or id] }
curl -X POST https://cms.example.com/api/v1/mcp \
  -H 'Authorization: Bearer orthacms_…' \
  -H 'X-Workspace-Id: 3f7a…' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{
        "name":"protection_review_status",
        "arguments":{"typeName":"article","entryId":"9c4b…"}}}'

There is no approve tool, on any surface

An approval is a person saying they read the entry. A model run acting as its caller would satisfy requireOtherPerson without a second person reading anything, so no surface offers one.

Extension points

Server — the publish-guard port. Protection registers through it; your own plugin can too. Every registered guard must allow; the first refusal wins.

import { Injectable, Module } from '@nestjs/common';
import {
    contentPublishGuardRegistrar,
    type ContentPublishGuard,
    type ContentPublishGuardContext,
    type PublishVerdict
} from '@orthacms/content-server';

@Injectable()
class FreezeGuard implements ContentPublishGuard {
    async check({ type, actor }: ContentPublishGuardContext): Promise<PublishVerdict> {
        if (type.name !== 'article' || !actor.isToken) return { allowed: true };
        return {
            allowed: false,
            status: 409,
            code: 'freeze.active',
            message: 'Articles cannot be published by a token this week.'
        };
    }
}

@Module({ providers: [FreezeGuard, contentPublishGuardRegistrar('freeze', FreezeGuard)] })
export class FreezeModule {}

// plugins.ts: { name: 'freeze', module: FreezeModule }

A guard runs inside the publish transaction. It authorizes; it must not validate field values — that is the publish gate’s job.

Admin — ENTRY_PUBLISH_GUARD_SLOT (from @orthacms/content-admin) is the editor-side counterpart. An item is { id, useVerdict(context, { dirty }) } returning { blocked, reason?, action?, overlay? } or null. A refusal keeps Publish an ordinary Publish; since 0.12.0 its reason is also an item in the editor’s status cluster. Protection also fills ENTRY_SIDEBAR_WIDGET_SLOT (the Review block in the Properties panel), RECORDS_COLUMN_SLOT, RECORDS_FILTER_FIELDS_SLOT, INSIGHTS_SIGNAL_SLOT (the Waiting on review line under Insights’ Needs attention, a card before 0.13.0), WORKSPACE_SETTINGS_TAB_SLOT (the Publishing section of workspace settings) and HOME_SECTION_SLOT (a card under Home’s Needs your attention counting the entries awaiting your review across your workspaces, shown at 0 too), and adds a Reviews route.

Activity log

KindWhen
review.requestedA request is opened, naming the reviewers
review.approvedWith the revision number
entry.publish_bypassedActor, rule, required and given
protection.rule_changedEvery rule write, including disabling and removing

Each is written through the outbox in the same transaction as the change.

With other plugins

  • Localization: revisions are per locale, so approving the German entry says nothing about the French one.
  • Alarms: alarms flag and never block; protection blocks and never judges content.
  • Transfer: an import never publishes, so it never meets the guard.

What it does not do

  • No workflow stages or third entry status; no review state on the public API.
  • Naming reviewers does not restrict who may approve.
  • No comments or notes on requests, votes or bypasses.
  • Editing drafts is never blocked; delete and unpublish are not covered.
  • Review requests are not mailed — the Reviews page is how reviewers find work.