Reader entitlements on the public content API. A segment is an audience; an entry carries an allow list and a deny list of them; a reader arrives carrying tags. Everything else follows from one predicate.
This is the plugin for member-only articles, per-customer documentation, staged regional launches and anything else where “published” and “visible to everybody” are not the same statement.
Installing it changes nothing
With no segment created, the catalogue is empty, the read scope returns nothing, no SQL fragment is emitted, and a public read costs exactly what it did before. That is the state every existing installation is in, and it is the first property the plugin keeps.
Install
npm install @apograph/segments-server @apograph/segments-adminBoth are installed in every app create-apograph-app generates.
Register
// apps/server/src/plugins.ts
import { SegmentsPlugin } from '@apograph/segments-server';
SegmentsPlugin(config.plugins.segments);// apps/admin/src/plugins.ts
import { SegmentsPlugin } from '@apograph/segments-admin';
SegmentsPlugin();Register the server half after ContentPlugin — it binds content’s read-scope
port. The admin half fills the Content Library’s entry-header and entry-tab
slots, so it reads after ContentPlugin(); its own directory page is
independent of that order.
Configuration
One setting, and it is the line this feature needs per install:
segments: {
resolver: {
resolve: async (request) => readTagsFrom(request)
}
}The resolver receives the request, so a JWT claim, a header your CDN sets, or a lookup against a billing system are all equally reachable. It reads no environment variables.
Leaving it out is a working configuration. Every reader is then anonymous: unrestricted content serves and restricted content does not. It fails in the safe direction — an audience nobody can be resolved into cannot accidentally be admitted.
The predicate
Three rules, and they are what the SQL says line for line:
- A reader matching the entry’s deny list is refused, whatever else is true.
- An empty allow list means unrestricted.
- Otherwise the reader must match the allow list.
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)COALESCE(…, true) is the load-bearing half: no row means unrestricted. An
entry with no access row is what every entry starts as, and reading absence as a
closed door would black out an installation on the day the plugin is installed.
Its mirror image: no reader context means anonymous. A request the middleware did not cover is treated as carrying no tags, because treating an unknown caller as unconstrained would turn every gap in coverage into an open door. Two absences, two opposite readings, both deliberate.
Relations are covered, on every protocol
A reader who may see an article is not thereby allowed to see everything it
links to. The scope is asked about the target type on every hop — REST
expansion, the /relations/:field route, GraphQL’s nested resolvers and the MCP
relations tool all reach the same check.
It sits inside the window and the count, so a restricted target is missing from the items and absent from the total. Counting it would leak the cardinality of what is hidden: “5 links, 2 visible” tells the reader that three restricted records exist here.
Admin reads pass no visibility and are untouched — an editor must see the records their entry links to in order to manage them.
Access travels with the save, and with the version
The admin does not call the access endpoint. The plugin binds content’s entry
write extension under the key access, so an entry’s audiences arrive in the
save body and are applied on the save’s own transaction. Three things follow:
- The access row and the entry row commit together. A save cannot land with its restriction missing.
- The revision that save appends captures the access it applied. A separate later request could only ever be captured by the next version, so every version would record the access the entry used to have.
- Restoring a version puts its audiences back with its words.
A PUT /api/segments/entries/:entryId route still exists for an API client that
is not saving an entry. It simply gets neither the atomicity nor the version.
Access is not a translated field
“Who may read this” is a fact about the record, not about the German wording of it, so it is written to the whole locale group — set on one locale, set on all of them, exactly as a non-localized field behaves.
Left per row it was a hole you could not see from any screen: an editor restricted the English article and published the German one to everyone.
Creating a translation inherits the group’s audiences rather than being born public beside a restricted sibling, and that inheritance needs no permission — nothing is being decided, and the alternative to inheriting is publishing it to everyone.
Routes
| Path | Scope | Needs |
|---|---|---|
/api/segments | Installation | segments:read / segments:manage |
/api/segments/lookup | Installation | segments:read |
/api/segments/:id | Installation | segments:read |
/api/segments/entries/:entryId | Workspace | segments:read / segments:manage |
/api/v1/content/:type/:id/access | Token bucket | segments:read / segments:manage |
Plus the access key of every entry save, which carries no permission of its
own at the route: the save already required content:create or
content:update on the record, and the extension checks segments:manage
itself — otherwise a contributor could restrict or un-restrict any entry they
can edit just by naming the key in the save body.
A request asking for exactly what is already stored changes nothing and needs no authority, which is what keeps restore working for anyone who may restore. A restore that genuinely would change the audiences is refused, and that is right rather than a special case.
Permissions
segments:read is held by contributor and viewer: an editor who cannot see
that an entry is restricted will publish one believing it is public.
segments:manage is admin-only — renaming a segment’s tags changes who
every entry naming it is visible to, which is a configuration decision rather
than an editorial one.
A segment names where it is offered
workspace_ids on a segment is a plain array, and empty means every
workspace — the same reading as an entry’s empty allow list.
It narrows where an audience can be chosen, never who it lets in. The predicate does not consult it and must not: a stored decision means what its editor meant, and re-deciding it from a screen about where an audience is offered would change who can read published content with nothing on either screen to say so.
What the predicate does not reach
Entitlements are enforced on content reads. Media bytes are not covered:
PublicMediaController is guarded by the API token guards alone and asks
nothing about audiences, so an asset attached to a restricted entry is served
by GET /api/v1/media/assets/:id/raw to any caller holding a valid token and
the asset id.
That matters most in the case this plugin is bought for. A gated report whose PDF is a media asset is gated in the API and open at the file. If the bytes themselves are the secret, put them behind your own CDN or proxy rules — the CMS does not do it for you.
The asset id is a uuid and is not guessable, so this is not a directory anyone can walk. It is still a URL that outlives the restriction, and one that travels: a reader who could see the entry yesterday keeps a working link to the file today.
Agent surfaces
Three tools reach this plugin: segments_list, content_access_get and
content_access_set. The write tool is offered over MCP only — an external
agent holding a token scoped to segments:manage — while the copilot reaches
access changes through a proposal a person accepts, carried out on the
entry’s own save like any other write.