barakoCMS

The built-in header

The built-in header

With no HeaderPath, the header is the logo, the page menu, HeaderLinks and the feed link. Four more settings shape it:

Setting What
activeOn on a link Space separated site paths the link is current on. / is the home page alone; any other path covers itself and everything below it, so /docs is current on /docs/intro. A trailing slash is ignored. The current link gets aria-current="page" and the class bp-current. It can name a path the link does not point to: a chat server's link can be current on /community
children on a link Links under this one, one level deep, each with its own label, href, badge, external and activeOn. Drawn as a dropdown that opens on hover and on keyboard focus and closes on Escape and when focus leaves, beside a button with aria-expanded that opens it too. A child's activeOn marks its parent current as well. The parent keeps its own href
MenuLinks The rows of the phone menu, in the shape of HeaderLinks. Children are drawn as an indented group. Unset or empty, the phone menu shows HeaderLinks
HeaderActions Up to four call to action links after the header links, each { label, href, variant } with badge and external as on any link. variant is primary (filled with the accent), secondary (outlined) or plain; unset or unknown is primary

Below 48rem, a site that uses any of these gets a menu button in place of the header links and the actions, opening a sheet with the MenuLinks rows, the feed link and the actions. The button is a details element and every link is in the server's HTML, so the menu and the dropdowns work without JavaScript; the script adds Escape and keeps aria-expanded true to what is shown. The words on the controls are openMenu, closeMenu, menu and submenu in Labels (submenu is {label} links, with the parent's label in place of {label}).

A site that sets none of them gets the header it had before, byte for byte.

A region page is chrome rather than somewhere to go, so it is left out of the menu and the sitemap, and it answers 404 at its own route: what it holds is already on every page. A collection's indexPage is treated the same way.

A header region replaces the whole band, the site name, the menu and the RSS link along with the top bar. There is no navigation or logo block yet, so a header region lists its own links until the block library has one.

A build-time site sets the same thing in its config, and a tenant's settings win over it:

export const config = defineConfig({
    site: { name: "Mabini Clinic", url: "https://clinic.example" },
    pages: "",
    regions: { footer: { path: "/site/footer", tone: "surface" } },
});

The registry the regions render with is the one passed to createSiteLayout(config, { blocks }), and the built-in blocks when none was passed.

Caching per tenant. Every read carries the tenant in X-Tenant and in its cache tag, <cacheTag>:<tenant>, and a narrower tag beside it: <tenant tag>:entry:<type>:<slug> on a read of one entry, <tenant tag>:type:<type> on a list, a search, the settings and the page tree. The webhook purges the tags of the tenant its host resolves to, so point each tenant's webhook at https://<that tenant's domain>/api/revalidate, signed with that tenant's own key (see One key per tenant). A publish on one tenant leaves every other tenant's cached reads in place.

When the CMS is down. Each successful read is also kept in the store, keyed by CMS, tenant and path. A read that fails with a network error, a 5xx, or no answer within cmsTimeoutMs answers from the last good copy and logs a warning; the next successful read replaces it. For ten seconds after such a failure that read answers from the copy without asking the CMS, so an outage costs one request per read every ten seconds rather than one per visitor. The same holds after a purge: a publish that lands within those ten seconds shows once the ten seconds are up. Known hosts keep resolving the same way. A page that was cached for a tenant keeps answering 200 with that tenant's identity and theme. A tenant never gets another tenant's kept answer.

Holding mode. A tenant can show a holding page on every route in place of its site, for a launch, maintenance or a seasonal break, and share the real site with a few people through site share links. Three fields on the site entry control it:

Field Type What
Mode string Live or Holding. Unset, or anything else, is Live
HoldingPath string A site path such as /coming-soon. The page the Pages module serves there is the holding page. Empty, or nothing served there, renders the default holding page: the name, the tagline and HoldingMessage, in the theme
HoldingMessage string The line the default holding page shows under the name and tagline, as text, for example Closed until 6 January. Unset or blank shows no line. Read only while holding. For more than a sentence, use HoldingPath

Holding is presentation, not access control: the API still delivers every published entry. Content that must stay hidden before launch stays unpublished with a scheduled publish.

