Public delivery API
Public delivery API
The anonymous read surface a website frontend talks to. No token, no cookie. It is separate from
/api/contents, which is the authoring API and requires a bearer token.
Three things have to be true before an entry is delivered, and none of them can be turned off by a query parameter:
- its content type has
isPubliclyDeliverable: true, - the entry's status is
Published, and - the entry's document sensitivity is
Public, orMembersand the caller holds a viewer token (see Members and viewer tokens).
Any field the content type marks as something other than Public is stripped from the payload. A
Members field is kept for a viewer token.
A type that has not opted in and a type that does not exist both return 404. That is deliberate: answering differently would confirm which types exist.
Responses carry Cache-Control: public, max-age=60 and Vary: X-Tenant, X-Viewer-Token. The tenant can be
resolved from the X-Tenant header (see docs/multi-tenancy.md) and the payload is built entirely
from that tenant's content. The viewer header is listed so a cache never hands an anonymous copy to a
caller who sent a token (see Members and viewer tokens). The one exception is a slug read served under a valid ?preview= token,
which is no-store because it can return an unpublished entry. Vary tells a conforming cache to
key on the header, but it is not a CDN setting on its own: see the caching section of
docs/deploy-in-production.md for what the CDN itself has to be configured to do.
Each read also says its cache class, carries a weak ETag and the tags it was built from, and answers 304 when the caller already holds it. See Cache classes, validators and tags.
Preview tokens are minted through the API, not the admin. POST /api/preview returns a token
for one entry in the resolved tenant. It is authenticated, and the caller also needs read
on the entry being previewed, so minting a token is not a way around the permissions that guard
reading it normally. There is no button for it in barakoCMS itself, so a front end that wants preview
links calls that endpoint from its own code with a token that satisfies both. Deferred deliberately
rather than overlooked (#306), and recorded here so nobody goes looking for a screen that does not
exist.
The route is deprecated and its 200 says so in a Deprecation header. The token is the key of an
entry share link that lasts 30 minutes: stored hashed, capped at 20 live per entry, deleted with
its entry. A link made with POST /api/contents/{id}/share-links replaces it; that one is
listed, audited and revocable, and its key is posted in a body rather than put in a URL. See
"Links to one entry or one page" and "Preview tokens" in site-settings.md.
Routes
| Method | Route | Returns |
|---|---|---|
| GET | /api/public/{type} |
a page of published entries |
| GET | /api/public/{type}/{slug} |
one entry by slug |
| GET | /api/public/{type}/search?q= |
ranked matches, not a page |
| GET | /api/public/{type}/feed.xml |
RSS |
| GET | /api/public/sitemap.xml |
sitemap |
| GET | /api/public/types/{type}/description |
the type's route template and its Public fields' roles |
| GET | /api/public/events |
a server-sent event stream of changes (off by default, see below) |
| POST | /api/public/{type}/lookup |
one entry's named fields, by a reference plus an identifier (see public-lookup.md) |
The feed takes an item's title, description and date from the fields the type gives the title,
summary and date roles, and the feed and the sitemap build each link from the type's
routeTemplate. A type that declares neither is read by field name and linked at
Feeds:Paths:{type} or /{type}/{slug}, as before. See
field-hints-and-roles.md.
Each link starts at the tenant's own site: the Url of its published site entry
(site-settings.md). A tenant that sets none, or sets one that is not an absolute
http or https URL without a query, links at Feeds:SiteUrl and then App:BaseUrl, which is what
every tenant got before, so a deployment that only sets Feeds:SiteUrl serves the same feed it
did. The {{links.public}} template placeholder builds the same address.
A site entry's Url takes precedence over Feeds:SiteUrl. A deployment that set both, with the
site entry pointing somewhere else (a staging host, say), now links its feed and sitemap at the site
entry's address. Check that the two agree before upgrading. The feed and the sitemap carry the
site type's cache tag, so a purge for a change to the site entry reaches them.
A read by slug of a type that declares a structuredDataType also carries structuredData, the
entry as schema.org JSON-LD built from its field roles. See structured-data.md.
A file field is answered as the public file it names: its id, url,
fileName, contentType, size, alt and caption. A field naming any other file, or a file
that is gone, is left out of the entry, and the entry is still delivered.
/{type}/{slug} needs the type to have a slug field: a field of type slug, or failing that a
Public string or text field named slug. Without one the route is 404. A Sensitive or
Hidden field named slug, or a token, is not served as a slug.
A slug is unique within its content type, so the route resolves to one entry. The authoring API enforces that on the way in: a create, an update or a rollback carrying a slug another entry of the type already holds is refused with 400. Every status counts, drafts and archived entries included, because a draft allowed to hold a taken slug only fails at the moment somebody publishes it, and the scheduler publishes on a timer with nobody to tell. Matching is case-insensitive, the same way this route matches. Reusing the slug of a retired entry means changing or clearing it on that entry first.
Content stored before that rule existed is not rewritten, so a database can still hold two entries on one slug. The route resolves those oldest first, with the entry id as the tiebreak, so the URL answers with the same entry on every request until the duplicate is cleaned up. It used to answer with whichever row the database returned first.
Pagination
GET /api/public/{type} takes page and pageSize.
| Parameter | Default | Range |
|---|---|---|
page |
1 | 1-indexed. Anything below 1 is clamped to 1 |
pageSize |
20 | clamped to 1..100 |
The cap is 100 whatever the caller asks for, so a page size of 5000 returns 100 rather than an error. Every paginated endpoint in barakoCMS returns the same envelope:
{
"items": [],
"page": 1,
"pageSize": 20,
"totalItems": 137,
"totalPages": 7,
"hasNextPage": true,
"hasPreviousPage": false
}
totalItems counts everything matching the query, filters included, not just the current page.
The count and the page come back from one query, so a list costs one round trip to Postgres. A page
past the last one has no row to carry the count, and only that request counts separately.
Only what changed
changedSince takes an ISO 8601 instant and keeps the entries whose updatedAt is after it, for a
build that fetches what changed since its last run:
GET /api/public/post?changedSince=2026-10-01T00:00:00Z&pageSize=100
It combines with filters, sorting and paging, and totalItems counts what it kept. It answers
additions and edits only. An entry that was unpublished, archived, made non-Public or deleted is
not in a published list, so changedSince cannot report it. A build learns of a removal from a
Webhook workflow action on the Unpublished and Deleted triggers (webhooks.md),
from content.unpublished on the event stream below while it is connected, or from a periodic full
fetch whose ids it compares with what it holds. GET /api/changes carries no content topic, so it
is not a way to learn of one.
A change to the type rather than to an entry does not move updatedAt. When a field goes from
Public to Sensitive, every entry's delivered data loses that field and none of them comes back from
changedSince. After a change to a type's fields, their sensitivity or its route template, fetch
the type in full.
A value that is not a date is a 400. Leaving it out lists everything, as before.
An entry looks like this:
{
"id": "0198b0e9-...",
"contentType": "blog-post",
"slug": "hello-world",
"data": { "title": "Hello world", "publishedAt": "2026-08-30" },
"createdAt": "2026-08-30T02:11:04.113507Z",
"updatedAt": "2026-08-30T02:11:04.113507Z"
}
data holds only the fields the type marks Public.
Filtering
filter[field][op]=value
Repeat the parameter for more than one filter. Each repeat is its own filter, including repeats of
the same field, so ?filter[tag][eq]=a&filter[tag][eq]=b is two filters rather than one filter for
the literal a,b.
Operators:
| Op | Meaning |
|---|---|
eq |
equals |
ne |
not equal |
lt lte gt gte |
ordered comparison |
contains |
case-insensitive substring |
has |
the field holds a list with this value as one whole element |
near |
within radiusKm of lat,lng, geopoint fields only; see The near filter |
At most five filters per request. A sixth returns 400. The cap is there because arbitrary filter combinations against a JSONB column on an anonymous endpoint is a denial-of-service surface.
A filter value is at most 256 characters. A longer one returns 400 instead of being trimmed, because a trimmed value matches entries the caller did not ask for.
Only fields the type marks Public can be filtered. Naming any other field returns 400 rather than
being ignored, because filtering on a field you cannot read is an oracle: the value never appears in
a response, but which entries come back reveals it. A silently ignored filter is worse still, since
the caller cannot tell "no filter applied" from "no matches".
A field name is matched without regard to case, in the request and in the stored entry. So when a
type declares two fields that differ only by case and one of them is not Public, neither name can
be filtered or sorted on: the lookup could land on the value the caller may not read. With both
Public, the first one declared is used.
Comparison happens in jsonb using the field's declared type, so a numeric field compares
numerically: filter[price][lt]=10 puts 9 below 10 instead of after it.
A choice field is matched by its stored value, exactly, never by its label:
filter[EntryType][eq]=FUN. A choice that holds a list (multiple) takes eq and ne only, meaning
the entry holds the value or does not; any other operator on it is 400. See Choice fields.
has takes a field that holds a list: an array, a choice with multiple, or a reference with
multiple. It matches an entry whose list holds the value as one whole element, so
filter[Tags][has]=abc-123 does not match a list holding abc-1234, where contains would, since
contains searches the list's text. How an element is compared depends on the field:
- An
arrayelement that is text, a number or true/false is compared by its text, exactly, case included:has=5matches5and"5"and not50, andhas=truematchestrue. An element that is itself an object or a list never matches. - A reference id is compared without regard to case, so
hasfinds an id however it was stored. - A choice value is compared exactly, as
eqcompares it.
Any other field is 400. A reference with multiple takes eq, ne and has, like a choice that
holds a list, and eq and ne compare ids the same way has does. has is served by no index, for
the reason given under What a filter costs.
Filters narrow what the published-and-public predicate already allows. No filter can widen it.
GET /api/public/blog-post?filter[category][eq]=engineering&filter[title][contains]=marten
What a filter costs
A filter finds its field by name inside the entry's data without regard to the key's casing, so no index serves it. The content type index narrows the scan to the entries of the type, and each of those is then compared. On a type with a hundred thousand entries a filtered request reads all of them. The five filter cap and the page size cap bound what one request asks for, not how many rows it reads.
The same filters on the entries list
GET /api/contents takes the same parameters when contentType is set:
GET /api/contents?contentType=lead&filter[Stage][eq]=won
The operators, the five filter cap and the 256 character cap are the same. What differs is who is asking:
- A filter is accepted on a declared field the caller reads unmasked: a
Publicfield, or aSensitiveorHiddenone their stored roles may see (byview_sensitive,view_hidden, or the field's own role list; see access-control.md). Any other field returns 400, in the same words as a field that does not exist, and the answer does not list the type's fields. - A filtered list leaves out an entry whose document sensitivity withholds its data from the caller. Unfiltered, that entry comes back blanked; matched by a filter, it would say what the blanked value is.
- A filter without
contentType, or on a type with no stored definition, returns 400. sortis not read on this route. Order issortOrderover the creation time, as before.
Counting entries by status
GET /api/contents/counts answers how many entries the caller may read, by status, so an overview
card does not have to page the whole collection:
GET /api/contents/counts?contentType=article
{ "total": 154, "draft": 27, "published": 121, "archived": 3, "scheduled": 3, "uncountedTypes": [] }
- Without
contentTypeit counts every type with a stored definition that the caller may read; with one, only that type. - Each status is its own number and
totalis all four together.scheduledis theScheduledstatus, entries waiting for their publish time. - Each type is counted under the same read rules the entries list pages with, so the numbers agree
with what
GET /api/contentswould list. A type whose rules for the caller depend on the entry in a way that cannot be turned into a query (a condition on$status, or on a value that is not a string) is named inuncountedTypesand left out of the numbers, rather than loaded row by row. Only a type the caller has some read rule for is named. - It needs a signed-in caller, or an API key with
content:read.
Sorting
sort=field ascending
sort=-field descending
Same allowlist as filtering: a field the type does not mark Public returns 400. A requested sort
replaces the default (createdAt descending) rather than adding to it, and createdAt stays as the
tiebreaker so a page boundary cannot move between two entries that compare equal.
Entries missing the sort field collect at the end in both directions.
sortBy was removed in 4.0. It was accepted here and honoured nowhere.
Locations and proximity
A field of type geopoint holds a coordinate pair:
{ "lat": 6.5031, "lng": 124.8469 }
Both keys are required, both must be JSON numbers, latitude within -90..90 and longitude within
-180..180. A string like "6.5031,124.8469" is refused on write, because the proximity query casts
the stored numbers and a string would not cast. The admin editor hint is geopoint.
The near filter
filter[field][near]=lat,lng,radiusKm
Everything within radiusKm of the centre. Only a geopoint field marked Public can be named,
one near per request, and it counts against the five-filter cap. A malformed centre, a centre
outside the valid range, a radius of zero or below, or a radius above the cap is 400 with the
reason. The cap is Delivery:MaxRadiusKm, default 1000, and a request above it is refused rather
than quietly narrowed.
The query runs in SQL over the stored JSONB in two stages: a bounding box on the latitude and longitude, then the haversine distance against the radius. It is applied in the same chain as every other filter, so the published, public and sensitivity rules apply unchanged: a Draft two kilometres from the centre is not returned.
Distances are great-circle on a sphere of mean radius 6371 km. That is right for "within 10 km" and for ordering a list. It is not geodesy: against the ellipsoid it is off by up to a third of a percent, and a survey or a legal boundary needs PostGIS, which this deliberately does not require.
Distance in the response
When a near filter is present each item carries distanceKm, kilometres from the centre to two
decimals, the same number the rows were filtered and ordered by. Without a near filter the key is
absent, not null.
sort=distance and sort=-distance order by it. Without a near filter, sort=distance is 400,
unless the type has a Public field called Distance, which then sorts as any other field would.
curl "https://cms.example.com/api/public/store?filter[Location][near]=6.5031,124.8469,60&sort=distance" \
-H "X-Tenant: default"
{
"items": [
{ "id": "3f2c1a9e-6b0d-4f7a-9c2e-1d5b8a7e4c01", "data": { "Title": "Koronadal", "Location": { "lat": 6.5031, "lng": 124.8469 } }, "distanceKm": 0 },
{ "id": "8a1e4d2c-0b9f-4e3a-a7c6-2f4d9b8e1c02", "data": { "Title": "General Santos", "Location": { "lat": 6.1164, "lng": 125.1716 } }, "distanceKm": 56.01 }
],
"totalItems": 2
}
Resolving references
include=author,category
Replaces reference ids with the referenced entries, in one batched load rather than one request per reference. At most five includes per request.
A named field must be of type reference and marked Public, or the request is 400. Each resolved
entry goes through the same projection as the list itself, so published state, document sensitivity,
type opt-in and the field allowlist all still apply. A target that does not survive that projection
has its field removed rather than left as an id, which makes an unreadable target
indistinguishable from no reference at all.
A reference with multiple holds a list of ids and resolves to a list of entries, in the order the
ids are stored. An entry that does not survive the projection is left out of the list and not named.
All the ids on a page are read in one query, and a page whose named fields hold more than 1000
distinct ids is 400, telling the caller to ask for a smaller pageSize or fewer fields. A single
reference never reaches that: a full page of 100 entries with five includes holds 500.
Many-valued references
A reference field declared with "multiple": true holds a list of ids:
{ "name": "Speakers", "type": "reference", "referenceType": "speaker", "multiple": true }
An entry write refuses, with 400, a value that is not a list (send a list even of one), more than 100 ids, an id not written in lower case with hyphens (the form this API returns ids in), the same id twice, and any id that is not an entry of the declared type in the tenant. The error names the ids at fault, and does not name the type a wrong one is of. An empty list holds nothing, and a required field refuses it. The list is stored in the order sent. The same check runs on every write that validates an entry: create, update, collection push, bulk and bundle import, a transition that carries data and a rollback. A bundle import points listed ids of records in the bundle at the records as imported, as it does a single reference, and takes records that list each other: a list may name a record of the same bundle that is written after it. A single reference still needs its target written first.
Two writers do not run that check, and refuse a list reference instead: the UpdateField workflow
action fails without retrying, since its value is one piece of text, and a collection sync refuses a
mapping or rule onto one when it is saved (and skips the item, naming the field, if the field became
a list after the sync was saved). A form cannot submit a reference at all, so a form on a type with a
required list reference cannot be submitted.
A stored field cannot change between one id and a list: there is no endpoint that changes a field's
type or multiple. A bundle import that writes a type over a stored one can, and entries stored
before then keep their value until their next save, which then has to send the new shape. include
reads what an entry holds rather than what the field declares, so either shape still resolves, and
eq and has on a list field do not match an entry still holding a single id (and ne does).
Every delivery route other than the list with include (the slug route, search, preview, share
links, the Pages routes and semantic search) serves a Public list reference as the ids it holds,
ids of drafts and of entries that are not Public included, as a single reference has always been
served. A caller who compares a list with and without include can therefore tell which listed ids
it may not read. Making the field non-Public keeps the ids out of delivery entirely.
Search
GET /api/public/{type}/search?q=marten&limit=20
limit is clamped to 1..50, default 20. A q shorter than two characters returns an empty result
rather than an error.
This is the one delivery endpoint that does not take the pagination envelope:
{ "results": [], "count": 0, "query": "marten" }
There is no stable ordering to page through and no total beyond the scan cap, so a caller asking for
page 3 of a relevance ranking would get something that changes underneath them. count is how many
of a bounded, ranked scan matched, not how many exist. Matching runs over public fields only, and a
title or name hit outranks a body hit.
Search takes the list's filter[field][op]=value parameters, near included, under the same rules
and caps:
GET /api/public/news/search?q=flood&filter[Category][eq]=advisory
The filter runs in the query, ahead of the scan cap and limit, so a search for one category
returns that category's best matches even when better matches sit in another. A refused filter is
400 whatever q holds. sort is not read: results are ranked.
Errors
| Status | When |
|---|---|
| 400 | unknown filter field, unknown operator, malformed filter[...], more than 5 filters, a filter value over 256 characters, unknown or non-reference include, more than 5 includes, more than 1000 distinct ids to resolve on one page, has on a field that holds no list, unsortable field, malformed near centre or radius, near on a field that is not a geopoint, sort=distance without a near filter, an operator other than eq, ne or has on a choice or reference that holds a list |
| 404 | unknown type, type not marked publicly deliverable, no slug field, no published entry at that slug |
A 400 carries the reason, including the fields that would have been accepted.
What a field value is, and is not
Delivery returns the value that was saved. Nothing here escapes, strips or rewrites it, including
for the richtext and markdown types, which are both stored and returned as the string somebody
typed. Whoever can edit an entry decides what a consumer renders.
So the consumer owns the escaping. Render markdown through something that drops raw HTML, and never
put a richtext value into innerHTML without sanitising it first. This is why the shipped
blueprints use markdown for every body field: the ordinary way to render markdown is already safe,
and the only way to render richtext is not. The feed and the sitemap are the exceptions, and only
because they build a document rather than hand you a field, so they escape what they put in it.
Members and viewer tokens
Members is a sensitivity level between Public and staff, on an entry and on a field. It is
appended after Hidden, so no stored value changes number, and nothing is Members until someone
sets it. Existing entries and fields stay Public, and a request without a viewer token is answered
exactly as before.
A site with its own sign-in mints a token for one of its members with
POST /api/viewer-tokens ({ "subject": "member-9", "lifetimeMinutes": 60 }), which needs the
manage_public_delivery capability. Lifetime defaults to 60 minutes, at most 480. Each mint is written to the audit trail as
viewer.token.minted (subject, tenant, expiry and the actor, never the token). If JWT:Key is not
set the route answers 503. The token goes in
the X-Viewer-Token header of GET /api/public/{type} and GET /api/public/{type}/{slug}.
- An anonymous call never sees a
Membersentry (404 on a slug, absent from a page) or field. The feed, the sitemap, search, the lookup route, the event stream, the type description and the OpenAPI document are anonymous only: they never serveMembers, with or without the header. - A viewer sees
PublicandMembersentries, andPublicandMembersfields.SensitiveandHiddenstay staff-only. Staff read aMembersvalue in the authoring API as they read aPublicone. - The token is bound to the tenant that minted it. A token for tenant A is a 401 on tenant B.
- A token that is expired, malformed or for another tenant is a 401, not an anonymous read, so an expired session shows as one instead of as a missing page.
- A token cannot be revoked. The short lifetime is the control, so mint for as little time as the
session needs. Rotating
JWT:Keysigns out every viewer at once, and every staff session with them. - The token is signed under its own
:vieweraudience. The bearer scheme rejects it, so it opens no authoring route and cannot mint another token. - A response served with a token is
Cache-Control: private, no-store,Vary: Authorization, X-Tenant, X-Viewer-Token, with no cache tags, ETag or Last-Modified. It is never the same response an anonymous caller is given, and no shared cache keeps it. - A reference or file field on a
Membersentry resolves only to targets anonymous delivery serves.filter,sortandincludework onPublicfields only, for a viewer as for anyone. - A row condition (own record) is not evaluated for a viewer yet:
Membersmeans every verified viewer of the tenant.
On a private site. Private mode (see site-settings.md) gates every
/api/public/* route before the handler runs, for a visitor with a site-wide share key or a
signed-in user who may read the site type. A viewer token is not a way in: on a private tenant a
member needs the site pass and the token, and the response is private, no-store either way.
Change events
GET /api/public/events is a server-sent event
stream of the tenant's content changes, for a frontend or a dashboard that wants to react to a
publish without polling. Anonymous, like every other route here, and it answers to the same three
rules: an entry is streamed only if its type has opted in, only while it is Published, and only
with its Public fields. The payload is produced by the same projection the REST reads use, so a
field GET /api/public/{type}/{slug} masks is masked here for the same reason: it is the same
function. ContentEventStreamTests asserts that directly against a Sensitive field.
It is off by default. Delivery:Events:Enabled turns it on; while it is off the route is 404.
This route must never be cached. Unlike every other route here it sends Cache-Control: no-store,
not max-age=60, because a stream has no single response to cache: buffering one connection's frames
and replaying them to a later request is wrong in a single tenant, and a cross-tenant version of the
same mistake is the class of bug #546 exists to close. It sends Vary: X-Tenant too, for consistency
with the rest of this surface, though a store honouring no-store never needs it. A reverse proxy or
CDN placed in front of this route has to be told two separate things: not to cache it at all (most
already treat no-store correctly, but confirm rather than assume), and, independently, not to buffer
the response before forwarding it. A proxy that buffers waits for the stream to end before sending
anything downstream, which breaks Server-Sent Events outright, whether or not caching is involved.
nginx's own setting for this is proxy_buffering off; on the route; check the equivalent for whatever
sits in front of it.
GET /api/public/events
GET /api/public/events?type=post&type=page
type repeats to filter by content type. A type that has not opted in never matches, which says
nothing about whether it exists. At most 20 types per connection.
Three event names, and every payload carries id, contentType and slug:
| Event | When | Payload |
|---|---|---|
content.published |
an entry became public: published, created as published, or its document sensitivity set back to Public | the entry, in the shape GET /api/public/{type}/{slug} returns |
content.updated |
a published entry changed: an edit, a lifecycle transition, or a field's sensitivity changed on its type | the entry, same shape |
content.unpublished |
a public entry stopped being public: moved to Draft or Archived, or its document sensitivity changed | id, contentType, slug only |
A draft save emits nothing. A draft moved to Archived emits nothing either, because it was never
on anybody's site and an unpublish for it would hand out the slug of an entry the REST API answers
404 for. Erasing an entry (DELETE /api/contents/{id}/erase) emits nothing yet.
A comment line (: keepalive) is sent whenever nothing else has been for
Delivery:Events:KeepAliveSeconds (15), so a proxy does not close an idle connection. An
EventSource never dispatches a comment. The frames carry no event id (the id: line is empty)
and there is no replay: a client that reconnects should re-read what it cares about, then resume
listening.
const source = new EventSource("/api/public/events?type=post");
source.addEventListener("content.published", e => render(JSON.parse(e.data)));
source.addEventListener("content.updated", e => render(JSON.parse(e.data)));
source.addEventListener("content.unpublished", e => remove(JSON.parse(e.data).id));
Caps
An anonymous long-lived connection is a resource anybody on the internet can hold, which is why the stream is opt-in, and why it is capped:
Delivery:Events:MaxConnections(100) is the number of open streams across all tenants on one instance. The next connection gets 503 withRetry-After.Delivery:Events:MaxConnectionsPerClient(5) is the number of open streams from one client address, so one caller cannot hold every slot under the instance cap and starve every other tenant on the instance. The next stream from that address gets 503 withRetry-Afterand a body that says the client is at its own limit, while another address still connects. The slot comes back when the stream closes. The address is the one the rate limiter partitions on: the socket peer, or the forwarded client whenForwardedHeadersnames the proxy. Behind a proxy that is not named, every client shares the proxy's address and the cap counts them together. Zero turns the per-client cap off.- Each connection buffers 64 changes. A subscriber that stops reading has its oldest change dropped, and the drop is logged once per connection. Nothing a slow subscriber does holds memory for anybody else.
One instance, its own writes
The stream is fanned out in process, on the instance that committed the write. With several API
instances behind a load balancer, each streams the writes it handled and nothing else: a subscriber
connected to instance A does not see a publish that went through instance B. That is the known
limitation until a shared bus exists between instances; a single-instance deployment sees
everything. Content types running with EventSourcing:DocumentTypesAppend off write no events,
so nothing about them is streamed, the same way nothing about them fires a workflow.
Serving public content from a module
A module that adds its own anonymous route resolves IPublicContentProjector from
barakoCMS.Core.Interfaces and hands it a Content plus its ContentTypeDefinition. It gets back a
PublicContentProjection, which serialises to the same JSON as /api/public/{type}/{slug}, or null
when the entry must not be served at all.
var def = await session.Query<ContentTypeDefinition>()
.FirstOrDefaultAsync(d => d.Name == type, ct);
if (!projector.IsDeliverable(def)) { await Send.NotFoundAsync(ct); return; }
var projected = projector.Project(entry, def);
if (projected is null) { await Send.NotFoundAsync(ct); return; }
HttpContext.Response.Headers.CacheControl = "public, max-age=60";
HttpContext.Response.Headers.Vary = "X-Tenant";
await Send.OkAsync(projected, ct);
Use it rather than filtering in the module. Four checks stand between a stored document and a public response (published status, document sensitivity, the type's delivery opt-in, and the field allowlist), each fails open in a different direction, and a second copy of them looks right until it delivers a draft. The core routes above call the same code, so there is one copy of the four and a module holds none of it.
The definition has to be the entry's own content type. Project checks it and returns null on a
mismatch, because the schema is what names the Public fields, so another type's definition would
apply another type's allowlist and the response would still read as authentic. Load the definition and
the entry under the same type name, as the example does, and never take the type from one place and
the entry from another.
Nothing in the interface queries. The module loads the entry and the definition from its own session,
which keeps tenant scoping where it already is. SlugField is there because a module addressing
entries by slug has to query the field delivery reads the slug back from. All three members take a
null definition, so the result of a FirstOrDefaultAsync can go straight in.
Filters on a module route
A module route that lists or searches content takes the list's filters through
IPublicContentFilterParser, from the same namespace. It reads the filter[...] keys of the query
string, accepts only fields the type marks Public, and applies the same operators and caps.
var filter = filters.Parse(HttpContext.Request.Query, def);
if (filter.Error is not null)
{
AddError(filter.Error);
await Send.ErrorsAsync(400, ct);
return;
}
var entries = await filter.Apply(session.Query<Content>()
.Where(c => c.ContentType == type
&& c.Status == ContentStatus.Published
&& c.Sensitivity == SensitivityLevel.Public))
.Take(50)
.ToListAsync(ct);
Apply adds to the query it is given. It does not select Published or document-Public entries, so
the module's query still has to, and each entry still goes through the projector. Apply throws
when Error is set, so a refused filter cannot run as no filter. sort is not read.
The cache headers a module route owes its callers
The two lines above are the contract the core public routes set, and a module route has to set them
itself: SetCache is internal to the delivery slice. The class and tags come from
barakoCMS.Infrastructure.Caching.DeliveryCache: call DeliveryCache.Shared(HttpContext, DeliveryCacheClass.Short, scopes) beside the two lines, and add
Options(x => x.WithMetadata(DeliveryCache.Validators)) in Configure for the ETag and the 304. The Cache-Control window is short on purpose,
long enough for a CDN to absorb a burst and short enough that a publish shows up quickly.
Vary: X-Tenant is not optional in a multi-tenant deployment. TenantResolutionMiddleware reads
that header before it reads Host, and the response is built entirely from the resolved tenant, so a
shared cache keyed on the URL alone in front of a header-routed deployment will serve one tenant's
public response to another. See docs/multi-tenancy.md for how the front end sets the header, and
docs/deploy-in-production.md for the caches that honour Vary only when configured to.
Cache classes, validators and tags
Cache-Control keeps the value each route always sent, so a deployment that sets nothing caches
exactly as long as before. Beside it every delivery read says, in X-Barako-Cache-Class, how long
the read may be kept, which a renderer can tell apart from the CDN hint:
| Class | Means | Sent by |
|---|---|---|
short |
minutes | /api/public/{type}, {type}/{slug}, {type}/search, {type}/semantic, {type}/feed.xml, sitemap.xml, types/{type}/description (all public, max-age=60), files/{id}/meta (public, max-age=300), redirects/resolve (no Cache-Control, held 5 minutes in the server's output cache) |
long |
hours or days | files/{id} (public, max-age=86400) |
no-store |
never kept | a slug read under a valid ?preview= token, JWT or share link key |
swr |
may be served stale while it is fetched | nothing by default |
ETag and Last-Modified
A short or long read carries a weak ETag, W/"...", a hash of the tenant and the exact body
sent. It is weak because a proxy that compresses the body changes its bytes but not its meaning
(nginx turns a strong tag weak when it gzips anyway), and no delivery route serves byte ranges,
the one case that needs a strong tag. Because the body is hashed after it is built, anything that
changes what a caller is sent changes the tag: an edit, a resolved reference, a field made
non-Public. Two tenants with the same body get different tags.
A file's tag is a hash of the stored record and how it is served, since its bytes never change, and is answered before the bytes are read.
Last-Modified is sent only where one timestamp covers everything in the body: a type description
(the type's UpdatedAt) and a file (its upload time). Lists, search, the feed and the sitemap have
none, because an entry leaving them moves no timestamp. A slug read has none either: a referenced
entry or a file can change what it sends without moving the entry's UpdatedAt.
If-None-Match matching the ETag (weak comparison, * matches anything) answers 304 Not Modified
with no body. If-Modified-Since is used only when If-None-Match is absent, as RFC 9110 says,
and only on a read that sent Last-Modified; anywhere else it is ignored and the read answers in
full.
Tags
Surrogate-Key (Fastly) and Cache-Tag (Cloudflare) carry the same space-separated value. Every
tag starts with the tenant, so a purge by tag cannot cross tenants:
| Tag | On |
|---|---|
t:<tenant> |
every tagged read |
t:<tenant>:type:<type> |
reads of that type, and of any type it refers to or includes; the sitemap for each type it lists, best effort |
t:<tenant>:entry:<id> |
every entry in the response, included references too |
t:<tenant>:file:<id> |
a public file and its metadata (a resized copy is tagged by its original) |
t:<tenant>:sitemap |
the sitemap |
t:<tenant>:redirects |
a redirect lookup |
Only values the response already shows go in a tag: a type name, a published entry's id, a public
file's id. Anything outside letters, digits and -_.~ is percent-encoded, so a type name cannot
pose as another tag. A no-store read carries no tag and no ETag.
A response carries at most 32 tags and 1024 bytes per header, which keeps both headers inside the 4
KB header buffer of a default nginx proxy. Past either bound the remaining tags are dropped in
order, and X-Barako-Cache-Tags-Dropped says how many. The order is the tenant, the type read,
each type it refers to or includes, then the entries, so entry tags are what falls off. That gives
the purge rule: when an entry of type X is published, purge t:<tenant>:entry:<id> and
t:<tenant>:type:X. The type tag reaches every list and search of X, and every read of another
type that refers to or includes X, whether or not its entry tags survived.
The sitemap is the exception, because a tenant can have more types than the bound holds. Purge
t:<tenant>:sitemap on every publish; its type tags are best effort and fall off past about thirty
types. It carries no entry tags, since it holds up to 50,000 entries.
These headers are exposed to browsers through CORS. Nothing purges a CDN yet; that is #561.
Stability and deprecation
There is no version segment in the URL and none is planned. The delivery API, meaning every route
under /api/public, follows the semantic version of the package that registers it instead. For the
routes above that is the core package. A module that adds a route under the same prefix, such as
/api/public/files/{id} from BarakoCMS.Files or /api/public/{type}/semantic from BarakoCMS.AI,
follows that module's own version under the same rules.
What counts as breaking. A route going away or changing shape, a response field being removed or changing type, a filter or operator changing meaning, or a default changing (page size, sort order, cache headers, what an omitted parameter means). Any of those lands only in a major version.
What can land in a minor. A new field, a new filter or operator, a new route, a new optional parameter. A client that ignores what it does not know keeps working, so write clients that way.
How a break is announced. At least one minor release before the major that ships it, the change
is recorded in CHANGELOG.md under a Delivery API lead inside the entry, and the field or
behaviour is marked deprecated in this document. Until the major, the old behaviour keeps working
unchanged. So a consumer reading the changelog for each minor sees every break coming with at least
one release of notice, and a consumer who only reads this document sees it too.
Why no version segment. /api/v1/public and /api/v2/public means two code paths, two test
suites and two sets of projection rules to keep in step, for a project of this size. The change that
prompted the question, 3.20.0 making public delivery opt-in, would not have been helped by it: that
was a breaking change shipped in a minor with no notice, and a v2 would have shipped the same
break to anyone who moved to it. What was missing was a written rule about when a break may ship and
how it is announced. This section is that rule.
Security is the one exception. A security fix ships in the next release whatever its number:
a hole in authentication, authorization, integrity or availability as much as a data exposure,
which is what 3.20.0 closed. It is still recorded under the same Delivery API lead and under
### Breaking, and it says what a consumer has to do.