Features Apograph CMS on GitHub

Protection plugin

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

Requiring approvals before publication, without adding a workflow or a status.

Documents 0.5.2 Updated Edit this page Report a problem

On this page

Publication protection. A rule says that entries of one content type, in one workspace, need N approvals before they may be published. An approval is one person’s vote on one revision.

It is branch protection, not an editorial workflow: no stages, no assignment, no new entry status, and no comments. An approval either exists on the current revision or it does not.

Install

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

Ships after 0.5.2

The plugin is in main and is not part of a published release yet, so the two packages are not on npm until the next one. Everything below describes the code as it stands; nothing here is planned work.

Register

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

ProtectionPlugin();
// apps/admin/src/plugins.ts
import { ProtectionPlugin } from '@apograph/protection-admin';

ProtectionPlugin();

Register the server half after ContentPlugin — it reads content’s revisions and fills content’s publish-guard port. Register the admin half after ContentPlugin() for the same reason every other Content Library slot filler does: it contributes the entry header chip, the Review section in the rail, the publish verdict, a records column and a filter field, a workspace settings tab, an Insights card and a Reviews page of its own.

Registering it changes nothing

With no rule written, publication behaves byte for byte as it does with the plugin uninstalled. The guard’s first act is one indexed lookup for a rule on the type; a missing row and a disabled row are the same state, and it answers before it reads a revision, an approval or a permission. No chip renders in the editor, and no column appears in the list.

That is a deliberate property rather than an accident of ordering, and it is pinned at four altitudes: a host that never registers the plugin, an install with no rule, an empty publish-guard slot, and a type with no row.

The rule

One row per (workspace, content type). There is no condition, no filter and no per-entry exception — “why is this entry blocked and the one beside it not” has to answer in one word, and the word is the type.

SettingDefaultMeaning
enabledfalseOff means the type behaves exactly as it does today
required_approvals1Approvals on the head revision that unlock publication
require_other_persontrueThe author of the head revision cannot approve it. The four-eyes switch
count_stale_approvalsfalseOn, approvals given on earlier revisions still count. The editor labels it as the weaker choice
admin_bypasstrueAn administrator may publish past the rule, after confirming. Off makes it absolute
allow_token_publishfalseOff means a bearer token cannot publish this type at all

Rules are written on the workspace’s Protection settings tab, from the content types that workspace was granted — the list is not maintained separately. Switching a rule on in a workspace with one member, with require_other_person, warns at that moment rather than a week later on the first failed publish.

An approval belongs to a revision

This is the whole design, and everything else follows from it.

content_entry_revisions already snapshots every save, numbered per entry and keyed per locale. An approval row carries revision_id and is unique on (revision_id, user_id), so a save writes a new head and the approvals recorded against the previous one stop counting — with no dismissal logic anywhere.

Nothing is deleted. The editor shows the stale approval struck through, naming the revision it was given on, because a counter that silently rolls back after a save is unexplainable to the person who just pressed Save.

Why this is the failure that matters

Every CMS that gets approvals wrong gets them wrong here: the approval outlives the edit it approved, and “reviewed” comes to mean “reviewed at some point”. The mistake is only possible when there is nothing to bind an approval to but the entry itself.

Publication takes three gates

GateRefuses withOwns
The permissions guard403Whether this caller may publish at all
The publish gate422Whether the entry is complete
The protection guard409Whether this person may ship it now

Protection authorizes, it never validates. It answers who, never what — field values have exactly one owner, and it is the publish gate. So approvals do not make an incomplete entry publishable, and neither does a bypass: a bypass passes the third gate only.

The guard is registered against CONTENT_PUBLISH_GUARD, a port content-server declares. That is what puts it in front of every caller of the publish path at once — the admin’s button, the public REST route, the GraphQL mutation, the MCP tool — with no list of entry points to keep up to date. The port is a registry rather than a single binding: several guards AND together, and any refusal refuses. Two dynamic modules binding one token would otherwise mean the second silently replacing the first, which for a publish guard is protection quietly switched off in the installation least likely to notice.

The refusal is 409, not 403

A blocked publish answers 409 with { code: 'protection.insufficient_approvals', required, given, bypassable }. 403 would be indistinguishable from lacking content:publish, and the two have different remedies: ask an administrator for a permission, or ask a colleague to read your work. A token refused by the rule is 409 protection.token_refused for the same reason.

