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

Segments plugin

Segments plugin @orthacms/segments-server@orthacms/segments-admin

Deciding who may read a published entry, enforced on every protocol.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

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

  1. Create a segment — key premium.
  2. Restrict an entry — allow premium.
  3. 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-admin

Both 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:

RequestReader’s tags
X-Reader-Tags: premium, eupremium, eu — comma-separated, trimmed
X-Reader-Tags: premium sent twicepremium, premium — repeated headers are joined
X-Reader-Tags: (empty) or no headernone — the anonymous reader
x-reader-tags: premiumpremium — 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.

Example language
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 items and total.
  • 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 resolver entirely 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 fieldRequiredRule
keyyesLower-case, URL-safe (^[a-z0-9][a-z0-9._-]*$), max 120. Cannot be changed later
labelyes1–200 characters
tagsnoReader tags it answers to, max 20 × 200 chars. Defaults to [key]
workspaceIdsnoWorkspaces 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
        }
    ]
}
  • state is allow (its readers can see the entry) or deny (they cannot). An entry naming it on both sides reads as deny, as the read check does.
  • items stops at 100, by type then title; total is exact.
  • A trashed entry still names the audience and is listed, trashed: true. An entry of a type no longer registered is listed with a null title.
  • The directory’s usageCount stays 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.

Example language
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 access and the entry’s access is left alone.
  • The admin PATCH replaces the whole values bag — send the full document.
  • The save needs content:update; changing access also needs segments:manage (403 otherwise). Sending exactly what is stored needs no extra permission, which keeps restore working for editors.
  • The public /api/v1 save does not take extensions. API clients use the PUT …/access route 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

  1. A reader in any deny segment is refused, whatever else is true.
  2. An empty allow list means everyone.
  3. 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

RouteAuthNeeds
GET /api/segments, GET /api/segments/lookup, GET /api/segments/:idSessionsegments:read
GET /api/segments/:id/usageSession + X-Workspace-Idsegments:read + content:read
POST /api/segments, PATCH/DELETE /api/segments/:idSessionsegments:manage
GET/PUT /api/segments/entries/:entryIdSession + X-Workspace-Idsegments:read / segments:manage
GET/PUT /api/v1/content/:typeName/:id/accessAPI tokensegments:read / segments:manage
extensions.access on an admin entry saveSessioncontent: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

ToolSurfacesNeeds
segments_listMCP, copilotsegments:read — segments offered in the workspace
content_access_getMCP, copilotsegments:read — { entryId }
content_access_setMCP onlysegments:manage — { entryId, typeName, allow, deny }

The copilot changes access through a proposal a person accepts, applied on the entry’s own save.