Reader entitlements on the public content API. A segment is a named set of reader tags. An entry has an allow list and a deny list of segments. A request arrives carrying the reader’s tags.
Use it for member-only articles, per-customer docs and staged regional launches.
Installing it changes nothing
With no segment created, no predicate is emitted and every public read costs what it did before. Existing clients that send no header keep seeing exactly what they saw.
Quick start
- Create a segment — key
premium. - Restrict an entry — allow
premium. - Read as a reader — send
X-Reader-Tags: premium.
# anonymous: the restricted entry is not in the list, and its single read is a 404
curl https://cms.example.com/api/v1/content/article \
-H 'Authorization: Bearer orthacms_…'
# a premium reader: it is served
curl https://cms.example.com/api/v1/content/article \
-H 'Authorization: Bearer orthacms_…' \
-H 'X-Reader-Tags: premium'Install and register
npm install @orthacms/segments-server @orthacms/segments-adminBoth ship in every app create-orthacms-app generates.
// apps/server/src/plugins.ts — after ContentPlugin, whose read-scope port it binds
import { SegmentsPlugin } from '@orthacms/segments-server';
SegmentsPlugin(config.plugins.segments);// apps/admin/src/plugins.ts — after ContentPlugin(), whose entry slots it fills
import { SegmentsPlugin } from '@orthacms/segments-admin';
SegmentsPlugin();The reader header (default resolver)
Since 0.7.0 a generated app resolves the reader from a request header.
apps/server/config/segments.ts:
import {
headerSegmentResolver,
type SegmentsPluginConfig
} from '@orthacms/segments-server';
import { readEnv } from '@orthacms/utils-server';
export function segmentsConfig(): SegmentsPluginConfig {
return {
resolver: headerSegmentResolver(
readEnv('SEGMENTS_READER_TAGS_HEADER') ?? 'x-reader-tags'
)
};
}# Renames the header. Empty = the default, X-Reader-Tags.
SEGMENTS_READER_TAGS_HEADER=How the header is read:
| Request | Reader’s tags |
|---|---|
X-Reader-Tags: premium, eu | premium, eu — comma-separated, trimmed |
X-Reader-Tags: premium sent twice | premium, premium — repeated headers are joined |
X-Reader-Tags: (empty) or no header | none — the anonymous reader |
x-reader-tags: premium | premium — header names are case-insensitive |
A tag matches a segment when it is one of the segment’s tags (which default
to its key). One matching tag is enough.
Set the header on your backend, never in the browser
The header is trusted because the API token already is. Send it from the server that holds the token — SSR frontend, BFF, edge function. A token shipped to a browser lets anyone name any segment.
Responses differ by reader. If you cache API responses, key the cache on the tag set too.
Reading as a reader
The same header works on every public protocol.
curl 'https://cms.example.com/api/v1/content/article?fields=title,slug' \
-H 'Authorization: Bearer orthacms_…' \
-H 'X-Workspace-Id: 6b1f0c2e-8a4d-4f7b-9c3e-2d5a7e9f1b40' \
-H 'X-Reader-Tags: premium, eu' // Server-side only: the token and the reader's tags never reach the browser.
export async function listArticles(reader) {
const response = await fetch(
'https://cms.example.com/api/v1/content/article?fields=title,slug',
{
headers: {
Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN,
'X-Reader-Tags': reader.plans.join(', ')
}
}
);
return response.json(); // { items, total, page, pageSize }
} const response = await fetch('https://cms.example.com/api/v1/graphql', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN,
'Content-Type': 'application/json',
'X-Reader-Tags': 'premium, eu'
},
body: JSON.stringify({
query: '{ articles(pageSize: 10) { total items { id title } } }'
})
});
const { data, errors } = await response.json(); curl -X POST https://cms.example.com/api/v1/mcp \
-H 'Authorization: Bearer orthacms_…' \
-H 'Accept: application/json, text/event-stream' \
-H 'Content-Type: application/json' \
-H 'X-Reader-Tags: premium' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"content_list","arguments":{"typeName":"article","fields":"title"}}}' What a reader who may not see an entry gets:
- List — the entry is left out of
itemsandtotal. - Single read (
/api/v1/content/:type/:id) —404, the same as an unknown id. - Relations — a hidden target is left out of the links and their count, on
REST expansion,
/relations/:field, GraphQL nested fields and the MCP relation tool.
A custom resolver
Anything other than a header — a JWT claim, a billing lookup — is a resolver
of your own. The port, from @orthacms/segments-domain:
export interface SegmentResolver<TRequest = unknown> {
/** The tags this reader carries. Empty is the anonymous reader. */
resolve(request: TRequest): Promise<readonly string[]>;
}SegmentsPluginConfig is { resolver?: SegmentResolver<unknown> }. The
resolver receives the Express request. Example — plans from a signed JWT your
backend forwards in its own header:
// apps/server/config/segments.ts
import type { IncomingMessage } from 'node:http';
import { jwtVerify } from 'jose';
import type { SegmentsPluginConfig } from '@orthacms/segments-server';
import { requireEnv } from '@orthacms/utils-server';
const key = new TextEncoder().encode(requireEnv('READER_JWT_SECRET'));
export function segmentsConfig(): SegmentsPluginConfig {
return {
resolver: {
async resolve(request) {
const token = (request as IncomingMessage).headers['x-reader-token'];
if (typeof token !== 'string') return []; // anonymous
const { payload } = await jwtVerify(token, key);
return Array.isArray(payload.plans) ? payload.plans.map(String) : [];
}
}
};
}- Don’t read
Authorization— that header carries the API token. - A resolver that throws is logged as a warning and the request is served as anonymous. It never fails the request.
- Omit
resolverentirely and every reader is anonymous: unrestricted content serves, restricted content does not.
Creating segments
Segments are installation-wide. The directory routes are admin API routes:
session cookie, segments:manage to write (administrators only).
# sign in once, keep the session cookie
curl -c cookies.txt https://cms.example.com/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@example.com","password":"…"}'
curl -b cookies.txt https://cms.example.com/api/segments \
-H 'Content-Type: application/json' \
-d '{"key":"premium","label":"Premium members","tags":["premium","plan-pro"]}'| Body field | Required | Rule |
|---|---|---|
key | yes | Lower-case, URL-safe (^[a-z0-9][a-z0-9._-]*$), max 120. Cannot be changed later |
label | yes | 1–200 characters |
tags | no | Reader tags it answers to, max 20 × 200 chars. Defaults to [key] |
workspaceIds | no | Workspaces it is offered in, max 100. Empty = every workspace |
Response (201):
{
"id": "8d0f5b7e-2c41-4b8a-9f3e-6a1d2c3b4e5f",
"key": "premium",
"label": "Premium members",
"tags": ["premium", "plan-pro"],
"workspaceIds": [],
"usageCount": 0
}Other directory routes: GET /api/segments?q=&workspace=&page=&pageSize=,
GET /api/segments/lookup?ids=a,b, GET|PATCH|DELETE /api/segments/:id.
PATCH takes label, tags, workspaceIds. Changing tags is how you follow
an identifier renamed upstream — every entry naming the segment keeps working.
DELETE also removes the segment from every entry’s lists.
Where a segment is used
GET /api/segments/:id/usage answers the entries naming one audience — but only
those of the workspace in X-Workspace-Id, because an entry’s title is content.
It needs segments:read and content:read, and membership of that workspace.
curl https://cms.example.com/api/segments/5d1e…/usage \
-b cookies.txt -H 'X-Workspace-Id: 3f7a…'{
"segmentId": "5d1e…",
"total": 2,
"truncated": false,
"items": [
{
"entryId": "9c4b…",
"typeSlug": "report",
"typeLabel": "Reports",
"title": "Q3 outlook",
"locale": null,
"state": "allow",
"trashed": false
}
]
}stateisallow(its readers can see the entry) ordeny(they cannot). An entry naming it on both sides reads asdeny, as the read check does.itemsstops at 100, by type then title;totalis exact.- A trashed entry still names the audience and is listed,
trashed: true. An entry of a type no longer registered is listed with anulltitle. - The directory’s
usageCountstays the installation-wide figure.
workspaceIds narrows where a segment can be chosen. It never changes who an
existing entry admits.
Setting an entry’s segments
Two lists of segment ids. Both are sent whole: the call replaces the stored state. Two empty lists make the entry public again.
Over the public API
PUT /api/v1/content/:typeName/:id/access — a full-access token
(segments:manage). GET on the same path needs segments:read.
curl -X PUT https://cms.example.com/api/v1/content/article/0c9e4d52-7f1a-4e3b-8d26-5b8f1a3c7e90/access \
-H 'Authorization: Bearer orthacms_…' \
-H 'Content-Type: application/json' \
-d '{"allow":["8d0f5b7e-2c41-4b8a-9f3e-6a1d2c3b4e5f"],"deny":[]}' await fetch(
'https://cms.example.com/api/v1/content/article/' + entryId + '/access',
{
method: 'PUT',
headers: {
Authorization: 'Bearer ' + process.env.ORTHACMS_TOKEN,
'Content-Type': 'application/json'
},
body: JSON.stringify({ allow: [premiumId], deny: [] })
}
); {
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "content_access_set",
"arguments": {
"typeName": "article",
"entryId": "0c9e4d52-7f1a-4e3b-8d26-5b8f1a3c7e90",
"allow": ["8d0f5b7e-2c41-4b8a-9f3e-6a1d2c3b4e5f"],
"deny": []
}
}
} Response:
{
"entryId": "0c9e4d52-7f1a-4e3b-8d26-5b8f1a3c7e90",
"restricted": true,
"allow": ["8d0f5b7e-2c41-4b8a-9f3e-6a1d2c3b4e5f"],
"deny": []
}- Max 200 ids per list, each a uuid.
- The change is live when the call returns.
- A type the workspace was not granted is a
400 Unknown content type. - Reading access is not reading the entry: this route works on an entry your own restriction hides.
Inside an entry save (admin API)
The admin writes access with the entry, under the access key of the save
body’s extensions. Entry and access commit in one transaction, and the new
version records the access it applied — so restoring a version restores its
segments too.
PATCH /api/content/article/0c9e4d52-7f1a-4e3b-8d26-5b8f1a3c7e90
X-Workspace-Id: 6b1f0c2e-8a4d-4f7b-9c3e-2d5a7e9f1b40
Content-Type: application/json
Cookie: <session>
{
"values": { "title": "Q3 report", "slug": "q3-report" },
"extensions": {
"access": {
"allow": ["8d0f5b7e-2c41-4b8a-9f3e-6a1d2c3b4e5f"],
"deny": []
}
}
}- Same on
POST /api/content/:typeName(create). - Omit
accessand the entry’s access is left alone. - The admin
PATCHreplaces the wholevaluesbag — send the full document. - The save needs
content:update; changing access also needssegments:manage(403otherwise). Sending exactly what is stored needs no extra permission, which keeps restore working for editors. - The public
/api/v1save does not takeextensions. API clients use thePUT …/accessroute above.
A session-only twin also exists: PUT /api/segments/entries/:entryId with
{ "typeSlug": "article", "allow": [], "deny": [] } and X-Workspace-Id.
Access belongs to the record, not the translation
On a localized type every write above applies to the whole locale group. Restrict the English article and the German one is restricted too. A new translation inherits the group’s segments.
The rule
- A reader in any deny segment is refused, whatever else is true.
- An empty allow list means everyone.
- Otherwise the reader must be in an allow segment.
It is one SQL predicate, AND-ed onto every public read:
COALESCE((
SELECT NOT (ea.deny && $reader)
AND (cardinality(ea.allow) = 0 OR ea.allow && $reader)
FROM entry_access ea WHERE ea.entry_id = article.id
), true)- No access row = unrestricted. Every entry starts that way.
- No reader = anonymous, never unrestricted.
- Admin reads are not filtered — editors see every record they manage.
In the editor
- Entry Access tab: one line first says the outcome in counts — “Public — every reader can see it.”, “Everyone except 5 audiences can see it.” or “Only 8 audiences can see it — “Not set” counts as “Cannot see”.” — then an All / Set switch, the search, Set all… and Manage segments, and a table of audiences (Audience · Access, the tags under each name), each Not set, Can see or Cannot see. The Set all… menu (All can see, None can see, Clear all) changes them all at once. Access is the one tab a plugin adds to the editor; the rest of the record is the Content form.
- Records list filter: Can be seen by, Cannot be seen by, Access restricted.
- Version history shows Who can read this.
- The Segments page is the directory, headed Audiences: each row has its key, its tags, Offered in (the workspaces, or Every workspace) and Used by.
- Creating an audience: reader tags are chips (paste a list to add many), Offered in is two cards — Every workspace or Some workspaces (Some with none ticked is refused) — and Try a reader beside the form says whether a set of tags would be in it, through the same matcher the server uses.
- Editing one: the key is read-only, Save is in the top bar, and Used by N entries beside the form lists the entries naming it in every workspace you can open, with Open in records.
- Delete audience says what changes first: entries that allowed it stop being shown to its readers only, and entries that refused it become readable by them again.
Routes
| Route | Auth | Needs |
|---|---|---|
GET /api/segments, GET /api/segments/lookup, GET /api/segments/:id | Session | segments:read |
GET /api/segments/:id/usage | Session + X-Workspace-Id | segments:read + content:read |
POST /api/segments, PATCH/DELETE /api/segments/:id | Session | segments:manage |
GET/PUT /api/segments/entries/:entryId | Session + X-Workspace-Id | segments:read / segments:manage |
GET/PUT /api/v1/content/:typeName/:id/access | API token | segments:read / segments:manage |
extensions.access on an admin entry save | Session | content:update + segments:manage |
segments:read is held by admin, contributor and viewer — an editor who can’t
see a restriction will publish believing the entry is public.
segments:manage is admin-only. Token scopes: read carries segments:read,
full adds segments:manage.
What it does not cover
Media bytes are not gated
GET /api/v1/media/assets/:id/raw checks the API token only. A PDF attached to
a restricted entry is served to any token holder who has the asset id. If the
file itself is the secret, put it behind your own CDN or proxy rules.
Agent tools
| Tool | Surfaces | Needs |
|---|---|---|
segments_list | MCP, copilot | segments:read — segments offered in the workspace |
content_access_get | MCP, copilot | segments:read — { entryId } |
content_access_set | MCP only | segments:manage — { entryId, typeName, allow, deny } |
The copilot changes access through a proposal a person accepts, applied on the entry’s own save.