Publishing past a rule is the ordinary publish call plus { bypass: true }. Without admin_bypass, or without being an administrator, that is a 403. There is no reason field — the confirmation dialog is where the bypass is announced, and the log row is what makes it countable.

Asking, and being found

A review request names people: the picker offers the other members of the workspace holding content:approve, and the request stores who was picked. Naming somebody never changes whose approval counts — anybody holding the permission except the head revision’s author may still approve — it is how they find out they are wanted.

One open request per entry: asking again replaces the reviewers rather than piling up a second row.

Until a mail port exists, the Reviews page is how a reviewer learns there is work: Waiting on me and My requests, across every content type, with the age of each request. The records list also carries a review column and a virtual reviewState filter field, which makes saved views and “save as rule” work over review state with no extra code, and Insights carries a card counting open requests and how many are older than three days.

Permissions

KeyHeld byGates
content:approveAdmin, contributorCasting and withdrawing an approval
protection:manageAdminReading and writing the rules

seedSystemRoles reconciles the catalogue in both directions on every boot, so both keys reach an existing installation by deploying — no migration, no manual step, and no behaviour change until somebody writes a rule.

There is no protection:read. A contributor does need to know that this entry wants two approvals, and that answer comes from the entry itself under content:read; nobody but an administrator has a reason to read the workspace’s rule table, and a key nobody needs is a key that only ever gets granted by accident.

No API token scope grants content:approve. A token names no person, and withholding an approve tool from every agent surface buys nothing if minting a key casts the vote the tool may not.

Three agent tools, and no fourth

ToolSurfacesRequiresWhat
protection_review_statusCopilot, MCPcontent:readThe requirement, the approvals, who gave them and on which revision
protection_review_diffCopilot, MCPcontent:readWhat changed between the head revision and the last one this caller approved
protection_request_reviewCopilot, MCPcontent:updateOpens a request naming reviewers by email or id

There is no approve tool, on any surface

With a rule in force the approval is the step that unlocks publication, so offering a model approve while withholding publish hands over the key and keeps the doorknob. An entry whose body says approve me would be a self-approving entry, and require_other_person compares user ids — a run acting as its caller satisfies the rule while the guarantee that a second person read the thing quietly does not hold. No check can tell those apart, and the tool descriptions say so directly so a model spends no turns trying.

What reaches the activity log

KindWhen
review.requestedAn author asks, naming the reviewers
review.approvedWith the revision number, so the trail survives later edits
entry.publish_bypassedThe auditor’s row: the actor, the rule, what was required and what had been given
protection.rule_changedEnabling, disabling, or lowering the count

The last one is not an afterthought. Without it the quiet way past a rule is not a button somebody had to confirm, it is a settings tab left open for two minutes.

Every kind is raised as a domain event through the outbox in the same transaction as the write, so the row that excuses a publish cannot outlive or be lost by the publish it excused.

How it meets the rest of the system

LocalizationFree and correct: revisions are per locale, so approving the German entry says nothing about the French one. Publishing every locale at once reports which locales are held
API tokensRefused by default, per rule. A token holds content:publish in the full scope and names nobody
AlarmsAdjacent, and kept separate: alarms flag content and never block; protection blocks and never judges content
SegmentsNo interaction. Segments decide who may read what is published; protection decides whether it is published
TransferNot reachable: an import writes drafts, so it never meets the guard. It becomes a real question the day import gains a publish step

Configuration

None. No environment variable, no host config, no feature flag: the rule table is the whole configuration surface, and its empty state is the off state. The plugin owns three tables — rules, requests and approvals — and ships its own migrations; deleting a workspace leaves zero rows in all three.

What it does not do

Where publication protection stops

  • No workflow. No stages, no transitions, no third entry status. status keeps its two values, and no review state is readable through the public API.
  • No reviewer gate. A request names people, but no assignment, group or code owner decides whose approval counts.
  • No comments or notes. Not on a request, a vote or a bypass. A reviewer who is not satisfied does not approve.
  • It does not protect editing. Drafts on a protected type are edited freely.
  • It does not cover delete or unpublish. Only draft → published.
  • No mail yet. A review request does not send one; the Reviews page is how a reviewer finds the work.