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.
statusstaysdraft/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-adminRegister
// 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"
}| Field | Default | Meaning |
|---|---|---|
enabled | false | Off behaves exactly like no rule, tokens included |
requiredApprovals | 1 | Approvals on the current revision that unlock publishing (1–100) |
requireOtherPerson | true | The author of the current revision cannot approve it — admins included |
countStaleApprovals | false | Count approvals given on earlier revisions. The weaker choice |
adminBypass | true | A protection:manage holder may publish past the rule, with bypass: true |
allowTokenPublish | false | Off: an API token cannot publish this type at all |
| Route | Permission |
|---|---|
GET /api/protection/rules | protection:manage |
PUT /api/protection/rules/:kind/:slug — kind is collection or single | protection:manage |
DELETE /api/protection/rules/:kind/:slug — idempotent 204, no grant check | protection: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 bodyreviewerIds: 1–50 unique user ids. Naming someone never changes whose approval counts.- Allowed on an unprotected type too.
DELETE …/requestwithdraws it (the requester or an admin). The row is kept as history.
| Status | code | When |
|---|---|---|
422 | protection.reviewer_not_eligible | A reviewer is not another member who can approve |
409 | protection.nothing_to_review | The 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
noteis a400. DELETE …/approvewithdraws your vote on the head only (204).- Approving your own head under
requireOtherPersonis409 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—truewhen 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/:typereturns{ 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:
| Gate | Refuses with | Owns |
|---|---|---|
| Permissions guard | 403 | May this caller publish at all (content:publish) |
| Publish gate | 422 | Is the entry complete |
| Protection guard | 409 | May this person ship it now |
Approvals never make an incomplete entry publishable, and neither does a bypass.
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
adminBypasson the rule, or withoutprotection:manage:403 protection.bypass_refused. - An allowed bypass writes
entry.publish_bypassedin the same transaction. bypassis ignored when nothing would have refused.- Bulk publish never bypasses.
code | Status | Fix |
|---|---|---|
protection.insufficient_approvals | 409 | Get approvals on the current revision, or bypass |
protection.token_refused | 409 | Publish as a person, or set allowTokenPublish |
protection.bypass_refused | 403 | Needs 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
}| Route | Permission | Returns |
|---|---|---|
GET /api/protection/queue — mine, limit (≤ 100, default 50), offset | content:read | The Reviews page’s first two tabs |
GET /api/protection/queue/approved | content: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 ids | content:read | { byEntry: { [id]: { protected, required, given, stale, requested, blocked } } } |
GET /api/insights/protection/reviews | content:read | { open, overdue, overdueAfterDays, oldestOpen } |
- Since 0.13.0 every queue line carries
headRevisionNumber,liveRevisionNumber(nullif never published) andfieldsChanged— how many fields the head changed since the live version, counted with content’s owndiffSnapshots(nullwhen 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, ornull. oldestOpennames the longest-waiting request (entryTitle,contentTypeLabel,waitingDays), or isnullwhen nothing is open.- An entry’s review also carries
countsStaleApprovals— the rule’s switch,falsewhen 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: trueand a request is refused with409 protection.nothing_to_reviewuntil 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
| Key | Held by | Gates |
|---|---|---|
content:approve | Admin, contributor | Approving and withdrawing a vote |
protection:manage | Admin | The rule routes, and bypass |
- There is no
protection:read: an entry’s review is readable withcontent:read. - No API token scope grants
content:approve. A token names no person. - Both keys reach an existing install on the next boot —
seedSystemRolesreconciles the catalogue.
Agent tools
| Tool | Surfaces | Requires | Input |
|---|---|---|---|
protection_review_status | Copilot, MCP | content:read | { typeName, entryId } |
protection_review_diff | Copilot, MCP | content:read | { typeName, entryId } — fields changed since the caller’s last approval |
protection_request_review | Copilot, MCP | content: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
| Kind | When |
|---|---|
review.requested | A request is opened, naming the reviewers |
review.approved | With the revision number |
entry.publish_bypassed | Actor, rule, required and given |
protection.rule_changed | Every 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.