While a tenant is holding, for that tenant only:

  • every page answers the holding page with noindex and Cache-Control: private, no-store. A page stops in siteConfig with a not-found before it reads anything, so its content and its title never reach the response. Next has already sent the status by then, so it is 200, the same for a path that exists and one that does not;
  • the holding page is read with GET /api/public/pages/resolve?path=<HoldingPath> and rendered with the page renderer and the registry passed to createSiteLayout(config, { blocks });
  • a link to HoldingPath is left out of the header, top bar and footer. feed.xml and sitemap.xml are 404 for everyone, session or not, and robots.txt answers Disallow: / with no sitemap line;
  • route handlers (revalidate, blocks, /_share, /api/share/redeem) and /_next/static are served as usual.

Switching Mode is a publish: the webhook drops the tag the settings read carries and the next request reads the new settings. No deploy.

Site share links. barakoCMS creates, lists and revokes them; anyone who may update the site type can. A client is given {site Url}/_share#{key}. The key is in the fragment, so no server log, proxy or referrer sees it:

  1. /_share is a tiny page. Its script reads the fragment, removes it from the address and history, and posts the key in a form to /api/share/redeem. Without JavaScript it says the link needs JavaScript. No key is ever accepted in a query string.
  2. /api/share/redeem asks barakoCMS once, POST /api/public/site/share-links/redeem with the key in the body and the tenant header. On 200 it sets __Host-press-share (HttpOnly, Secure, SameSite=Lax, Path=/, no Domain, so only that host gets it) and answers a no-store 303 to /. On 404, 429 or any failure it sets nothing and sends the visitor to /#share-invalid, the holding page with "This link is not valid or has expired." A post whose Origin or Sec-Fetch-Site names another site is refused the same way. The key is never logged.
  3. With a valid cookie that visitor sees the real site for that tenant. A forged, expired or other-tenant cookie is ignored.

The cookie holds no key. It is an expiry and an HMAC-SHA256 over the tenant and that expiry, so each later request is checked in process without asking the CMS, and a cookie made for one tenant opens no other. It needs one setting:

Variable What
PRESS_SECRET The HMAC key, at least 32 characters, for example openssl rand -base64 48. Read per request. Unset or shorter, no session is issued or accepted and everyone gets the holding page. Every instance behind one domain needs the same value. PRESS_PREVIEW_SECRET is read in its place when PRESS_SECRET is unset. See One secret
CMS_RENDERER_KEY Optional. Sent to barakoCMS as X-Barako-Renderer-Key when a link is redeemed, and must match the renderer key barakoCMS is configured with. Read per request and never logged. Unset, no key header is sent

Redemption is rate limited per tenant and visitor. Every redemption leaves this container from the same address, so barakoCMS needs the visitor's address to tell visitors apart. Name the header a proxy in front sets it in, sites: { visitorIpHeader: "x-real-ip" }, and it is sent on as X-Barako-Visitor-IP. Name it only behind a proxy that sets that header and strips a caller's value, the same rule as hostHeader and tenantHeader. A value that is not exactly one IPv4 or IPv6 address, a comma separated list included, is not sent. barakoCMS trusts the address only when CMS_RENDERER_KEY matches, so both are needed for a per visitor limit. With no header named nothing is sent: a Next route handler never sees the socket's address, and the X-Forwarded-For Next adds keeps whatever a caller put there.

A session lasts until the link expires or for 24 hours, whichever is sooner. Opening the link again starts a new one while the link is valid. A revoked link can keep working for up to 24 hours for someone who already opened it, because the session is checked here, not in barakoCMS. To end every session now, change PRESS_SECRET, which does it for every tenant on that deployment, and changes every tenant's webhook key with it.

Whether a request gets the holding page is decided by the proxy, from the cookie, once per request, and the answer is a segment of the path the render is kept under. A render made for a visitor with a session is never served to one without, or the reverse, because the two are different paths. A page you write by hand must call siteConfig(config, params) before it reads or renders anything, which is what keeps it behind the holding page and what gives it its tenant. A build-time site has no settings entry and no holding mode.

generateStaticParams factories return nothing on a request-time site, since there is no tenant at build. scripts/two-hosts.sh runs the built reference app against a stand-in CMS with two tenants and checks all of the above; CI runs it on every push.